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

- feat: Added keybind `v` to view additional information about the selected entry. - feat: Added config option `true_dir_size` to see the true size of directories. - docs: Added default config values to readme.

+168 -44
+4
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 + ## v0.9.2 (2025-03-23) 4 + - feat: Added keybind `v` to view additional information about the selected entry. 5 + - feat: Added config option `true_dir_size` to see the true size of directories. 6 + 3 7 ## v0.9.1 (2025-03-23) 4 8 - feat: File permissions are now displayed in the file information bar to the 5 9 bottom of Jido.
+27 -18
README.md
··· 50 50 d :Create directory. Will enter input mode. 51 51 % :Create file. Will enter input mode. 52 52 / :Fuzzy search directory. Will enter input mode. 53 - . :Show/Hide hidden files. 53 + . :Toggle hidden files. 54 54 : :Allows for Jido commands to be entered. Please refer to the 55 55 "Command mode" section for available commands. Will enter 56 56 input mode. 57 + v :Verbose mode. Provides more information about selected entry. 57 58 58 59 Input mode: 59 60 <Esc> :Cancel input. ··· 73 74 - `$HOME/.jido/config.json` 74 75 - `$XDG_CONFIG_HOME/jido/config.json`. 75 76 76 - Jido will look for these env variables specifically. If they are not set, Jido will 77 - not be able to find the config file. 77 + Jido will look for these env variables specifically. If they are not set, Jido 78 + will not be able to find the config file. 78 79 79 80 An example config file can be found [here](https://github.com/BrookJeynes/jido/blob/main/example-config.json). 80 81 81 82 Config schema: 82 83 ``` 83 84 Config = struct { 84 - .show_hidden: bool, 85 - .sort_dirs: bool, 86 - .show_images: bool, 87 - .preview_file: bool, 88 - .empty_trash_on_exit: bool, 85 + .show_hidden: bool = true, 86 + .sort_dirs: bool = true, 87 + .show_images: bool = true, -- Images are only supported in a terminal 88 + supporting the `kitty image protocol`. 89 + .preview_file: bool = true, 90 + .empty_trash_on_exit: bool = false, -- Emptying the trash permanently deletes 91 + all files within the trash. These 92 + files are not recoverable past this 93 + point. 94 + .true_dir_size: bool = false, -- Display size of directory including 95 + all its children. This can and will 96 + cause lag on deeply nested directories. 89 97 .keybinds: Keybinds, 90 98 .styles: Styles 91 99 } 92 100 93 101 Keybinds = struct { 94 - .toggle_hidden_files: Char, 95 - .delete: Char, 96 - .rename: Char, 97 - .create_dir: Char, 98 - .create_file: Char, 99 - .fuzzy_find: Char, 100 - .change_dir: Char, 101 - .enter_command_mode: Char 102 - .jump_top: Char 103 - .jump_bottom: Char 102 + .toggle_hidden_files: Char = '.', 103 + .delete: Char = 'D', 104 + .rename: Char = 'R', 105 + .create_dir: Char = 'd', 106 + .create_file: Char = '%', 107 + .fuzzy_find: Char = '/', 108 + .change_dir: Char = 'c', 109 + .enter_command_mode: Char = ':', 110 + .jump_top: Char = 'g', 111 + .jump_bottom: Char = 'G', 112 + .toggle_verbose_file_information: Char = 'v' 104 113 } 105 114 106 115 NotificationStyles = struct {
+2
build.zig
··· 12 12 const libvaxis = b.dependency("vaxis", .{ .target = target }).module("vaxis"); 13 13 const fuzzig = b.dependency("fuzzig", .{ .target = target }).module("fuzzig"); 14 14 const zuid = b.dependency("zuid", .{ .target = target }).module("zuid"); 15 + const zeit = b.dependency("zeit", .{ .target = target }).module("zeit"); 15 16 16 17 const exe = b.addExecutable(.{ 17 18 .name = exe_name, ··· 23 24 exe.root_module.addImport("vaxis", libvaxis); 24 25 exe.root_module.addImport("fuzzig", fuzzig); 25 26 exe.root_module.addImport("zuid", zuid); 27 + exe.root_module.addImport("zeit", zeit); 26 28 27 29 return exe; 28 30 }
+5 -1
build.zig.zon
··· 1 1 .{ 2 2 .name = .jido, 3 3 .fingerprint = 0xee45eabe36cafb57, 4 - .version = "0.9.1", 4 + .version = "0.9.2", 5 5 .minimum_zig_version = "0.14.0", 6 6 7 7 .dependencies = .{ ··· 16 16 .zuid = .{ 17 17 .url = "git+https://github.com/KeithBrown39423/zuid#b6129f6cee45bd90b7ac97b8839dc28d21bedcb2", 18 18 .hash = "zuid-2.0.0-AAAAADxXAAA4MAzwwRhfZ9AC2FMPZ8hUrZbfpmJ_azpK", 19 + }, 20 + .zeit = .{ 21 + .url = "git+https://github.com/rockorager/zeit/#175cf91a641790799e9d676878a9fe814aaed134", 22 + .hash = "zeit-0.6.0-5I6bk5daAgC-P60TjxRqW0bYknfCGxJp-03eS9UjGrO7", 19 23 }, 20 24 }, 21 25
+2 -2
src/app.zig
··· 52 52 state: State = .normal, 53 53 actions: CircStack(Action, actions_len), 54 54 command_history: CommandHistory = CommandHistory{}, 55 + drawer: Drawer = Drawer{}, 55 56 56 57 directories: Directories, 57 58 notification: Notification, ··· 114 115 } 115 116 116 117 pub fn run(self: *App) !void { 117 - var drawer = Drawer{}; 118 118 try self.directories.populateEntries(""); 119 119 120 120 var loop: vaxis.Loop(Event) = .{ ··· 171 171 } 172 172 } 173 173 174 - try drawer.draw(self); 174 + try self.drawer.draw(self); 175 175 176 176 var buffered = self.tty.bufferedWriter(); 177 177 try self.vx.render(buffered.writer().any());
+2
src/config.zig
··· 16 16 show_images: bool = true, 17 17 preview_file: bool = true, 18 18 empty_trash_on_exit: bool = false, 19 + true_dir_size: bool = false, 19 20 // TODO(10-01-25): This needs to be implemented. 20 21 // command_history_len: usize = 10, 21 22 styles: Styles = .{}, ··· 190 191 enter_command_mode: Char = @enumFromInt(':'), 191 192 jump_top: Char = @enumFromInt('g'), 192 193 jump_bottom: Char = @enumFromInt('G'), 194 + toggle_verbose_file_information: Char = @enumFromInt('v'), 193 195 }; 194 196 195 197 const Styles = struct {
+19
src/directories.zig
··· 71 71 return try self.dir.realpath(relative_path, &self.path_buf); 72 72 } 73 73 74 + pub fn getDirSize(self: Self, dir: std.fs.Dir) !usize { 75 + var total_size: usize = 0; 76 + 77 + var walker = try dir.walk(self.alloc); 78 + defer walker.deinit(); 79 + 80 + while (try walker.next()) |entry| { 81 + switch (entry.kind) { 82 + .file => { 83 + const stat = try entry.dir.statFile(entry.basename); 84 + total_size += stat.size; 85 + }, 86 + else => {}, 87 + } 88 + } 89 + 90 + return total_size; 91 + } 92 + 74 93 pub fn populateChildEntries( 75 94 self: *Self, 76 95 relative_path: []const u8,
+106 -23
src/drawer.zig
··· 6 6 const vaxis = @import("vaxis"); 7 7 const Git = @import("./git.zig"); 8 8 const inputToSlice = @import("./event_handlers.zig").inputToSlice; 9 + const zeit = @import("zeit"); 9 10 10 11 const Drawer = @This(); 11 12 12 13 const top_div: u16 = 1; 13 14 const info_div: u16 = 1; 14 - const bottom_div: u16 = 1; 15 15 16 16 // Used to detect whether to re-render an image. 17 17 current_item_path_buf: [std.fs.max_path_bytes]u8 = undefined, ··· 21 21 file_info_buf: [std.fs.max_path_bytes]u8 = undefined, 22 22 file_name_buf: [std.fs.max_path_bytes + 2]u8 = undefined, // +2 to accomodate for [<file_name>] 23 23 git_branch: [1024]u8 = undefined, 24 + verbose: bool = false, 24 25 25 26 pub fn draw(self: *Drawer, app: *App) !void { 26 27 const win = app.vx.window(); 27 28 win.clear(); 28 29 29 30 const abs_file_path_bar = try self.drawAbsFilePath(app.alloc, &app.directories, win); 30 - const file_info_bar = try self.drawFileInfo(&app.directories, win); 31 - app.last_known_height = try drawDirList( 31 + const file_info_bar = try self.drawFileInfo(app.alloc, &app.directories, win); 32 + app.last_known_height = try self.drawDirList( 32 33 &app.directories, 33 34 win, 34 35 abs_file_path_bar, ··· 81 82 win: vaxis.Window, 82 83 file_name_win: vaxis.Window, 83 84 ) !void { 85 + const bottom_div: u16 = 1; 86 + 84 87 const preview_win = win.child(.{ 85 88 .x_off = win.width / 2, 86 89 .y_off = top_div + 1, ··· 116 119 } 117 120 }, 118 121 .file => file: { 119 - var file = app.directories.dir.openFile(entry.name, .{ .mode = .read_only }) catch |err| { 122 + var file = app.directories.dir.openFile( 123 + entry.name, 124 + .{ .mode = .read_only }, 125 + ) catch |err| { 120 126 switch (err) { 121 127 error.AccessDenied => try app.notification.writeErr(.PermissionDenied), 122 128 else => try app.notification.writeErr(.UnknownError), ··· 233 239 234 240 fn drawFileInfo( 235 241 self: *Drawer, 242 + alloc: std.mem.Allocator, 236 243 directories: *Directories, 237 244 win: vaxis.Window, 238 245 ) !vaxis.Window { 246 + const bottom_div: u16 = if (self.verbose) 6 else 1; 247 + 239 248 const file_info_win = win.child(.{ 240 249 .x_off = 0, 241 250 .y_off = win.height - bottom_div, ··· 250 259 }; 251 260 252 261 var fbs = std.io.fixedBufferStream(&self.file_info_buf); 262 + 263 + // Selected entry. 253 264 try fbs.writer().print( 254 - "{d}/{d} ", 255 - .{ directories.entries.selected + 1, directories.entries.len() }, 265 + "{s}{d}/{d}{s}", 266 + .{ 267 + if (self.verbose) "Entry: " else "", 268 + directories.entries.selected + 1, 269 + directories.entries.len(), 270 + if (self.verbose) "\n" else " ", 271 + }, 256 272 ); 257 273 274 + // Time created / last modified 275 + if (self.verbose) lbl: { 276 + var maybe_meta: ?std.fs.File.Metadata = null; 277 + if (entry.kind == .directory) { 278 + maybe_meta = try directories.dir.metadata(); 279 + } else if (entry.kind == .file) { 280 + var file = try directories.dir.openFile(entry.name, .{}); 281 + maybe_meta = try file.metadata(); 282 + } 283 + 284 + const meta = maybe_meta orelse break :lbl; 285 + var env = try std.process.getEnvMap(alloc); 286 + defer env.deinit(); 287 + const local = try zeit.local(alloc, &env); 288 + defer local.deinit(); 289 + 290 + const ctime_instant = try zeit.instant(.{ 291 + .source = .{ .unix_nano = meta.created().? }, 292 + .timezone = &local, 293 + }); 294 + const ctime = ctime_instant.time(); 295 + try ctime.strftime(fbs.writer().any(), "Created: %Y-%m-%d %H:%M:%S\n"); 296 + 297 + const mtime_instant = try zeit.instant(.{ 298 + .source = .{ .unix_nano = meta.modified() }, 299 + .timezone = &local, 300 + }); 301 + const mtime = mtime_instant.time(); 302 + try mtime.strftime(fbs.writer().any(), "Last modified: %Y-%m-%d %H:%M:%S\n"); 303 + } 304 + 305 + // File permissions. 258 306 var file_perm_buf: [11]u8 = undefined; 259 - const file_perms: ?usize = lbl: { 307 + const file_perms: usize = lbl: { 308 + if (self.verbose) try fbs.writer().writeAll("Permissions: "); 260 309 var file_perm_fbs = std.io.fixedBufferStream(&file_perm_buf); 261 310 262 311 if (entry.kind == .directory) { ··· 268 317 "r--", "r-x", "rw-", "rwx", 269 318 }; 270 319 271 - const stat = directories.dir.statFile(entry.name) catch break :lbl null; 320 + const stat = directories.dir.statFile(entry.name) catch { 321 + _ = try file_perm_fbs.write("---------\n"); 322 + break :lbl 10; 323 + }; 272 324 // Ignore upper bytes as they represent file type. 273 325 const perms = @as(u9, @truncate(stat.mode)); 274 326 ··· 278 330 _ = try file_perm_fbs.write(perm_strings[perm]); 279 331 } 280 332 281 - _ = try file_perm_fbs.write(" "); 333 + if (self.verbose) { 334 + _ = try file_perm_fbs.write("\n"); 335 + } else { 336 + _ = try file_perm_fbs.write(" "); 337 + } 282 338 283 339 if (entry.kind == .directory) { 284 340 break :lbl 11; ··· 286 342 break :lbl 10; 287 343 } 288 344 }; 289 - if (file_perms) |perms_bytes| try fbs.writer().writeAll(file_perm_buf[0..perms_bytes]); 345 + try fbs.writer().writeAll(file_perm_buf[0..file_perms]); 290 346 291 - if (entry.kind == .directory) { 292 - _ = file_info_win.printSegment(.{ 293 - .text = fbs.getWritten(), 294 - .style = config.styles.file_information, 295 - }, .{}); 296 - return file_info_win; 297 - } 347 + // Size. 348 + const size: ?usize = lbl: { 349 + const stat = directories.dir.statFile(entry.name) catch break :lbl null; 350 + if (entry.kind == .file) { 351 + break :lbl stat.size; 352 + } else if (entry.kind == .directory) { 353 + if (config.true_dir_size) { 354 + var dir = directories.dir.openDir( 355 + entry.name, 356 + .{ .iterate = true }, 357 + ) catch break :lbl null; 358 + defer dir.close(); 359 + break :lbl directories.getDirSize(dir) catch break :lbl null; 360 + } else { 361 + break :lbl stat.size; 362 + } 363 + } 298 364 299 - const file_size: u64 = lbl: { 300 - const stat = directories.dir.statFile(entry.name) catch break :lbl 0; 301 - break :lbl stat.size; 365 + break :lbl 0; 302 366 }; 303 - try fbs.writer().print("{:.2} ", .{std.fmt.fmtIntSizeDec(file_size)}); 367 + if (size) |s| try fbs.writer().print("{s}{:.2}\n", .{ 368 + if (self.verbose) "Size: " else "", 369 + std.fmt.fmtIntSizeDec(s), 370 + }); 304 371 372 + // Extension. 305 373 const extension = std.fs.path.extension(entry.name); 306 - if (extension.len > 0) try fbs.writer().print("{s} ", .{extension}); 374 + if (self.verbose) { 375 + try fbs.writer().print( 376 + "Extension: {s}\n", 377 + .{if (entry.kind == .directory) "Dir" else extension}, 378 + ); 379 + } else { 380 + try fbs.writer().print( 381 + "{s} ", 382 + .{if (entry.kind == .directory) "dir" else extension}, 383 + ); 384 + } 307 385 308 386 _ = file_info_win.printSegment(.{ 309 387 .text = fbs.getWritten(), ··· 319 397 abs_file_path: vaxis.Window, 320 398 file_information: vaxis.Window, 321 399 ) !u16 { 400 + const bottom_div: u16 = 1; 401 + 322 402 const current_dir_list_win = win.child(.{ 323 403 .x_off = 0, 324 404 .y_off = top_div + 1, ··· 389 469 if (text_input.buf.realLength() > 0) { 390 470 text_input.drawWithStyle( 391 471 user_input_win, 392 - if (std.mem.eql(u8, input, ":UnsupportedCommand")) config.styles.text_input_err else config.styles.text_input, 472 + if (std.mem.eql(u8, input, ":UnsupportedCommand")) 473 + config.styles.text_input_err 474 + else 475 + config.styles.text_input, 393 476 ); 394 477 } 395 478
+1
src/event_handlers.zig
··· 178 178 app.directories.entries.selectLast(app.last_known_height); 179 179 }, 180 180 .jump_top => app.directories.entries.selectFirst(), 181 + .toggle_verbose_file_information => app.drawer.verbose = !app.drawer.verbose, 181 182 } 182 183 } else { 183 184 switch (key.codepoint) {