Add --delete-command option

Fixes #215.

delete.zig's item replacement/refresh code is pretty awful and may be
buggy in some edge cases. Existing refresh infrastructure wasn't
designed to update an individual file.
This commit is contained in:
Yorhel 2025-07-15 15:58:00 +02:00
parent 67f34090fb
commit 66b875eb00
6 changed files with 184 additions and 83 deletions

26
ncdu.1
View file

@ -44,6 +44,7 @@
.Op Fl \-group\-directories\-first , \-no\-group\-directories\-first .Op Fl \-group\-directories\-first , \-no\-group\-directories\-first
.Op Fl \-confirm\-quit , \-no\-confirm\-quit .Op Fl \-confirm\-quit , \-no\-confirm\-quit
.Op Fl \-confirm\-delete , \-no\-confirm\-delete .Op Fl \-confirm\-delete , \-no\-confirm\-delete
.Op Fl \-delete\-command Ar command
.Op Fl \-color Ar off | dark | dark-bg .Op Fl \-color Ar off | dark | dark-bg
.Op Ar path .Op Ar path
.Nm .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. Require a confirmation before deleting a file or directory.
Enabled by default, but can be disabled if you're absolutely sure you won't Enabled by default, but can be disabled if you're absolutely sure you won't
accidentally press 'd'. 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 .It Fl \-color Ar off | dark | dark-bg
Set the color scheme. Set the color scheme.
The following schemes are recognized: The following schemes are recognized:

View file

@ -6,6 +6,9 @@ const main = @import("main.zig");
const model = @import("model.zig"); const model = @import("model.zig");
const ui = @import("ui.zig"); const ui = @import("ui.zig");
const browser = @import("browser.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 util = @import("util.zig");
const c = @import("c.zig").c; 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; 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. // Returns the item that should be selected in the browser.
pub fn delete() ?*model.Entry { pub fn delete() ?*model.Entry {
while (main.state == .delete and state == .confirm) while (main.state == .delete and state == .confirm)
@ -89,15 +143,22 @@ pub fn delete() ?*model.Entry {
path.append('/') catch unreachable; path.append('/') catch unreachable;
path.appendSlice(entry.name()) catch unreachable; path.appendSlice(entry.name()) catch unreachable;
if (main.config.delete_command.len == 0) {
_ = deleteItem(std.fs.cwd(), util.arrayListBufZ(&path), it); _ = deleteItem(std.fs.cwd(), util.arrayListBufZ(&path), it);
model.inodes.addAllStats(); model.inodes.addAllStats();
return if (it.* == e) e else next_sel; 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 { fn drawConfirm() void {
browser.draw(); browser.draw();
const box = ui.Box.create(6, 60, "Confirm delete"); const box = ui.Box.create(6, 60, "Confirm delete");
box.move(1, 2); box.move(1, 2);
if (main.config.delete_command.len == 0) {
ui.addstr("Are you sure you want to delete \""); ui.addstr("Are you sure you want to delete \"");
ui.addstr(ui.shorten(ui.toUtf8(entry.name()), 21)); ui.addstr(ui.shorten(ui.toUtf8(entry.name()), 21));
ui.addch('"'); ui.addch('"');
@ -107,6 +168,15 @@ fn drawConfirm() void {
box.move(2, 18); box.move(2, 18);
ui.addstr("and all of its contents?"); 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); box.move(4, 15);
ui.style(if (confirm == .yes) .sel else .default); ui.style(if (confirm == .yes) .sel else .default);

View file

@ -114,6 +114,7 @@ pub const config = struct {
pub var confirm_quit: bool = false; pub var confirm_quit: bool = false;
pub var confirm_delete: bool = true; pub var confirm_delete: bool = true;
pub var ignore_delete_errors: bool = false; pub var ignore_delete_errors: bool = false;
pub var delete_command: [:0]const u8 = "";
}; };
pub var state: enum { scan, browse, refresh, shell, delete } = .scan; 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("--no-confirm-quit")) config.confirm_quit = false
else if (opt.is("--confirm-delete")) config.confirm_delete = true 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("--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")) { else if (opt.is("--color")) {
const val = try args.arg(); const val = try args.arg();
if (std.mem.eql(u8, val, "off")) config.ui_color = .off 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 \\ --group-directories-first Sort directories before files
\\ --confirm-quit Ask confirmation before quitting ncdu \\ --confirm-quit Ask confirmation before quitting ncdu
\\ --no-confirm-delete Don't ask confirmation before deletion \\ --no-confirm-delete Don't ask confirmation before deletion
\\ --delete-command CMD Command to run for file deletion
\\ --color SCHEME off / dark / dark-bg \\ --color SCHEME off / dark / dark-bg
\\ \\
\\Refer to `man ncdu` for more information. \\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 { fn readExcludeFile(path: [:0]const u8) !void {
const f = try std.fs.cwd().openFileZ(path, .{}); const f = try std.fs.cwd().openFileZ(path, .{});
defer f.close(); defer f.close();
@ -671,13 +622,18 @@ pub fn main() void {
browser.loadDir(0); browser.loadDir(0);
}, },
.shell => { .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; state = .browse;
}, },
.delete => { .delete => {
const next = delete.delete(); const next = delete.delete();
if (state != .refresh) {
state = .browse; state = .browse;
browser.loadDir(if (next) |n| n.nameHash() else 0); browser.loadDir(if (next) |n| n.nameHash() else 0);
}
}, },
else => handleEvent(true, false) else => handleEvent(true, false)
} }

View file

@ -17,6 +17,24 @@ pub const Thread = struct {
arena: std.heap.ArenaAllocator = std.heap.ArenaAllocator.init(std.heap.page_allocator), 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 { pub const Dir = struct {
dir: *model.Dir, dir: *model.Dir,
entries: Map, 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); const e = self.getEntry(t, stat.etype, main.config.extended and !stat.ext.isEmpty(), name);
e.pack.blocks = stat.blocks; statToEntry(stat, e, self.dir);
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;
return e; return e;
} }

View file

@ -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 // 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; var stat: std.c.Stat = undefined;
if (std.c.fstatat(parent.fd, name, &stat, if (follow) 0 else std.c.AT.SYMLINK_NOFOLLOW) != 0) { 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, else => error.Unexpected,
}; };
} }
symlink.* = std.c.S.ISLNK(stat.mode); if (symlink) |s| s.* = std.c.S.ISLNK(stat.mode);
return sink.Stat{ return sink.Stat{
.etype = .etype =
if (std.c.S.ISDIR(stat.mode)) .dir if (std.c.S.ISDIR(stat.mode)) .dir

View file

@ -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", die("Error reading keyboard input, assuming TTY has been lost.\n(Potentially nonsensical error message: {s})\n",
.{ c.strerror(@intFromEnum(std.posix.errno(-1))) }); .{ 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;
}
}