diff --git a/src/browser.zig b/src/browser.zig index 9024f75cc0ace42c9a0f26d259cd255759657b2f..2fffae289001b2befc718c265b202a624d6923f2 100644 --- a/src/browser.zig +++ b/src/browser.zig @@ -2,6 +2,154 @@ const std = @import("std"); const main = @import("main.zig"); const model = @import("model.zig"); const ui = @import("ui.zig"); +usingnamespace @import("util.zig"); + +// Sorted list of all items in the currently opened directory. +// (first item may be null to indicate the "parent directory" item) +var dir_items = std.ArrayList(?*model.Entry).init(main.allocator); + +// Currently opened directory and its parents. +var dir_parents = model.Parents{}; + +fn sortIntLt(a: anytype, b: @TypeOf(a)) ?bool { + return if (a == b) null else if (main.config.sort_order == .asc) a < b else a > b; +} + +fn sortLt(_: void, ap: ?*model.Entry, bp: ?*model.Entry) bool { + const a = ap.?; + const b = bp.?; + + if (main.config.sort_dirsfirst and (a.etype == .dir) != (b.etype == .dir)) + return a.etype == .dir; + + switch (main.config.sort_col) { + .name => {}, // name sorting is the fallback + .blocks => { + if (sortIntLt(a.blocks, b.blocks)) |r| return r; + if (sortIntLt(a.size, b.size)) |r| return r; + }, + .size => { + if (sortIntLt(a.size, b.size)) |r| return r; + if (sortIntLt(a.blocks, b.blocks)) |r| return r; + }, + .items => { + const ai = if (a.dir()) |d| d.total_items else 0; + const bi = if (b.dir()) |d| d.total_items else 0; + if (sortIntLt(ai, bi)) |r| return r; + if (sortIntLt(a.blocks, b.blocks)) |r| return r; + if (sortIntLt(a.size, b.size)) |r| return r; + }, + .mtime => { + if (!a.isext or !b.isext) return a.isext; + if (sortIntLt(a.ext().?.mtime, b.ext().?.mtime)) |r| return r; + }, + } + + // TODO: Unicode-aware sorting might be nice (and slow) + const an = a.name(); + const bn = b.name(); + return if (main.config.sort_order == .asc) std.mem.lessThan(u8, an, bn) + else std.mem.lessThan(u8, bn, an) or std.mem.eql(u8, an, bn); +} + +// Should be called when: +// - config.sort_* changes +// - dir_items changes (i.e. from loadDir()) +// - files in this dir have changed in a way that affects their ordering +fn sortDir() void { + // No need to sort the first item if that's the parent dir reference, + // excluding that allows sortLt() to ignore null values. + const lst = dir_items.items[(if (dir_items.items.len > 0 and dir_items.items[0] == null) @as(usize, 1) else 0)..]; + std.sort.sort(?*model.Entry, lst, @as(void, undefined), sortLt); + // TODO: Fixup selected item index +} + +// Must be called when: +// - dir_parents changes (i.e. we change directory) +// - config.show_hidden changes +// - files in this dir have been added or removed +fn loadDir() !void { + dir_items.shrinkRetainingCapacity(0); + if (dir_parents.top() != model.root) + try dir_items.append(null); + var it = dir_parents.top().sub; + while (it) |e| { + if (main.config.show_hidden) // fast path + try dir_items.append(e) + else { + const excl = if (e.file()) |f| f.excluded else false; + const name = e.name(); + if (!excl and name[0] != '.' and name[name.len-1] != '~') + try dir_items.append(e); + } + it = e.next; + } + sortDir(); +} + +// Open the given dir for browsing; takes ownership of the Parents struct. +pub fn open(dir: model.Parents) !void { + dir_parents.deinit(); + dir_parents = dir; + try loadDir(); + + // TODO: Load view & cursor position if we've opened this dir before. +} + +const Row = struct { + row: u32, + col: u32 = 0, + bg: ui.Bg = .default, + item: ?*model.Entry, + + const Self = @This(); + + fn flag(self: *Self) !void { + defer self.col += 2; + const item = self.item orelse return; + const ch: u7 = ch: { + if (item.file()) |f| { + if (f.err) break :ch '!'; + if (f.excluded) break :ch '<'; + if (f.other_fs) break :ch '>'; + if (f.kernfs) break :ch '^'; + if (f.notreg) break :ch '@'; + } else if (item.dir()) |d| { + if (d.err) break :ch '!'; + if (d.suberr) break :ch '.'; + if (d.sub == null) break :ch 'e'; + } else if (item.link()) |_| break :ch 'H'; + return; + }; + ui.move(self.row, self.col); + self.bg.fg(.flag); + ui.addch(ch); + } + + fn size(self: *Self) !void { + defer self.col += if (main.config.si) @as(u32, 9) else 10; + const item = self.item orelse return; + ui.move(self.row, self.col); + ui.addsize(self.bg, if (main.config.show_blocks) blocksToSize(item.blocks) else item.size); + // TODO: shared sizes + } + + fn name(self: *Self) !void { + ui.move(self.row, self.col); + self.bg.fg(.default); + if (self.item) |i| { + ui.addch(if (i.etype == .dir) '/' else ' '); + ui.addstr(try ui.shorten(try ui.toUtf8(i.name()), saturateSub(ui.cols, saturateSub(self.col, 1)))); + } else + ui.addstr("/.."); + } + + fn draw(self: *Self) !void { + try self.flag(); + try self.size(); + try self.name(); + } +}; pub fn draw() !void { ui.style(.hd); @@ -13,19 +161,35 @@ pub fn draw() !void { ui.addch('?'); ui.style(.hd); ui.addstr(" for help"); - // TODO: [imported]/[readonly] indicators + if (main.config.read_only) { + ui.move(0, saturateSub(ui.cols, 10)); + ui.addstr("[readonly]"); + } + // TODO: [imported] indicator ui.style(.default); ui.move(1,0); ui.hline('-', ui.cols); ui.move(1,3); ui.addch(' '); - ui.addstr(try ui.shorten(try ui.toUtf8(model.root.entry.name()), std.math.sub(u32, ui.cols, 5) catch 4)); + ui.addstr(try ui.shorten(try ui.toUtf8(model.root.entry.name()), saturateSub(ui.cols, 5))); ui.addch(' '); + var i: u32 = 0; + while (i < saturateSub(ui.rows, 3)) : (i += 1) { + if (i >= dir_items.items.len) break; + var row = Row{ .row = i+2, .item = dir_items.items[i] }; + try row.draw(); + } + ui.style(.hd); ui.move(ui.rows-1, 0); ui.hline(' ', ui.cols); ui.move(ui.rows-1, 1); - ui.addstr("No items to display."); + ui.addstr("Total disk usage: "); + ui.addsize(.hd, blocksToSize(dir_parents.top().entry.blocks)); + ui.addstr(" Apparent size: "); + ui.addsize(.hd, dir_parents.top().entry.size); + ui.addstr(" Items: "); + ui.addnum(.hd, dir_parents.top().total_items); } diff --git a/src/main.zig b/src/main.zig index cb6043cd78267712b75c7f0eea3a5856b53dd8df..04d299121d8fdde3bc8509e9ff60be759a06a17a 100644 --- a/src/main.zig +++ b/src/main.zig @@ -23,6 +23,12 @@ pub const Config = struct { ui_color: enum { off, dark } = .off, thousands_sep: []const u8 = ".", + show_hidden: bool = true, + show_blocks: bool = true, + sort_col: enum { name, blocks, size, items, mtime } = .blocks, + sort_order: enum { asc, desc } = .desc, + sort_dirsfirst: bool = false, + read_only: bool = false, can_shell: bool = true, confirm_quit: bool = false, @@ -239,9 +245,11 @@ pub fn main() anyerror!void { ui.die("The --exclude-kernfs tag is currently only supported on Linux.\n", .{}); try scan.scanRoot(scan_dir orelse "."); + try browser.open(model.Parents{}); ui.init(); defer ui.deinit(); + try browser.draw(); _ = ui.c.getch(); diff --git a/src/model.zig b/src/model.zig index 34e3aa975e04ba62080b7b6008da1c16ebd3c0a0..48340dd4b86c13083a39519da3ab93a6187c8833 100644 --- a/src/model.zig +++ b/src/model.zig @@ -1,24 +1,15 @@ const std = @import("std"); const main = @import("main.zig"); +usingnamespace @import("util.zig"); // While an arena allocator is optimimal for almost all scenarios in which ncdu // is used, it doesn't allow for re-using deleted nodes after doing a delete or // refresh operation, so a long-running ncdu session with regular refreshes // will leak memory, but I'd say that's worth the efficiency gains. -// (TODO: Measure, though. Might as well use a general purpose allocator if the -// memory overhead turns out to be insignificant.) +// TODO: Can still implement a simple bucketed free list on top of this arena +// allocator to reuse nodes, if necessary. var allocator = std.heap.ArenaAllocator.init(std.heap.page_allocator); -fn saturateAdd(a: anytype, b: @TypeOf(a)) @TypeOf(a) { - std.debug.assert(@typeInfo(@TypeOf(a)).Int.signedness == .unsigned); - return std.math.add(@TypeOf(a), a, b) catch std.math.maxInt(@TypeOf(a)); -} - -fn saturateSub(a: anytype, b: @TypeOf(a)) @TypeOf(a) { - std.debug.assert(@typeInfo(@TypeOf(a)).Int.signedness == .unsigned); - return std.math.sub(@TypeOf(a), a, b) catch std.math.minInt(@TypeOf(a)); -} - pub const EType = packed enum(u2) { dir, link, file }; // Memory layout: @@ -57,7 +48,7 @@ pub const Entry = packed struct { return if (self.etype == .file) @ptrCast(*File, self) else null; } - fn name_offset(etype: EType) usize { + fn nameOffset(etype: EType) usize { return switch (etype) { .dir => @byteOffsetOf(Dir, "name"), .link => @byteOffsetOf(Link, "name"), @@ -66,25 +57,25 @@ pub const Entry = packed struct { } pub fn name(self: *const Self) [:0]const u8 { - const ptr = @intToPtr([*:0]u8, @ptrToInt(self) + name_offset(self.etype)); + const ptr = @intToPtr([*:0]u8, @ptrToInt(self) + nameOffset(self.etype)); return ptr[0..std.mem.lenZ(ptr) :0]; } pub fn ext(self: *Self) ?*Ext { if (!self.isext) return null; const n = self.name(); - return @intToPtr(*Ext, std.mem.alignForward(@ptrToInt(self) + name_offset(self.etype) + n.len + 1, @alignOf(Ext))); + return @intToPtr(*Ext, std.mem.alignForward(@ptrToInt(self) + nameOffset(self.etype) + n.len + 1, @alignOf(Ext))); } pub fn create(etype: EType, isext: bool, ename: []const u8) !*Entry { - const base_size = name_offset(etype) + ename.len + 1; + const base_size = nameOffset(etype) + ename.len + 1; const size = (if (isext) std.mem.alignForward(base_size, @alignOf(Ext))+@sizeOf(Ext) else base_size); var ptr = try allocator.allocator.allocWithOptions(u8, size, @alignOf(Entry), null); std.mem.set(u8, ptr, 0); // kind of ugly, but does the trick var e = @ptrCast(*Entry, ptr); e.etype = etype; e.isext = isext; - var name_ptr = @intToPtr([*]u8, @ptrToInt(e) + name_offset(etype)); + var name_ptr = @intToPtr([*]u8, @ptrToInt(e) + nameOffset(etype)); std.mem.copy(u8, name_ptr[0..ename.len], ename); //std.debug.warn("{any}\n", .{ @ptrCast([*]u8, e)[0..size] }); return e; @@ -145,8 +136,8 @@ pub const Entry = packed struct { add_total = true; } if(add_total) { - p.total_size = saturateAdd(p.total_size, self.size); - p.total_blocks = saturateAdd(p.total_blocks, self.blocks); + p.entry.size = saturateAdd(p.entry.size, self.size); + p.entry.blocks = saturateAdd(p.entry.blocks, self.blocks); p.total_items = saturateAdd(p.total_items, 1); } } @@ -160,17 +151,15 @@ pub const Dir = packed struct { sub: ?*Entry, - // total_*: Total size of all unique files + dirs. Non-shared hardlinks are counted only once. + // entry.{blocks,size}: Total size of all unique files + dirs. Non-shared hardlinks are counted only once. // (i.e. the space you'll need if you created a filesystem with only this dir) // shared_*: Unique hardlinks that still have references outside of this directory. // (i.e. the space you won't reclaim by deleting this dir) - // (space reclaimed by deleting a dir =~ total_ - shared_) - total_blocks: u64, + // (space reclaimed by deleting a dir =~ entry. - shared_) shared_blocks: u64, - total_size: u64, shared_size: u64, - total_items: u32, shared_items: u32, + total_items: u32, // TODO: ncdu1 only keeps track of a total item count including duplicate hardlinks. // That number seems useful, too. Include it somehow? @@ -355,6 +344,10 @@ pub const Parents = struct { i += 1; } } + + pub fn deinit(self: *Self) void { + self.stack.deinit(); + } }; test "name offsets" { diff --git a/src/scan.zig b/src/scan.zig index ee673d8a05450cab737b670e12428ff2ee504509..d640150b6dd4f0b03357f434a43444de7ba565f3 100644 --- a/src/scan.zig +++ b/src/scan.zig @@ -217,13 +217,7 @@ fn scanDir(ctx: *Context, dir: std.fs.Dir) std.mem.Allocator.Error!void { var e = try model.Entry.create(etype, main.config.extended, entry.name); e.blocks = stat.blocks; e.size = stat.size; - if (e.dir()) |d| { - d.dev = try model.getDevId(stat.dev); - // The dir entry itself also counts. - d.total_blocks = stat.blocks; - d.total_size = stat.size; - d.total_items = 1; - } + if (e.dir()) |d| d.dev = try model.getDevId(stat.dev); if (e.file()) |f| f.notreg = !stat.dir and !stat.reg; if (e.link()) |l| { l.ino = stat.ino; diff --git a/src/ui.zig b/src/ui.zig index 14608bd880d778191115946c4a83a811a2ca8f35..a685624b082f8ab94e3ae355ce7d57df41283ae3 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -209,7 +209,7 @@ const styles = [_]StyleDef{ .dark = .{ .fg = c.COLOR_MAGENTA, .bg = c.COLOR_GREEN, .attr = 0 } }, }; -const Style = lbl: { +pub const Style = lbl: { var fields: [styles.len]std.builtin.TypeInfo.EnumField = undefined; var decls = [_]std.builtin.TypeInfo.Declaration{}; inline for (styles) |s, i| { @@ -229,6 +229,35 @@ const Style = lbl: { }); }; +const ui = @This(); + +pub const Bg = enum { + default, hd, sel, + + // Set the style to the selected bg combined with the given fg. + pub fn fg(self: @This(), s: Style) void { + ui.style(switch (self) { + .default => s, + .hd => + switch (s) { + .default => Style.hd, + .key => Style.key_hd, + .num => Style.num_hd, + else => unreachable, + }, + .sel => + switch (s) { + .default => Style.sel, + .num => Style.num_sel, + .dir => Style.dir_sel, + .flag => Style.flag_sel, + .graph => Style.graph_sel, + else => unreachable, + } + }); + } +}; + fn updateSize() void { // getmax[yx] macros are marked as "legacy", but Zig can't deal with the "proper" getmaxyx macro. rows = @intCast(u32, c.getmaxy(c.stdscr)); @@ -287,6 +316,63 @@ pub fn addch(ch: c.chtype) void { _ = c.addch(ch); } +// Print a human-readable size string, formatted into the given bavkground. +// Takes 8 columns in SI mode, 9 otherwise. +// "###.# XB" +// "###.# XiB" +pub fn addsize(bg: Bg, v: u64) void { + var f = @intToFloat(f32, v); + var unit: [:0]const u8 = undefined; + if (main.config.si) { + if(f < 1000.0) { unit = " B"; } + else if(f < 1e6) { unit = " KB"; f /= 1e3; } + else if(f < 1e9) { unit = " MB"; f /= 1e6; } + else if(f < 1e12) { unit = " GB"; f /= 1e9; } + else if(f < 1e15) { unit = " TB"; f /= 1e12; } + else if(f < 1e18) { unit = " PB"; f /= 1e15; } + else { unit = " EB"; f /= 1e18; } + } + else { + if(f < 1000.0) { unit = " B"; } + else if(f < 1023e3) { unit = " KiB"; f /= 1024.0; } + else if(f < 1023e6) { unit = " MiB"; f /= 1048576.0; } + else if(f < 1023e9) { unit = " GiB"; f /= 1073741824.0; } + else if(f < 1023e12) { unit = " TiB"; f /= 1099511627776.0; } + else if(f < 1023e15) { unit = " PiB"; f /= 1125899906842624.0; } + else { unit = " EiB"; f /= 1152921504606846976.0; } + } + var buf: [8:0]u8 = undefined; + _ = std.fmt.bufPrintZ(&buf, "{d:>5.1}", .{f}) catch unreachable; + bg.fg(.num); + addstr(&buf); + bg.fg(.default); + addstr(unit); +} + +// Print a full decimal number with thousand separators. +// Max: 18,446,744,073,709,551,615 -> 26 columns +// (Assuming thousands_sep takes a single column) +pub fn addnum(bg: Bg, v: u64) void { + var buf: [32]u8 = undefined; + const s = std.fmt.bufPrint(&buf, "{d}", .{v}) catch unreachable; + var f: [64:0]u8 = undefined; + var i: usize = 0; + for (s) |digit, n| { + if (n != 0 and (s.len - n) % 3 == 0) { + for (main.config.thousands_sep) |ch| { + f[i] = ch; + i += 1; + } + } + f[i] = digit; + i += 1; + } + f[i] = 0; + bg.fg(.num); + addstr(&f); + bg.fg(.default); +} + pub fn hline(ch: c.chtype, len: u32) void { _ = c.hline(ch, @intCast(i32, len)); } diff --git a/src/util.zig b/src/util.zig new file mode 100644 index 0000000000000000000000000000000000000000..3b6986ad8666b362fbe7525c84af0484c7a619d3 --- /dev/null +++ b/src/util.zig @@ -0,0 +1,16 @@ +const std = @import("std"); + +pub fn saturateAdd(a: anytype, b: @TypeOf(a)) @TypeOf(a) { + std.debug.assert(@typeInfo(@TypeOf(a)).Int.signedness == .unsigned); + return std.math.add(@TypeOf(a), a, b) catch std.math.maxInt(@TypeOf(a)); +} + +pub fn saturateSub(a: anytype, b: @TypeOf(a)) @TypeOf(a) { + std.debug.assert(@typeInfo(@TypeOf(a)).Int.signedness == .unsigned); + return std.math.sub(@TypeOf(a), a, b) catch std.math.minInt(@TypeOf(a)); +} + +// Multiplies by 512, saturating. +pub fn blocksToSize(b: u64) u64 { + return if (b & 0xFF80000000000000 > 0) std.math.maxInt(u64) else b << 9; +}