diff --git a/README.md b/README.md index 668c97832c21837ad4a11a7ddee9cbcd7dba399d..5401904f0be9c7457fb6d6317d4471b643f8a972 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,6 @@ backported to the C version, depending on how viable a proper Zig release is. Missing features: -- Listing paths for the same hard link - Help window - Directory refresh - File deletion @@ -51,6 +50,7 @@ Already implemented: [#36](https://code.blicky.net/yorhel/ncdu/issues/36)). - Faster --exclude-kernfs thanks to `statfs()` caching. - Improved handling of Unicode and special characters. +- Key to switch to path from a file's hard link listing. - Remembers item position when switching directories. Potentially to be implemented: diff --git a/src/browser.zig b/src/browser.zig index e5f1f84ce7dddcb2c92f2ea723316c956d9b8ba4..2cbcafb1b4367503b4f24425cd818899d01c820a 100644 --- a/src/browser.zig +++ b/src/browser.zig @@ -127,7 +127,7 @@ pub fn loadDir() void { dir_max_blocks = 1; dir_has_shared = false; - if (dir_parents.top() != model.root) + if (!dir_parents.isRoot()) dir_items.append(null) catch unreachable; var it = dir_parents.top().sub; while (it) |e| { @@ -329,9 +329,65 @@ const quit = struct { }; const info = struct { - // TODO: List of paths for the same hardlink. + const Tab = enum { info, links }; + + var tab: Tab = .info; + var entry: ?*model.Entry = null; + var links: ?model.LinkPaths = null; + var links_top: usize = 0; + var links_idx: usize = 0; + + // Set the displayed entry to the currently selected item and open the tab. + fn set(e: ?*model.Entry, t: Tab) void { + if (e != entry) { + if (links) |*l| l.deinit(); + links = null; + links_top = 0; + links_idx = 0; + } + entry = e; + if (e == null) { + state = .main; + return; + } + state = .info; + tab = t; + if (tab == .links and links == null) { + links = model.LinkPaths.find(&dir_parents, e.?.link().?); + for (links.?.paths.items) |n,i| { + if (&n.node.entry == e) { + links_idx = i; + } + } + } + } + + fn drawLinks(box: ui.Box, row: *u32, rows: u32, cols: u32) void { + var pathbuf = std.ArrayList(u8).init(main.allocator); + + const numrows = saturateSub(rows, 4); + if (links_idx < links_top) links_top = links_idx; + if (links_idx >= links_top + numrows) links_top = links_idx - numrows + 1; + + var i: u32 = 0; + while (i < numrows) : (i += 1) { + if (i + links_top >= links.?.paths.items.len) break; + const e = links.?.paths.items[i+links_top]; + ui.style(if (i+links_top == links_idx) .sel else .default); + box.move(row.*, 2); + ui.addch(if (&e.node.entry == entry) '*' else ' '); + pathbuf.shrinkRetainingCapacity(0); + e.fmtPath(false, &pathbuf); + ui.addstr(ui.shorten(ui.toUtf8(arrayListBufZ(&pathbuf)), saturateSub(cols, 5))); + row.* += 1; + } + ui.style(.default); + box.move(rows-2, 4); + ui.addprint("{:>3}/{}", .{ links_idx+1, links.?.paths.items.len }); + pathbuf.deinit(); + } - fn drawSizeRow(box: *const ui.Box, row: *u32, label: [:0]const u8, size: u64) void { + fn drawSizeRow(box: ui.Box, row: *u32, label: [:0]const u8, size: u64) void { box.move(row.*, 3); ui.addstr(label); ui.addsize(.default, size); @@ -341,7 +397,7 @@ const info = struct { row.* += 1; } - fn drawSize(box: *const ui.Box, row: *u32, label: [:0]const u8, size: u64, shared: u64) void { + fn drawSize(box: ui.Box, row: *u32, label: [:0]const u8, size: u64, shared: u64) void { ui.style(.bold); drawSizeRow(box, row, label, size); if (shared > 0) { @@ -351,33 +407,17 @@ const info = struct { } } - fn draw() void { - const e = dir_items.items[cursor_idx].?; - // XXX: The dynamic height is a bit jarring, especially when that - // causes the same lines of information to be placed on different rows - // for each item. Not really sure how to handle yet. - const rows = 5 // border + padding + close message - + 4 // name + type + disk usage + apparent size - + (if (e.ext() != null) @as(u32, 1) else 0) // last modified - + (if (e.link() != null) @as(u32, 1) else 0) // link count - + (if (e.dir()) |d| 1 // sub items - + (if (d.shared_size > 0) @as(u32, 2) else 0) - + (if (d.shared_blocks > 0) @as(u32, 2) else 0) - else 0); - const cols = 60; // TODO: dynamic width? - const box = ui.Box.create(rows, cols, "Item info"); - var row: u32 = 2; - + fn drawInfo(box: ui.Box, row: *u32, cols: u32, e: *model.Entry) void { // Name - box.move(row, 3); + box.move(row.*, 3); ui.style(.bold); ui.addstr("Name: "); ui.style(.default); ui.addstr(ui.shorten(ui.toUtf8(e.name()), cols-11)); - row += 1; + row.* += 1; // Type / Mode+UID+GID - box.move(row, 3); + box.move(row.*, 3); ui.style(.bold); if (e.ext()) |ext| { ui.addstr("Mode: "); @@ -397,47 +437,78 @@ const info = struct { ui.style(.default); ui.addstr(if (e.isDirectory()) "Directory" else if (if (e.file()) |f| f.notreg else false) "Other" else "File"); } - row += 1; + row.* += 1; // Last modified if (e.ext()) |ext| { - box.move(row, 3); + box.move(row.*, 3); ui.style(.bold); ui.addstr("Last modified: "); ui.addts(.default, ext.mtime); - row += 1; + row.* += 1; } // Disk usage & Apparent size - drawSize(&box, &row, " Disk usage: ", blocksToSize(e.blocks), if (e.dir()) |d| blocksToSize(d.shared_blocks) else 0); - drawSize(&box, &row, "Apparent size: ", e.size, if (e.dir()) |d| d.shared_size else 0); + drawSize(box, row, " Disk usage: ", blocksToSize(e.blocks), if (e.dir()) |d| blocksToSize(d.shared_blocks) else 0); + drawSize(box, row, "Apparent size: ", e.size, if (e.dir()) |d| d.shared_size else 0); // Number of items if (e.dir()) |d| { - box.move(row, 3); + box.move(row.*, 3); ui.style(.bold); ui.addstr(" Sub items: "); ui.addnum(.default, d.items); - row += 1; + row.* += 1; } // Number of links + inode (dev?) if (e.link()) |l| { - box.move(row, 3); + box.move(row.*, 3); ui.style(.bold); ui.addstr(" Link count: "); ui.addnum(.default, l.nlink); - box.move(row, 23); + box.move(row.*, 23); ui.style(.bold); ui.addstr(" Inode: "); ui.style(.default); var buf: [32]u8 = undefined; ui.addstr(std.fmt.bufPrintZ(&buf, "{}", .{ l.ino }) catch unreachable); - row += 1; + row.* += 1; + } + } + + fn draw() void { + const e = dir_items.items[cursor_idx].?; + // XXX: The dynamic height is a bit jarring, especially when that + // causes the same lines of information to be placed on different rows + // for each item. Think it's better to have a dynamic height based on + // terminal size and scroll if the content doesn't fit. + const rows = 5 // border + padding + close message + + if (tab == .links) 8 else + 4 // name + type + disk usage + apparent size + + (if (e.ext() != null) @as(u32, 1) else 0) // last modified + + (if (e.link() != null) @as(u32, 1) else 0) // link count + + (if (e.dir()) |d| 1 // sub items + + (if (d.shared_size > 0) @as(u32, 2) else 0) + + (if (d.shared_blocks > 0) @as(u32, 2) else 0) + else 0); + const cols = 60; // TODO: dynamic width? + const box = ui.Box.create(rows, cols, "Item info"); + var row: u32 = 2; + + // Tabs + if (e.etype == .link) { + box.tab(cols-19, tab == .info, 1, "Info"); + box.tab(cols-10, tab == .links, 2, "Links"); + } + + switch (tab) { + .info => drawInfo(box, &row, cols, e), + .links => drawLinks(box, &row, rows, cols), } // "Press i to close this window" - box.move(row+1, cols-30); + box.move(rows-2, cols-30); ui.style(.default); ui.addstr("Press "); ui.style(.key); @@ -446,15 +517,42 @@ const info = struct { ui.addstr(" to close this window"); } - fn keyInput(ch: i32) void { - if (keyInputSelection(ch)) { - if (dir_items.items[cursor_idx] == null) state = .main; - return; + fn keyInput(ch: i32) bool { + if (entry.?.etype == .link) { + switch (ch) { + '1', 'h', ui.c.KEY_LEFT => { set(entry, .info); return true; }, + '2', 'l', ui.c.KEY_RIGHT => { set(entry, .links); return true; }, + else => {}, + } + } + if (tab == .links) { + if (keyInputSelection(ch, &links_idx, links.?.paths.items.len, 5)) + return true; + if (ch == 10) { // Enter - go to selected entry + // XXX: This jump can be a little bit jarring as, usually, + // browsing to parent directory will cause the previously + // opened dir to be selected. This jump doesn't update the View + // state of parent dirs, so that won't be the case anymore. + const p = links.?.paths.items[links_idx]; + dir_parents.stack.shrinkRetainingCapacity(0); + dir_parents.stack.appendSlice(p.path.stack.items) catch unreachable; + loadDir(); + for (dir_items.items) |e, i| { + if (e == &p.node.entry) + cursor_idx = i; + } + set(null, .info); + } + } + if (keyInputSelection(ch, &cursor_idx, dir_items.items.len, saturateSub(ui.rows, 3))) { + set(dir_items.items[cursor_idx], .info); + return true; } switch (ch) { - 'i', 'q' => state = .main, - else => {}, + 'i', 'q' => set(null, .info), + else => return false, } + return true; } }; @@ -484,7 +582,7 @@ pub fn draw() void { ui.style(.dir); var pathbuf = std.ArrayList(u8).init(main.allocator); - dir_parents.path(&pathbuf); + dir_parents.fmtPath(true, &pathbuf); ui.addstr(ui.shorten(ui.toUtf8(arrayListBufZ(&pathbuf)), saturateSub(ui.cols, 5))); pathbuf.deinit(); @@ -537,35 +635,35 @@ fn sortToggle(col: main.config.SortCol, default_order: main.config.SortOrder) vo sortDir(); } -fn keyInputSelection(ch: i32) bool { +fn keyInputSelection(ch: i32, idx: *usize, len: usize, page: u32) bool { switch (ch) { 'j', ui.c.KEY_DOWN => { - if (cursor_idx+1 < dir_items.items.len) cursor_idx += 1; + if (idx.*+1 < len) idx.* += 1; }, 'k', ui.c.KEY_UP => { - if (cursor_idx > 0) cursor_idx -= 1; + if (idx.* > 0) idx.* -= 1; }, - ui.c.KEY_HOME => cursor_idx = 0, - ui.c.KEY_END, ui.c.KEY_LL => cursor_idx = saturateSub(dir_items.items.len, 1), - ui.c.KEY_PPAGE => cursor_idx = saturateSub(cursor_idx, saturateSub(ui.rows, 3)), - ui.c.KEY_NPAGE => cursor_idx = std.math.min(saturateSub(dir_items.items.len, 1), cursor_idx + saturateSub(ui.rows, 3)), + ui.c.KEY_HOME => idx.* = 0, + ui.c.KEY_END, ui.c.KEY_LL => idx.* = saturateSub(len, 1), + ui.c.KEY_PPAGE => idx.* = saturateSub(idx.*, page), + ui.c.KEY_NPAGE => idx.* = std.math.min(saturateSub(len, 1), idx.* + page), else => return false, } return true; } pub fn keyInput(ch: i32) void { + defer current_view.save(); + switch (state) { .main => {}, // fallthrough .quit => return quit.keyInput(ch), - .info => return info.keyInput(ch), + .info => if (info.keyInput(ch)) return, } - defer current_view.save(); - switch (ch) { 'q' => if (main.config.confirm_quit) { state = .quit; } else ui.quit(), - 'i' => if (dir_items.items[cursor_idx] != null) { state = .info; }, + 'i' => info.set(dir_items.items[cursor_idx], .info), // Sort & filter settings 'n' => sortToggle(.name, .asc), @@ -575,6 +673,7 @@ pub fn keyInput(ch: i32) void { 'e' => { main.config.show_hidden = !main.config.show_hidden; loadDir(); + state = .main; }, 't' => { main.config.sort_dirsfirst = !main.config.sort_dirsfirst; @@ -599,16 +698,19 @@ pub fn keyInput(ch: i32) void { if (e.dir()) |d| { dir_parents.push(d); loadDir(); + state = .main; } - } else if (dir_parents.top() != model.root) { + } else if (!dir_parents.isRoot()) { dir_parents.pop(); loadDir(); + state = .main; } }, 'h', '<', ui.c.KEY_BACKSPACE, ui.c.KEY_LEFT => { - if (dir_parents.top() != model.root) { + if (!dir_parents.isRoot()) { dir_parents.pop(); loadDir(); + state = .main; } }, @@ -628,6 +730,6 @@ pub fn keyInput(ch: i32) void { .unique => .off, }, - else => _ = keyInputSelection(ch), + else => _ = keyInputSelection(ch, &cursor_idx, dir_items.items.len, saturateSub(ui.rows, 3)), } } diff --git a/src/model.zig b/src/model.zig index 26ca9849ee4a55a8d86aa11857d36260ebaf8d0a..47c2636208499073a20ec97c0655535bd0ae2559 100644 --- a/src/model.zig +++ b/src/model.zig @@ -392,6 +392,10 @@ pub const Parents = struct { return self.stack.append(dir) catch unreachable; } + pub fn isRoot(self: *Self) bool { + return self.stack.items.len == 0; + } + // Attempting to remove the root node is considered a bug. pub fn pop(self: *Self) void { _ = self.stack.pop(); @@ -419,23 +423,97 @@ pub const Parents = struct { } // Append the path to the given arraylist. The list is assumed to use main.allocator, so it can't fail. - pub fn path(self: *const Self, out: *std.ArrayList(u8)) void { + pub fn fmtPath(self: *const Self, withRoot: bool, out: *std.ArrayList(u8)) void { const r = root.entry.name(); - out.appendSlice(r) catch unreachable; + if (withRoot) out.appendSlice(r) catch unreachable; var i: usize = 0; while (i < self.stack.items.len) { - if (i != 0 or r[r.len-1] != '/') out.append('/') catch unreachable; + if (i != 0 or (withRoot and r[r.len-1] != '/')) out.append('/') catch unreachable; out.appendSlice(self.stack.items[i].entry.name()) catch unreachable; i += 1; } } + pub fn copy(self: *const Self) Self { + var c = Self{}; + c.stack.appendSlice(self.stack.items) catch unreachable; + return c; + } + pub fn deinit(self: *Self) void { self.stack.deinit(); } }; +// List of paths for the same inode. +pub const LinkPaths = struct { + paths: std.ArrayList(Path) = std.ArrayList(Path).init(main.allocator), + + pub const Path = struct { + path: Parents, + node: *Link, + + fn lt(_: void, a: Path, b: Path) bool { + var i: usize = 0; + while (i < a.path.stack.items.len and i < b.path.stack.items.len) : (i += 1) + if (a.path.stack.items[i] != b.path.stack.items[i]) + return std.mem.lessThan(u8, a.path.stack.items[i].entry.name(), b.path.stack.items[i].entry.name()); + if (a.path.stack.items.len != b.path.stack.items.len) + return a.path.stack.items.len < b.path.stack.items.len; + return std.mem.lessThan(u8, a.node.entry.name(), b.node.entry.name()); + } + + pub fn fmtPath(self: Path, withRoot: bool, out: *std.ArrayList(u8)) void { + self.path.fmtPath(withRoot, out); + out.append('/') catch unreachable; + out.appendSlice(self.node.entry.name()) catch unreachable; + } + }; + + const Self = @This(); + + fn findRec(self: *Self, parent: *Parents, node: *const Link) void { + var entry = parent.top().sub; + while (entry) |e| : (entry = e.next) { + if (e.link()) |l| { + if (l.ino == node.ino) + self.paths.append(Path{ .path = parent.copy(), .node = l }) catch unreachable; + } + if (e.dir()) |d| { + if (d.dev == parent.top().dev) { + parent.push(d); + self.findRec(parent, node); + parent.pop(); + } + } + } + } + + // Find all paths for the given link + pub fn find(parents_: *const Parents, node: *const Link) Self { + var parents = parents_.copy(); + var self = Self{}; + // First find the bottom-most parent that has no shared_size, + // all links are guaranteed to be inside that directory. + while (!parents.isRoot() and parents.top().shared_size > 0) + parents.pop(); + self.findRec(&parents, node); + // TODO: Zig's sort() implementation is type-generic and not very + // small. I suspect we can get a good save on our binary size by using + // a smaller or non-generic sort. This doesn't have to be very fast. + std.sort.sort(Path, self.paths.items, @as(void, undefined), Path.lt); + parents.deinit(); + return self; + } + + pub fn deinit(self: *Self) void { + for (self.paths.items) |*p| p.path.deinit(); + self.paths.deinit(); + } +}; + + test "entry" { var e = Entry.create(.file, false, "hello") catch unreachable; std.debug.assert(e.etype == .file); diff --git a/src/scan.zig b/src/scan.zig index 5a5377c71a5652325663c62fb1df951c1efa0a92..f653737a0ed6930bcc513f2beb01ee1f75e2d987 100644 --- a/src/scan.zig +++ b/src/scan.zig @@ -175,7 +175,7 @@ const Context = struct { self.path_indices.items.len -= 1; if (self.stat.dir) { - if (self.parents) |*p| if (p.top() != model.root) p.pop(); + if (self.parents) |*p| if (!p.isRoot()) p.pop(); if (self.wr) |w| w.writer().writeByte(']') catch |e| writeErr(e); } else self.stat.dir = true; // repeated popPath()s mean we're closing parent dirs. diff --git a/src/ui.zig b/src/ui.zig index 57a09abbe0f682af3f05e8f2c5d882ad95b106df..66cad66fd854e5c63e0a6454ef029b95d5b2cf8a 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -330,7 +330,6 @@ pub fn init() void { updateSize(); _ = c.cbreak(); _ = c.noecho(); - _ = c.nonl(); _ = c.curs_set(0); _ = c.keypad(c.stdscr, true); @@ -530,6 +529,17 @@ pub const Box = struct { return s; } + pub fn tab(s: Self, col: u32, sel: bool, num: u3, label: [:0]const u8) void { + const bg: Bg = if (sel) .hd else .default; + s.move(0, col); + bg.fg(.key); + addch('0' + @as(u8, num)); + bg.fg(.default); + addch(':'); + addstr(label); + style(.default); + } + // Move the global cursor to the given coordinates inside the box. pub fn move(s: Self, row: u32, col: u32) void { ui.move(s.start_row + row, s.start_col + col);