diff --git a/README.md b/README.md index 5552cb1366f4caf1b02378e2488c1a8e4900a4bb..ed6cd8d99cb51ba06b9568410b7ac10a287b24f3 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,6 @@ backported to the C version, depending on how viable a proper Zig release is. Missing features: - Help window -- File deletion ### Improvements compared to the C version @@ -76,11 +75,9 @@ Aside from this implementation being unfinished: Not sure if these count as improvements or regressions, so I'll just list these separately: -- The browsing UI is not visible during refresh. +- The browsing UI is not visible during refresh or file deletion. - Some columns in the file browser are hidden automatically if the terminal is not wide enough to display them. -- Browsing keys other than changing the currently selected item don't work - anymore while the info window is being displayed. - The file's path is not displayed in the item window anymore (it's redundant). - The item window's height is dynamic based on its contents. diff --git a/src/browser.zig b/src/browser.zig index 0c44bdea6c9210804a38d708099874d0f6375602..0e315867b9d391ea25c1aec930cca72e9cf64a7d 100644 --- a/src/browser.zig +++ b/src/browser.zig @@ -2,6 +2,7 @@ const std = @import("std"); const main = @import("main.zig"); const model = @import("model.zig"); const scan = @import("scan.zig"); +const delete = @import("delete.zig"); const ui = @import("ui.zig"); const c = @cImport(@cInclude("time.h")); usingnamespace @import("util.zig"); @@ -44,12 +45,12 @@ const View = struct { } // Should be called after dir_parents or dir_items has changed, will load the last saved view and find the proper cursor_idx. - fn load(self: *@This()) void { + fn load(self: *@This(), sel: ?*const model.Entry) void { if (opened_dir_views.get(@ptrToInt(dir_parents.top()))) |v| self.* = v else self.* = @This(){}; cursor_idx = 0; for (dir_items.items) |e, i| { - if (self.cursor_hash == hashEntry(e)) { + if (if (sel != null) e == sel else self.cursor_hash == hashEntry(e)) { cursor_idx = i; break; } @@ -110,19 +111,19 @@ fn sortLt(_: void, ap: ?*model.Entry, bp: ?*model.Entry) bool { // - 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 { +fn sortDir(next_sel: ?*const model.Entry) 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); - current_view.load(); + current_view.load(next_sel); } // 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 -pub fn loadDir() void { +pub fn loadDir(next_sel: ?*const model.Entry) void { dir_items.shrinkRetainingCapacity(0); dir_max_size = 1; dir_max_blocks = 1; @@ -145,7 +146,7 @@ pub fn loadDir() void { } it = e.next; } - sortDir(); + sortDir(next_sel); } const Row = struct { @@ -531,18 +532,10 @@ const info = struct { 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; - } + loadDir(&p.node.entry); set(null, .info); } } @@ -630,7 +623,7 @@ pub fn draw() void { const box = ui.Box.create(6, 60, "Message"); box.move(2, 2); ui.addstr(m); - box.move(4, 34); + box.move(4, 33); ui.addstr("Press any key to continue"); } if (sel_row > 0) ui.move(sel_row, 0); @@ -641,7 +634,7 @@ fn sortToggle(col: main.config.SortCol, default_order: main.config.SortOrder) vo else if (main.config.sort_order == .asc) main.config.sort_order = .desc else main.config.sort_order = .asc; main.config.sort_col = col; - sortDir(); + sortDir(null); } fn keyInputSelection(ch: i32, idx: *usize, len: usize, page: u32) bool { @@ -677,7 +670,7 @@ pub fn keyInput(ch: i32) void { switch (ch) { 'q' => if (main.config.confirm_quit) { state = .quit; } else ui.quit(), - 'i' => info.set(dir_items.items[cursor_idx], .info), + 'i' => if (dir_items.items.len > 0) info.set(dir_items.items[cursor_idx], .info), 'r' => { if (main.config.imported) message = "Directory imported from file, refreshing is disabled." @@ -694,6 +687,21 @@ pub fn keyInput(ch: i32) void { else main.state = .shell; }, + 'd' => { + if (dir_items.items.len == 0) { + } else if (main.config.imported) + message = "Deletion feature not available for imported directories." + else if (main.config.read_only) + message = "Deletion feature disabled in read-only mode." + else if (dir_items.items[cursor_idx]) |e| { + main.state = .delete; + const next = + if (cursor_idx+1 < dir_items.items.len) dir_items.items[cursor_idx+1] + else if (cursor_idx == 0) null + else dir_items.items[cursor_idx-1]; + delete.setup(dir_parents.copy(), e, next); + } + }, // Sort & filter settings 'n' => sortToggle(.name, .asc), @@ -702,22 +710,22 @@ pub fn keyInput(ch: i32) void { 'M' => if (main.config.extended) sortToggle(.mtime, .desc), 'e' => { main.config.show_hidden = !main.config.show_hidden; - loadDir(); + loadDir(null); state = .main; }, 't' => { main.config.sort_dirsfirst = !main.config.sort_dirsfirst; - sortDir(); + sortDir(null); }, 'a' => { main.config.show_blocks = !main.config.show_blocks; if (main.config.show_blocks and main.config.sort_col == .size) { main.config.sort_col = .blocks; - sortDir(); + sortDir(null); } if (!main.config.show_blocks and main.config.sort_col == .blocks) { main.config.sort_col = .size; - sortDir(); + sortDir(null); } }, @@ -727,19 +735,20 @@ pub fn keyInput(ch: i32) void { } else if (dir_items.items[cursor_idx]) |e| { if (e.dir()) |d| { dir_parents.push(d); - loadDir(); + loadDir(null); state = .main; } } else if (!dir_parents.isRoot()) { dir_parents.pop(); - loadDir(); + loadDir(null); state = .main; } }, 'h', '<', ui.c.KEY_BACKSPACE, ui.c.KEY_LEFT => { if (!dir_parents.isRoot()) { + const e = dir_parents.top(); dir_parents.pop(); - loadDir(); + loadDir(&e.entry); state = .main; } }, diff --git a/src/delete.zig b/src/delete.zig new file mode 100644 index 0000000000000000000000000000000000000000..36ebe4ac9acfb5ac53ff54f43dc4f9343be4af3d --- /dev/null +++ b/src/delete.zig @@ -0,0 +1,224 @@ +const std = @import("std"); +const main = @import("main.zig"); +const model = @import("model.zig"); +const ui = @import("ui.zig"); +const browser = @import("browser.zig"); +usingnamespace @import("util.zig"); + +var parents: model.Parents = .{}; +var entry: *model.Entry = undefined; +var next_sel: ?*model.Entry = undefined; // Which item to select if deletion succeeds +var state: enum { confirm, busy, err } = .confirm; +var confirm: enum { yes, no, ignore } = .no; +var error_option: enum { abort, ignore, all } = .abort; +var error_code: anyerror = undefined; + +// ownership of p is passed to this function +pub fn setup(p: model.Parents, e: *model.Entry, n: ?*model.Entry) void { + parents = p; + entry = e; + next_sel = n; + state = if (main.config.confirm_delete) .confirm else .busy; + confirm = .no; +} + + +// Returns true to abort scanning. +fn err(e: anyerror) bool { + if (main.config.ignore_delete_errors) + return false; + error_code = e; + state = .err; + + while (main.state == .delete and state == .err) + main.handleEvent(true, false); + + return main.state != .delete; +} + +fn deleteItem(dir: std.fs.Dir, path: [:0]const u8, ptr: *align(1) ?*model.Entry) bool { + entry = ptr.*.?; + main.handleEvent(false, false); + if (main.state != .delete) + return true; + + if (entry.dir()) |d| { + var fd = dir.openDirZ(path, .{ .access_sub_paths = true, .iterate = false }) + catch |e| return err(e); + var it = &d.sub; + parents.push(d); + defer parents.pop(); + while (it.*) |n| { + if (deleteItem(fd, n.name(), it)) { + fd.close(); + return true; + } + if (it.* == n) // item deletion failed, make sure to still advance to next + it = &n.next; + } + fd.close(); + dir.deleteDirZ(path) catch |e| + return if (e != error.DirNotEmpty or d.sub == null) err(e) else false; + } else + dir.deleteFileZ(path) catch |e| return err(e); + ptr.*.?.delStats(&parents); + ptr.* = ptr.*.?.next; + return false; +} + +// Returns the item that should be selected in the browser. +pub fn delete() ?*model.Entry { + defer parents.deinit(); + while (main.state == .delete and state == .confirm) + main.handleEvent(true, false); + if (main.state != .delete) + return entry; + + // Find the pointer to this entry + const e = entry; + var it = &parents.top().sub; + while (it.*) |n| : (it = &n.next) + if (it.* == entry) + break; + + var path = std.ArrayList(u8).init(main.allocator); + defer path.deinit(); + parents.fmtPath(true, &path); + if (path.items.len == 0 or path.items[path.items.len-1] != '/') + path.append('/') catch unreachable; + path.appendSlice(entry.name()) catch unreachable; + + _ = deleteItem(std.fs.cwd(), arrayListBufZ(&path), it); + return if (it.* == e) e else next_sel; +} + +fn drawConfirm() void { + browser.draw(); + const box = ui.Box.create(6, 60, "Confirm delete"); + box.move(1, 2); + ui.addstr("Are you sure you want to delete \""); + ui.addstr(ui.shorten(ui.toUtf8(entry.name()), 21)); + ui.addch('"'); + if (entry.etype != .dir) + ui.addch('?') + else { + box.move(2, 18); + ui.addstr("and all of its contents?"); + } + + box.move(4, 15); + ui.style(if (confirm == .yes) .sel else .default); + ui.addstr("yes"); + + box.move(4, 25); + ui.style(if (confirm == .no) .sel else .default); + ui.addstr("no"); + + box.move(4, 31); + ui.style(if (confirm == .ignore) .sel else .default); + ui.addstr("don't ask me again"); +} + +fn drawProgress() void { + var path = std.ArrayList(u8).init(main.allocator); + defer path.deinit(); + parents.fmtPath(false, &path); + path.append('/') catch unreachable; + path.appendSlice(entry.name()) catch unreachable; + + // TODO: Item counts and progress bar would be nice. + + const box = ui.Box.create(6, 60, "Deleting..."); + box.move(2, 2); + ui.addstr(ui.shorten(ui.toUtf8(arrayListBufZ(&path)), 56)); + box.move(4, 41); + ui.addstr("Press "); + ui.style(.key); + ui.addch('q'); + ui.style(.default); + ui.addstr(" to abort"); +} + +fn drawErr() void { + var path = std.ArrayList(u8).init(main.allocator); + defer path.deinit(); + parents.fmtPath(false, &path); + path.append('/') catch unreachable; + path.appendSlice(entry.name()) catch unreachable; + + const box = ui.Box.create(6, 60, "Error"); + box.move(1, 2); + ui.addstr("Error deleting "); + ui.addstr(ui.shorten(ui.toUtf8(arrayListBufZ(&path)), 41)); + box.move(2, 4); + ui.addstr(ui.errorString(error_code)); + + box.move(4, 14); + ui.style(if (error_option == .abort) .sel else .default); + ui.addstr("abort"); + + box.move(4, 23); + ui.style(if (error_option == .ignore) .sel else .default); + ui.addstr("ignore"); + + box.move(4, 33); + ui.style(if (error_option == .all) .sel else .default); + ui.addstr("ignore all"); +} + +pub fn draw() void { + switch (state) { + .confirm => drawConfirm(), + .busy => drawProgress(), + .err => drawErr(), + } +} + +pub fn keyInput(ch: i32) void { + switch (state) { + .confirm => switch (ch) { + 'h', ui.c.KEY_LEFT => confirm = switch (confirm) { + .ignore => .no, + else => .yes, + }, + 'l', ui.c.KEY_RIGHT => confirm = switch (confirm) { + .yes => .no, + else => .ignore, + }, + 'q' => main.state = .browse, + '\n' => switch (confirm) { + .yes => state = .busy, + .no => main.state = .browse, + .ignore => { + main.config.confirm_delete = false; + state = .busy; + }, + }, + else => {} + }, + .busy => { + if (ch == 'q') + main.state = .browse; + }, + .err => switch (ch) { + 'h', ui.c.KEY_LEFT => error_option = switch (error_option) { + .all => .ignore, + else => .abort, + }, + 'l', ui.c.KEY_RIGHT => error_option = switch (error_option) { + .abort => .ignore, + else => .all, + }, + 'q' => main.state = .browse, + '\n' => switch (error_option) { + .abort => main.state = .browse, + .ignore => state = .busy, + .all => { + main.config.ignore_delete_errors = true; + state = .busy; + }, + }, + else => {} + }, + } +} diff --git a/src/main.zig b/src/main.zig index 08f9980dd9b3eb5bab6ac273a5819a6d8eb534d2..70c6466235b8cc44b0c165156cdb28b49c1f3c2c 100644 --- a/src/main.zig +++ b/src/main.zig @@ -5,6 +5,7 @@ const model = @import("model.zig"); const scan = @import("scan.zig"); const ui = @import("ui.zig"); const browser = @import("browser.zig"); +const delete = @import("delete.zig"); const c = @cImport(@cInclude("locale.h")); // "Custom" allocator that wraps the libc allocator and calls ui.oom() on error. @@ -65,9 +66,11 @@ pub const config = struct { pub var imported: bool = false; pub var can_shell: bool = true; pub var confirm_quit: bool = false; + pub var confirm_delete: bool = true; + pub var ignore_delete_errors: bool = false; }; -pub var state: enum { scan, browse, refresh, shell } = .scan; +pub var state: enum { scan, browse, refresh, shell, delete } = .scan; // 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.: @@ -332,19 +335,24 @@ pub fn main() void { config.scan_ui = .full; // in case we're refreshing from the UI, always in full mode. ui.init(); state = .browse; - browser.loadDir(); + browser.loadDir(null); while (true) { switch (state) { .refresh => { scan.scan(); state = .browse; - browser.loadDir(); + browser.loadDir(null); }, .shell => { spawnShell(); state = .browse; }, + .delete => { + const next = delete.delete(); + state = .browse; + browser.loadDir(next); + }, else => handleEvent(true, false) } } @@ -360,6 +368,7 @@ pub fn handleEvent(block: bool, force_draw: bool) void { switch (state) { .scan, .refresh => scan.draw(), .browse => browser.draw(), + .delete => delete.draw(), .shell => unreachable, } if (ui.inited) _ = ui.c.refresh(); @@ -378,6 +387,7 @@ pub fn handleEvent(block: bool, force_draw: bool) void { switch (state) { .scan, .refresh => scan.keyInput(ch), .browse => browser.keyInput(ch), + .delete => delete.keyInput(ch), .shell => unreachable, } firstblock = false; diff --git a/src/scan.zig b/src/scan.zig index 4ac7c5f2cdf90fe885c0964e618d577c36babc88..60fcacaf62b4fa1d90aee50e5b9528716de63f4f 100644 --- a/src/scan.zig +++ b/src/scan.zig @@ -384,6 +384,7 @@ const Context = struct { else if (self.wr) |wr| self.writeSpecial(wr.writer(), t) catch |e| writeErr(e); + self.stat.dir = false; // So that popPath() doesn't consider this as leaving a dir. self.items_seen += 1; } diff --git a/src/ui.zig b/src/ui.zig index 7910ee52076c31d4ca93dc6aa35e17439cb434ee..cc1784bb6492fce7207871797c418431f401fd7e 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -53,11 +53,12 @@ pub fn oom() void { pub fn errorString(e: anyerror) [:0]const u8 { return switch (e) { error.AccessDenied => "Access denied", + error.DirNotEmpty => "Directory not empty", error.DiskQuota => "Disk quota exceeded", + error.FileBusy => "File is busy", error.FileNotFound => "No such file or directory", error.FileSystem => "I/O error", // This one is shit, Zig uses this for both EIO and ELOOP in execve(). error.FileTooBig => "File too big", - error.FileBusy => "File is busy", error.InputOutput => "I/O error", error.InvalidExe => "Invalid executable", error.IsDir => "Is a directory", @@ -66,6 +67,7 @@ pub fn errorString(e: anyerror) [:0]const u8 { error.NotDir => "Not a directory", error.OutOfMemory, error.SystemResources => "Out of memory", error.ProcessFdQuotaExceeded => "Process file descriptor limit exceeded", + error.ReadOnlyFilesystem => "Read-only filesystem", error.SymlinkLoop => "Symlink loop", error.SystemFdQuotaExceeded => "System file descriptor limit exceeded", else => "Unknown error", // rather useless :(