地圖 (Jido) is a lightweight Unix TUI file explorer designed for speed and simplicity.

feat: Added ability to copy folders. This is done by (y)anking the file, then (p)asting in the desired directory. This action can be (u)ndone and behind the scenes is a deletion.

+1 -1
PROJECT_BOARD.md
··· 9 9 10 10 ### New features 11 11 - [ ] File/Folder movement. 12 - - [ ] Copy files. 12 + - [x] Copy files. 13 13 - [ ] Copy folders. 14 14 - [ ] Keybind to unzip archives. 15 15 - [x] Keybind to hard delete items (bypass trash).
+4
README.md
··· 56 56 "Command mode" section for available commands. Will enter 57 57 input mode. 58 58 v :Verbose mode. Provides more information about selected entry. 59 + y :Yank selected item. 60 + p :Past yanked item. 59 61 60 62 Input mode: 61 63 <Esc> :Cancel input. ··· 114 116 .toggle_verbose_file_information: ?Char = 'v', 115 117 .force_delete: ?Char = null -- Files deleted this way are 116 118 not recoverable 119 + .yank: ?Char = 'y' 120 + .paste: ?Char = 'p' 117 121 } 118 122 119 123 NotificationStyles = struct {
+1 -1
build.zig
··· 2 2 const builtin = @import("builtin"); 3 3 4 4 ///Must match the `version` in `build.zig.zon`. 5 - const version = std.SemanticVersion{ .major = 0, .minor = 9, .patch = 6 }; 5 + const version = std.SemanticVersion{ .major = 0, .minor = 9, .patch = 7 }; 6 6 7 7 const targets: []const std.Target.Query = &.{ 8 8 .{ .cpu_arch = .aarch64, .os_tag = .macos },
+1 -1
build.zig.zon
··· 1 1 .{ 2 2 .name = .jido, 3 3 .fingerprint = 0xee45eabe36cafb57, 4 - .version = "0.9.6", 4 + .version = "0.9.7", 5 5 .minimum_zig_version = "0.14.0", 6 6 7 7 .dependencies = .{
+10
src/app.zig
··· 38 38 " \"Command mode\" section for available commands. Will enter ", 39 39 " input mode.", 40 40 "v :Verbose mode. Provides more information about selected entry. ", 41 + "y :Yank selected item.", 42 + "p :Past yanked item.", 41 43 "", 42 44 "Input mode:", 43 45 "<Esc> :Cancel input.", ··· 74 76 pub const Action = union(enum) { 75 77 delete: ActionPaths, 76 78 rename: ActionPaths, 79 + paste: []const u8, 77 80 }; 78 81 79 82 pub const Event = union(enum) { ··· 103 106 text_input: vaxis.widgets.TextInput, 104 107 text_input_buf: [std.fs.max_path_bytes]u8 = undefined, 105 108 109 + yanked: ?struct { dir: []const u8, entry: std.fs.Dir.Entry } = null, 106 110 image: ?vaxis.Image = null, 107 111 last_known_height: usize, 108 112 ··· 140 144 self.alloc.free(a.new); 141 145 self.alloc.free(a.old); 142 146 }, 147 + .paste => |a| self.alloc.free(a), 143 148 } 149 + } 150 + 151 + if (self.yanked) |yanked| { 152 + self.alloc.free(yanked.dir); 153 + self.alloc.free(yanked.entry.name); 144 154 } 145 155 146 156 self.command_history.resetSelected();
+2
src/config.zig
··· 210 210 jump_bottom: ?Char = @enumFromInt('G'), 211 211 toggle_verbose_file_information: ?Char = @enumFromInt('v'), 212 212 force_delete: ?Char = null, 213 + paste: ?Char = @enumFromInt('p'), 214 + yank: ?Char = @enumFromInt('y'), 213 215 }; 214 216 215 217 const Styles = struct {
+25
src/environment.zig
··· 1 1 const std = @import("std"); 2 + const zuid = @import("zuid"); 2 3 const builtin = @import("builtin"); 3 4 4 5 pub fn getHomeDir() !?std.fs.Dir { ··· 21 22 } 22 23 } 23 24 return null; 25 + } 26 + 27 + pub fn checkDuplicatePath( 28 + buf: []u8, 29 + dir: std.fs.Dir, 30 + relative_path: []const u8, 31 + ) std.fmt.BufPrintError!struct { 32 + path: []const u8, 33 + had_duplicate: bool, 34 + } { 35 + var had_duplicate = false; 36 + const new_path = if (fileExists(dir, relative_path)) lbl: { 37 + had_duplicate = true; 38 + const extension = std.fs.path.extension(relative_path); 39 + break :lbl try std.fmt.bufPrint( 40 + buf, 41 + "{s}-{s}{s}", 42 + .{ relative_path[0 .. relative_path.len - extension.len], zuid.new.v4(), extension }, 43 + ); 44 + } else lbl: { 45 + break :lbl try std.fmt.bufPrint(buf, "{s}", .{relative_path}); 46 + }; 47 + 48 + return .{ .path = new_path, .had_duplicate = had_duplicate }; 24 49 } 25 50 26 51 pub fn openFile(
+157 -20
src/event_handlers.zig
··· 192 192 }; 193 193 app.directories.removeSelected(); 194 194 }, 195 + .yank => { 196 + if (app.yanked) |yanked| { 197 + app.alloc.free(yanked.dir); 198 + app.alloc.free(yanked.entry.name); 199 + } 200 + 201 + app.yanked = lbl: { 202 + const entry = (app.directories.getSelected() catch { 203 + app.notification.write("Failed to yank item - no item selected.", .warn) catch {}; 204 + break :lbl null; 205 + }) orelse break :lbl null; 206 + 207 + switch (entry.kind) { 208 + .file => { 209 + break :lbl .{ 210 + .dir = try app.alloc.dupe(u8, app.directories.fullPath(".") catch { 211 + const message = try std.fmt.allocPrint( 212 + app.alloc, 213 + "Failed to yank '{s}' - unable to retrieve directory path.", 214 + .{entry.name}, 215 + ); 216 + defer app.alloc.free(message); 217 + app.notification.write(message, .err) catch {}; 218 + break :lbl null; 219 + }), 220 + .entry = .{ 221 + .kind = entry.kind, 222 + .name = try app.alloc.dupe(u8, entry.name), 223 + }, 224 + }; 225 + }, 226 + else => { 227 + const message = try std.fmt.allocPrint( 228 + app.alloc, 229 + "Failed to yank '{s}' - unsupported file type '{s}'.", 230 + .{ entry.name, @tagName(entry.kind) }, 231 + ); 232 + defer app.alloc.free(message); 233 + app.notification.write(message, .warn) catch {}; 234 + break :lbl null; 235 + }, 236 + } 237 + }; 238 + if (app.yanked) |y| { 239 + const message = try std.fmt.allocPrint(app.alloc, "Yanked '{s}'.", .{y.entry.name}); 240 + defer app.alloc.free(message); 241 + app.notification.write(message, .info) catch {}; 242 + } 243 + }, 244 + .paste => { 245 + const yanked = if (app.yanked) |y| y else return; 246 + 247 + var new_path_buf: [std.fs.max_path_bytes]u8 = undefined; 248 + const new_path_res = environment.checkDuplicatePath(&new_path_buf, app.directories.dir, yanked.entry.name) catch { 249 + const message = try std.fmt.allocPrint(app.alloc, "Failed to copy '{s}' - path too long.", .{yanked.entry.name}); 250 + defer app.alloc.free(message); 251 + app.notification.write(message, .err) catch {}; 252 + return; 253 + }; 254 + 255 + switch (yanked.entry.kind) { 256 + .file => { 257 + var source_dir = std.fs.openDirAbsolute(yanked.dir, .{ .iterate = true }) catch { 258 + const message = try std.fmt.allocPrint(app.alloc, "Failed to copy '{s}' - unable to open directory '{s}'.", .{ yanked.entry.name, yanked.dir }); 259 + defer app.alloc.free(message); 260 + app.notification.write(message, .err) catch {}; 261 + return; 262 + }; 263 + defer source_dir.close(); 264 + std.fs.Dir.copyFile( 265 + source_dir, 266 + yanked.entry.name, 267 + app.directories.dir, 268 + new_path_res.path, 269 + .{}, 270 + ) catch |err| switch (err) { 271 + error.FileNotFound => { 272 + const message = try std.fmt.allocPrint(app.alloc, "Failed to copy '{s}' - the original file was deleted or moved.", .{yanked.entry.name}); 273 + defer app.alloc.free(message); 274 + app.notification.write(message, .err) catch {}; 275 + return; 276 + }, 277 + else => { 278 + const message = try std.fmt.allocPrint(app.alloc, "Failed to copy '{s}' - {}.", .{ yanked.entry.name, err }); 279 + defer app.alloc.free(message); 280 + app.notification.write(message, .err) catch {}; 281 + return; 282 + }, 283 + }; 284 + 285 + // Append action to undo history. 286 + { 287 + var new_path_abs_buf: [std.fs.max_path_bytes]u8 = undefined; 288 + const new_path_abs = app.directories.dir.realpath(new_path_res.path, &new_path_abs_buf) catch { 289 + const message = try std.fmt.allocPrint( 290 + app.alloc, 291 + "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.", 292 + .{ new_path_res.path, yanked.entry.name }, 293 + ); 294 + defer app.alloc.free(message); 295 + app.notification.write(message, .err) catch {}; 296 + return; 297 + }; 298 + 299 + if (app.actions.push(.{ 300 + .paste = try app.alloc.dupe(u8, new_path_abs), 301 + })) |prev_elem| { 302 + app.alloc.free(prev_elem.delete.prev_path); 303 + app.alloc.free(prev_elem.delete.new_path); 304 + } 305 + } 306 + 307 + const message = try std.fmt.allocPrint(app.alloc, "Copied '{s}'.", .{yanked.entry.name}); 308 + defer app.alloc.free(message); 309 + app.notification.write(message, .info) catch {}; 310 + }, 311 + else => { 312 + const message = try std.fmt.allocPrint( 313 + app.alloc, 314 + "Failed to copy '{s}' - unsupported file type '{s}'.", 315 + .{ yanked.entry.name, @tagName(yanked.entry.kind) }, 316 + ); 317 + defer app.alloc.free(message); 318 + app.notification.write(message, .warn) catch {}; 319 + }, 320 + } 321 + 322 + app.directories.clearEntries(); 323 + app.directories.populateEntries("") catch |err| { 324 + switch (err) { 325 + error.AccessDenied => app.notification.writeErr(.PermissionDenied) catch {}, 326 + else => app.notification.writeErr(.UnknownError) catch {}, 327 + } 328 + }; 329 + }, 195 330 } 196 331 } else { 197 332 switch (key.codepoint) { ··· 287 422 288 423 switch (action) { 289 424 .delete => |a| { 290 - defer app.alloc.free(a.new); 291 - defer app.alloc.free(a.old); 425 + defer app.alloc.free(a.new_path); 426 + defer app.alloc.free(a.prev_path); 292 427 293 - var had_duplicate = false; 294 - 295 - // Handle if item with same name already exists. 296 428 var new_path_buf: [std.fs.max_path_bytes]u8 = undefined; 297 - const new_path = if (environment.fileExists(app.directories.dir, a.old)) lbl: { 298 - const extension = std.fs.path.extension(a.old); 299 - had_duplicate = true; 300 - break :lbl try std.fmt.bufPrint( 301 - &new_path_buf, 302 - "{s}-{s}{s}", 303 - .{ a.old[0 .. a.old.len - extension.len], zuid.new.v4(), extension }, 304 - ); 305 - } else lbl: { 306 - break :lbl a.old; 307 - }; 429 + const new_path_res = try environment.checkDuplicatePath(&new_path_buf, app.directories.dir, a.prev_path); 308 430 309 - if (app.directories.dir.rename(a.new, new_path)) { 431 + if (app.directories.dir.rename(a.new_path, new_path_res.path)) { 310 432 app.directories.clearEntries(); 311 - const fuzzy = inputToSlice(app); 312 - app.directories.populateEntries(fuzzy) catch |err| { 433 + app.directories.populateEntries("") catch |err| { 313 434 switch (err) { 314 435 error.AccessDenied => try app.notification.writeErr(.PermissionDenied), 315 436 else => try app.notification.writeErr(.UnknownError), 316 437 } 317 438 }; 318 - if (had_duplicate) { 439 + if (new_path_res.had_duplicate) { 319 440 try app.notification.writeWarn(.DuplicateFileOnUndo); 320 441 } else { 321 442 try app.notification.writeInfo(.RestoredDelete); ··· 361 482 } else |_| { 362 483 try app.notification.writeErr(.UnableToUndo); 363 484 } 485 + }, 486 + .paste => |path| { 487 + defer app.alloc.free(path); 488 + 489 + app.directories.dir.deleteTree(path) catch { 490 + try app.notification.writeErr(.UnableToUndo); 491 + return; 492 + }; 493 + 494 + app.directories.clearEntries(); 495 + app.directories.populateEntries("") catch |err| { 496 + switch (err) { 497 + error.AccessDenied => try app.notification.writeErr(.PermissionDenied), 498 + else => try app.notification.writeErr(.UnknownError), 499 + } 500 + }; 364 501 }, 365 502 } 366 503