地圖 (Jido) is a lightweight Unix TUI file explorer designed for speed and simplicity.
at main 343 lines 12 kB view raw
1const std = @import("std"); 2const builtin = @import("builtin"); 3 4const vaxis = @import("vaxis"); 5const Key = vaxis.Key; 6const zuid = @import("zuid"); 7 8const Archive = @import("./archive.zig"); 9const CircStack = @import("./circ_stack.zig").CircularStack; 10const CommandHistory = @import("./commands.zig").CommandHistory; 11const Directories = @import("./directories.zig"); 12const Drawer = @import("./drawer.zig"); 13const environment = @import("./environment.zig"); 14const EventHandlers = @import("./event_handlers.zig"); 15const FileLogger = @import("./file_logger.zig"); 16const Image = @import("./image.zig"); 17const List = @import("./list.zig").List; 18const Notification = @import("./notification.zig"); 19const Preview = @import("./preview.zig"); 20 21const config = &@import("./config.zig").config; 22const help_menu_items = [_][]const u8{ 23 "Global:", 24 "<CTRL-c> :Exit.", 25 "<CTRL-r> :Reload config.", 26 "", 27 "Normal mode:", 28 "j / <Down> :Go down.", 29 "k / <Up> :Go up.", 30 "h / <Left> / - :Go to the parent directory.", 31 "l / <Right> :Open item or change directory.", 32 "g :Go to the top.", 33 "G :Go to the bottom.", 34 "c :Change directory via path. Will enter input mode.", 35 "R :Rename item. Will enter input mode.", 36 "D :Delete item.", 37 "u :Undo delete/rename.", 38 "d :Create directory. Will enter input mode.", 39 "% :Create file. Will enter input mode.", 40 "/ :Fuzzy search directory. Will enter input mode.", 41 ". :Toggle hidden files.", 42 ": :Allows for Jido commands to be entered. Please refer to the ", 43 " \"Command mode\" section for available commands. Will enter ", 44 " input mode.", 45 "v :Verbose mode. Provides more information about selected entry. ", 46 "y :Yank selected item.", 47 "p :Past yanked item.", 48 "x :Extract archive to `<name>/`", 49 "", 50 "Input mode:", 51 "<Esc> :Cancel input.", 52 "<CR> :Confirm input.", 53 "", 54 "Command mode:", 55 "<Up> / <Down> :Cycle previous commands.", 56 ":q :Exit.", 57 ":h :View available keybinds. 'q' to return to app.", 58 ":config :Navigate to config directory if it exists.", 59 ":trash :Navigate to trash directory if it exists.", 60 ":empty_trash :Empty trash if it exists. This action cannot be undone.", 61 ":cd <path> :Change directory via path. Will enter input mode.", 62 ":extract :Extract archive under cursor.", 63}; 64 65pub const State = enum { 66 normal, 67 fuzzy, 68 new_dir, 69 new_file, 70 change_dir, 71 rename, 72 command, 73 help_menu, 74}; 75 76pub const Action = union(enum) { 77 delete: struct { prev_path: []const u8, new_path: []const u8 }, 78 rename: struct { prev_path: []const u8, new_path: []const u8 }, 79 paste: []const u8, 80}; 81 82pub const Event = union(enum) { 83 image_ready, 84 notification, 85 key_press: Key, 86 winsize: vaxis.Winsize, 87}; 88 89const actions_len = 100; 90const image_cache_cap = 100; 91 92const App = @This(); 93 94alloc: std.mem.Allocator, 95should_quit: bool, 96vx: vaxis.Vaxis = undefined, 97tty_buffer: [1024]u8 = undefined, 98tty: vaxis.Tty = undefined, 99loop: vaxis.Loop(Event) = undefined, 100state: State = .normal, 101actions: CircStack(Action, actions_len), 102command_history: CommandHistory = CommandHistory{}, 103drawer: Drawer = Drawer{}, 104 105help_menu: List([]const u8), 106directories: Directories, 107archive_files: ?Archive.ArchiveContents = null, 108notification: Notification = Notification{}, 109file_logger: ?FileLogger = null, 110 111text_input: vaxis.widgets.TextInput, 112text_input_buf: [std.fs.max_path_bytes]u8 = undefined, 113 114yanked: ?struct { dir: []const u8, entry: std.fs.Dir.Entry } = null, 115last_known_height: usize, 116 117// Used to detect whether to re-render an image. 118current_item_path_buf: [std.fs.max_path_bytes]u8 = undefined, 119current_item_path: []u8 = "", 120last_item_path_buf: [std.fs.max_path_bytes]u8 = undefined, 121last_item_path: []u8 = "", 122 123images: Image.Cache, 124preview_cache: Preview.PreviewCache, 125 126pub fn init(alloc: std.mem.Allocator, entry_dir: ?[]const u8) !App { 127 var vx = try vaxis.init(alloc, .{ 128 .kitty_keyboard_flags = .{ 129 .report_text = false, 130 .disambiguate = false, 131 .report_events = false, 132 .report_alternate_keys = false, 133 .report_all_as_ctl_seqs = false, 134 }, 135 }); 136 137 var help_menu = List([]const u8).init(alloc); 138 try help_menu.fromArray(&help_menu_items); 139 140 var app: App = .{ 141 .alloc = alloc, 142 .should_quit = false, 143 .vx = vx, 144 .directories = try Directories.init(alloc, entry_dir), 145 .help_menu = help_menu, 146 .text_input = vaxis.widgets.TextInput.init(alloc), 147 .actions = CircStack(Action, actions_len).init(), 148 .last_known_height = vx.window().height, 149 .images = .{ .cache = .init(alloc) }, 150 .preview_cache = Preview.PreviewCache.init(alloc), 151 }; 152 app.tty = try vaxis.Tty.init(&app.tty_buffer); 153 app.loop = vaxis.Loop(Event){ 154 .vaxis = &app.vx, 155 .tty = &app.tty, 156 }; 157 158 return app; 159} 160 161pub fn deinit(self: *App) void { 162 while (self.actions.pop()) |action| { 163 switch (action) { 164 .delete => |a| { 165 self.alloc.free(a.new_path); 166 self.alloc.free(a.prev_path); 167 }, 168 .rename => |a| { 169 self.alloc.free(a.new_path); 170 self.alloc.free(a.prev_path); 171 }, 172 .paste => |a| self.alloc.free(a), 173 } 174 } 175 176 if (self.yanked) |yanked| { 177 self.alloc.free(yanked.dir); 178 self.alloc.free(yanked.entry.name); 179 } 180 181 self.command_history.deinit(self.alloc); 182 183 self.help_menu.deinit(); 184 self.directories.deinit(); 185 self.text_input.deinit(); 186 self.vx.deinit(self.alloc, self.tty.writer()); 187 self.tty.deinit(); 188 if (self.file_logger) |file_logger| file_logger.deinit(); 189 if (self.archive_files) |*archive_files| archive_files.deinit(self.alloc); 190 191 var image_iter = self.images.cache.iterator(); 192 while (image_iter.next()) |img| { 193 img.value_ptr.deinit(self.alloc, self.vx, &self.tty); 194 } 195 self.images.cache.deinit(); 196 self.preview_cache.deinit(); 197} 198 199/// Reads the current text input without consuming it. 200/// The returned slice is valid until the next call to readInput() or until 201/// the text_input buffer is modified. 202pub fn readInput(self: *App) []const u8 { 203 const first = self.text_input.buf.firstHalf(); 204 const second = self.text_input.buf.secondHalf(); 205 var dest_idx: usize = 0; 206 207 const first_len = @min(first.len, self.text_input_buf.len - dest_idx); 208 @memcpy(self.text_input_buf[dest_idx .. dest_idx + first_len], first[0..first_len]); 209 dest_idx += first_len; 210 211 const second_len = @min(second.len, self.text_input_buf.len - dest_idx); 212 @memcpy(self.text_input_buf[dest_idx .. dest_idx + second_len], second[0..second_len]); 213 dest_idx += second_len; 214 215 return self.text_input_buf[0..dest_idx]; 216} 217 218pub fn repopulateDirectory(self: *App, fuzzy: []const u8) error{OutOfMemory}!void { 219 // Save current selection name to restore cursor position after repopulation 220 const prev_name = if (self.directories.getSelected() catch null) |entry| 221 try self.alloc.dupe(u8, entry.name) 222 else 223 null; 224 defer if (prev_name) |name| self.alloc.free(name); 225 226 self.directories.clearEntries(); 227 self.directories.populateEntries(fuzzy) catch |err| { 228 const message = try std.fmt.allocPrint(self.alloc, "Failed to read directory entries - {}.", .{err}); 229 defer self.alloc.free(message); 230 self.notification.write(message, .err) catch {}; 231 if (self.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 232 }; 233 234 // Try to restore cursor to the same file by name 235 if (prev_name) |name| { 236 for (self.directories.entries.all(), 0..) |entry, i| { 237 if (std.mem.eql(u8, entry.name, name)) { 238 self.directories.entries.selected = i; 239 break; 240 } 241 } 242 } 243 244 // Revalidate current entry for display 245 self.preview_cache.invalidate(); 246 Preview.loadPreviewForCurrentEntry(self) catch |err| { 247 if (self.file_logger) |file_logger| { 248 const msg = std.fmt.allocPrint(self.alloc, "Failed to load preview after repopulate: {}", .{err}) catch return; 249 defer self.alloc.free(msg); 250 file_logger.write(msg, .err) catch {}; 251 } 252 }; 253} 254 255pub fn run(self: *App) !void { 256 try self.repopulateDirectory(""); 257 try self.loop.start(); 258 defer self.loop.stop(); 259 260 try self.vx.enterAltScreen(self.tty.writer()); 261 try self.vx.queryTerminal(self.tty.writer(), 1 * std.time.ns_per_s); 262 self.vx.caps.kitty_graphics = true; 263 264 while (!self.should_quit) { 265 self.loop.pollEvent(); 266 while (self.loop.tryEvent()) |event| { 267 const selected = self.directories.getSelected() catch |err| err: { 268 const message = try std.fmt.allocPrint(self.alloc, "Can not display file - {}", .{err}); 269 defer self.alloc.free(message); 270 self.notification.write(message, .err) catch {}; 271 if (self.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 272 break :err null; 273 }; 274 275 if (selected) |entry| err: { 276 @memcpy(&self.last_item_path_buf, &self.current_item_path_buf); 277 self.last_item_path = self.last_item_path_buf[0..self.current_item_path.len]; 278 self.current_item_path = try std.fmt.bufPrint( 279 &self.current_item_path_buf, 280 "{s}/{s}", 281 .{ self.directories.fullPath(".") catch { 282 const message = try std.fmt.allocPrint(self.alloc, "Can not display file - unable to retrieve directory path.", .{}); 283 defer self.alloc.free(message); 284 self.notification.write(message, .err) catch {}; 285 if (self.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 286 break :err; 287 }, entry.name }, 288 ); 289 } 290 291 // Global keybinds. 292 try EventHandlers.handleGlobalEvent(self, event); 293 294 // State specific keybinds. 295 switch (self.state) { 296 .normal => { 297 try EventHandlers.handleNormalEvent(self, event); 298 }, 299 .help_menu => { 300 try EventHandlers.handleHelpMenuEvent(self, event); 301 }, 302 else => { 303 try EventHandlers.handleInputEvent(self, event); 304 }, 305 } 306 } 307 308 try self.drawer.draw(self); 309 310 const writer = self.tty.writer(); 311 try self.vx.render(writer); 312 try writer.flush(); 313 } 314 315 if (config.empty_trash_on_exit) { 316 var trash_dir = dir: { 317 notfound: { 318 break :dir (config.trashDir() catch break :notfound) orelse break :notfound; 319 } 320 if (self.file_logger) |file_logger| file_logger.write("Failed to open trash directory.", .err) catch { 321 std.log.err("Failed to open trash directory.", .{}); 322 }; 323 return; 324 }; 325 defer trash_dir.close(); 326 327 const failed = environment.deleteContents(trash_dir) catch |err| { 328 const message = try std.fmt.allocPrint(self.alloc, "Failed to empty trash - {}.", .{err}); 329 defer self.alloc.free(message); 330 if (self.file_logger) |file_logger| file_logger.write(message, .err) catch { 331 std.log.err("Failed to empty trash - {}.", .{err}); 332 }; 333 return; 334 }; 335 if (failed > 0) { 336 const message = try std.fmt.allocPrint(self.alloc, "Failed to empty {d} items from the trash.", .{failed}); 337 defer self.alloc.free(message); 338 if (self.file_logger) |file_logger| file_logger.write(message, .err) catch { 339 std.log.err("Failed to empty {d} items from the trash.", .{failed}); 340 }; 341 } 342 } 343}