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

Compare changes

Choose any two refs to compare.

+1113 -183
+11
.gila/done/creamy_ogre_ggk/creamy_ogre_ggk.md
··· 1 + --- 2 + title: feat: display archive contents 3 + status: done 4 + priority_value: 50 5 + priority: medium 6 + owner: brookjeynes 7 + created: 2026-01-14T21:48:51Z 8 + completed: 2026-01-14T21:49:06Z 9 + --- 10 + When a user scrolls past a compressed file (.tar, .zip, etc.), they should be 11 + able to see the top level contents.
+11
.gila/done/grave_zone_233/grave_zone_233.md
··· 1 + --- 2 + title: feat: support cursor placement in input 3 + status: done 4 + priority_value: 50 5 + priority: high 6 + owner: brookjeynes 7 + created: 2026-01-11T21:53:52Z 8 + completed: 2026-01-14T21:48:27Z 9 + --- 10 + Currently the user cannot move their cursor left or right when typing in the 11 + input field. This is painful when renaming files or changing directories.
+11
.gila/todo/helpful_heat_b1b/helpful_heat_b1b.md
··· 1 + --- 2 + title: feat: add keybind to compress item 3 + status: todo 4 + priority_value: 50 5 + priority: low 6 + owner: brookjeynes 7 + created: 2026-01-14T21:46:27Z 8 + --- 9 + Allow users to compress files/folders via a keybind 10 + 11 + This should have a config option as to what file format to compress as.
+9
.gila/todo/intelligent_dino_17y/intelligent_dino_17y.md
··· 1 + --- 2 + title: feat: add keybind to extraction archive 3 + status: todo 4 + priority_value: 50 5 + priority: low 6 + owner: brookjeynes 7 + created: 2026-01-11T21:52:59Z 8 + --- 9 + Allow users to extract archives via a keybind
-9
PROJECT_BOARD.md
··· 1 - # Project board 2 - 3 - Key: 4 - - `[ ]` Todo 5 - - `[-]` In progress 6 - - `[x]` Done 7 - 8 - ## Backlog 9 - - [ ] Keybind to unzip archives.
+12 -10
README.md
··· 10 10 Vim-like bindings and a minimalist interface, Jido focuses on speed and 11 11 simplicity. 12 12 13 - Jido used Zig `0.14.0`. 13 + Jido is built with Zig v`0.15.2`. 14 14 15 15 - [Installation](#installation) 16 16 - [Integrations](#integrations) ··· 87 87 Config = struct { 88 88 .show_hidden: bool = true, 89 89 .sort_dirs: bool = true, 90 - .show_images: bool = true, -- Images are only supported in a terminal 91 - supporting the `kitty image protocol`. 90 + .show_images: bool = true, -- Images are only supported in a terminal 91 + supporting the `kitty image protocol`. 92 92 .preview_file: bool = true, 93 - .empty_trash_on_exit: bool = false, -- Emptying the trash permanently deletes 94 - all files within the trash. These 95 - files are not recoverable past this 96 - point. 97 - .true_dir_size: bool = false, -- Display size of directory including 98 - all its children. This can and will 99 - cause lag on deeply nested directories. 93 + .empty_trash_on_exit: bool = false, -- Emptying the trash permanently deletes 94 + all files within the trash. These 95 + files are not recoverable past this 96 + point. 97 + .true_dir_size: bool = false, -- Display size of directory including 98 + all its children. This can and will 99 + cause lag on deeply nested directories. 100 + .archive_traversal_limit: usize = 100, -- How many files to be traversed when reading 101 + an archive (zip, tar, etc.). 100 102 .keybinds: Keybinds, 101 103 .styles: Styles 102 104 }
+52 -7
build.zig
··· 2 2 const builtin = @import("builtin"); 3 3 4 4 ///Must match the `version` in `build.zig.zon`. 5 - const version = std.SemanticVersion{ .major = 1, .minor = 3, .patch = 0 }; 5 + const version = std.SemanticVersion{ .major = 1, .minor = 4, .patch = 0 }; 6 6 7 7 const targets: []const std.Target.Query = &.{ 8 8 .{ .cpu_arch = .aarch64, .os_tag = .macos }, ··· 20 20 ) !*std.Build.Step.Compile { 21 21 const libvaxis = b.dependency("vaxis", .{ .target = target, .optimize = optimize }).module("vaxis"); 22 22 const fuzzig = b.dependency("fuzzig", .{ .target = target, .optimize = optimize }).module("fuzzig"); 23 - const zuid = b.dependency("zuid", .{ .target = target, .optimize = optimize }).module("zuid"); 24 23 const zeit = b.dependency("zeit", .{ .target = target, .optimize = optimize }).module("zeit"); 24 + const zuid = b.dependency("zuid", .{ .target = target, .optimize = optimize }).module("zuid"); 25 25 26 26 const exe = b.addExecutable(.{ 27 27 .name = exe_name, 28 - .root_source_file = b.path("src/main.zig"), 29 - .target = target, 30 - .optimize = optimize, 28 + .root_module = b.createModule(.{ 29 + .root_source_file = b.path("src/main.zig"), 30 + .target = target, 31 + .optimize = optimize, 32 + }), 31 33 }); 32 34 33 35 exe.root_module.addImport("options", build_options); 34 36 exe.root_module.addImport("vaxis", libvaxis); 35 37 exe.root_module.addImport("fuzzig", fuzzig); 36 - exe.root_module.addImport("zuid", zuid); 37 38 exe.root_module.addImport("zeit", zeit); 39 + exe.root_module.addImport("zuid", zuid); 38 40 39 41 return exe; 40 42 } ··· 48 50 build_options.addOption(std.SemanticVersion, "version", version); 49 51 const build_options_module = build_options.createModule(); 50 52 51 - // Building targets for release. 52 53 const build_all = b.option(bool, "all-targets", "Build all targets in ReleaseSafe mode.") orelse false; 53 54 if (build_all) { 54 55 try buildTargets(b, build_options_module); ··· 65 66 } 66 67 const run_step = b.step("run", "Run the app"); 67 68 run_step.dependOn(&run_cmd.step); 69 + 70 + const libvaxis = b.dependency("vaxis", .{ .target = target, .optimize = optimize }).module("vaxis"); 71 + const fuzzig = b.dependency("fuzzig", .{ .target = target, .optimize = optimize }).module("fuzzig"); 72 + const zuid = b.dependency("zuid", .{ .target = target, .optimize = optimize }).module("zuid"); 73 + const zeit = b.dependency("zeit", .{ .target = target, .optimize = optimize }).module("zeit"); 74 + const test_step = b.step("test", "Run unit tests"); 75 + const unit_tests = b.addTest(.{ 76 + .root_module = b.createModule(.{ 77 + .root_source_file = b.path("src/main.zig"), 78 + .target = target, 79 + .optimize = optimize, 80 + }), 81 + }); 82 + unit_tests.root_module.addImport("options", build_options_module); 83 + unit_tests.root_module.addImport("vaxis", libvaxis); 84 + unit_tests.root_module.addImport("fuzzig", fuzzig); 85 + unit_tests.root_module.addImport("zeit", zeit); 86 + unit_tests.root_module.addImport("zuid", zuid); 87 + 88 + const run_unit_tests = b.addRunArtifact(unit_tests); 89 + test_step.dependOn(&run_unit_tests.step); 90 + 91 + const integration_tests = &[_][]const u8{ 92 + "src/test_navigation.zig", 93 + "src/test_file_operations.zig", 94 + }; 95 + 96 + for (integration_tests) |test_file| { 97 + const test_exe = b.addTest(.{ 98 + .root_module = b.createModule(.{ 99 + .root_source_file = b.path(test_file), 100 + .target = target, 101 + .optimize = optimize, 102 + }), 103 + }); 104 + test_exe.root_module.addImport("vaxis", libvaxis); 105 + test_exe.root_module.addImport("fuzzig", fuzzig); 106 + test_exe.root_module.addImport("zuid", zuid); 107 + test_exe.root_module.addImport("zeit", zeit); 108 + test_exe.root_module.addImport("options", build_options_module); 109 + 110 + const run_test = b.addRunArtifact(test_exe); 111 + test_step.dependOn(&run_test.step); 112 + } 68 113 } 69 114 70 115 fn buildTargets(b: *std.Build, build_options: *std.Build.Module) !void {
+17 -10
build.zig.zon
··· 2 2 .name = .jido, 3 3 .fingerprint = 0xee45eabe36cafb57, 4 4 .version = "1.3.0", 5 - .minimum_zig_version = "0.14.0", 5 + .minimum_zig_version = "0.15.2", 6 6 7 7 .dependencies = .{ 8 + // Replace with rockorager/libvaxis once https://github.com/rockorager/libvaxis/pull/293 is merged 8 9 .vaxis = .{ 9 - .url = "git+https://github.com/rockorager/libvaxis#1e24e0dfb509e974e1c8713bcd119d0ae032a8c7", 10 - .hash = "vaxis-0.1.0-BWNV_MHyCAARemSCSwwc3sA1etNgv7ge0BCIXspX6CZv", 10 + .url = "git+https://github.com/rob9315/libvaxis.git#8d04cffd9137b4a8c56b356de98b32023ae752f3", 11 + .hash = "vaxis-0.5.1-BWNV_OA-CQDeFBHIx9ryyASogr2GE3FsAm-l5Ii5-HZT", 11 12 }, 12 13 .fuzzig = .{ 13 - .url = "git+https://github.com/fjebaker/fuzzig#44c04733c7c0fee3db83672aaaaf4ed03e943156", 14 - .hash = "fuzzig-0.1.1-AAAAALNIAQBmbHr-MPalGuR393Vem2pTQXI7_LXeNJgX", 14 + .url = "git+https://github.com/fjebaker/fuzzig#4251fe4230d38e721514394a485db62ee1667ff3", 15 + .hash = "fuzzig-0.1.1-Ji0xivxIAQBD0g8O_NV_0foqoPf3elsg9Sc3pNfdVH4D", 16 + }, 17 + .zeit = .{ 18 + .url = "git+https://github.com/rockorager/zeit#7ac64d72dbfb1a4ad549102e7d4e232a687d32d8", 19 + .hash = "zeit-0.6.0-5I6bk36tAgATpSl9wjFmRPMqYN2Mn0JQHgIcRNcqDpJA", 15 20 }, 21 + // Replace with KeithBrown39423/zuid once https://github.com/KeithBrown39423/zuid/pull/4 is merged 16 22 .zuid = .{ 17 - .url = "git+https://github.com/KeithBrown39423/zuid#b6129f6cee45bd90b7ac97b8839dc28d21bedcb2", 18 - .hash = "zuid-2.0.0-AAAAADxXAAA4MAzwwRhfZ9AC2FMPZ8hUrZbfpmJ_azpK", 23 + .url = "https://github.com/BrookJeynes/zuid/archive/refs/heads/bj/2025-12-31/feat/0.15.1.tar.gz", 24 + .hash = "zuid-3.0.0-l7aPyUlXAAAk9BLSDm2roA3i78Sy6_GvQI4hwe0PHI_m", 19 25 }, 20 - .zeit = .{ 21 - .url = "git+https://github.com/rockorager/zeit/#175cf91a641790799e9d676878a9fe814aaed134", 22 - .hash = "zeit-0.6.0-5I6bk5daAgC-P60TjxRqW0bYknfCGxJp-03eS9UjGrO7", 26 + // Replace with zigimg/zigimg once https://github.com/zigimg/zigimg/pull/305 is merged 27 + .zigimg = .{ 28 + .url = "git+https://github.com/brookjeynes/zigimg.git#9714df09f76891323c7fdbbbf23a17b79024fffb", 29 + .hash = "zigimg-0.1.0-8_eo2j4mFwCU7tWnqvkYtzqe-OPRn_bxEql_IJhW85LT", 23 30 }, 24 31 }, 25 32
+33 -39
src/app.zig
··· 8 8 const Directories = @import("./directories.zig"); 9 9 const FileLogger = @import("./file_logger.zig"); 10 10 const CircStack = @import("./circ_stack.zig").CircularStack; 11 + const Image = @import("./image.zig"); 12 + const Archive = @import("./archive.zig"); 11 13 const zuid = @import("zuid"); 12 14 const vaxis = @import("vaxis"); 13 15 const Key = vaxis.Key; ··· 74 76 75 77 pub const Event = union(enum) { 76 78 image_ready, 79 + notification, 77 80 key_press: Key, 78 81 winsize: vaxis.Winsize, 79 82 }; 80 83 81 - pub 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 - 103 84 const actions_len = 100; 104 85 const image_cache_cap = 100; 105 86 ··· 108 89 alloc: std.mem.Allocator, 109 90 should_quit: bool, 110 91 vx: vaxis.Vaxis = undefined, 92 + tty_buffer: [1024]u8 = undefined, 111 93 tty: vaxis.Tty = undefined, 112 94 loop: vaxis.Loop(Event) = undefined, 113 95 state: State = .normal, ··· 117 99 118 100 help_menu: List([]const u8), 119 101 directories: Directories, 102 + archive_files: ?Archive.ArchiveContents = null, 120 103 notification: Notification = Notification{}, 121 104 file_logger: ?FileLogger = null, 122 105 ··· 126 109 yanked: ?struct { dir: []const u8, entry: std.fs.Dir.Entry } = null, 127 110 last_known_height: usize, 128 111 129 - images: struct { 130 - mutex: std.Thread.Mutex = .{}, 131 - cache: std.StringHashMap(Image), 132 - }, 112 + images: Image.Cache, 133 113 134 114 pub fn init(alloc: std.mem.Allocator, entry_dir: ?[]const u8) !App { 135 115 var vx = try vaxis.init(alloc, .{ ··· 149 129 .alloc = alloc, 150 130 .should_quit = false, 151 131 .vx = vx, 152 - .tty = try vaxis.Tty.init(), 153 132 .directories = try Directories.init(alloc, entry_dir), 154 133 .help_menu = help_menu, 155 - .text_input = vaxis.widgets.TextInput.init(alloc, &vx.unicode), 134 + .text_input = vaxis.widgets.TextInput.init(alloc), 156 135 .actions = CircStack(Action, actions_len).init(), 157 136 .last_known_height = vx.window().height, 158 137 .images = .{ .cache = .init(alloc) }, 159 138 }; 160 - 139 + app.tty = try vaxis.Tty.init(&app.tty_buffer); 161 140 app.loop = vaxis.Loop(Event){ 162 141 .vaxis = &app.vx, 163 142 .tty = &app.tty, ··· 191 170 self.help_menu.deinit(); 192 171 self.directories.deinit(); 193 172 self.text_input.deinit(); 194 - self.vx.deinit(self.alloc, self.tty.anyWriter()); 173 + self.vx.deinit(self.alloc, self.tty.writer()); 195 174 self.tty.deinit(); 196 175 if (self.file_logger) |file_logger| file_logger.deinit(); 176 + if (self.archive_files) |*archive_files| archive_files.deinit(self.alloc); 197 177 198 178 var image_iter = self.images.cache.iterator(); 199 179 while (image_iter.next()) |img| { 200 - img.value_ptr.deinit(self.alloc); 180 + img.value_ptr.deinit(self.alloc, self.vx, &self.tty); 201 181 } 202 182 self.images.cache.deinit(); 203 183 } 204 184 205 - pub 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); 185 + /// Reads the current text input without consuming it. 186 + /// The returned slice is valid until the next call to readInput() or until 187 + /// the text_input buffer is modified. 188 + pub fn readInput(self: *App) []const u8 { 189 + const first = self.text_input.buf.firstHalf(); 190 + const second = self.text_input.buf.secondHalf(); 191 + var dest_idx: usize = 0; 192 + 193 + const first_len = @min(first.len, self.text_input_buf.len - dest_idx); 194 + @memcpy(self.text_input_buf[dest_idx .. dest_idx + first_len], first[0..first_len]); 195 + dest_idx += first_len; 196 + 197 + const second_len = @min(second.len, self.text_input_buf.len - dest_idx); 198 + @memcpy(self.text_input_buf[dest_idx .. dest_idx + second_len], second[0..second_len]); 199 + dest_idx += second_len; 200 + 201 + return self.text_input_buf[0..dest_idx]; 208 202 } 209 203 210 204 pub fn repopulateDirectory(self: *App, fuzzy: []const u8) error{OutOfMemory}!void { ··· 222 216 try self.loop.start(); 223 217 defer self.loop.stop(); 224 218 225 - try self.vx.enterAltScreen(self.tty.anyWriter()); 226 - try self.vx.queryTerminal(self.tty.anyWriter(), 1 * std.time.ns_per_s); 219 + try self.vx.enterAltScreen(self.tty.writer()); 220 + try self.vx.queryTerminal(self.tty.writer(), 1 * std.time.ns_per_s); 227 221 self.vx.caps.kitty_graphics = true; 228 222 229 223 while (!self.should_quit) { ··· 248 242 249 243 try self.drawer.draw(self); 250 244 251 - var buffered = self.tty.bufferedWriter(); 252 - try self.vx.render(buffered.writer().any()); 253 - try buffered.flush(); 245 + const writer = self.tty.writer(); 246 + try self.vx.render(writer); 247 + try writer.flush(); 254 248 } 255 249 256 250 if (config.empty_trash_on_exit) {
+206
src/archive.zig
··· 1 + const std = @import("std"); 2 + const ascii = @import("std").ascii; 3 + 4 + const archive_buf_size = 8192; 5 + 6 + pub const ArchiveType = enum { 7 + tar, 8 + @"tar.gz", 9 + @"tar.xz", 10 + @"tar.zst", 11 + zip, 12 + 13 + pub fn fromPath(file_path: []const u8) ?ArchiveType { 14 + if (ascii.endsWithIgnoreCase(file_path, ".tar")) return .tar; 15 + if (ascii.endsWithIgnoreCase(file_path, ".tgz")) return .@"tar.gz"; 16 + if (ascii.endsWithIgnoreCase(file_path, ".tar.gz")) return .@"tar.gz"; 17 + if (ascii.endsWithIgnoreCase(file_path, ".txz")) return .@"tar.xz"; 18 + if (ascii.endsWithIgnoreCase(file_path, ".tar.xz")) return .@"tar.xz"; 19 + if (ascii.endsWithIgnoreCase(file_path, ".tzst")) return .@"tar.zst"; 20 + if (ascii.endsWithIgnoreCase(file_path, ".tar.zst")) return .@"tar.zst"; 21 + if (ascii.endsWithIgnoreCase(file_path, ".zip")) return .zip; 22 + if (ascii.endsWithIgnoreCase(file_path, ".jar")) return .zip; 23 + return null; 24 + } 25 + }; 26 + 27 + pub const ArchiveContents = struct { 28 + entries: std.ArrayList([]const u8), 29 + 30 + pub fn deinit(self: *ArchiveContents, alloc: std.mem.Allocator) void { 31 + for (self.entries.items) |entry| alloc.free(entry); 32 + self.entries.deinit(alloc); 33 + } 34 + }; 35 + 36 + pub fn listArchiveContents( 37 + alloc: std.mem.Allocator, 38 + file: std.fs.File, 39 + archive_type: ArchiveType, 40 + traversal_limit: usize, 41 + ) !ArchiveContents { 42 + var buffer: [archive_buf_size]u8 = undefined; 43 + var reader = file.reader(&buffer); 44 + 45 + const contents = switch (archive_type) { 46 + .tar => try listTar(alloc, &reader.interface, traversal_limit), 47 + .@"tar.gz" => try listTarGz(alloc, &reader.interface, traversal_limit), 48 + .@"tar.xz" => try listTarXz(alloc, &reader.interface, traversal_limit), 49 + .@"tar.zst" => try listTarZst(alloc, &reader.interface, traversal_limit), 50 + .zip => try listZip(alloc, file, traversal_limit), 51 + }; 52 + 53 + return contents; 54 + } 55 + 56 + fn extractTopLevelEntry( 57 + alloc: std.mem.Allocator, 58 + full_path: []const u8, 59 + is_directory: bool, 60 + truncated: bool, 61 + ) ![]const u8 { 62 + var is_directory_internal = is_directory; 63 + var path = full_path; 64 + 65 + if (std.mem.indexOfScalar(u8, full_path, '/')) |idx| { 66 + path = full_path[0..idx]; 67 + is_directory_internal = true; 68 + } 69 + 70 + return try std.fmt.allocPrint( 71 + alloc, 72 + "{s}{s}{s}", 73 + .{ path, if (truncated) "..." else "", if (is_directory_internal) "/" else "" }, 74 + ); 75 + } 76 + 77 + fn listTar( 78 + alloc: std.mem.Allocator, 79 + reader: anytype, 80 + traversal_limit: usize, 81 + ) !ArchiveContents { 82 + var entries: std.ArrayList([]const u8) = .empty; 83 + errdefer { 84 + for (entries.items) |e| alloc.free(e); 85 + entries.deinit(alloc); 86 + } 87 + 88 + var seen = std.StringHashMap(void).init(alloc); 89 + defer seen.deinit(); 90 + 91 + var diagnostics: std.tar.Diagnostics = .{ .allocator = alloc }; 92 + defer diagnostics.deinit(); 93 + 94 + var file_name_buffer: [std.fs.max_path_bytes]u8 = undefined; 95 + var link_name_buffer: [std.fs.max_path_bytes]u8 = undefined; 96 + var iter = std.tar.Iterator.init(reader, .{ 97 + .file_name_buffer = &file_name_buffer, 98 + .link_name_buffer = &link_name_buffer, 99 + }); 100 + iter.diagnostics = &diagnostics; 101 + 102 + for (0..traversal_limit) |_| { 103 + const tar_file = try iter.next(); 104 + if (tar_file == null) break; 105 + 106 + const is_dir = tar_file.?.kind == .directory; 107 + const truncated = tar_file.?.name.len >= std.fs.max_path_bytes; 108 + const entry = try extractTopLevelEntry(alloc, tar_file.?.name, is_dir, truncated); 109 + 110 + const gop = try seen.getOrPut(entry); 111 + if (gop.found_existing) { 112 + alloc.free(entry); 113 + continue; 114 + } 115 + 116 + try entries.append(alloc, entry); 117 + } 118 + 119 + return ArchiveContents{ 120 + .entries = entries, 121 + }; 122 + } 123 + 124 + fn listTarGz( 125 + alloc: std.mem.Allocator, 126 + reader: anytype, 127 + limit: usize, 128 + ) !ArchiveContents { 129 + var flate_buffer: [std.compress.flate.max_window_len]u8 = undefined; 130 + var decompress = std.compress.flate.Decompress.init(reader, .gzip, &flate_buffer); 131 + return try listTar(alloc, &decompress.reader, limit); 132 + } 133 + 134 + fn listTarXz( 135 + alloc: std.mem.Allocator, 136 + reader: anytype, 137 + limit: usize, 138 + ) !ArchiveContents { 139 + var dcp = try std.compress.xz.decompress(alloc, reader.adaptToOldInterface()); 140 + defer dcp.deinit(); 141 + var adapter_buffer: [1024]u8 = undefined; 142 + var adapter = dcp.reader().adaptToNewApi(&adapter_buffer); 143 + return try listTar(alloc, &adapter.new_interface, limit); 144 + } 145 + 146 + fn listTarZst( 147 + alloc: std.mem.Allocator, 148 + reader: anytype, 149 + limit: usize, 150 + ) !ArchiveContents { 151 + const window_len = std.compress.zstd.default_window_len; 152 + const window_buffer = try alloc.alloc(u8, window_len + std.compress.zstd.block_size_max); 153 + var decompress: std.compress.zstd.Decompress = .init(reader, window_buffer, .{ 154 + .verify_checksum = false, 155 + .window_len = window_len, 156 + }); 157 + return try listTar(alloc, &decompress.reader, limit); 158 + } 159 + 160 + fn listZip( 161 + alloc: std.mem.Allocator, 162 + file: std.fs.File, 163 + traversal_limit: usize, 164 + ) !ArchiveContents { 165 + var entries: std.ArrayList([]const u8) = .empty; 166 + errdefer { 167 + for (entries.items) |e| alloc.free(e); 168 + entries.deinit(alloc); 169 + } 170 + 171 + var seen = std.StringHashMap(void).init(alloc); 172 + defer seen.deinit(); 173 + 174 + var buffer: [archive_buf_size]u8 = undefined; 175 + var file_reader = file.reader(&buffer); 176 + 177 + var iter = try std.zip.Iterator.init(&file_reader); 178 + var file_name_buf: [std.fs.max_path_bytes]u8 = undefined; 179 + 180 + for (0..traversal_limit) |_| { 181 + const zip_file = try iter.next(); 182 + if (zip_file == null) break; 183 + 184 + const file_name_len = @min(zip_file.?.filename_len, file_name_buf.len); 185 + const truncated = zip_file.?.filename_len > file_name_buf.len; 186 + 187 + try file_reader.seekTo(zip_file.?.header_zip_offset + @sizeOf(std.zip.CentralDirectoryFileHeader)); 188 + const file_name = file_name_buf[0..file_name_len]; 189 + try file_reader.interface.readSliceAll(file_name); 190 + 191 + const is_dir = std.mem.endsWith(u8, file_name, "/"); 192 + const entry = try extractTopLevelEntry(alloc, file_name, is_dir, truncated); 193 + 194 + const gop = try seen.getOrPut(entry); 195 + if (gop.found_existing) { 196 + alloc.free(entry); 197 + continue; 198 + } 199 + 200 + try entries.append(alloc, entry); 201 + } 202 + 203 + return ArchiveContents{ 204 + .entries = entries, 205 + }; 206 + }
+44 -1
src/circ_stack.zig
··· 30 30 pub fn pop(self: *Self) ?T { 31 31 if (self.count == 0) return null; 32 32 33 - self.head = (self.head - 1) % capacity; 33 + self.head = if (self.head == 0) capacity - 1 else self.head - 1; 34 34 const value = self.buf[self.head]; 35 35 self.count -= 1; 36 36 return value; 37 37 } 38 38 }; 39 39 } 40 + 41 + const testing = std.testing; 42 + 43 + test "CircularStack: push and pop basic operations" { 44 + var stack = CircularStack(u32, 5).init(); 45 + 46 + _ = stack.push(1); 47 + _ = stack.push(2); 48 + _ = stack.push(3); 49 + 50 + try testing.expectEqual(@as(?u32, 3), stack.pop()); 51 + try testing.expectEqual(@as(?u32, 2), stack.pop()); 52 + try testing.expectEqual(@as(?u32, 1), stack.pop()); 53 + try testing.expectEqual(@as(?u32, null), stack.pop()); 54 + } 55 + 56 + test "CircularStack: wraparound behavior at capacity" { 57 + var stack = CircularStack(u32, 3).init(); 58 + 59 + _ = stack.push(1); 60 + _ = stack.push(2); 61 + _ = stack.push(3); 62 + 63 + const evicted = stack.push(4); 64 + try testing.expectEqual(@as(?u32, 1), evicted); 65 + 66 + try testing.expectEqual(@as(?u32, 4), stack.pop()); 67 + try testing.expectEqual(@as(?u32, 3), stack.pop()); 68 + try testing.expectEqual(@as(?u32, 2), stack.pop()); 69 + } 70 + 71 + test "CircularStack: reset clears all entries" { 72 + var stack = CircularStack(u32, 5).init(); 73 + 74 + _ = stack.push(1); 75 + _ = stack.push(2); 76 + _ = stack.push(3); 77 + 78 + stack.reset(); 79 + 80 + try testing.expectEqual(@as(?u32, null), stack.pop()); 81 + try testing.expectEqual(@as(usize, 0), stack.count); 82 + }
+50
src/commands.zig
··· 172 172 try app.repopulateDirectory(""); 173 173 app.directories.history.reset(); 174 174 } 175 + 176 + const testing = std.testing; 177 + 178 + test "CommandHistory: add and retrieve commands" { 179 + var history = CommandHistory{}; 180 + defer history.deinit(testing.allocator); 181 + 182 + try history.add(":cd /tmp", testing.allocator); 183 + try history.add(":config", testing.allocator); 184 + 185 + try testing.expectEqual(@as(usize, 2), history.count); 186 + } 187 + 188 + test "CommandHistory: previous/next navigation" { 189 + var history = CommandHistory{}; 190 + defer history.deinit(testing.allocator); 191 + 192 + try history.add(":cmd1", testing.allocator); 193 + try history.add(":cmd2", testing.allocator); 194 + try history.add(":cmd3", testing.allocator); 195 + 196 + const cmd3 = history.previous(); 197 + try testing.expectEqualStrings(":cmd3", cmd3.?); 198 + 199 + const cmd2 = history.previous(); 200 + try testing.expectEqualStrings(":cmd2", cmd2.?); 201 + 202 + const cmd3_again = history.next(); 203 + try testing.expectEqualStrings(":cmd3", cmd3_again.?); 204 + 205 + const at_end = history.next(); 206 + try testing.expect(at_end == null); 207 + } 208 + 209 + test "CommandHistory: wraparound at capacity" { 210 + var history = CommandHistory{}; 211 + defer history.deinit(testing.allocator); 212 + 213 + var i: u32 = 0; 214 + while (i < 15) : (i += 1) { 215 + const cmd = try std.fmt.allocPrint(testing.allocator, ":cmd{}", .{i}); 216 + defer testing.allocator.free(cmd); 217 + try history.add(cmd, testing.allocator); 218 + } 219 + 220 + try testing.expectEqual(@as(usize, 10), history.count); 221 + 222 + const recent = history.previous(); 223 + try testing.expectEqualStrings(":cmd14", recent.?); 224 + }
+1
src/config.zig
··· 19 19 empty_trash_on_exit: bool = false, 20 20 true_dir_size: bool = false, 21 21 entry_dir: ?[]const u8 = null, 22 + archive_traversal_limit: usize = 100, 22 23 styles: Styles = .{}, 23 24 keybinds: Keybinds = .{}, 24 25
+94 -3
src/directories.zig
··· 2 2 const List = @import("./list.zig").List; 3 3 const CircStack = @import("./circ_stack.zig").CircularStack; 4 4 const config = &@import("./config.zig").config; 5 - const vaxis = @import("vaxis"); 6 5 const fuzzig = @import("fuzzig"); 7 6 8 7 const history_len: usize = 100; ··· 112 111 continue; 113 112 } 114 113 115 - try self.child_entries.append(try self.alloc.dupe(u8, entry.name)); 114 + if (entry.kind == .directory) { 115 + try self.child_entries.append(try std.fmt.allocPrint(self.alloc, "{s}/", .{entry.name})); 116 + } else { 117 + try self.child_entries.append(try self.alloc.dupe(u8, entry.name)); 118 + } 116 119 } 117 120 118 121 if (config.sort_dirs == true) { ··· 134 137 135 138 try self.entries.append(.{ 136 139 .kind = entry.kind, 137 - .name = try self.alloc.dupe(u8, entry.name), 140 + .name = if (entry.kind == .directory) try std.fmt.allocPrint(self.alloc, "{s}/", .{entry.name}) else try self.alloc.dupe(u8, entry.name), 138 141 }); 139 142 } 140 143 ··· 164 167 } 165 168 self.child_entries.clear(); 166 169 } 170 + 171 + const testing = std.testing; 172 + 173 + test "Directories: populateEntries respects show_hidden config" { 174 + const local_config = &@import("./config.zig").config; 175 + 176 + var tmp = testing.tmpDir(.{}); 177 + defer tmp.cleanup(); 178 + 179 + { 180 + const visible = try tmp.dir.createFile("visible.txt", .{}); 181 + visible.close(); 182 + const hidden = try tmp.dir.createFile(".hidden.txt", .{}); 183 + hidden.close(); 184 + } 185 + 186 + var path_buf: [std.fs.max_path_bytes]u8 = undefined; 187 + const tmp_path = try tmp.dir.realpath(".", &path_buf); 188 + const iter_dir = try std.fs.openDirAbsolute(tmp_path, .{ .iterate = true }); 189 + 190 + var dirs = try Self.init(testing.allocator, null); 191 + defer { 192 + dirs.clearEntries(); 193 + dirs.clearChildEntries(); 194 + dirs.entries.deinit(); 195 + dirs.child_entries.deinit(); 196 + dirs.searcher.deinit(); 197 + } 198 + dirs.dir.close(); 199 + dirs.dir = iter_dir; 200 + 201 + local_config.show_hidden = false; 202 + try dirs.populateEntries(""); 203 + try testing.expectEqual(@as(usize, 1), dirs.entries.len()); 204 + 205 + dirs.clearEntries(); 206 + local_config.show_hidden = true; 207 + try dirs.populateEntries(""); 208 + try testing.expectEqual(@as(usize, 2), dirs.entries.len()); 209 + } 210 + 211 + test "Directories: fuzzy search filters entries" { 212 + var tmp = testing.tmpDir(.{}); 213 + defer tmp.cleanup(); 214 + 215 + { 216 + const f1 = try tmp.dir.createFile("test_file.txt", .{}); 217 + f1.close(); 218 + const f2 = try tmp.dir.createFile("other.txt", .{}); 219 + f2.close(); 220 + const f3 = try tmp.dir.createFile("test_another.txt", .{}); 221 + f3.close(); 222 + } 223 + 224 + var path_buf: [std.fs.max_path_bytes]u8 = undefined; 225 + const tmp_path = try tmp.dir.realpath(".", &path_buf); 226 + const iter_dir = try std.fs.openDirAbsolute(tmp_path, .{ .iterate = true }); 227 + 228 + var dirs = try Self.init(testing.allocator, null); 229 + defer { 230 + dirs.clearEntries(); 231 + dirs.clearChildEntries(); 232 + dirs.entries.deinit(); 233 + dirs.child_entries.deinit(); 234 + dirs.searcher.deinit(); 235 + } 236 + dirs.dir.close(); 237 + dirs.dir = iter_dir; 238 + 239 + try dirs.populateEntries("test"); 240 + // Should match test_* 241 + try testing.expect(dirs.entries.len() >= 2); 242 + 243 + // Verify all entries contain "test" 244 + for (dirs.entries.all()) |entry| { 245 + try testing.expect(std.mem.indexOf(u8, entry.name, "test") != null); 246 + } 247 + } 248 + 249 + test "Directories: fullPath resolves relative paths" { 250 + var dirs = try Self.init(testing.allocator, "."); 251 + defer dirs.deinit(); 252 + 253 + const path = try dirs.fullPath("."); 254 + try testing.expect(path.len > 0); 255 + // Should be absolute 256 + try testing.expect(std.mem.indexOf(u8, path, "/") != null); 257 + }
+73 -53
src/drawer.zig
··· 5 5 const Directories = @import("./directories.zig"); 6 6 const config = &@import("./config.zig").config; 7 7 const vaxis = @import("vaxis"); 8 + const sort = @import("./sort.zig"); 8 9 const Git = @import("./git.zig"); 9 10 const List = @import("./list.zig").List; 10 11 const zeit = @import("zeit"); 12 + const Image = @import("./image.zig"); 13 + const Archive = @import("./archive.zig"); 11 14 12 15 const Drawer = @This(); 13 16 ··· 62 65 try self.drawFilePreview(app, win, file_name_bar); 63 66 } 64 67 65 - const input = app.inputToSlice(); 68 + const input = app.readInput(); 66 69 drawUserInput(app.state, &app.text_input, input, win); 67 70 68 71 // Notification should be drawn last. ··· 208 211 break :file; 209 212 } 210 213 214 + if (cache_entry.status == .failed) { 215 + _ = preview_win.print(&.{ 216 + .{ .text = "Failed to process image." }, 217 + }, .{}); 218 + break :file; 219 + } 220 + 211 221 if (cache_entry.image) |img| { 212 222 img.draw(preview_win, .{ .scale = .contain }) catch |err| { 213 223 const message = try std.fmt.allocPrint(app.alloc, "Failed to draw image to screen - {}.", .{err}); ··· 224 234 } else { 225 235 if (cache_entry.data == null) { 226 236 const path = try app.alloc.dupe(u8, self.current_item_path); 227 - processImage(app, path) catch break :unsupported; 237 + Image.processImage(app.alloc, app, path) catch { 238 + app.alloc.free(path); 239 + break :unsupported; 240 + }; 241 + _ = preview_win.print(&.{ 242 + .{ .text = "Image still processing." }, 243 + }, .{}); 244 + break :file; 228 245 } 229 246 230 - if (app.vx.transmitImage(app.alloc, app.tty.anyWriter(), &cache_entry.data.?, .rgba)) |img| { 247 + if (app.vx.transmitImage(app.alloc, app.tty.writer(), &cache_entry.data.?, .rgba)) |img| { 231 248 img.draw(preview_win, .{ .scale = .contain }) catch |err| { 232 249 const message = try std.fmt.allocPrint(app.alloc, "Failed to draw image to screen - {}.", .{err}); 233 250 defer app.alloc.free(message); ··· 240 257 break :file; 241 258 }; 242 259 cache_entry.image = img; 243 - cache_entry.data.?.deinit(); 260 + if (cache_entry.data) |data| { 261 + var d = data; 262 + d.deinit(app.alloc); 263 + } 244 264 cache_entry.data = null; 245 265 } else |_| { 246 266 break :unsupported; ··· 249 269 250 270 break :file; 251 271 } else { 272 + _ = preview_win.print(&.{ 273 + .{ .text = "Processing image." }, 274 + }, .{}); 275 + 252 276 const path = try app.alloc.dupe(u8, self.current_item_path); 253 - processImage(app, path) catch break :unsupported; 277 + Image.processImage(app.alloc, app, path) catch { 278 + app.alloc.free(path); 279 + break :unsupported; 280 + }; 254 281 } 255 282 256 283 break :file; ··· 295 322 break :file; 296 323 } 297 324 325 + // Handle archives 326 + if (Archive.ArchiveType.fromPath(entry.name)) |archive_type| { 327 + if (app.archive_files) |*files| { 328 + files.deinit(app.alloc); 329 + app.archive_files = null; 330 + } 331 + 332 + app.archive_files = Archive.listArchiveContents( 333 + app.alloc, 334 + file, 335 + archive_type, 336 + config.archive_traversal_limit, 337 + ) catch |err| { 338 + const message = try std.fmt.allocPrint(app.alloc, "Failed to read archive: {s}", .{@errorName(err)}); 339 + defer app.alloc.free(message); 340 + app.notification.write(message, .err) catch {}; 341 + if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 342 + _ = preview_win.print(&.{.{ .text = "Failed to read archive." }}, .{}); 343 + break :file; 344 + }; 345 + 346 + if (config.sort_dirs) { 347 + std.mem.sort([]const u8, app.archive_files.?.entries.items, {}, sort.string); 348 + } 349 + 350 + for (app.archive_files.?.entries.items, 0..) |path, i| { 351 + if (i >= preview_win.height) break; 352 + const w = preview_win.child(.{ .y_off = @intCast(i), .height = 1 }); 353 + w.fill(vaxis.Cell{ .style = config.styles.list_item }); 354 + _ = w.print(&.{.{ .text = path, .style = config.styles.list_item }}, .{}); 355 + } 356 + break :file; 357 + } 358 + 298 359 // Handle utf-8. 299 360 if (std.unicode.utf8ValidateSlice(app.directories.file_contents[0..bytes])) { 300 361 _ = preview_win.print(&.{ ··· 350 411 351 412 // Time created / last modified 352 413 if (self.verbose) lbl: { 353 - var maybe_meta: ?std.fs.File.Metadata = null; 414 + var maybe_meta: ?std.fs.File.Stat = null; 354 415 if (entry.kind == .directory) { 355 - maybe_meta = directories.dir.metadata() catch break :lbl; 416 + maybe_meta = directories.dir.stat() catch break :lbl; 356 417 } else if (entry.kind == .file) { 357 418 var file = directories.dir.openFile(entry.name, .{}) catch break :lbl; 358 - maybe_meta = file.metadata() catch break :lbl; 419 + maybe_meta = file.stat() catch break :lbl; 359 420 } 360 421 361 422 const meta = maybe_meta orelse break :lbl; ··· 365 426 defer local.deinit(); 366 427 367 428 const ctime_instant = zeit.instant(.{ 368 - .source = .{ .unix_nano = meta.created().? }, 429 + .source = .{ .unix_nano = meta.ctime }, 369 430 .timezone = &local, 370 431 }) catch break :lbl; 371 432 const ctime = ctime_instant.time(); 372 433 ctime.strftime(fbs.writer().any(), "Created: %Y-%m-%d %H:%M:%S\n") catch break :lbl; 373 434 374 435 const mtime_instant = zeit.instant(.{ 375 - .source = .{ .unix_nano = meta.modified() }, 436 + .source = .{ .unix_nano = meta.mtime }, 376 437 .timezone = &local, 377 438 }) catch break :lbl; 378 439 const mtime = mtime_instant.time(); ··· 441 502 442 503 break :lbl 0; 443 504 }; 444 - if (size) |s| try fbs.writer().print("{s}{:.2}\n", .{ 505 + if (size) |s| try fbs.writer().print("{s}{B:.2}\n", .{ 445 506 if (self.verbose) "Size: " else "", 446 - std.fmt.fmtIntSizeDec(s), 507 + s, 447 508 }); 448 509 449 510 // Extension. ··· 627 688 .style = config.styles.notification.box, 628 689 }, .{ .wrap = .word }); 629 690 } 630 - 631 - fn processImage(app: *App, path: []const u8) error{ Unsupported, OutOfMemory }!void { 632 - app.images.cache.put(path, .{ .path = path, .status = .processing }) catch { 633 - const message = try std.fmt.allocPrint(app.alloc, "Failed to load image '{s}' - error occurred while attempting to add image to cache.", .{path}); 634 - defer app.alloc.free(message); 635 - app.notification.write(message, .err) catch {}; 636 - if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 637 - return error.Unsupported; 638 - }; 639 - 640 - const load_img_thread = std.Thread.spawn(.{}, loadImage, .{ 641 - app, 642 - path, 643 - }) catch return error.Unsupported; 644 - load_img_thread.detach(); 645 - } 646 - 647 - fn loadImage(app: *App, path: []const u8) error{ Unsupported, OutOfMemory }!void { 648 - const data = vaxis.zigimg.Image.fromFilePath(app.alloc, path) catch { 649 - const message = try std.fmt.allocPrint(app.alloc, "Failed to load image '{s}' - error occurred while attempting to read image from path.", .{path}); 650 - defer app.alloc.free(message); 651 - app.notification.write(message, .err) catch {}; 652 - if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 653 - return error.Unsupported; 654 - }; 655 - 656 - app.images.mutex.lock(); 657 - if (app.images.cache.getPtr(path)) |entry| { 658 - entry.status = .ready; 659 - entry.data = data; 660 - } else { 661 - const message = try std.fmt.allocPrint(app.alloc, "Failed to load image '{s}' - error occurred while attempting to add image to cache.", .{path}); 662 - defer app.alloc.free(message); 663 - app.notification.write(message, .err) catch {}; 664 - if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 665 - return error.Unsupported; 666 - } 667 - app.images.mutex.unlock(); 668 - 669 - app.loop.postEvent(.image_ready); 670 - }
+1 -1
src/environment.zig
··· 38 38 const extension = std.fs.path.extension(relative_path); 39 39 break :lbl try std.fmt.bufPrint( 40 40 buf, 41 - "{s}-{s}{s}", 41 + "{s}-{f}{s}", 42 42 .{ relative_path[0 .. relative_path.len - extension.len], zuid.new.v4(), extension }, 43 43 ); 44 44 } else lbl: {
+12 -11
src/event_handlers.zig
··· 133 133 } 134 134 }, 135 135 .image_ready => {}, 136 - .winsize => |ws| try app.vx.resize(app.alloc, app.tty.anyWriter(), ws), 136 + .notification => {}, 137 + .winsize => |ws| try app.vx.resize(app.alloc, app.tty.writer(), ws), 137 138 } 138 139 } 139 140 ··· 161 162 .new_file => try events.createNewFile(app), 162 163 .rename => try events.rename(app), 163 164 .change_dir => { 164 - const path = app.inputToSlice(); 165 + const path = try app.text_input.toOwnedSlice(); 166 + defer app.alloc.free(path); 165 167 try commands.cd(app, path); 166 - app.text_input.clearAndFree(); 167 168 }, 168 169 .command => { 169 - const command = app.inputToSlice(); 170 + const command = try app.text_input.toOwnedSlice(); 171 + defer app.alloc.free(command); 170 172 171 173 // Push command to history if it's not empty. 172 174 if (!std.mem.eql(u8, std.mem.trim(u8, command, " "), ":")) { ··· 208 210 break :supported; 209 211 } 210 212 211 - app.text_input.clearAndFree(); 212 213 try app.text_input.insertSliceAtCursor(":UnsupportedCommand"); 213 214 } 214 215 ··· 220 221 if (app.state != .help_menu) app.state = .normal; 221 222 app.directories.entries.selected = selected; 222 223 }, 223 - Key.left => app.text_input.cursorLeft(), 224 - Key.right => app.text_input.cursorRight(), 225 224 Key.up => { 226 225 if (app.state == .command) { 227 226 if (app.command_history.previous()) |command| { ··· 260 259 261 260 switch (app.state) { 262 261 .fuzzy => { 263 - const fuzzy = app.inputToSlice(); 262 + const fuzzy = app.readInput(); 264 263 try app.repopulateDirectory(fuzzy); 265 264 }, 266 265 .command => { 267 - const command = app.inputToSlice(); 266 + const command = app.readInput(); 268 267 if (!std.mem.startsWith(u8, command, ":")) { 269 268 app.text_input.clearAndFree(); 270 269 app.text_input.insertSliceAtCursor(":") catch |err| { ··· 283 282 } 284 283 }, 285 284 .image_ready => {}, 286 - .winsize => |ws| try app.vx.resize(app.alloc, app.tty.anyWriter(), ws), 285 + .notification => {}, 286 + .winsize => |ws| try app.vx.resize(app.alloc, app.tty.writer(), ws), 287 287 } 288 288 } 289 289 ··· 298 298 } 299 299 }, 300 300 .image_ready => {}, 301 - .winsize => |ws| try app.vx.resize(app.alloc, app.tty.anyWriter(), ws), 301 + .notification => {}, 302 + .winsize => |ws| try app.vx.resize(app.alloc, app.tty.writer(), ws), 302 303 } 303 304 }
+11 -17
src/events.zig
··· 49 49 return; 50 50 } 51 51 52 - const tmp_path = try std.fmt.allocPrint(app.alloc, "{s}/{s}-{s}", .{ trash_dir_path, entry.name, zuid.new.v4() }); 52 + const tmp_path = try std.fmt.allocPrint(app.alloc, "{s}/{s}-{f}", .{ trash_dir_path, entry.name, zuid.new.v4() }); 53 53 if (app.directories.dir.rename(entry.name, tmp_path)) { 54 54 if (app.actions.push(.{ 55 55 .delete = .{ .prev_path = prev_path_alloc, .new_path = tmp_path }, ··· 87 87 return; 88 88 }; 89 89 90 - const new_path = app.inputToSlice(); 90 + const new_path = try app.text_input.toOwnedSlice(); 91 + defer app.alloc.free(new_path); 91 92 92 93 if (environment.fileExists(app.directories.dir, new_path)) { 93 94 message = try std.fmt.allocPrint(app.alloc, "Can not rename file - '{s}' already exists.", .{new_path}); ··· 111 112 } 112 113 113 114 try app.repopulateDirectory(""); 114 - app.text_input.clearAndFree(); 115 115 116 116 message = try std.fmt.allocPrint(app.alloc, "Renamed '{s}' to '{s}'.", .{ entry.name, new_path }); 117 117 app.notification.write(message.?, .info) catch {}; 118 118 } 119 - 120 - app.text_input.clearAndFree(); 121 119 } 122 120 123 121 pub fn forceDelete(app: *App) error{OutOfMemory}!void { ··· 419 417 }, 420 418 .file => { 421 419 if (environment.getEditor()) |editor| { 422 - try app.vx.exitAltScreen(app.tty.anyWriter()); 423 - try app.vx.resetState(app.tty.anyWriter()); 420 + try app.vx.exitAltScreen(app.tty.writer()); 421 + try app.vx.resetState(app.tty.writer()); 424 422 app.loop.stop(); 425 423 426 424 environment.openFile(app.alloc, app.directories.dir, entry.name, editor) catch |err| { ··· 430 428 }; 431 429 432 430 try app.loop.start(); 433 - try app.vx.enterAltScreen(app.tty.anyWriter()); 434 - try app.vx.enableDetectedFeatures(app.tty.anyWriter()); 431 + try app.vx.enterAltScreen(app.tty.writer()); 432 + try app.vx.enableDetectedFeatures(app.tty.writer()); 435 433 app.vx.queueRefresh(); 436 434 } else { 437 435 app.notification.write("Can not open file - $EDITOR not set.", .warn) catch {}; ··· 445 443 var message: ?[]const u8 = null; 446 444 defer if (message) |msg| app.alloc.free(msg); 447 445 448 - const dir = app.inputToSlice(); 446 + const dir = try app.text_input.toOwnedSlice(); 447 + defer app.alloc.free(dir); 449 448 450 449 app.directories.dir.makeDir(dir) catch |err| { 451 450 message = try std.fmt.allocPrint(app.alloc, "Failed to create directory '{s}' - {}", .{ dir, err }); 452 451 app.notification.write(message.?, .err) catch {}; 453 452 if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 454 - app.text_input.clearAndFree(); 455 453 return; 456 454 }; 457 455 458 456 try app.repopulateDirectory(""); 459 - app.text_input.clearAndFree(); 460 457 461 458 message = try std.fmt.allocPrint(app.alloc, "Created new directory '{s}'.", .{dir}); 462 459 app.notification.write(message.?, .info) catch {}; ··· 466 463 var message: ?[]const u8 = null; 467 464 defer if (message) |msg| app.alloc.free(msg); 468 465 469 - const file = app.inputToSlice(); 466 + const file = try app.text_input.toOwnedSlice(); 467 + defer app.alloc.free(file); 470 468 471 469 if (environment.fileExists(app.directories.dir, file)) { 472 470 message = try std.fmt.allocPrint(app.alloc, "Can not create file - '{s}' already exists.", .{file}); ··· 476 474 message = try std.fmt.allocPrint(app.alloc, "Failed to create file '{s}' - {}", .{ file, err }); 477 475 app.notification.write(message.?, .err) catch {}; 478 476 if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 479 - app.text_input.clearAndFree(); 480 477 return; 481 478 }; 482 479 483 480 try app.repopulateDirectory(""); 484 - app.text_input.clearAndFree(); 485 481 486 482 message = try std.fmt.allocPrint(app.alloc, "Created new file '{s}'.", .{file}); 487 483 app.notification.write(message.?, .info) catch {}; 488 484 } 489 - 490 - app.text_input.clearAndFree(); 491 485 } 492 486 493 487 pub fn undo(app: *App) error{OutOfMemory}!void {
+13 -15
src/file_logger.zig
··· 24 24 file: ?std.fs.File, 25 25 26 26 pub fn init(dir: std.fs.Dir) FileLogger { 27 - var file: ?std.fs.File = null; 28 - if (!environment.fileExists(dir, LOG_PATH)) { 29 - file = dir.createFile(LOG_PATH, .{}) catch lbl: { 30 - std.log.err("Failed to create log file.", .{}); 31 - break :lbl null; 32 - }; 33 - } else { 34 - file = dir.openFile(LOG_PATH, .{ .mode = .write_only }) catch lbl: { 35 - std.log.err("Failed to open log file.", .{}); 36 - break :lbl null; 37 - }; 38 - } 27 + const file = dir.createFile(LOG_PATH, .{ .truncate = false, .read = true }) catch |err| { 28 + std.log.err("Failed to create/open log file: {s}", .{@errorName(err)}); 29 + return .{ .dir = dir, .file = null }; 30 + }; 39 31 40 32 return .{ .dir = dir, .file = file }; 41 33 } ··· 49 41 50 42 pub fn write(self: FileLogger, msg: []const u8, level: LogLevel) !void { 51 43 const file = if (self.file) |file| file else return error.NoLogFile; 52 - if (try file.tryLock(std.fs.File.Lock.shared)) { 44 + 45 + if (try file.tryLock(.exclusive)) { 53 46 defer file.unlock(); 54 - try file.seekFromEnd(0); 55 47 56 - try file.writer().print( 48 + var buffer: [1024]u8 = undefined; 49 + var file_writer_impl = file.writer(&buffer); 50 + const file_writer = &file_writer_impl.interface; 51 + try file_writer_impl.seekTo(file.getEndPos() catch 0); 52 + 53 + try file_writer.print( 57 54 "({d}) {s}: {s}\n", 58 55 .{ std.time.timestamp(), LogLevel.toString(level), msg }, 59 56 ); 57 + try file_writer.flush(); 60 58 } 61 59 }
+98
src/image.zig
··· 1 + const std = @import("std"); 2 + const vaxis = @import("vaxis"); 3 + const App = @import("app.zig"); 4 + 5 + pub const Cache = struct { 6 + mutex: std.Thread.Mutex = .{}, 7 + cache: std.StringHashMap(Image), 8 + }; 9 + 10 + const Status = enum { 11 + ready, 12 + processing, 13 + failed, 14 + }; 15 + 16 + const Image = @This(); 17 + 18 + ///Only use on first transmission. Subsequent draws should use 19 + ///`Image.image`. 20 + data: ?vaxis.zigimg.Image = null, 21 + image: ?vaxis.Image = null, 22 + path: ?[]const u8 = null, 23 + status: Status = .processing, 24 + 25 + pub fn deinit(self: Image, alloc: std.mem.Allocator, vx: vaxis.Vaxis, tty: *vaxis.Tty) void { 26 + if (self.image) |image| { 27 + vx.freeImage(tty.writer(), image.id); 28 + } 29 + if (self.data) |data| { 30 + var d = data; 31 + d.deinit(alloc); 32 + } 33 + if (self.path) |path| alloc.free(path); 34 + } 35 + 36 + pub fn processImage(alloc: std.mem.Allocator, app: *App, path: []const u8) error{ Unsupported, OutOfMemory }!void { 37 + app.images.cache.put(path, .{ .path = path, .status = .processing }) catch { 38 + const message = try std.fmt.allocPrint(alloc, "Failed to load image '{s}' - error occurred while attempting to add image to cache.", .{path}); 39 + defer alloc.free(message); 40 + app.notification.write(message, .err) catch {}; 41 + if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 42 + return error.Unsupported; 43 + }; 44 + 45 + const load_img_thread = std.Thread.spawn(.{}, loadImage, .{ 46 + alloc, 47 + app, 48 + path, 49 + }) catch { 50 + app.images.mutex.lock(); 51 + if (app.images.cache.getPtr(path)) |entry| { 52 + entry.status = .failed; 53 + } 54 + app.images.mutex.unlock(); 55 + 56 + const message = try std.fmt.allocPrint(alloc, "Failed to load image '{s}' - error occurred while attempting to spawn processing thread.", .{path}); 57 + defer alloc.free(message); 58 + app.notification.write(message, .err) catch {}; 59 + if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 60 + 61 + return error.Unsupported; 62 + }; 63 + load_img_thread.detach(); 64 + } 65 + 66 + fn loadImage(alloc: std.mem.Allocator, app: *App, path: []const u8) error{OutOfMemory}!void { 67 + var buf: [(1024 * 1024) * 5]u8 = undefined; // 5mb 68 + const data = vaxis.zigimg.Image.fromFilePath(alloc, path, &buf) catch { 69 + app.images.mutex.lock(); 70 + if (app.images.cache.getPtr(path)) |entry| { 71 + entry.status = .failed; 72 + } 73 + app.images.mutex.unlock(); 74 + 75 + const message = try std.fmt.allocPrint(alloc, "Failed to load image '{s}' - error occurred while attempting to read image from path.", .{path}); 76 + defer alloc.free(message); 77 + app.notification.write(message, .err) catch {}; 78 + if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 79 + 80 + return; 81 + }; 82 + 83 + app.images.mutex.lock(); 84 + if (app.images.cache.getPtr(path)) |entry| { 85 + entry.status = .ready; 86 + entry.data = data; 87 + entry.path = path; 88 + } else { 89 + const message = try std.fmt.allocPrint(alloc, "Failed to load image '{s}' - error occurred while attempting to add image to cache.", .{path}); 90 + defer alloc.free(message); 91 + app.notification.write(message, .err) catch {}; 92 + if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 93 + return; 94 + } 95 + app.images.mutex.unlock(); 96 + 97 + app.loop.postEvent(.image_ready); 98 + }
+71 -4
src/list.zig
··· 12 12 pub fn init(alloc: std.mem.Allocator) Self { 13 13 return Self{ 14 14 .alloc = alloc, 15 - .items = std.ArrayList(T).init(alloc), 15 + .items = .empty, 16 16 .selected = 0, 17 17 }; 18 18 } 19 19 20 20 pub fn deinit(self: *Self) void { 21 - self.items.deinit(); 21 + self.items.deinit(self.alloc); 22 22 } 23 23 24 24 pub fn append(self: *Self, item: T) !void { 25 - try self.items.append(item); 25 + try self.items.append(self.alloc, item); 26 26 } 27 27 28 28 pub fn clear(self: *Self) void { 29 - self.items.clearAndFree(); 29 + self.items.clearAndFree(self.alloc); 30 30 self.selected = 0; 31 31 } 32 32 ··· 85 85 } 86 86 }; 87 87 } 88 + 89 + const testing = std.testing; 90 + 91 + test "List: navigation respects bounds" { 92 + var list = List(u32).init(testing.allocator); 93 + defer list.deinit(); 94 + 95 + try list.append(1); 96 + try list.append(2); 97 + try list.append(3); 98 + 99 + try testing.expectEqual(@as(usize, 0), list.selected); 100 + 101 + list.next(); 102 + try testing.expectEqual(@as(usize, 1), list.selected); 103 + 104 + list.next(); 105 + list.next(); 106 + // Try to go past end 107 + list.next(); 108 + // Should stay at last 109 + try testing.expectEqual(@as(usize, 2), list.selected); 110 + 111 + list.previous(); 112 + try testing.expectEqual(@as(usize, 1), list.selected); 113 + 114 + list.previous(); 115 + // Try to go before start 116 + list.previous(); 117 + // Should stay at first 118 + try testing.expectEqual(@as(usize, 0), list.selected); 119 + } 120 + 121 + test "List: getSelected handles empty list" { 122 + var list = List(u32).init(testing.allocator); 123 + defer list.deinit(); 124 + 125 + const result = try list.getSelected(); 126 + try testing.expect(result == null); 127 + } 128 + 129 + test "List: append and get operations" { 130 + var list = List(u32).init(testing.allocator); 131 + defer list.deinit(); 132 + 133 + try list.append(42); 134 + try list.append(84); 135 + 136 + try testing.expectEqual(@as(usize, 2), list.len()); 137 + try testing.expectEqual(@as(u32, 42), try list.get(0)); 138 + try testing.expectEqual(@as(u32, 84), try list.get(1)); 139 + } 140 + 141 + test "List: selectFirst and selectLast" { 142 + var list = List(u32).init(testing.allocator); 143 + defer list.deinit(); 144 + 145 + try list.append(1); 146 + try list.append(2); 147 + try list.append(3); 148 + 149 + list.selectLast(); 150 + try testing.expectEqual(@as(usize, 2), list.selected); 151 + 152 + list.selectFirst(); 153 + try testing.expectEqual(@as(usize, 0), list.selected); 154 + }
+11 -3
src/main.zig
··· 104 104 } 105 105 106 106 if (opts.version) { 107 - std.debug.print("jido v{}\n", .{options.version}); 107 + std.debug.print("jido v{f}\n", .{options.version}); 108 108 return; 109 109 } 110 110 ··· 139 139 }, 140 140 }; 141 141 142 - app.file_logger = if (config.config_dir) |dir| FileLogger.init(dir) else null; 142 + app.file_logger = if (config.config_dir) |dir| FileLogger.init(dir) else logger: { 143 + std.log.err("Failed to initialise file logger - no config directory found", .{}); 144 + break :logger null; 145 + }; 146 + app.notification.loop = &app.loop; 143 147 144 148 try app.run(); 145 149 ··· 151 155 // Must be printed after app has deinit as part of that process clears 152 156 // the screen. 153 157 if (last_dir) |path| { 154 - const stdout = std.io.getStdOut().writer(); 158 + var stdout_buffer: [std.fs.max_path_bytes]u8 = undefined; 159 + var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer); 160 + const stdout = &stdout_writer.interface; 155 161 stdout.print("{s}\n", .{path}) catch {}; 162 + stdout.flush() catch {}; 163 + 156 164 alloc.free(path); 157 165 } 158 166 }
+8
src/notification.zig
··· 1 1 const std = @import("std"); 2 + const vaxis = @import("vaxis"); 3 + const Event = @import("app.zig").Event; 4 + 2 5 const FileLogger = @import("file_logger.zig"); 3 6 4 7 const Self = @This(); ··· 18 21 fbs: std.io.FixedBufferStream([]u8) = std.io.fixedBufferStream(&buf), 19 22 /// How long until the notification disappears in seconds. 20 23 timer: i64 = 0, 24 + loop: ?*vaxis.Loop(Event) = null, 21 25 22 26 pub fn write(self: *Self, text: []const u8, style: Style) !void { 23 27 self.fbs.reset(); 24 28 _ = try self.fbs.write(text); 25 29 self.timer = std.time.timestamp(); 26 30 self.style = style; 31 + 32 + if (self.loop) |loop| { 33 + loop.postEvent(.notification); 34 + } 27 35 } 28 36 29 37 pub fn reset(self: *Self) void {
+5
src/sort.zig
··· 1 + const std = @import("std"); 2 + 3 + pub fn string(_: void, lhs: []const u8, rhs: []const u8) bool { 4 + return std.mem.lessThan(u8, lhs, rhs); 5 + }
+84
src/test_file_operations.zig
··· 1 + const std = @import("std"); 2 + const testing = std.testing; 3 + const TestEnv = @import("test_helpers.zig").TestEnv; 4 + const Directories = @import("directories.zig"); 5 + const environment = @import("environment.zig"); 6 + 7 + test "FileOps: create new directory" { 8 + var env = try TestEnv.init(testing.allocator); 9 + defer env.deinit(); 10 + 11 + var dirs = try Directories.init(testing.allocator, env.tmp_path); 12 + defer dirs.deinit(); 13 + 14 + try dirs.dir.makeDir("testdir"); 15 + 16 + var test_dir = dirs.dir.openDir("testdir", .{}) catch |err| { 17 + std.debug.print("Failed to open created directory: {}\n", .{err}); 18 + return err; 19 + }; 20 + test_dir.close(); 21 + 22 + try dirs.populateEntries(""); 23 + var found = false; 24 + for (dirs.entries.all()) |entry| { 25 + if (std.mem.eql(u8, entry.name, "testdir")) { 26 + found = true; 27 + try testing.expectEqual(std.fs.Dir.Entry.Kind.directory, entry.kind); 28 + } 29 + } 30 + try testing.expect(found); 31 + } 32 + 33 + test "FileOps: create new file" { 34 + var env = try TestEnv.init(testing.allocator); 35 + defer env.deinit(); 36 + 37 + var dirs = try Directories.init(testing.allocator, env.tmp_path); 38 + defer dirs.deinit(); 39 + 40 + const file = try dirs.dir.createFile("testfile.txt", .{}); 41 + file.close(); 42 + 43 + try testing.expect(environment.fileExists(dirs.dir, "testfile.txt")); 44 + 45 + try dirs.populateEntries(""); 46 + var found = false; 47 + for (dirs.entries.all()) |entry| { 48 + if (std.mem.eql(u8, entry.name, "testfile.txt")) { 49 + found = true; 50 + try testing.expectEqual(std.fs.Dir.Entry.Kind.file, entry.kind); 51 + } 52 + } 53 + try testing.expect(found); 54 + } 55 + 56 + test "FileOps: rename file" { 57 + var env = try TestEnv.init(testing.allocator); 58 + defer env.deinit(); 59 + 60 + try env.createFiles(&.{"oldname.txt"}); 61 + 62 + var dirs = try Directories.init(testing.allocator, env.tmp_path); 63 + defer dirs.deinit(); 64 + 65 + try dirs.populateEntries(""); 66 + 67 + try testing.expect(environment.fileExists(dirs.dir, "oldname.txt")); 68 + try dirs.dir.rename("oldname.txt", "newname.txt"); 69 + try testing.expect(!environment.fileExists(dirs.dir, "oldname.txt")); 70 + try testing.expect(environment.fileExists(dirs.dir, "newname.txt")); 71 + 72 + dirs.clearEntries(); 73 + try dirs.populateEntries(""); 74 + 75 + var found_old = false; 76 + var found_new = false; 77 + for (dirs.entries.all()) |entry| { 78 + if (std.mem.eql(u8, entry.name, "oldname.txt")) found_old = true; 79 + if (std.mem.eql(u8, entry.name, "newname.txt")) found_new = true; 80 + } 81 + 82 + try testing.expect(!found_old); 83 + try testing.expect(found_new); 84 + }
+61
src/test_helpers.zig
··· 1 + const std = @import("std"); 2 + 3 + pub const TestEnv = struct { 4 + allocator: std.mem.Allocator, 5 + tmp_dir: std.testing.TmpDir, 6 + tmp_path: []const u8, 7 + 8 + pub fn init(allocator: std.mem.Allocator) !TestEnv { 9 + var tmp_dir = std.testing.tmpDir(.{}); 10 + const real_path = try tmp_dir.dir.realpathAlloc(allocator, "."); 11 + 12 + return TestEnv{ 13 + .allocator = allocator, 14 + .tmp_dir = tmp_dir, 15 + .tmp_path = real_path, 16 + }; 17 + } 18 + 19 + pub fn deinit(self: *TestEnv) void { 20 + self.allocator.free(self.tmp_path); 21 + self.tmp_dir.cleanup(); 22 + } 23 + 24 + pub fn createFiles(self: *TestEnv, names: []const []const u8) !void { 25 + for (names) |name| { 26 + const file = try self.tmp_dir.dir.createFile(name, .{}); 27 + file.close(); 28 + } 29 + } 30 + 31 + pub const DirNode = struct { 32 + name: []const u8, 33 + children: ?[]const DirNode, 34 + }; 35 + 36 + pub fn createDirStructure(self: *TestEnv, nodes: []const DirNode) !void { 37 + for (nodes) |node| { 38 + if (node.children) |children| { 39 + try self.tmp_dir.dir.makeDir(node.name); 40 + var subdir = try self.tmp_dir.dir.openDir(node.name, .{}); 41 + defer subdir.close(); 42 + 43 + for (children) |child| { 44 + if (child.children) |_| { 45 + try subdir.makeDir(child.name); 46 + } else { 47 + const file = try subdir.createFile(child.name, .{}); 48 + file.close(); 49 + } 50 + } 51 + } else { 52 + const file = try self.tmp_dir.dir.createFile(node.name, .{}); 53 + file.close(); 54 + } 55 + } 56 + } 57 + 58 + pub fn path(self: *TestEnv, relative: []const u8) ![]const u8 { 59 + return try std.fs.path.join(self.allocator, &.{ self.tmp_path, relative }); 60 + } 61 + };
+114
src/test_navigation.zig
··· 1 + const std = @import("std"); 2 + const testing = std.testing; 3 + const TestEnv = @import("test_helpers.zig").TestEnv; 4 + const Directories = @import("directories.zig"); 5 + const events = @import("events.zig"); 6 + const App = @import("app.zig"); 7 + 8 + test "Navigation: traverse left to parent directory" { 9 + var env = try TestEnv.init(testing.allocator); 10 + defer env.deinit(); 11 + 12 + try env.createDirStructure(&.{ 13 + .{ .name = "parent", .children = &.{ 14 + .{ .name = "child", .children = &.{} }, 15 + .{ .name = "sibling.txt", .children = null }, 16 + } }, 17 + }); 18 + 19 + const child_path = try env.path("parent/child"); 20 + defer testing.allocator.free(child_path); 21 + 22 + var dirs = try Directories.init(testing.allocator, child_path); 23 + defer dirs.deinit(); 24 + 25 + const before_path = try dirs.fullPath("."); 26 + try testing.expect(std.mem.endsWith(u8, before_path, "child")); 27 + 28 + const parent_dir = try dirs.dir.openDir("../", .{ .iterate = true }); 29 + dirs.dir.close(); 30 + dirs.dir = parent_dir; 31 + 32 + const after_path = try dirs.fullPath("."); 33 + try testing.expect(std.mem.endsWith(u8, after_path, "parent")); 34 + 35 + try dirs.populateEntries(""); 36 + var found_child = false; 37 + for (dirs.entries.all()) |entry| { 38 + if (std.mem.eql(u8, entry.name, "child")) { 39 + found_child = true; 40 + try testing.expectEqual(std.fs.Dir.Entry.Kind.directory, entry.kind); 41 + } 42 + } 43 + try testing.expect(found_child); 44 + } 45 + 46 + test "Navigation: traverse right into directory" { 47 + var env = try TestEnv.init(testing.allocator); 48 + defer env.deinit(); 49 + 50 + try env.createDirStructure(&.{ 51 + .{ .name = "subdir", .children = &.{ 52 + .{ .name = "inner.txt", .children = null }, 53 + } }, 54 + .{ .name = "file.txt", .children = null }, 55 + }); 56 + 57 + var dirs = try Directories.init(testing.allocator, env.tmp_path); 58 + defer dirs.deinit(); 59 + 60 + try dirs.populateEntries(""); 61 + 62 + for (dirs.entries.all(), 0..) |entry, i| { 63 + if (std.mem.eql(u8, entry.name, "subdir")) { 64 + dirs.entries.selected = i; 65 + break; 66 + } 67 + } 68 + 69 + const selected = try dirs.getSelected(); 70 + try testing.expect(selected != null); 71 + try testing.expectEqualStrings("subdir", selected.?.name); 72 + 73 + const subdir = try dirs.dir.openDir("subdir", .{ .iterate = true }); 74 + dirs.dir.close(); 75 + dirs.dir = subdir; 76 + 77 + const current_path = try dirs.fullPath("."); 78 + try testing.expect(std.mem.endsWith(u8, current_path, "subdir")); 79 + 80 + dirs.clearEntries(); 81 + try dirs.populateEntries(""); 82 + try testing.expectEqual(@as(usize, 1), dirs.entries.len()); 83 + 84 + const inner = try dirs.entries.get(0); 85 + try testing.expectEqualStrings("inner.txt", inner.name); 86 + } 87 + 88 + test "Navigation: move selection with next and previous" { 89 + var env = try TestEnv.init(testing.allocator); 90 + defer env.deinit(); 91 + 92 + try env.createFiles(&.{ "file1.txt", "file2.txt", "file3.txt", "file4.txt", "file5.txt" }); 93 + 94 + var dirs = try Directories.init(testing.allocator, env.tmp_path); 95 + defer dirs.deinit(); 96 + 97 + try dirs.populateEntries(""); 98 + try testing.expectEqual(@as(usize, 5), dirs.entries.len()); 99 + try testing.expectEqual(@as(usize, 0), dirs.entries.selected); 100 + 101 + dirs.entries.next(); 102 + dirs.entries.next(); 103 + dirs.entries.next(); 104 + try testing.expectEqual(@as(usize, 3), dirs.entries.selected); 105 + 106 + dirs.entries.previous(); 107 + try testing.expectEqual(@as(usize, 2), dirs.entries.selected); 108 + 109 + dirs.entries.selectLast(); 110 + try testing.expectEqual(@as(usize, 4), dirs.entries.selected); 111 + 112 + dirs.entries.selectFirst(); 113 + try testing.expectEqual(@as(usize, 0), dirs.entries.selected); 114 + }