Implement trash functionality for file deletion and update delete confirmation options. Closes #215.

This commit is contained in:
Ivan Stepanov 2025-07-09 21:44:23 +02:00
parent 67f34090fb
commit 52c6fa7e82
3 changed files with 239 additions and 39 deletions

View file

@ -1,10 +1,12 @@
// SPDX-FileCopyrightText: Yorhel <projects@yorhel.nl> // SPDX-FileCopyrightText: Yorhel <projects@yorhel.nl>
// SPDX-FileCopyrightText: 2025 Ivan Stepanov <ivanstepanov@gmail.com>
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
const std = @import("std"); const std = @import("std");
const main = @import("main.zig"); 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 trash = @import("trash.zig");
const browser = @import("browser.zig"); const browser = @import("browser.zig");
const util = @import("util.zig"); const util = @import("util.zig");
const c = @import("c.zig").c; const c = @import("c.zig").c;
@ -12,8 +14,8 @@ const c = @import("c.zig").c;
var parent: *model.Dir = undefined; var parent: *model.Dir = undefined;
var entry: *model.Entry = undefined; var entry: *model.Entry = undefined;
var next_sel: ?*model.Entry = undefined; // Which item to select if deletion succeeds var next_sel: ?*model.Entry = undefined; // Which item to select if deletion succeeds
var state: enum { confirm, busy, err } = .confirm; var state: enum { confirm, busy_delete, busy_trash, err } = .confirm;
var confirm: enum { yes, no, ignore } = .no; var confirm: enum { delete, trash, no, always_trash, always_delete } = .no;
var error_option: enum { abort, ignore, all } = .abort; var error_option: enum { abort, ignore, all } = .abort;
var error_code: anyerror = undefined; var error_code: anyerror = undefined;
@ -21,8 +23,14 @@ pub fn setup(p: *model.Dir, e: *model.Entry, n: ?*model.Entry) void {
parent = p; parent = p;
entry = e; entry = e;
next_sel = n; next_sel = n;
state = if (main.config.confirm_delete) .confirm else .busy; switch (main.config.delete_action) {
confirm = .no; .ask => {
state = .confirm;
confirm = .no;
},
.always_delete => state = .busy_delete,
.always_trash => state = .busy_trash,
}
} }
@ -39,6 +47,24 @@ fn err(e: anyerror) bool {
return main.state != .delete; return main.state != .delete;
} }
fn trashItem(path: *std.ArrayList(u8), ptr: *align(1) ?*model.Entry) bool {
entry = ptr.*.?;
main.handleEvent(false, false);
if (main.state != .delete)
return true;
const original_len = path.items.len;
defer path.shrinkRetainingCapacity(original_len);
path.appendSlice(entry.name()) catch unreachable;
// Only trash the top-level selected item, not recursively each file
trash.trashFile(main.allocator, util.arrayListBufZ(path)) catch |e| return err(e);
ptr.*.?.zeroStats(parent);
ptr.* = ptr.*.?.next.ptr;
return false;
}
fn deleteItem(dir: std.fs.Dir, path: [:0]const u8, ptr: *align(1) ?*model.Entry) bool { fn deleteItem(dir: std.fs.Dir, path: [:0]const u8, ptr: *align(1) ?*model.Entry) bool {
entry = ptr.*.?; entry = ptr.*.?;
main.handleEvent(false, false); main.handleEvent(false, false);
@ -82,14 +108,45 @@ pub fn delete() ?*model.Entry {
if (it.* == entry) if (it.* == entry)
break; break;
var path = std.ArrayList(u8).init(main.allocator); // var path = std.ArrayList(u8).init(main.allocator);
defer path.deinit(); // defer path.deinit();
parent.fmtPath(true, &path); // parent.fmtPath(true, &path);
if (path.items.len == 0 or path.items[path.items.len-1] != '/') // if (path.items.len == 0 or path.items[path.items.len-1] != '/')
path.append('/') catch unreachable; // path.append('/') catch unreachable;
path.appendSlice(entry.name()) catch unreachable; // path.appendSlice(entry.name()) catch unreachable;
//
// _ = deleteItem(std.fs.cwd(), util.arrayListBufZ(&path), it);
switch (state) {
.busy_trash => {
var path = std.ArrayList(u8).init(main.allocator);
defer path.deinit();
parent.fmtPath(true, &path);
// if (path.items.len > 0 and path.items[path.items.len - 1] != '/') {
// path.append('/') catch unreachable;
// }
if (path.items.len == 0 or path.items[path.items.len - 1] != '/') {
path.append('/') catch unreachable;
}
_ = trashItem(&path, it);
},
.busy_delete => {
var path = std.ArrayList(u8).init(main.allocator);
defer path.deinit();
path.appendSlice(entry.name()) catch unreachable;
// if (path.items.len > 0 and path.items[path.items.len - 1] != '/') {
// path.append('/') catch unreachable;
// }
// if (path.items.len == 0 or path.items[path.items.len - 1] != '/') {
// path.append('/') catch unreachable;
// }
_ = deleteItem(std.fs.cwd(), util.arrayListBufZ(&path), it);
},
else => unreachable,
}
_ = 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;
} }
@ -104,33 +161,50 @@ fn drawConfirm() void {
if (entry.pack.etype != .dir) if (entry.pack.etype != .dir)
ui.addch('?') ui.addch('?')
else { else {
box.move(2, 18); box.move(2, 17);
ui.addstr("and all of its contents?"); ui.addstr("and all of its contents?");
} }
box.move(4, 15); box.move(4, 4);
ui.style(if (confirm == .yes) .sel else .default); ui.style(if (confirm == .delete) .sel else .default);
ui.addstr("yes"); ui.addstr("delete");
box.move(4, 25); box.move(4, 13);
ui.style(if (confirm == .trash) .sel else .default);
ui.addstr("trash");
box.move(4, 21);
ui.style(if (confirm == .no) .sel else .default); ui.style(if (confirm == .no) .sel else .default);
ui.addstr("no"); ui.addstr("no");
box.move(4, 31); box.move(4, 26);
ui.style(if (confirm == .ignore) .sel else .default); ui.style(if (confirm == .always_trash) .sel else .default);
ui.addstr("don't ask me again"); ui.addstr("always trash");
box.move(4, 41);
ui.style(if (confirm == .always_delete) .sel else .default);
ui.addstr("always delete");
} }
fn drawProgress() void { fn drawProgress() void {
var path = std.ArrayList(u8).init(main.allocator); var path = std.ArrayList(u8).init(main.allocator);
defer path.deinit(); defer path.deinit();
// This is a bit of a hack, but parent is not set correctly during trashItem recursion
// if (parent.sub.ptr == null) parent = entry.link().?.parent;
parent.fmtPath(false, &path); parent.fmtPath(false, &path);
path.append('/') catch unreachable; path.append('/') catch unreachable;
path.appendSlice(entry.name()) catch unreachable; path.appendSlice(entry.name()) catch unreachable;
// TODO: Item counts and progress bar would be nice. // TODO: Item counts and progress bar would be nice.
const box = ui.Box.create(6, 60, "Deleting..."); const title = switch (state) {
.busy_trash => "Trashing...",
.busy_delete => "Deleting...",
else => unreachable,
};
const box = ui.Box.create(6, 60, title);
box.move(2, 2); box.move(2, 2);
ui.addstr(ui.shorten(ui.toUtf8(util.arrayListBufZ(&path)), 56)); ui.addstr(ui.shorten(ui.toUtf8(util.arrayListBufZ(&path)), 56));
box.move(4, 41); box.move(4, 41);
@ -171,7 +245,7 @@ fn drawErr() void {
pub fn draw() void { pub fn draw() void {
switch (state) { switch (state) {
.confirm => drawConfirm(), .confirm => drawConfirm(),
.busy => drawProgress(), .busy_delete, .busy_trash => drawProgress(),
.err => drawErr(), .err => drawErr(),
} }
} }
@ -180,25 +254,36 @@ pub fn keyInput(ch: i32) void {
switch (state) { switch (state) {
.confirm => switch (ch) { .confirm => switch (ch) {
'h', c.KEY_LEFT => confirm = switch (confirm) { 'h', c.KEY_LEFT => confirm = switch (confirm) {
.ignore => .no, .delete => .always_delete,
else => .yes, .trash => .delete,
.no => .trash,
.always_trash => .no,
.always_delete => .always_trash,
}, },
'l', c.KEY_RIGHT => confirm = switch (confirm) { 'l', c.KEY_RIGHT => confirm = switch (confirm) {
.yes => .no, .delete => .trash,
else => .ignore, .trash => .no,
.no => .always_trash,
.always_trash => .always_delete,
.always_delete => .delete,
}, },
'q' => main.state = .browse, 'q' => main.state = .browse,
'\n' => switch (confirm) { '\n' => switch (confirm) {
.yes => state = .busy, .delete => state = .busy_delete,
.trash => state = .busy_trash,
.no => main.state = .browse, .no => main.state = .browse,
.ignore => { .always_trash => {
main.config.confirm_delete = false; main.config.delete_action = .always_trash;
state = .busy; state = .busy_trash;
},
.always_delete => {
main.config.delete_action = .always_delete;
state = .busy_delete;
}, },
}, },
else => {} else => {},
}, },
.busy => { .busy_delete, .busy_trash => {
if (ch == 'q') if (ch == 'q')
main.state = .browse; main.state = .browse;
}, },
@ -214,13 +299,16 @@ pub fn keyInput(ch: i32) void {
'q' => main.state = .browse, 'q' => main.state = .browse,
'\n' => switch (error_option) { '\n' => switch (error_option) {
.abort => main.state = .browse, .abort => main.state = .browse,
.ignore => state = .busy, .ignore, .all => {
.all => { if (error_option == .all)
main.config.ignore_delete_errors = true; main.config.ignore_delete_errors = true;
state = .busy; state = switch (state) {
.busy_trash, .busy_delete => state,
else => unreachable,
};
}, },
}, },
else => {} else => {},
}, },
} }
} }

View file

@ -1,6 +1,8 @@
// SPDX-FileCopyrightText: Yorhel <projects@yorhel.nl> // SPDX-FileCopyrightText: Yorhel <projects@yorhel.nl>
// SPDX-FileCopyrightText: 2025 Ivan Stepanov <ivanstepanov@gmail.com>
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
// zig fmt: off
pub const program_version = "2.8.2"; pub const program_version = "2.8.2";
const std = @import("std"); const std = @import("std");
@ -17,6 +19,7 @@ const ui = @import("ui.zig");
const browser = @import("browser.zig"); const browser = @import("browser.zig");
const delete = @import("delete.zig"); const delete = @import("delete.zig");
const util = @import("util.zig"); const util = @import("util.zig");
const trash = @import("trash.zig");
const exclude = @import("exclude.zig"); const exclude = @import("exclude.zig");
const c = @import("c.zig").c; const c = @import("c.zig").c;
@ -34,6 +37,7 @@ test "imports" {
_ = browser; _ = browser;
_ = delete; _ = delete;
_ = util; _ = util;
_ = trash;
_ = exclude; _ = exclude;
} }
@ -112,7 +116,8 @@ pub const config = struct {
pub var can_shell: ?bool = null; pub var can_shell: ?bool = null;
pub var can_refresh: ?bool = null; pub var can_refresh: ?bool = null;
pub var confirm_quit: bool = false; pub var confirm_quit: bool = false;
pub var confirm_delete: bool = true; pub const DeleteAction = enum { ask, always_trash, always_delete };
pub var delete_action: DeleteAction = .ask;
pub var ignore_delete_errors: bool = false; pub var ignore_delete_errors: bool = false;
}; };
@ -304,8 +309,8 @@ fn argConfig(args: *Args, opt: Args.Option, infile: bool) !void {
config.export_block_size = @as(usize, num) * 1024; config.export_block_size = @as(usize, num) * 1024;
} else if (opt.is("--confirm-quit")) config.confirm_quit = true } else if (opt.is("--confirm-quit")) config.confirm_quit = true
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.delete_action = .ask
else if (opt.is("--no-confirm-delete")) config.confirm_delete = false else if (opt.is("--no-confirm-delete")) config.delete_action = .always_delete
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 +433,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
\\ --trash-on-delete Use trash-can by default on 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.

106
src/trash.zig Normal file
View file

@ -0,0 +1,106 @@
// SPDX-FileCopyrightText: 2025 Ivan Stepanov <ivanstepanov@gmail.com>
// SPDX-License-Identifier: MIT
const std = @import("std");
const fs = std.fs;
const mem = std.mem;
const time = std.time;
/// Implements the FreeDesktop.org Trash specification for a single file or empty directory.
/// This function is not recursive; recursion should be handled by the caller.
pub fn trashFile(allocator: mem.Allocator, full_path: []const u8) !void {
const cwd = fs.cwd();
// 1. Determine the top-level trash directory path.
const trash_dir_path = try getTrashDir(allocator);
defer allocator.free(trash_dir_path);
// 2. Ensure the trash subdirectories 'files' and 'info' exist.
const trash_files_path = try fs.path.join(allocator, &.{ trash_dir_path, "files" });
defer allocator.free(trash_files_path);
const trash_info_path = try fs.path.join(allocator, &.{ trash_dir_path, "info" });
defer allocator.free(trash_info_path);
// Ensure the trash subdirectories exist using cwd relative operations
cwd.makeDir(trash_files_path) catch |e| switch (e) {
error.PathAlreadyExists => {},
else => return e,
};
cwd.makeDir(trash_info_path) catch |e| switch (e) {
error.PathAlreadyExists => {},
else => return e,
};
// 3. Handle name collisions to find a unique name in the trash.
const original_basename = fs.path.basename(full_path);
var dest_basename_buf = std.ArrayList(u8).init(allocator);
defer dest_basename_buf.deinit();
var counter: u32 = 1;
while (true) {
try dest_basename_buf.writer().print("{s}", .{original_basename});
const dest_file_path = try fs.path.join(allocator, &.{ trash_files_path, dest_basename_buf.items });
defer allocator.free(dest_file_path);
// Try to access the file - if it doesn't exist, we found our unique name
cwd.access(dest_file_path, .{}) catch |err| {
if (err == error.FileNotFound) {
// This name is available
break;
}
return err;
};
// File exists, try next name
counter += 1;
dest_basename_buf.clearRetainingCapacity();
const ext = fs.path.extension(original_basename);
const stem = fs.path.stem(original_basename);
try dest_basename_buf.writer().print("{s}.{d}{s}", .{ stem, counter, ext });
}
const final_basename = try dest_basename_buf.toOwnedSlice();
defer allocator.free(final_basename);
// 4. Create and write the .trashinfo file.
const info_file_name = try std.fmt.allocPrint(allocator, "{s}.trashinfo", .{final_basename});
defer allocator.free(info_file_name);
const info_file_path = try fs.path.join(allocator, &.{ trash_info_path, info_file_name });
defer allocator.free(info_file_path);
const info_file = try cwd.createFile(info_file_path, .{});
defer info_file.close();
const datetime = "2025-01-01T00:00:00"; // Simple placeholder for now
// For simplicity, use the path as-is (proper URI encoding would be ideal)
const uri_encoded_path = full_path;
try info_file.writer().print(
\\ [Trash Info]
\\ Path=file://{s}
\\ DeletionDate={s}
\\
, .{ uri_encoded_path, datetime });
// 5. Finally, move the original file to the trash 'files' directory.
const final_dest_path = try fs.path.join(allocator, &.{ trash_files_path, final_basename });
defer allocator.free(final_dest_path);
try cwd.rename(full_path, final_dest_path);
}
/// Finds the user's trash directory according to the XDG Base Directory Specification.
fn getTrashDir(allocator: mem.Allocator) ![]u8 {
if (std.posix.getenv("XDG_DATA_HOME")) |xdg_data_home| {
if (xdg_data_home.len > 0) {
return fs.path.join(allocator, &.{ xdg_data_home, "Trash" });
}
}
const home_dir = std.posix.getenv("HOME") orelse {
return error.HomeDirectoryNotFound;
};
return fs.path.join(allocator, &.{ home_dir, ".local", "share", "Trash" });
}