Skip to content
Snippets Groups Projects
  • Yorhel's avatar
    e12eb455
    UI: Implement dir navigation & remember view of past dirs · e12eb455
    Yorhel authored
    Now we're getting somewhere. This works surprisingly well, too. Existing
    ncdu behavior is to remember which entry was previously selected but not
    which entry was displayed at the top, so the view would be slightly
    different when switching directories. This new approach remembers both
    the entry and the offset.
    e12eb455
    History
    UI: Implement dir navigation & remember view of past dirs
    Yorhel authored
    Now we're getting somewhere. This works surprisingly well, too. Existing
    ncdu behavior is to remember which entry was previously selected but not
    which entry was displayed at the top, so the view would be slightly
    different when switching directories. This new approach remembers both
    the entry and the offset.
main.zig 10.84 KiB
pub const program_version = "2.0";

const std = @import("std");
const model = @import("model.zig");
const scan = @import("scan.zig");
const ui = @import("ui.zig");
const browser = @import("browser.zig");
const c = @cImport(@cInclude("locale.h"));

pub const allocator = std.heap.c_allocator;

pub const SortCol = enum { name, blocks, size, items, mtime };
pub const SortOrder = enum { asc, desc };

pub const Config = struct {
    same_fs: bool = true,
    extended: bool = false,
    follow_symlinks: bool = false,
    exclude_caches: bool = false,
    exclude_kernfs: bool = false,
    exclude_patterns: std.ArrayList([:0]const u8) = std.ArrayList([:0]const u8).init(allocator),

    update_delay: u64 = 100*std.time.ns_per_ms,
    si: bool = false,
    nc_tty: bool = false,
    ui_color: enum { off, dark } = .off,
    thousands_sep: []const u8 = ".",

    show_hidden: bool = true,
    show_blocks: bool = true,
    sort_col: SortCol = .blocks,
    sort_order: SortOrder = .desc,
    sort_dirsfirst: bool = false,

    read_only: bool = false,
    can_shell: bool = true,
    confirm_quit: bool = false,
};

pub var config = Config{};

pub var state: enum { browse, quit } = .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.:
//   var args = Args(std.process.ArgIteratorPosix).init(std.process.ArgIteratorPosix.init());
fn Args(T: anytype) type {
    return struct {
        it: T,
        short: ?[:0]const u8 = null, // Remainder after a short option, e.g. -x<stuff> (which may be either more short options or an argument)
        last: ?[]const u8 = null,
        last_arg: ?[:0]const u8 = null, // In the case of --option=<arg>
        shortbuf: [2]u8 = undefined,
        argsep: bool = false,

        const Self = @This();
        const Option = struct {
            opt: bool,
            val: []const u8,

            fn is(self: @This(), cmp: []const u8) bool {
                return self.opt and std.mem.eql(u8, self.val, cmp);
            }
        };

        fn init(it: T) Self {
            return Self{ .it = it };
        }

        fn shortopt(self: *Self, s: [:0]const u8) Option {
            self.shortbuf[0] = '-';
            self.shortbuf[1] = s[0];
            self.short = if (s.len > 1) s[1.. :0] else null;
            self.last = &self.shortbuf;
            return .{ .opt = true, .val = &self.shortbuf };
        }

        /// Return the next option or positional argument.
        /// 'opt' indicates whether it's an option or positional argument,
        /// 'val' will be either -x, --something or the argument.
        pub fn next(self: *Self) ?Option {
            if (self.last_arg != null) ui.die("Option '{s}' does not expect an argument.\n", .{ self.last.? });
            if (self.short) |s| return self.shortopt(s);
            const val = self.it.next() orelse return null;
            if (self.argsep or val.len == 0 or val[0] != '-') return Option{ .opt = false, .val = val };
            if (val.len == 1) ui.die("Invalid option '-'.\n", .{});
            if (val.len == 2 and val[1] == '-') {
                self.argsep = true;
                return self.next();
            }
            if (val[1] == '-') {
                if (std.mem.indexOfScalar(u8, val, '=')) |sep| {
                    if (sep == 2) ui.die("Invalid option '{s}'.\n", .{val});
                    self.last_arg = val[sep+1.. :0];
                    self.last = val[0..sep];
                    return Option{ .opt = true, .val = self.last.? };
                }
                self.last = val;
                return Option{ .opt = true, .val = val };
            }
            return self.shortopt(val[1..:0]);
        }

        /// Returns the argument given to the last returned option. Dies with an error if no argument is provided.
        pub fn arg(self: *Self) [:0]const u8 {
            if (self.short) |a| {
                defer self.short = null;
                return a;
            }
            if (self.last_arg) |a| {
                defer self.last_arg = null;
                return a;
            }
            if (self.it.next()) |o| return o;
            ui.die("Option '{s}' requires an argument.\n", .{ self.last.? });
        }
    };
}

fn version() noreturn {
    std.io.getStdOut().writer().writeAll("ncdu " ++ program_version ++ "\n") catch {};
    std.process.exit(0);
}

fn help() noreturn {
    std.io.getStdOut().writer().writeAll(
        "ncdu <options> <directory>\n\n"
     ++ "  -h,--help                  This help message\n"
     ++ "  -q                         Quiet mode, refresh interval 2 seconds\n"
     ++ "  -v,-V,--version            Print version\n"
     ++ "  -x                         Same filesystem\n"
     ++ "  -e                         Enable extended information\n"
     ++ "  -r                         Read only\n"
     ++ "  -o FILE                    Export scanned directory to FILE\n"
     ++ "  -f FILE                    Import scanned directory from FILE\n"
     ++ "  -0,-1,-2                   UI to use when scanning (0=none,2=full ncurses)\n"
     ++ "  --si                       Use base 10 (SI) prefixes instead of base 2\n"
     ++ "  --exclude PATTERN          Exclude files that match PATTERN\n"
     ++ "  -X, --exclude-from FILE    Exclude files that match any pattern in FILE\n"
     ++ "  -L, --follow-symlinks      Follow symbolic links (excluding directories)\n"
     ++ "  --exclude-caches           Exclude directories containing CACHEDIR.TAG\n"
     ++ "  --exclude-kernfs           Exclude Linux pseudo filesystems (procfs,sysfs,cgroup,...)\n"
     ++ "  --confirm-quit             Confirm quitting ncdu\n"
     ++ "  --color SCHEME             Set color scheme (off/dark)\n"
    ) catch {};
    std.process.exit(0);
}

fn readExcludeFile(path: []const u8) !void {
    const f = try std.fs.cwd().openFile(path, .{});
    defer f.close();
    var rd = std.io.bufferedReader(f.reader()).reader();
    var buf = std.ArrayList(u8).init(allocator);
    while (true) {
        rd.readUntilDelimiterArrayList(&buf, '\n', 4096)
            catch |e| if (e != error.EndOfStream) return e else if (buf.items.len == 0) break;
        if (buf.items.len > 0)
            try config.exclude_patterns.append(try buf.toOwnedSliceSentinel(0));
    }
}

pub fn main() anyerror!void {
    // Grab thousands_sep from the current C locale.
    // (We can safely remove this when not linking against libc, it's a somewhat obscure feature)
    _ = c.setlocale(c.LC_ALL, "");
    if (c.localeconv()) |locale| {
        if (locale.*.thousands_sep) |sep| {
            const span = std.mem.spanZ(sep);
            if (span.len > 0)
                config.thousands_sep = span;
        }
    }

    var args = Args(std.process.ArgIteratorPosix).init(std.process.ArgIteratorPosix.init());
    var scan_dir: ?[]const u8 = null;
    _ = args.next(); // program name
    while (args.next()) |opt| {
        if (!opt.opt) {
            // XXX: ncdu 1.x doesn't error, it just silently ignores all but the last argument.
            if (scan_dir != null) ui.die("Multiple directories given, see ncdu -h for help.\n", .{});
            scan_dir = opt.val;
            continue;
        }
        if (opt.is("-h") or opt.is("-?") or opt.is("--help")) help()
        else if(opt.is("-v") or opt.is("-V") or opt.is("--version")) version()
        else if(opt.is("-q")) config.update_delay = 2*std.time.ns_per_s
        else if(opt.is("-x")) config.same_fs = true
        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("--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())
        else if(opt.is("-X") or opt.is("--exclude-from")) {
            const arg = args.arg();
            readExcludeFile(arg) catch |e| ui.die("Error reading excludes from {s}: {}.\n", .{ arg, e });
        } else if(opt.is("--exclude-caches")) config.exclude_caches = true
        else if(opt.is("--exclude-kernfs")) config.exclude_kernfs = true
        else if(opt.is("--confirm-quit")) config.confirm_quit = true
        else if(opt.is("--color")) {
            const val = args.arg();
            if (std.mem.eql(u8, val, "off")) config.ui_color = .off
            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", .{});

    event_delay_timer = try std.time.Timer.start();

    try scan.scanRoot(scan_dir orelse ".");
    try browser.loadDir();

    ui.init();
    defer ui.deinit();

    // TODO: Handle OOM errors
    // TODO: Confirm quit
    while (state != .quit) try handleEvent(true, false);
}

var event_delay_timer: std.time.Timer = undefined;

// Draw the screen and handle the next input event.
// 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();
        event_delay_timer.reset();
    }

    var ch = ui.getch(block);
    if (ch == 0) return;
    if (ch == -1) return handleEvent(block, true);
    try browser.key(ch);
}


test "argument parser" {
    const L = struct {
        lst: []const [:0]const u8,
        idx: usize = 0,
        fn next(s: *@This()) ?[:0]const u8 {
            if (s.idx == s.lst.len) return null;
            defer s.idx += 1;
            return s.lst[s.idx];
        }
    };
    const lst = [_][:0]const u8{ "a", "-abcd=e", "--opt1=arg1", "--opt2", "arg2", "-x", "foo", "", "--", "--arg", "", "-", };
    const l = L{ .lst = &lst };
    const T = struct {
        a: Args(L),
        fn opt(self: *@This(), isopt: bool, val: []const u8) void {
            const o = self.a.next().?;
            std.testing.expectEqual(isopt, o.opt);
            std.testing.expectEqualStrings(val, o.val);
            std.testing.expectEqual(o.is(val), isopt);
        }
        fn arg(self: *@This(), val: []const u8) void {
            std.testing.expectEqualStrings(val, self.a.arg());
        }
    };
    var t = T{ .a = Args(L).init(l) };
    t.opt(false, "a");
    t.opt(true, "-a");
    t.opt(true, "-b");
    t.arg("cd=e");
    t.opt(true, "--opt1");
    t.arg("arg1");
    t.opt(true, "--opt2");
    t.arg("arg2");
    t.opt(true, "-x");
    t.arg("foo");
    t.opt(false, "");
    t.opt(false, "--arg");
    t.opt(false, "");
    t.opt(false, "-");
    std.testing.expectEqual(t.a.next(), null);
}