mirror of
https://code.blicky.net/yorhel/ncdu.git
synced 2026-01-12 17:08:39 -09:00
Implement trash functionality for file deletion and update delete confirmation options. Closes #215.
This commit is contained in:
parent
67f34090fb
commit
52c6fa7e82
3 changed files with 239 additions and 39 deletions
160
src/delete.zig
160
src/delete.zig
|
|
@ -1,10 +1,12 @@
|
|||
// SPDX-FileCopyrightText: Yorhel <projects@yorhel.nl>
|
||||
// SPDX-FileCopyrightText: 2025 Ivan Stepanov <ivanstepanov@gmail.com>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const std = @import("std");
|
||||
const main = @import("main.zig");
|
||||
const model = @import("model.zig");
|
||||
const ui = @import("ui.zig");
|
||||
const trash = @import("trash.zig");
|
||||
const browser = @import("browser.zig");
|
||||
const util = @import("util.zig");
|
||||
const c = @import("c.zig").c;
|
||||
|
|
@ -12,8 +14,8 @@ const c = @import("c.zig").c;
|
|||
var parent: *model.Dir = undefined;
|
||||
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 state: enum { confirm, busy_delete, busy_trash, err } = .confirm;
|
||||
var confirm: enum { delete, trash, no, always_trash, always_delete } = .no;
|
||||
var error_option: enum { abort, ignore, all } = .abort;
|
||||
var error_code: anyerror = undefined;
|
||||
|
||||
|
|
@ -21,8 +23,14 @@ pub fn setup(p: *model.Dir, e: *model.Entry, n: ?*model.Entry) void {
|
|||
parent = p;
|
||||
entry = e;
|
||||
next_sel = n;
|
||||
state = if (main.config.confirm_delete) .confirm else .busy;
|
||||
confirm = .no;
|
||||
switch (main.config.delete_action) {
|
||||
.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;
|
||||
}
|
||||
|
||||
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 {
|
||||
entry = ptr.*.?;
|
||||
main.handleEvent(false, false);
|
||||
|
|
@ -82,14 +108,45 @@ pub fn delete() ?*model.Entry {
|
|||
if (it.* == entry)
|
||||
break;
|
||||
|
||||
var path = std.ArrayList(u8).init(main.allocator);
|
||||
defer path.deinit();
|
||||
parent.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;
|
||||
// var path = std.ArrayList(u8).init(main.allocator);
|
||||
// defer path.deinit();
|
||||
// parent.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(), 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();
|
||||
return if (it.* == e) e else next_sel;
|
||||
}
|
||||
|
|
@ -104,33 +161,50 @@ fn drawConfirm() void {
|
|||
if (entry.pack.etype != .dir)
|
||||
ui.addch('?')
|
||||
else {
|
||||
box.move(2, 18);
|
||||
box.move(2, 17);
|
||||
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, 4);
|
||||
ui.style(if (confirm == .delete) .sel else .default);
|
||||
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.addstr("no");
|
||||
|
||||
box.move(4, 31);
|
||||
ui.style(if (confirm == .ignore) .sel else .default);
|
||||
ui.addstr("don't ask me again");
|
||||
box.move(4, 26);
|
||||
ui.style(if (confirm == .always_trash) .sel else .default);
|
||||
ui.addstr("always trash");
|
||||
|
||||
box.move(4, 41);
|
||||
ui.style(if (confirm == .always_delete) .sel else .default);
|
||||
ui.addstr("always delete");
|
||||
}
|
||||
|
||||
fn drawProgress() void {
|
||||
var path = std.ArrayList(u8).init(main.allocator);
|
||||
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);
|
||||
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...");
|
||||
const title = switch (state) {
|
||||
.busy_trash => "Trashing...",
|
||||
.busy_delete => "Deleting...",
|
||||
else => unreachable,
|
||||
};
|
||||
const box = ui.Box.create(6, 60, title);
|
||||
box.move(2, 2);
|
||||
ui.addstr(ui.shorten(ui.toUtf8(util.arrayListBufZ(&path)), 56));
|
||||
box.move(4, 41);
|
||||
|
|
@ -171,7 +245,7 @@ fn drawErr() void {
|
|||
pub fn draw() void {
|
||||
switch (state) {
|
||||
.confirm => drawConfirm(),
|
||||
.busy => drawProgress(),
|
||||
.busy_delete, .busy_trash => drawProgress(),
|
||||
.err => drawErr(),
|
||||
}
|
||||
}
|
||||
|
|
@ -180,25 +254,36 @@ pub fn keyInput(ch: i32) void {
|
|||
switch (state) {
|
||||
.confirm => switch (ch) {
|
||||
'h', c.KEY_LEFT => confirm = switch (confirm) {
|
||||
.ignore => .no,
|
||||
else => .yes,
|
||||
.delete => .always_delete,
|
||||
.trash => .delete,
|
||||
.no => .trash,
|
||||
.always_trash => .no,
|
||||
.always_delete => .always_trash,
|
||||
},
|
||||
'l', c.KEY_RIGHT => confirm = switch (confirm) {
|
||||
.yes => .no,
|
||||
else => .ignore,
|
||||
.delete => .trash,
|
||||
.trash => .no,
|
||||
.no => .always_trash,
|
||||
.always_trash => .always_delete,
|
||||
.always_delete => .delete,
|
||||
},
|
||||
'q' => main.state = .browse,
|
||||
'\n' => switch (confirm) {
|
||||
.yes => state = .busy,
|
||||
.delete => state = .busy_delete,
|
||||
.trash => state = .busy_trash,
|
||||
.no => main.state = .browse,
|
||||
.ignore => {
|
||||
main.config.confirm_delete = false;
|
||||
state = .busy;
|
||||
.always_trash => {
|
||||
main.config.delete_action = .always_trash;
|
||||
state = .busy_trash;
|
||||
},
|
||||
.always_delete => {
|
||||
main.config.delete_action = .always_delete;
|
||||
state = .busy_delete;
|
||||
},
|
||||
},
|
||||
else => {}
|
||||
else => {},
|
||||
},
|
||||
.busy => {
|
||||
.busy_delete, .busy_trash => {
|
||||
if (ch == 'q')
|
||||
main.state = .browse;
|
||||
},
|
||||
|
|
@ -214,13 +299,16 @@ pub fn keyInput(ch: i32) void {
|
|||
'q' => main.state = .browse,
|
||||
'\n' => switch (error_option) {
|
||||
.abort => main.state = .browse,
|
||||
.ignore => state = .busy,
|
||||
.all => {
|
||||
main.config.ignore_delete_errors = true;
|
||||
state = .busy;
|
||||
.ignore, .all => {
|
||||
if (error_option == .all)
|
||||
main.config.ignore_delete_errors = true;
|
||||
state = switch (state) {
|
||||
.busy_trash, .busy_delete => state,
|
||||
else => unreachable,
|
||||
};
|
||||
},
|
||||
},
|
||||
else => {}
|
||||
else => {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
12
src/main.zig
12
src/main.zig
|
|
@ -1,6 +1,8 @@
|
|||
// SPDX-FileCopyrightText: Yorhel <projects@yorhel.nl>
|
||||
// SPDX-FileCopyrightText: 2025 Ivan Stepanov <ivanstepanov@gmail.com>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// zig fmt: off
|
||||
pub const program_version = "2.8.2";
|
||||
|
||||
const std = @import("std");
|
||||
|
|
@ -17,6 +19,7 @@ const ui = @import("ui.zig");
|
|||
const browser = @import("browser.zig");
|
||||
const delete = @import("delete.zig");
|
||||
const util = @import("util.zig");
|
||||
const trash = @import("trash.zig");
|
||||
const exclude = @import("exclude.zig");
|
||||
const c = @import("c.zig").c;
|
||||
|
||||
|
|
@ -34,6 +37,7 @@ test "imports" {
|
|||
_ = browser;
|
||||
_ = delete;
|
||||
_ = util;
|
||||
_ = trash;
|
||||
_ = exclude;
|
||||
}
|
||||
|
||||
|
|
@ -112,7 +116,8 @@ pub const config = struct {
|
|||
pub var can_shell: ?bool = null;
|
||||
pub var can_refresh: ?bool = null;
|
||||
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;
|
||||
};
|
||||
|
||||
|
|
@ -304,8 +309,8 @@ fn argConfig(args: *Args, opt: Args.Option, infile: bool) !void {
|
|||
config.export_block_size = @as(usize, num) * 1024;
|
||||
} 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("--confirm-delete")) config.confirm_delete = true
|
||||
else if (opt.is("--no-confirm-delete")) config.confirm_delete = false
|
||||
else if (opt.is("--confirm-delete")) config.delete_action = .ask
|
||||
else if (opt.is("--no-confirm-delete")) config.delete_action = .always_delete
|
||||
else if (opt.is("--color")) {
|
||||
const val = try args.arg();
|
||||
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
|
||||
\\ --confirm-quit Ask confirmation before quitting ncdu
|
||||
\\ --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
|
||||
\\
|
||||
\\Refer to `man ncdu` for more information.
|
||||
|
|
|
|||
106
src/trash.zig
Normal file
106
src/trash.zig
Normal 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" });
|
||||
}
|
||||
Loading…
Reference in a new issue