diff --git a/README.md b/README.md index 5d1989b49599058a29a7373d7d2746b377ca4509..1407b0c6854efb7dfbaf81d6b7ba49075c3cef13 100644 --- a/README.md +++ b/README.md @@ -30,11 +30,11 @@ Missing features: - Export/import - Most directory listing settings -- Scaning UI - Lots of informational UI windows - Directory refresh - File deletion - Opening a shell +- OOM handling ### Improvements compared to the C version @@ -54,6 +54,7 @@ Already implemented: (Implemented in the data model, but not displayed in the UI yet) - Faster --exclude-kernfs thanks to `statfs()` caching. - Improved handling of Unicode and special characters. +- Remembers item position when switching directories. Potentially to be implemented: diff --git a/src/browser.zig b/src/browser.zig index 571018db1ae89c2fad388709e48ab54752e5b80d..697187f245c152a5e84f68337d0d49a4fbd340d6 100644 --- a/src/browser.zig +++ b/src/browser.zig @@ -257,7 +257,7 @@ pub fn key(ch: i32) !void { defer current_view.save(); switch (ch) { - 'q' => main.state = .quit, + 'q' => ui.quit(), // TODO: Confirm quit // Selection 'j', ui.c.KEY_DOWN => { diff --git a/src/main.zig b/src/main.zig index f409fc5ed9f2babf8dc5b1ae81b58c72dead3062..44f06f40ce2a5507eef3e114a1144fa2293aec83 100644 --- a/src/main.zig +++ b/src/main.zig @@ -21,6 +21,7 @@ pub const Config = struct { exclude_patterns: std.ArrayList([:0]const u8) = std.ArrayList([:0]const u8).init(allocator), update_delay: u64 = 100*std.time.ns_per_ms, + scan_ui: enum { none, line, full } = .full, si: bool = false, nc_tty: bool = false, ui_color: enum { off, dark } = .off, @@ -39,7 +40,7 @@ pub const Config = struct { pub var config = Config{}; -pub var state: enum { browse, quit } = .browse; +pub var state: enum { scan, browse } = .browse; // Simple generic argument parser, supports getopt_long() style arguments. // T can be any type that has a 'fn next(T) ?[:0]const u8' method, e.g.: @@ -173,6 +174,9 @@ pub fn main() anyerror!void { var args = Args(std.process.ArgIteratorPosix).init(std.process.ArgIteratorPosix.init()); var scan_dir: ?[]const u8 = null; + var import_file: ?[]const u8 = null; + var export_file: ?[]const u8 = null; + var has_scan_ui = false; _ = args.next(); // program name while (args.next()) |opt| { if (!opt.opt) { @@ -188,6 +192,11 @@ pub fn main() anyerror!void { else if(opt.is("-e")) config.extended = true else if(opt.is("-r") and config.read_only) config.can_shell = false else if(opt.is("-r")) config.read_only = true + else if(opt.is("-0")) { has_scan_ui = true; config.scan_ui = .none; } + else if(opt.is("-1")) { has_scan_ui = true; config.scan_ui = .line; } + else if(opt.is("-2")) { has_scan_ui = true; config.scan_ui = .full; } + else if(opt.is("-o")) export_file = args.arg() + else if(opt.is("-f")) import_file = args.arg() else if(opt.is("--si")) config.si = true else if(opt.is("-L") or opt.is("--follow-symlinks")) config.follow_symlinks = true else if(opt.is("--exclude")) try config.exclude_patterns.append(args.arg()) @@ -203,23 +212,34 @@ pub fn main() anyerror!void { else if (std.mem.eql(u8, val, "dark")) config.ui_color = .dark else ui.die("Unknown --color option: {s}.\n", .{val}); } else ui.die("Unrecognized option '{s}'.\n", .{opt.val}); - // TODO: -o, -f, -0, -1, -2 } if (std.builtin.os.tag != .linux and config.exclude_kernfs) ui.die("The --exclude-kernfs tag is currently only supported on Linux.\n", .{}); + const is_out_tty = std.io.getStdOut().isTty(); + if (!has_scan_ui) { + if (export_file) |f| { + if (!is_out_tty or std.mem.eql(u8, f, "-")) config.scan_ui = .none + else config.scan_ui = .line; + } + } + if (!is_out_tty and (export_file == null or config.scan_ui != .none)) + ui.die("Standard output is not a TTY, can't initialize ncurses UI.\n", .{}); + event_delay_timer = try std.time.Timer.start(); + defer ui.deinit(); + state = .scan; try scan.scanRoot(scan_dir orelse "."); - try browser.loadDir(); + config.scan_ui = .full; // in case we're refreshing from the UI, always in full mode. ui.init(); - defer ui.deinit(); + state = .browse; + try browser.loadDir(); // TODO: Handle OOM errors - // TODO: Confirm quit - while (state != .quit) try handleEvent(true, false); + while (true) try handleEvent(true, false); } var event_delay_timer: std.time.Timer = undefined; @@ -228,16 +248,26 @@ var event_delay_timer: std.time.Timer = undefined; // In non-blocking mode, screen drawing is rate-limited to keep this function fast. pub fn handleEvent(block: bool, force_draw: bool) !void { if (block or force_draw or event_delay_timer.read() > config.update_delay) { - _ = ui.c.erase(); - try browser.draw(); - _ = ui.c.refresh(); + if (ui.inited) _ = ui.c.erase(); + switch (state) { + .scan => try scan.draw(), + .browse => try browser.draw(), + } + if (ui.inited) _ = ui.c.refresh(); event_delay_timer.reset(); } + if (!ui.inited) { + std.debug.assert(!block); + return; + } var ch = ui.getch(block); if (ch == 0) return; if (ch == -1) return handleEvent(block, true); - try browser.key(ch); + switch (state) { + .scan => try scan.key(ch), + .browse => try browser.key(ch), + } } diff --git a/src/scan.zig b/src/scan.zig index d640150b6dd4f0b03357f434a43444de7ba565f3..04af0372b48d8a9d0ab12ab01a7064d882703b8c 100644 --- a/src/scan.zig +++ b/src/scan.zig @@ -1,6 +1,8 @@ const std = @import("std"); const main = @import("main.zig"); const model = @import("model.zig"); +const ui = @import("ui.zig"); +usingnamespace @import("util.zig"); const c_statfs = @cImport(@cInclude("sys/vfs.h")); const c_fnmatch = @cImport(@cInclude("fnmatch.h")); @@ -98,11 +100,14 @@ const Context = struct { parents: model.Parents = .{}, path: std.ArrayList(u8) = std.ArrayList(u8).init(main.allocator), path_indices: std.ArrayList(usize) = std.ArrayList(usize).init(main.allocator), + items_seen: u32 = 1, // 0-terminated name of the top entry, points into 'path', invalid after popPath(). // This is a workaround to Zig's directory iterator not returning a [:0]const u8. name: [:0]const u8 = undefined, + last_error: ?[:0]u8 = null, + const Self = @This(); fn pushPath(self: *Self, name: []const u8) !void { @@ -120,8 +125,27 @@ const Context = struct { self.path.items.len = self.path_indices.items[self.path_indices.items.len-1]; self.path_indices.items.len -= 1; } + + fn pathZ(self: *Self) [:0]const u8 { + self.path.append(0) catch unreachable; + defer self.path.items.len -= 1; + return self.path.items[0..self.path.items.len-1:0]; + } + + // Insert the current path as an error entry + fn setError(self: *Self) !void { + var e = try model.Entry.create(.file, false, self.name); + e.insert(&self.parents) catch unreachable; + e.set_err(&self.parents); + + if (self.last_error) |p| main.allocator.free(p); + self.last_error = try main.allocator.dupeZ(u8, self.path.items); + } }; +// Context that is currently being used for scanning. +var active_context: ?*Context = null; + // Read and index entries of the given dir. The entry for the directory is already assumed to be in 'ctx.parents'. // (TODO: shouldn't error on OOM but instead call a function that waits or something) fn scanDir(ctx: *Context, dir: std.fs.Dir) std.mem.Allocator.Error!void { @@ -131,16 +155,16 @@ fn scanDir(ctx: *Context, dir: std.fs.Dir) std.mem.Allocator.Error!void { ctx.parents.top().entry.set_err(&ctx.parents); return; } orelse break; + ctx.items_seen += 1; try ctx.pushPath(entry.name); + try main.handleEvent(false, false); defer ctx.popPath(); // XXX: This algorithm is extremely slow, can be optimized with some clever pattern parsing. const excluded = blk: { for (main.config.exclude_patterns.items) |pat| { - ctx.path.append(0) catch unreachable; - var path = ctx.path.items[0..ctx.path.items.len-1:0]; - ctx.path.items.len -= 1; + var path = ctx.pathZ(); while (path.len > 0) { if (c_fnmatch.fnmatch(pat, path, 0) == 0) break :blk true; if (std.mem.indexOfScalar(u8, path, '/')) |idx| path = path[idx+1..:0] @@ -157,16 +181,12 @@ fn scanDir(ctx: *Context, dir: std.fs.Dir) std.mem.Allocator.Error!void { } var stat = Stat.read(dir, ctx.name, false) catch { - var e = try model.Entry.create(.file, false, entry.name); - e.insert(&ctx.parents) catch unreachable; - e.set_err(&ctx.parents); + try ctx.setError(); continue; }; if (main.config.same_fs and stat.dev != model.getDev(ctx.parents.top().dev)) { - var e = try model.Entry.create(.file, false, entry.name); - e.file().?.other_fs = true; - e.insert(&ctx.parents) catch unreachable; + try ctx.setError(); continue; } @@ -184,9 +204,7 @@ fn scanDir(ctx: *Context, dir: std.fs.Dir) std.mem.Allocator.Error!void { var edir = if (stat.dir) dir.openDirZ(ctx.name, .{ .access_sub_paths = true, .iterate = true, .no_follow = true }) catch { - var e = try model.Entry.create(.file, false, entry.name); - e.insert(&ctx.parents) catch unreachable; - e.set_err(&ctx.parents); + try ctx.setError(); continue; } else null; defer if (edir != null) edir.?.close(); @@ -248,5 +266,94 @@ pub fn scanRoot(path: []const u8) !void { var ctx = Context{}; try ctx.pushPath(full_path); const dir = try std.fs.cwd().openDirZ(model.root.entry.name(), .{ .access_sub_paths = true, .iterate = true }); + + active_context = &ctx; + defer active_context = null; try scanDir(&ctx, dir); } + +var animation_pos: u32 = 0; + +fn drawBox() !void { + ui.init(); + const ctx = active_context.?; + const width = saturateSub(ui.cols, 5); + const box = ui.Box.create(10, width, "Scanning..."); + box.move(2, 2); + ui.addstr("Total items: "); + ui.addnum(.default, ctx.items_seen); + + if (width > 48 and true) { // TODO: When not exporting to file + box.move(2, 30); + ui.addstr("size: "); + ui.addsize(.default, blocksToSize(model.root.entry.blocks)); + } + + box.move(3, 2); + ui.addstr("Current item: "); + ui.addstr(try ui.shorten(try ui.toUtf8(ctx.pathZ()), saturateSub(width, 18))); + + if (ctx.last_error) |path| { + box.move(5, 2); + ui.style(.bold); + ui.addstr("Warning: "); + ui.style(.default); + ui.addstr("error scanning "); + ui.addstr(try ui.shorten(try ui.toUtf8(path), saturateSub(width, 28))); + box.move(6, 3); + ui.addstr("some directory sizes may not be correct."); + } + + box.move(8, saturateSub(width, 18)); + ui.addstr("Press "); + ui.style(.key); + ui.addch('q'); + ui.style(.default); + ui.addstr(" to abort"); + + if (main.config.update_delay < std.time.ns_per_s and width > 40) { + const txt = "Scanning..."; + animation_pos += 1; + if (animation_pos >= txt.len*2) animation_pos = 0; + if (animation_pos < txt.len) { + var i: u32 = 0; + box.move(8, 2); + while (i <= animation_pos) : (i += 1) ui.addch(txt[i]); + } else { + var i: u32 = txt.len-1; + while (i > animation_pos-txt.len) : (i -= 1) { + box.move(8, 2+i); + ui.addch(txt[i]); + } + } + } +} + +pub fn draw() !void { + switch (main.config.scan_ui) { + .none => {}, + .line => { + var buf: [256]u8 = undefined; + var line: []const u8 = undefined; + if (false) { // TODO: When exporting to file; no total size known + line = std.fmt.bufPrint(&buf, "\x1b7\x1b[J{s: <63} {d:>9} files\x1b8", + .{ ui.shorten(active_context.?.pathZ(), 63), active_context.?.items_seen } + ) catch return; + } else { + const r = ui.FmtSize.fmt(blocksToSize(model.root.entry.blocks)); + line = std.fmt.bufPrint(&buf, "\x1b7\x1b[J{s: <51} {d:>9} files / {s}{s}\x1b8", + .{ ui.shorten(active_context.?.pathZ(), 51), active_context.?.items_seen, r.num(), r.unit } + ) catch return; + } + _ = std.io.getStdErr().write(line) catch {}; + }, + .full => try drawBox(), + } +} + +pub fn key(ch: i32) !void { + switch (ch) { + 'q' => ui.quit(), // TODO: Confirm quit + else => {}, + } +} diff --git a/src/ui.zig b/src/ui.zig index 02f7e6a0c5c68caf13e6cc97b371836e975ecd4f..30b4657ed1687464b37811ee124f8b2517713153 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -2,6 +2,7 @@ const std = @import("std"); const main = @import("main.zig"); +usingnamespace @import("util.zig"); pub const c = @cImport({ @cInclude("stdio.h"); @@ -12,7 +13,7 @@ pub const c = @cImport({ @cInclude("locale.h"); }); -var inited: bool = false; +pub var inited: bool = false; pub var rows: u32 = undefined; pub var cols: u32 = undefined; @@ -23,6 +24,11 @@ pub fn die(comptime fmt: []const u8, args: anytype) noreturn { std.process.exit(1); } +pub fn quit() noreturn { + deinit(); + std.process.exit(0); +} + var to_utf8_buf = std.ArrayList(u8).init(main.allocator); fn toUtf8BadChar(ch: u8) bool { @@ -141,13 +147,6 @@ extern fn ncdu_acs_lrcorner() c.chtype; extern fn ncdu_acs_hline() c.chtype; extern fn ncdu_acs_vline() c.chtype; -pub fn acs_ulcorner() c.chtype { return ncdu_acs_ulcorner(); } -pub fn acs_llcorner() c.chtype { return ncdu_acs_llcorner(); } -pub fn acs_urcorner() c.chtype { return ncdu_acs_urcorner(); } -pub fn acs_lrcorner() c.chtype { return ncdu_acs_lrcorner(); } -pub fn acs_hline() c.chtype { return ncdu_acs_hline() ; } -pub fn acs_vline() c.chtype { return ncdu_acs_vline() ; } - const StyleAttr = struct { fg: i16, bg: i16, attr: u32 }; const StyleDef = struct { name: []const u8, @@ -165,6 +164,9 @@ const styles = [_]StyleDef{ .{ .name = "default", .off = .{ .fg = -1, .bg = -1, .attr = 0 }, .dark = .{ .fg = -1, .bg = -1, .attr = 0 } }, + .{ .name = "bold", + .off = .{ .fg = -1, .bg = -1, .attr = c.A_BOLD }, + .dark = .{ .fg = -1, .bg = -1, .attr = c.A_BOLD } }, .{ .name = "box_title", .off = .{ .fg = -1, .bg = -1, .attr = c.A_BOLD }, .dark = .{ .fg = c.COLOR_BLUE, .bg = -1, .attr = c.A_BOLD } }, @@ -266,6 +268,9 @@ fn updateSize() void { pub fn init() void { if (inited) return; + // Send a "clear from cursor to end of screen" instruction, to clear a + // potential line left behind from scanning in -1 mode. + _ = std.io.getStdErr().write("\x1b[J") catch {}; if (main.config.nc_tty) { var tty = c.fopen("/dev/tty", "r+"); if (tty == null) die("Error opening /dev/tty: {s}.\n", .{ c.strerror(std.c.getErrno(-1)) }); @@ -317,37 +322,51 @@ 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; } +// Format an integer to a human-readable size string. +// num() = "###.#" +// unit = " XB" or " XiB" +// Concatenated, these take 8 columns in SI mode or 9 otherwise. +pub const FmtSize = struct { + buf: [8:0]u8, + unit: [:0]const u8, + + pub fn fmt(v: u64) @This() { + var r: @This() = undefined; + var f = @intToFloat(f32, v); + if (main.config.si) { + if(f < 1000.0) { r.unit = " B"; } + else if(f < 1e6) { r.unit = " KB"; f /= 1e3; } + else if(f < 1e9) { r.unit = " MB"; f /= 1e6; } + else if(f < 1e12) { r.unit = " GB"; f /= 1e9; } + else if(f < 1e15) { r.unit = " TB"; f /= 1e12; } + else if(f < 1e18) { r.unit = " PB"; f /= 1e15; } + else { r.unit = " EB"; f /= 1e18; } + } + else { + if(f < 1000.0) { r.unit = " B"; } + else if(f < 1023e3) { r.unit = " KiB"; f /= 1024.0; } + else if(f < 1023e6) { r.unit = " MiB"; f /= 1048576.0; } + else if(f < 1023e9) { r.unit = " GiB"; f /= 1073741824.0; } + else if(f < 1023e12) { r.unit = " TiB"; f /= 1099511627776.0; } + else if(f < 1023e15) { r.unit = " PiB"; f /= 1125899906842624.0; } + else { r.unit = " EiB"; f /= 1152921504606846976.0; } + } + _ = std.fmt.bufPrintZ(&r.buf, "{d:>5.1}", .{f}) catch unreachable; + return r; } - 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; } + + pub fn num(self: *const @This()) [:0]const u8 { + return std.mem.spanZ(&self.buf); } - var buf: [8:0]u8 = undefined; - _ = std.fmt.bufPrintZ(&buf, "{d:>5.1}", .{f}) catch unreachable; +}; + +// Print a formatted human-readable size string onto the given background. +pub fn addsize(bg: Bg, v: u64) void { + const r = FmtSize.fmt(v); bg.fg(.num); - addstr(&buf); + addstr(r.num()); bg.fg(.default); - addstr(unit); + addstr(r.unit); } // Print a full decimal number with thousand separators. @@ -378,6 +397,52 @@ pub fn hline(ch: c.chtype, len: u32) void { _ = c.hline(ch, @intCast(i32, len)); } +// Draws a bordered box in the center of the screen. +pub const Box = struct { + start_row: u32, + start_col: u32, + + const Self = @This(); + + pub fn create(height: u32, width: u32, title: [:0]const u8) Self { + const s = Self{ + .start_row = saturateSub(rows>>1, height>>1), + .start_col = saturateSub(cols>>1, width>>1), + }; + style(.default); + if (width < 6 or height < 3) return s; + + const ulcorner = ncdu_acs_ulcorner(); + const llcorner = ncdu_acs_llcorner(); + const urcorner = ncdu_acs_urcorner(); + const lrcorner = ncdu_acs_lrcorner(); + const acs_hline = ncdu_acs_hline(); + const acs_vline = ncdu_acs_vline(); + + var i: u32 = 0; + while (i < height) : (i += 1) { + s.move(i, 0); + addch(if (i == 0) ulcorner else if (i == height-1) llcorner else acs_hline); + hline(if (i == 0 or i == height-1) acs_vline else ' ', width-2); + s.move(i, width-1); + addch(if (i == 0) urcorner else if (i == height-1) lrcorner else acs_hline); + } + + s.move(0, 3); + style(.box_title); + addch(' '); + addstr(title); + addch(' '); + style(.default); + return s; + } + + // 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); + } +}; + // Returns 0 if no key was pressed in non-blocking mode. // Returns -1 if it was KEY_RESIZE, requiring a redraw of the screen. pub fn getch(block: bool) i32 {