diff --git a/ncdu.1 b/ncdu.1 index bcb7a55..37715c0 100644 --- a/ncdu.1 +++ b/ncdu.1 @@ -44,6 +44,7 @@ .Op Fl \-group\-directories\-first , \-no\-group\-directories\-first .Op Fl \-confirm\-quit , \-no\-confirm\-quit .Op Fl \-confirm\-delete , \-no\-confirm\-delete +.Op Fl \-delete\-command Ar command .Op Fl \-color Ar off | dark | dark-bg .Op Ar path .Nm @@ -359,6 +360,31 @@ Can be helpful when you accidentally press 'q' during or after a very long scan. Require a confirmation before deleting a file or directory. Enabled by default, but can be disabled if you're absolutely sure you won't accidentally press 'd'. +.It Fl \-delete\-command Ar command +When set to a non-empty string, replace the built-in file deletion feature with +a custom shell command. +.Pp +The absolute path of the item to be deleted is appended to the given command +and the result is evaluated in a shell. +The command is run from the same directory that ncdu itself was started in. +The +.Ev NCDU_DELETE_PATH +environment variable is set to the absolute path of the item to be deleted and +.Ev NCDU_LEVEL +is set in the same fashion as when spawning a shell from within ncdu. +.Pp +After command completion, the in-memory view of the selected item is refreshed +and directory sizes are adjusted as necessary. +This is not a full refresh of the complete directory tree, so if the item has +been renamed or moved to another directory, it's new location is not +automatically picked up. +.Pp +For example, to use +.Xr rm 1 +interactive mode to prompt before each deletion: +.Dl ncdu --no-confirm-delete --delete-command \[aq]rm -ri --\[aq] +Or to move files to trash: +.Dl ncdu --delete-command \[aq]gio trash --\[aq] .It Fl \-color Ar off | dark | dark-bg Set the color scheme. The following schemes are recognized: diff --git a/src/delete.zig b/src/delete.zig index ad76c2f..00bdbdb 100644 --- a/src/delete.zig +++ b/src/delete.zig @@ -6,6 +6,9 @@ const main = @import("main.zig"); const model = @import("model.zig"); const ui = @import("ui.zig"); const browser = @import("browser.zig"); +const scan = @import("scan.zig"); +const sink = @import("sink.zig"); +const mem_sink = @import("mem_sink.zig"); const util = @import("util.zig"); const c = @import("c.zig").c; @@ -68,6 +71,57 @@ fn deleteItem(dir: std.fs.Dir, path: [:0]const u8, ptr: *align(1) ?*model.Entry) return false; } +// Returns true if the item has been deleted successfully. +fn deleteCmd(path: [:0]const u8, ptr: *align(1) ?*model.Entry) bool { + { + var env = std.process.getEnvMap(main.allocator) catch unreachable; + defer env.deinit(); + env.put("NCDU_DELETE_PATH", path) catch unreachable; + + // Since we're passing the path as an environment variable and go through + // the shell anyway, we can refer to the variable and avoid error-prone + // shell escaping. + const cmd = std.fmt.allocPrint(main.allocator, "{s} \"$NCDU_DELETE_PATH\"", .{main.config.delete_command}) catch unreachable; + defer main.allocator.free(cmd); + ui.runCmd(&.{"/bin/sh", "-c", cmd}, null, &env, true); + } + + const stat = scan.statAt(std.fs.cwd(), path, false, null) catch { + // Stat failed. Would be nice to display an error if it's not + // 'FileNotFound', but w/e, let's just assume the item has been + // deleted as expected. + ptr.*.?.zeroStats(parent); + ptr.* = ptr.*.?.next.ptr; + return true; + }; + + // If either old or new entry is not a dir, remove & re-add entry in the in-memory tree. + if (ptr.*.?.pack.etype != .dir or stat.etype != .dir) { + ptr.*.?.zeroStats(parent); + const e = model.Entry.create(main.allocator, stat.etype, main.config.extended and !stat.ext.isEmpty(), ptr.*.?.name()); + e.next.ptr = ptr.*.?.next.ptr; + mem_sink.statToEntry(&stat, e, parent); + ptr.* = e; + + var it : ?*model.Dir = parent; + while (it) |p| : (it = p.parent) { + if (stat.etype != .link) { + p.entry.pack.blocks +|= e.pack.blocks; + p.entry.size +|= e.size; + } + p.items +|= 1; + } + } + + // If new entry is a dir, recursively scan. + if (ptr.*.?.dir()) |d| { + main.state = .refresh; + sink.global.sink = .mem; + mem_sink.global.root = d; + } + return false; +} + // Returns the item that should be selected in the browser. pub fn delete() ?*model.Entry { while (main.state == .delete and state == .confirm) @@ -89,23 +143,39 @@ pub fn delete() ?*model.Entry { path.append('/') catch unreachable; path.appendSlice(entry.name()) catch unreachable; - _ = deleteItem(std.fs.cwd(), util.arrayListBufZ(&path), it); - model.inodes.addAllStats(); - return if (it.* == e) e else next_sel; + if (main.config.delete_command.len == 0) { + _ = deleteItem(std.fs.cwd(), util.arrayListBufZ(&path), it); + model.inodes.addAllStats(); + return if (it.* == e) e else next_sel; + } else { + const isdel = deleteCmd(util.arrayListBufZ(&path), it); + model.inodes.addAllStats(); + return if (isdel) next_sel else it.*; + } } 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.pack.etype != .dir) - ui.addch('?') - else { - box.move(2, 18); - ui.addstr("and all of its contents?"); + if (main.config.delete_command.len == 0) { + ui.addstr("Are you sure you want to delete \""); + ui.addstr(ui.shorten(ui.toUtf8(entry.name()), 21)); + ui.addch('"'); + if (entry.pack.etype != .dir) + ui.addch('?') + else { + box.move(2, 18); + ui.addstr("and all of its contents?"); + } + } else { + ui.addstr("Are you sure you want to run \""); + ui.addstr(ui.shorten(ui.toUtf8(main.config.delete_command), 25)); + ui.addch('"'); + box.move(2, 4); + ui.addstr("on \""); + ui.addstr(ui.shorten(ui.toUtf8(entry.name()), 50)); + ui.addch('"'); } box.move(4, 15); diff --git a/src/main.zig b/src/main.zig index 63aa386..cac56d9 100644 --- a/src/main.zig +++ b/src/main.zig @@ -114,6 +114,7 @@ pub const config = struct { pub var confirm_quit: bool = false; pub var confirm_delete: bool = true; pub var ignore_delete_errors: bool = false; + pub var delete_command: [:0]const u8 = ""; }; pub var state: enum { scan, browse, refresh, shell, delete } = .scan; @@ -306,6 +307,7 @@ fn argConfig(args: *Args, opt: Args.Option, infile: bool) !void { else if (opt.is("--no-confirm-quit")) config.confirm_quit = false else if (opt.is("--confirm-delete")) config.confirm_delete = true else if (opt.is("--no-confirm-delete")) config.confirm_delete = false + else if (opt.is("--delete-command")) config.delete_command = allocator.dupeZ(u8, try args.arg()) catch unreachable else if (opt.is("--color")) { const val = try args.arg(); if (std.mem.eql(u8, val, "off")) config.ui_color = .off @@ -428,6 +430,7 @@ fn help() noreturn { \\ --group-directories-first Sort directories before files \\ --confirm-quit Ask confirmation before quitting ncdu \\ --no-confirm-delete Don't ask confirmation before deletion + \\ --delete-command CMD Command to run for file deletion \\ --color SCHEME off / dark / dark-bg \\ \\Refer to `man ncdu` for more information. @@ -437,58 +440,6 @@ fn help() noreturn { } -fn spawnShell() void { - ui.deinit(); - defer ui.init(); - - var env = std.process.getEnvMap(allocator) catch unreachable; - defer env.deinit(); - // NCDU_LEVEL can only count to 9, keeps the implementation simple. - if (env.get("NCDU_LEVEL")) |l| - env.put("NCDU_LEVEL", if (l.len == 0) "1" else switch (l[0]) { - '0'...'8' => |d| &[1] u8{d+1}, - '9' => "9", - else => "1" - }) catch unreachable - else - env.put("NCDU_LEVEL", "1") catch unreachable; - - const shell = std.posix.getenvZ("NCDU_SHELL") orelse std.posix.getenvZ("SHELL") orelse "/bin/sh"; - var child = std.process.Child.init(&.{shell}, allocator); - child.cwd = browser.dir_path; - child.env_map = &env; - - const stdin = std.io.getStdIn(); - const stderr = std.io.getStdErr(); - const term = child.spawnAndWait() catch |e| blk: { - stderr.writer().print( - "Error spawning shell: {s}\n\nPress enter to continue.\n", - .{ ui.errorString(e) } - ) catch {}; - stdin.reader().skipUntilDelimiterOrEof('\n') catch unreachable; - break :blk std.process.Child.Term{ .Exited = 0 }; - }; - if (term != .Exited) { - const n = switch (term) { - .Exited => "status", - .Signal => "signal", - .Stopped => "stopped", - .Unknown => "unknown", - }; - const v = switch (term) { - .Exited => |v| v, - .Signal => |v| v, - .Stopped => |v| v, - .Unknown => |v| v, - }; - stderr.writer().print( - "Shell returned with {s} code {}.\n\nPress enter to continue.\n", .{ n, v } - ) catch {}; - stdin.reader().skipUntilDelimiterOrEof('\n') catch unreachable; - } -} - - fn readExcludeFile(path: [:0]const u8) !void { const f = try std.fs.cwd().openFileZ(path, .{}); defer f.close(); @@ -671,13 +622,18 @@ pub fn main() void { browser.loadDir(0); }, .shell => { - spawnShell(); + const shell = std.posix.getenvZ("NCDU_SHELL") orelse std.posix.getenvZ("SHELL") orelse "/bin/sh"; + var env = std.process.getEnvMap(allocator) catch unreachable; + defer env.deinit(); + ui.runCmd(&.{shell}, browser.dir_path, &env, false); state = .browse; }, .delete => { const next = delete.delete(); - state = .browse; - browser.loadDir(if (next) |n| n.nameHash() else 0); + if (state != .refresh) { + state = .browse; + browser.loadDir(if (next) |n| n.nameHash() else 0); + } }, else => handleEvent(true, false) } diff --git a/src/mem_sink.zig b/src/mem_sink.zig index b930364..37b0ece 100644 --- a/src/mem_sink.zig +++ b/src/mem_sink.zig @@ -17,6 +17,24 @@ pub const Thread = struct { arena: std.heap.ArenaAllocator = std.heap.ArenaAllocator.init(std.heap.page_allocator), }; +pub fn statToEntry(stat: *const sink.Stat, e: *model.Entry, parent: *model.Dir) void { + e.pack.blocks = stat.blocks; + e.size = stat.size; + if (e.dir()) |d| { + d.parent = parent; + d.pack.dev = model.devices.getId(stat.dev); + } + if (e.link()) |l| { + l.parent = parent; + l.ino = stat.ino; + l.pack.nlink = stat.nlink; + model.inodes.lock.lock(); + defer model.inodes.lock.unlock(); + l.addLink(); + } + if (e.ext()) |ext| ext.* = stat.ext; +} + pub const Dir = struct { dir: *model.Dir, entries: Map, @@ -107,21 +125,7 @@ pub const Dir = struct { } const e = self.getEntry(t, stat.etype, main.config.extended and !stat.ext.isEmpty(), name); - e.pack.blocks = stat.blocks; - e.size = stat.size; - if (e.dir()) |d| { - d.parent = self.dir; - d.pack.dev = model.devices.getId(stat.dev); - } - if (e.link()) |l| { - l.parent = self.dir; - l.ino = stat.ino; - l.pack.nlink = stat.nlink; - model.inodes.lock.lock(); - defer model.inodes.lock.unlock(); - l.addLink(); - } - if (e.ext()) |ext| ext.* = stat.ext; + statToEntry(stat, e, self.dir); return e; } diff --git a/src/scan.zig b/src/scan.zig index a78311c..a284191 100644 --- a/src/scan.zig +++ b/src/scan.zig @@ -46,7 +46,7 @@ fn truncate(comptime T: type, comptime field: anytype, x: anytype) std.meta.fiel } -fn statAt(parent: std.fs.Dir, name: [:0]const u8, follow: bool, symlink: *bool) !sink.Stat { +pub fn statAt(parent: std.fs.Dir, name: [:0]const u8, follow: bool, symlink: ?*bool) !sink.Stat { // std.posix.fstatatZ() in Zig 0.14 is not suitable due to https://github.com/ziglang/zig/issues/23463 var stat: std.c.Stat = undefined; if (std.c.fstatat(parent.fd, name, &stat, if (follow) 0 else std.c.AT.SYMLINK_NOFOLLOW) != 0) { @@ -58,7 +58,7 @@ fn statAt(parent: std.fs.Dir, name: [:0]const u8, follow: bool, symlink: *bool) else => error.Unexpected, }; } - symlink.* = std.c.S.ISLNK(stat.mode); + if (symlink) |s| s.* = std.c.S.ISLNK(stat.mode); return sink.Stat{ .etype = if (std.c.S.ISDIR(stat.mode)) .dir diff --git a/src/ui.zig b/src/ui.zig index a7cd180..de2f9a9 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -642,3 +642,48 @@ pub fn getch(block: bool) i32 { die("Error reading keyboard input, assuming TTY has been lost.\n(Potentially nonsensical error message: {s})\n", .{ c.strerror(@intFromEnum(std.posix.errno(-1))) }); } + + +pub fn runCmd(cmd: []const []const u8, cwd: ?[]const u8, env: *std.process.EnvMap, reporterr: bool) void { + deinit(); + defer init(); + + // NCDU_LEVEL can only count to 9, keeps the implementation simple. + if (env.get("NCDU_LEVEL")) |l| + env.put("NCDU_LEVEL", if (l.len == 0) "1" else switch (l[0]) { + '0'...'8' => |d| &[1] u8{d+1}, + '9' => "9", + else => "1" + }) catch unreachable + else + env.put("NCDU_LEVEL", "1") catch unreachable; + + var child = std.process.Child.init(cmd, main.allocator); + child.cwd = cwd; + child.env_map = env; + + const stdin = std.io.getStdIn(); + const stderr = std.io.getStdErr(); + const term = child.spawnAndWait() catch |e| blk: { + stderr.writer().print( + "Error running command: {s}\n\nPress enter to continue.\n", + .{ ui.errorString(e) } + ) catch {}; + stdin.reader().skipUntilDelimiterOrEof('\n') catch unreachable; + break :blk std.process.Child.Term{ .Exited = 0 }; + }; + + const n = switch (term) { + .Exited => "error", + .Signal => "signal", + .Stopped => "stopped", + .Unknown => "unknown", + }; + const v = switch (term) { inline else => |v| v }; + if (term != .Exited or (reporterr and v != 0)) { + stderr.writer().print( + "\nCommand returned with {s} code {}.\nPress enter to continue.\n", .{ n, v } + ) catch {}; + stdin.reader().skipUntilDelimiterOrEof('\n') catch unreachable; + } +}