地圖 (Jido) is a lightweight Unix TUI file explorer designed for speed and simplicity.
1const std = @import("std");
2const builtin = @import("builtin");
3const environment = @import("./environment.zig");
4const Drawer = @import("./drawer.zig");
5const Notification = @import("./notification.zig");
6const config = &@import("./config.zig").config;
7const List = @import("./list.zig").List;
8const Directories = @import("./directories.zig");
9const FileLogger = @import("./file_logger.zig");
10const CircStack = @import("./circ_stack.zig").CircularStack;
11const zuid = @import("zuid");
12const vaxis = @import("vaxis");
13const Key = vaxis.Key;
14const EventHandlers = @import("./event_handlers.zig");
15const CommandHistory = @import("./commands.zig").CommandHistory;
16
17const help_menu_items = [_][]const u8{
18 "Global:",
19 "<CTRL-c> :Exit.",
20 "<CTRL-r> :Reload config.",
21 "",
22 "Normal mode:",
23 "j / <Down> :Go down.",
24 "k / <Up> :Go up.",
25 "h / <Left> / - :Go to the parent directory.",
26 "l / <Right> :Open item or change directory.",
27 "g :Go to the top.",
28 "G :Go to the bottom.",
29 "c :Change directory via path. Will enter input mode.",
30 "R :Rename item. Will enter input mode.",
31 "D :Delete item.",
32 "u :Undo delete/rename.",
33 "d :Create directory. Will enter input mode.",
34 "% :Create file. Will enter input mode.",
35 "/ :Fuzzy search directory. Will enter input mode.",
36 ". :Toggle hidden files.",
37 ": :Allows for Jido commands to be entered. Please refer to the ",
38 " \"Command mode\" section for available commands. Will enter ",
39 " input mode.",
40 "v :Verbose mode. Provides more information about selected entry. ",
41 "y :Yank selected item.",
42 "p :Past yanked item.",
43 "",
44 "Input mode:",
45 "<Esc> :Cancel input.",
46 "<CR> :Confirm input.",
47 "",
48 "Command mode:",
49 "<Up> / <Down> :Cycle previous commands.",
50 ":q :Exit.",
51 ":h :View available keybinds. 'q' to return to app.",
52 ":config :Navigate to config directory if it exists.",
53 ":trash :Navigate to trash directory if it exists.",
54 ":empty_trash :Empty trash if it exists. This action cannot be undone.",
55 ":cd <path> :Change directory via path. Will enter input mode.",
56};
57
58pub const State = enum {
59 normal,
60 fuzzy,
61 new_dir,
62 new_file,
63 change_dir,
64 rename,
65 command,
66 help_menu,
67};
68
69pub const Action = union(enum) {
70 delete: struct { prev_path: []const u8, new_path: []const u8 },
71 rename: struct { prev_path: []const u8, new_path: []const u8 },
72 paste: []const u8,
73};
74
75pub const Event = union(enum) {
76 image_ready,
77 notification,
78 key_press: Key,
79 winsize: vaxis.Winsize,
80};
81
82pub const Image = struct {
83 const Status = enum {
84 ready,
85 processing,
86 failed,
87 };
88
89 ///Only use on first transmission. Subsequent draws should use
90 ///`Image.image`.
91 data: ?vaxis.zigimg.Image = null,
92 image: ?vaxis.Image = null,
93 path: ?[]const u8 = null,
94 status: Status = .processing,
95
96 pub fn deinit(self: @This(), alloc: std.mem.Allocator, vx: vaxis.Vaxis, tty: *vaxis.Tty) void {
97 if (self.image) |image| {
98 vx.freeImage(tty.writer(), image.id);
99 }
100 if (self.data) |data| {
101 var d = data;
102 d.deinit(alloc);
103 }
104 if (self.path) |path| alloc.free(path);
105 }
106};
107
108const actions_len = 100;
109const image_cache_cap = 100;
110
111const App = @This();
112
113alloc: std.mem.Allocator,
114should_quit: bool,
115vx: vaxis.Vaxis = undefined,
116tty_buffer: [1024]u8 = undefined,
117tty: vaxis.Tty = undefined,
118loop: vaxis.Loop(Event) = undefined,
119state: State = .normal,
120actions: CircStack(Action, actions_len),
121command_history: CommandHistory = CommandHistory{},
122drawer: Drawer = Drawer{},
123
124help_menu: List([]const u8),
125directories: Directories,
126notification: Notification = Notification{},
127file_logger: ?FileLogger = null,
128
129text_input: vaxis.widgets.TextInput,
130text_input_buf: [std.fs.max_path_bytes]u8 = undefined,
131
132yanked: ?struct { dir: []const u8, entry: std.fs.Dir.Entry } = null,
133last_known_height: usize,
134
135images: struct {
136 mutex: std.Thread.Mutex = .{},
137 cache: std.StringHashMap(Image),
138},
139
140pub fn init(alloc: std.mem.Allocator, entry_dir: ?[]const u8) !App {
141 var vx = try vaxis.init(alloc, .{
142 .kitty_keyboard_flags = .{
143 .report_text = false,
144 .disambiguate = false,
145 .report_events = false,
146 .report_alternate_keys = false,
147 .report_all_as_ctl_seqs = false,
148 },
149 });
150
151 var help_menu = List([]const u8).init(alloc);
152 try help_menu.fromArray(&help_menu_items);
153
154 var app: App = .{
155 .alloc = alloc,
156 .should_quit = false,
157 .vx = vx,
158 .directories = try Directories.init(alloc, entry_dir),
159 .help_menu = help_menu,
160 .text_input = vaxis.widgets.TextInput.init(alloc),
161 .actions = CircStack(Action, actions_len).init(),
162 .last_known_height = vx.window().height,
163 .images = .{ .cache = .init(alloc) },
164 };
165 app.tty = try vaxis.Tty.init(&app.tty_buffer);
166 app.loop = vaxis.Loop(Event){
167 .vaxis = &app.vx,
168 .tty = &app.tty,
169 };
170
171 return app;
172}
173
174pub fn deinit(self: *App) void {
175 while (self.actions.pop()) |action| {
176 switch (action) {
177 .delete => |a| {
178 self.alloc.free(a.new_path);
179 self.alloc.free(a.prev_path);
180 },
181 .rename => |a| {
182 self.alloc.free(a.new_path);
183 self.alloc.free(a.prev_path);
184 },
185 .paste => |a| self.alloc.free(a),
186 }
187 }
188
189 if (self.yanked) |yanked| {
190 self.alloc.free(yanked.dir);
191 self.alloc.free(yanked.entry.name);
192 }
193
194 self.command_history.deinit(self.alloc);
195
196 self.help_menu.deinit();
197 self.directories.deinit();
198 self.text_input.deinit();
199 self.vx.deinit(self.alloc, self.tty.writer());
200 self.tty.deinit();
201 if (self.file_logger) |file_logger| file_logger.deinit();
202
203 var image_iter = self.images.cache.iterator();
204 while (image_iter.next()) |img| {
205 img.value_ptr.deinit(self.alloc, self.vx, &self.tty);
206 }
207 self.images.cache.deinit();
208}
209
210pub fn inputToSlice(self: *App) []const u8 {
211 self.text_input.buf.cursor = self.text_input.buf.realLength();
212 return self.text_input.sliceToCursor(&self.text_input_buf);
213}
214
215pub fn repopulateDirectory(self: *App, fuzzy: []const u8) error{OutOfMemory}!void {
216 self.directories.clearEntries();
217 self.directories.populateEntries(fuzzy) catch |err| {
218 const message = try std.fmt.allocPrint(self.alloc, "Failed to read directory entries - {}.", .{err});
219 defer self.alloc.free(message);
220 self.notification.write(message, .err) catch {};
221 if (self.file_logger) |file_logger| file_logger.write(message, .err) catch {};
222 };
223}
224
225pub fn run(self: *App) !void {
226 try self.repopulateDirectory("");
227 try self.loop.start();
228 defer self.loop.stop();
229
230 try self.vx.enterAltScreen(self.tty.writer());
231 try self.vx.queryTerminal(self.tty.writer(), 1 * std.time.ns_per_s);
232 self.vx.caps.kitty_graphics = true;
233
234 while (!self.should_quit) {
235 self.loop.pollEvent();
236 while (self.loop.tryEvent()) |event| {
237 // Global keybinds.
238 try EventHandlers.handleGlobalEvent(self, event);
239
240 // State specific keybinds.
241 switch (self.state) {
242 .normal => {
243 try EventHandlers.handleNormalEvent(self, event);
244 },
245 .help_menu => {
246 try EventHandlers.handleHelpMenuEvent(self, event);
247 },
248 else => {
249 try EventHandlers.handleInputEvent(self, event);
250 },
251 }
252 }
253
254 try self.drawer.draw(self);
255
256 try self.vx.render(self.tty.writer());
257 }
258
259 if (config.empty_trash_on_exit) {
260 var trash_dir = dir: {
261 notfound: {
262 break :dir (config.trashDir() catch break :notfound) orelse break :notfound;
263 }
264 if (self.file_logger) |file_logger| file_logger.write("Failed to open trash directory.", .err) catch {
265 std.log.err("Failed to open trash directory.", .{});
266 };
267 return;
268 };
269 defer trash_dir.close();
270
271 const failed = environment.deleteContents(trash_dir) catch |err| {
272 const message = try std.fmt.allocPrint(self.alloc, "Failed to empty trash - {}.", .{err});
273 defer self.alloc.free(message);
274 if (self.file_logger) |file_logger| file_logger.write(message, .err) catch {
275 std.log.err("Failed to empty trash - {}.", .{err});
276 };
277 return;
278 };
279 if (failed > 0) {
280 const message = try std.fmt.allocPrint(self.alloc, "Failed to empty {d} items from the trash.", .{failed});
281 defer self.alloc.free(message);
282 if (self.file_logger) |file_logger| file_logger.write(message, .err) catch {
283 std.log.err("Failed to empty {d} items from the trash.", .{failed});
284 };
285 }
286 }
287}