diff --git a/PROJECT_BOARD.md b/PROJECT_BOARD.md index 50a6332..7544779 100644 --- a/PROJECT_BOARD.md +++ b/PROJECT_BOARD.md @@ -9,7 +9,7 @@ Key: ### New features - [ ] File/Folder movement. - - [ ] Copy files. + - [x] Copy files. - [ ] Copy folders. - [ ] Keybind to unzip archives. - [x] Keybind to hard delete items (bypass trash). diff --git a/README.md b/README.md index 014345e..4fb1092 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,8 @@ d :Create directory. Will enter input mode. "Command mode" section for available commands. Will enter input mode. v :Verbose mode. Provides more information about selected entry. +y :Yank selected item. +p :Past yanked item. Input mode: :Cancel input. @@ -114,6 +116,8 @@ Keybinds = struct { .toggle_verbose_file_information: ?Char = 'v', .force_delete: ?Char = null -- Files deleted this way are not recoverable + .yank: ?Char = 'y' + .paste: ?Char = 'p' } NotificationStyles = struct { diff --git a/build.zig b/build.zig index 90cfc92..27a604b 100644 --- a/build.zig +++ b/build.zig @@ -2,7 +2,7 @@ const std = @import("std"); const builtin = @import("builtin"); ///Must match the `version` in `build.zig.zon`. -const version = std.SemanticVersion{ .major = 0, .minor = 9, .patch = 6 }; +const version = std.SemanticVersion{ .major = 0, .minor = 9, .patch = 7 }; const targets: []const std.Target.Query = &.{ .{ .cpu_arch = .aarch64, .os_tag = .macos }, diff --git a/build.zig.zon b/build.zig.zon index c3089e3..1a49c70 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,7 +1,7 @@ .{ .name = .jido, .fingerprint = 0xee45eabe36cafb57, - .version = "0.9.6", + .version = "0.9.7", .minimum_zig_version = "0.14.0", .dependencies = .{ diff --git a/src/app.zig b/src/app.zig index ab3a7cd..c058cae 100644 --- a/src/app.zig +++ b/src/app.zig @@ -38,6 +38,8 @@ const help_menu_items = [_][]const u8{ " \"Command mode\" section for available commands. Will enter ", " input mode.", "v :Verbose mode. Provides more information about selected entry. ", + "y :Yank selected item.", + "p :Past yanked item.", "", "Input mode:", " :Cancel input.", @@ -74,6 +76,7 @@ const ActionPaths = struct { pub const Action = union(enum) { delete: ActionPaths, rename: ActionPaths, + paste: []const u8, }; pub const Event = union(enum) { @@ -103,6 +106,7 @@ file_logger: FileLogger = undefined, text_input: vaxis.widgets.TextInput, text_input_buf: [std.fs.max_path_bytes]u8 = undefined, +yanked: ?struct { dir: []const u8, entry: std.fs.Dir.Entry } = null, image: ?vaxis.Image = null, last_known_height: usize, @@ -140,9 +144,15 @@ pub fn deinit(self: *App) void { self.alloc.free(a.new); self.alloc.free(a.old); }, + .paste => |a| self.alloc.free(a), } } + if (self.yanked) |yanked| { + self.alloc.free(yanked.dir); + self.alloc.free(yanked.entry.name); + } + self.command_history.resetSelected(); while (self.command_history.next()) |command| { self.alloc.free(command); diff --git a/src/config.zig b/src/config.zig index adaae7c..6e068cb 100644 --- a/src/config.zig +++ b/src/config.zig @@ -210,6 +210,8 @@ pub const Keybinds = struct { jump_bottom: ?Char = @enumFromInt('G'), toggle_verbose_file_information: ?Char = @enumFromInt('v'), force_delete: ?Char = null, + paste: ?Char = @enumFromInt('p'), + yank: ?Char = @enumFromInt('y'), }; const Styles = struct { diff --git a/src/environment.zig b/src/environment.zig index 2cf28b8..55cb4f9 100644 --- a/src/environment.zig +++ b/src/environment.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const zuid = @import("zuid"); const builtin = @import("builtin"); pub fn getHomeDir() !?std.fs.Dir { @@ -23,6 +24,30 @@ pub fn getEditor() ?[]const u8 { return null; } +pub fn checkDuplicatePath( + buf: []u8, + dir: std.fs.Dir, + relative_path: []const u8, +) std.fmt.BufPrintError!struct { + path: []const u8, + had_duplicate: bool, +} { + var had_duplicate = false; + const new_path = if (fileExists(dir, relative_path)) lbl: { + had_duplicate = true; + const extension = std.fs.path.extension(relative_path); + break :lbl try std.fmt.bufPrint( + buf, + "{s}-{s}{s}", + .{ relative_path[0 .. relative_path.len - extension.len], zuid.new.v4(), extension }, + ); + } else lbl: { + break :lbl try std.fmt.bufPrint(buf, "{s}", .{relative_path}); + }; + + return .{ .path = new_path, .had_duplicate = had_duplicate }; +} + pub fn openFile( alloc: std.mem.Allocator, dir: std.fs.Dir, diff --git a/src/event_handlers.zig b/src/event_handlers.zig index e7739e1..747d8d2 100644 --- a/src/event_handlers.zig +++ b/src/event_handlers.zig @@ -192,6 +192,141 @@ pub fn handleNormalEvent( }; app.directories.removeSelected(); }, + .yank => { + if (app.yanked) |yanked| { + app.alloc.free(yanked.dir); + app.alloc.free(yanked.entry.name); + } + + app.yanked = lbl: { + const entry = (app.directories.getSelected() catch { + app.notification.write("Failed to yank item - no item selected.", .warn) catch {}; + break :lbl null; + }) orelse break :lbl null; + + switch (entry.kind) { + .file => { + break :lbl .{ + .dir = try app.alloc.dupe(u8, app.directories.fullPath(".") catch { + const message = try std.fmt.allocPrint( + app.alloc, + "Failed to yank '{s}' - unable to retrieve directory path.", + .{entry.name}, + ); + defer app.alloc.free(message); + app.notification.write(message, .err) catch {}; + break :lbl null; + }), + .entry = .{ + .kind = entry.kind, + .name = try app.alloc.dupe(u8, entry.name), + }, + }; + }, + else => { + const message = try std.fmt.allocPrint( + app.alloc, + "Failed to yank '{s}' - unsupported file type '{s}'.", + .{ entry.name, @tagName(entry.kind) }, + ); + defer app.alloc.free(message); + app.notification.write(message, .warn) catch {}; + break :lbl null; + }, + } + }; + if (app.yanked) |y| { + const message = try std.fmt.allocPrint(app.alloc, "Yanked '{s}'.", .{y.entry.name}); + defer app.alloc.free(message); + app.notification.write(message, .info) catch {}; + } + }, + .paste => { + const yanked = if (app.yanked) |y| y else return; + + var new_path_buf: [std.fs.max_path_bytes]u8 = undefined; + const new_path_res = environment.checkDuplicatePath(&new_path_buf, app.directories.dir, yanked.entry.name) catch { + const message = try std.fmt.allocPrint(app.alloc, "Failed to copy '{s}' - path too long.", .{yanked.entry.name}); + defer app.alloc.free(message); + app.notification.write(message, .err) catch {}; + return; + }; + + switch (yanked.entry.kind) { + .file => { + var source_dir = std.fs.openDirAbsolute(yanked.dir, .{ .iterate = true }) catch { + const message = try std.fmt.allocPrint(app.alloc, "Failed to copy '{s}' - unable to open directory '{s}'.", .{ yanked.entry.name, yanked.dir }); + defer app.alloc.free(message); + app.notification.write(message, .err) catch {}; + return; + }; + defer source_dir.close(); + std.fs.Dir.copyFile( + source_dir, + yanked.entry.name, + app.directories.dir, + new_path_res.path, + .{}, + ) catch |err| switch (err) { + error.FileNotFound => { + const message = try std.fmt.allocPrint(app.alloc, "Failed to copy '{s}' - the original file was deleted or moved.", .{yanked.entry.name}); + defer app.alloc.free(message); + app.notification.write(message, .err) catch {}; + return; + }, + else => { + const message = try std.fmt.allocPrint(app.alloc, "Failed to copy '{s}' - {}.", .{ yanked.entry.name, err }); + defer app.alloc.free(message); + app.notification.write(message, .err) catch {}; + return; + }, + }; + + // Append action to undo history. + { + var new_path_abs_buf: [std.fs.max_path_bytes]u8 = undefined; + const new_path_abs = app.directories.dir.realpath(new_path_res.path, &new_path_abs_buf) catch { + const message = try std.fmt.allocPrint( + app.alloc, + "Failed to push copy action for '{s}' to undo history - unable to retrieve absolute directory path for '{s}'. This action will not be able to be undone via the `undo` keybind.", + .{ new_path_res.path, yanked.entry.name }, + ); + defer app.alloc.free(message); + app.notification.write(message, .err) catch {}; + return; + }; + + if (app.actions.push(.{ + .paste = try app.alloc.dupe(u8, new_path_abs), + })) |prev_elem| { + app.alloc.free(prev_elem.delete.prev_path); + app.alloc.free(prev_elem.delete.new_path); + } + } + + const message = try std.fmt.allocPrint(app.alloc, "Copied '{s}'.", .{yanked.entry.name}); + defer app.alloc.free(message); + app.notification.write(message, .info) catch {}; + }, + else => { + const message = try std.fmt.allocPrint( + app.alloc, + "Failed to copy '{s}' - unsupported file type '{s}'.", + .{ yanked.entry.name, @tagName(yanked.entry.kind) }, + ); + defer app.alloc.free(message); + app.notification.write(message, .warn) catch {}; + }, + } + + app.directories.clearEntries(); + app.directories.populateEntries("") catch |err| { + switch (err) { + error.AccessDenied => app.notification.writeErr(.PermissionDenied) catch {}, + else => app.notification.writeErr(.UnknownError) catch {}, + } + }; + }, } } else { switch (key.codepoint) { @@ -287,35 +422,21 @@ pub fn handleNormalEvent( switch (action) { .delete => |a| { - defer app.alloc.free(a.new); - defer app.alloc.free(a.old); - - var had_duplicate = false; + defer app.alloc.free(a.new_path); + defer app.alloc.free(a.prev_path); - // Handle if item with same name already exists. var new_path_buf: [std.fs.max_path_bytes]u8 = undefined; - const new_path = if (environment.fileExists(app.directories.dir, a.old)) lbl: { - const extension = std.fs.path.extension(a.old); - had_duplicate = true; - break :lbl try std.fmt.bufPrint( - &new_path_buf, - "{s}-{s}{s}", - .{ a.old[0 .. a.old.len - extension.len], zuid.new.v4(), extension }, - ); - } else lbl: { - break :lbl a.old; - }; + const new_path_res = try environment.checkDuplicatePath(&new_path_buf, app.directories.dir, a.prev_path); - if (app.directories.dir.rename(a.new, new_path)) { + if (app.directories.dir.rename(a.new_path, new_path_res.path)) { app.directories.clearEntries(); - const fuzzy = inputToSlice(app); - app.directories.populateEntries(fuzzy) catch |err| { + app.directories.populateEntries("") catch |err| { switch (err) { error.AccessDenied => try app.notification.writeErr(.PermissionDenied), else => try app.notification.writeErr(.UnknownError), } }; - if (had_duplicate) { + if (new_path_res.had_duplicate) { try app.notification.writeWarn(.DuplicateFileOnUndo); } else { try app.notification.writeInfo(.RestoredDelete); @@ -362,6 +483,22 @@ pub fn handleNormalEvent( try app.notification.writeErr(.UnableToUndo); } }, + .paste => |path| { + defer app.alloc.free(path); + + app.directories.dir.deleteTree(path) catch { + try app.notification.writeErr(.UnableToUndo); + return; + }; + + app.directories.clearEntries(); + app.directories.populateEntries("") catch |err| { + switch (err) { + error.AccessDenied => try app.notification.writeErr(.PermissionDenied), + else => try app.notification.writeErr(.UnknownError), + } + }; + }, } app.directories.entries.selected = selected;