地圖 (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 key_press: Key,
78 winsize: vaxis.Winsize,
79};
80
81pub const Image = struct {
82 const Status = enum {
83 ready,
84 processing,
85 };
86
87 ///Only use on first transmission. Subsequent draws should use
88 ///`Image.image`.
89 data: ?vaxis.zigimg.Image = null,
90 image: ?vaxis.Image = null,
91 path: ?[]const u8 = null,
92 status: Status = .processing,
93
94 pub fn deinit(self: @This(), alloc: std.mem.Allocator) void {
95 if (self.data) |data| {
96 var d = data;
97 d.deinit();
98 }
99 if (self.path) |path| alloc.free(path);
100 }
101};
102
103const actions_len = 100;
104const image_cache_cap = 100;
105
106const App = @This();
107
108alloc: std.mem.Allocator,
109should_quit: bool,
110vx: vaxis.Vaxis = undefined,
111tty: vaxis.Tty = undefined,
112loop: vaxis.Loop(Event) = undefined,
113state: State = .normal,
114actions: CircStack(Action, actions_len),
115command_history: CommandHistory = CommandHistory{},
116drawer: Drawer = Drawer{},
117
118help_menu: List([]const u8),
119directories: Directories,
120notification: Notification = Notification{},
121file_logger: ?FileLogger = null,
122
123text_input: vaxis.widgets.TextInput,
124text_input_buf: [std.fs.max_path_bytes]u8 = undefined,
125
126yanked: ?struct { dir: []const u8, entry: std.fs.Dir.Entry } = null,
127last_known_height: usize,
128
129images: struct {
130 mutex: std.Thread.Mutex = .{},
131 cache: std.StringHashMap(Image),
132},
133
134pub fn init(alloc: std.mem.Allocator, entry_dir: ?[]const u8) !App {
135 var vx = try vaxis.init(alloc, .{
136 .kitty_keyboard_flags = .{
137 .report_text = false,
138 .disambiguate = false,
139 .report_events = false,
140 .report_alternate_keys = false,
141 .report_all_as_ctl_seqs = false,
142 },
143 });
144
145 var help_menu = List([]const u8).init(alloc);
146 try help_menu.fromArray(&help_menu_items);
147
148 var app: App = .{
149 .alloc = alloc,
150 .should_quit = false,
151 .vx = vx,
152 .tty = try vaxis.Tty.init(),
153 .directories = try Directories.init(alloc, entry_dir),
154 .help_menu = help_menu,
155 .text_input = vaxis.widgets.TextInput.init(alloc, &vx.unicode),
156 .actions = CircStack(Action, actions_len).init(),
157 .last_known_height = vx.window().height,
158 .images = .{ .cache = .init(alloc) },
159 };
160
161 app.loop = vaxis.Loop(Event){
162 .vaxis = &app.vx,
163 .tty = &app.tty,
164 };
165
166 return app;
167}
168
169pub fn deinit(self: *App) void {
170 while (self.actions.pop()) |action| {
171 switch (action) {
172 .delete => |a| {
173 self.alloc.free(a.new_path);
174 self.alloc.free(a.prev_path);
175 },
176 .rename => |a| {
177 self.alloc.free(a.new_path);
178 self.alloc.free(a.prev_path);
179 },
180 .paste => |a| self.alloc.free(a),
181 }
182 }
183
184 if (self.yanked) |yanked| {
185 self.alloc.free(yanked.dir);
186 self.alloc.free(yanked.entry.name);
187 }
188
189 self.command_history.deinit(self.alloc);
190
191 self.help_menu.deinit();
192 self.directories.deinit();
193 self.text_input.deinit();
194 self.vx.deinit(self.alloc, self.tty.anyWriter());
195 self.tty.deinit();
196 if (self.file_logger) |file_logger| file_logger.deinit();
197
198 var image_iter = self.images.cache.iterator();
199 while (image_iter.next()) |img| {
200 img.value_ptr.deinit(self.alloc);
201 }
202 self.images.cache.deinit();
203}
204
205pub fn inputToSlice(self: *App) []const u8 {
206 self.text_input.buf.cursor = self.text_input.buf.realLength();
207 return self.text_input.sliceToCursor(&self.text_input_buf);
208}
209
210pub fn repopulateDirectory(self: *App, fuzzy: []const u8) error{OutOfMemory}!void {
211 self.directories.clearEntries();
212 self.directories.populateEntries(fuzzy) catch |err| {
213 const message = try std.fmt.allocPrint(self.alloc, "Failed to read directory entries - {}.", .{err});
214 defer self.alloc.free(message);
215 self.notification.write(message, .err) catch {};
216 if (self.file_logger) |file_logger| file_logger.write(message, .err) catch {};
217 };
218}
219
220pub fn run(self: *App) !void {
221 try self.repopulateDirectory("");
222 try self.loop.start();
223 defer self.loop.stop();
224
225 try self.vx.enterAltScreen(self.tty.anyWriter());
226 try self.vx.queryTerminal(self.tty.anyWriter(), 1 * std.time.ns_per_s);
227 self.vx.caps.kitty_graphics = true;
228
229 while (!self.should_quit) {
230 self.loop.pollEvent();
231 while (self.loop.tryEvent()) |event| {
232 // Global keybinds.
233 try EventHandlers.handleGlobalEvent(self, event);
234
235 // State specific keybinds.
236 switch (self.state) {
237 .normal => {
238 try EventHandlers.handleNormalEvent(self, event);
239 },
240 .help_menu => {
241 try EventHandlers.handleHelpMenuEvent(self, event);
242 },
243 else => {
244 try EventHandlers.handleInputEvent(self, event);
245 },
246 }
247 }
248
249 try self.drawer.draw(self);
250
251 var buffered = self.tty.bufferedWriter();
252 try self.vx.render(buffered.writer().any());
253 try buffered.flush();
254 }
255
256 if (config.empty_trash_on_exit) {
257 var trash_dir = dir: {
258 notfound: {
259 break :dir (config.trashDir() catch break :notfound) orelse break :notfound;
260 }
261 if (self.file_logger) |file_logger| file_logger.write("Failed to open trash directory.", .err) catch {
262 std.log.err("Failed to open trash directory.", .{});
263 };
264 return;
265 };
266 defer trash_dir.close();
267
268 const failed = environment.deleteContents(trash_dir) catch |err| {
269 const message = try std.fmt.allocPrint(self.alloc, "Failed to empty trash - {}.", .{err});
270 defer self.alloc.free(message);
271 if (self.file_logger) |file_logger| file_logger.write(message, .err) catch {
272 std.log.err("Failed to empty trash - {}.", .{err});
273 };
274 return;
275 };
276 if (failed > 0) {
277 const message = try std.fmt.allocPrint(self.alloc, "Failed to empty {d} items from the trash.", .{failed});
278 defer self.alloc.free(message);
279 if (self.file_logger) |file_logger| file_logger.write(message, .err) catch {
280 std.log.err("Failed to empty {d} items from the trash.", .{failed});
281 };
282 }
283 }
284}