地圖 (Jido) is a lightweight Unix TUI file explorer designed for speed and simplicity.
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}