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

Compare changes

Choose any two refs to compare.

+4065 -969
+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.
+10
.gila/done/intelligent_dino_17y/intelligent_dino_17y.md
··· 1 + --- 2 + title: feat: add keybind to extraction archive 3 + status: done 4 + priority_value: 50 5 + priority: low 6 + owner: brookjeynes 7 + created: 2026-01-11T21:52:59Z 8 + completed: 2026-01-21T07:05:03Z 9 + --- 10 + Allow users to extract archives via a keybind
+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.
+1 -1
.github/workflows/create-draft-release.yml
··· 18 18 - name: Set up Zig 19 19 uses: korandoru/setup-zig@v1 20 20 with: 21 - zig-version: "0.13.0" 21 + zig-version: "0.14.0" 22 22 23 23 - name: Build application 24 24 run: |
+29
.github/workflows/mirror.yml
··· 1 + name: Mirror to tangled 2 + 3 + on: 4 + push: 5 + branches: 6 + - main 7 + 8 + jobs: 9 + mirror: 10 + runs-on: ubuntu-latest 11 + 12 + steps: 13 + - name: Checkout source repo 14 + uses: actions/checkout@v4 15 + with: 16 + fetch-depth: 0 17 + 18 + - name: Set up SSH key 19 + run: | 20 + mkdir -p ~/.ssh 21 + echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519 22 + chmod 600 ~/.ssh/id_ed25519 23 + ssh-keyscan -H tangled.sh >> ~/.ssh/known_hosts 24 + shell: bash 25 + 26 + - name: Mirror 27 + run: | 28 + git remote add tangled git@tangled.sh:brookjeynes.dev/jido 29 + git push --mirror tangled
+230
CHANGELOG.md
··· 1 + # Changelog 2 + 3 + ## v1.3.0 (2025-05-26) 4 + - feat: add `--choose-dir` arg 5 + - feat: add `--entry-dir=PATH` arg 6 + 7 + ## v1.2.0 (2025-05-26) 8 + - feat(images): Cache images to avoid unecessary re-processing 9 + 10 + ## v1.1.0 (2025-05-21) 11 + - fix(images): Improve performance by only locking critical parts of image loading 12 + - fix(images): Thread the image loading process as not to block user input 13 + 14 + ## v1.0.1 (2025-04-14) 15 + - fix(errors): Ensure logged enums are wrapped in `@tagName()` for readability. 16 + 17 + ## v1.0.0 (2025-04-06) 18 + - New Keybinds: 19 + - Added ability to copy files. 20 + This is done by (y)anking the file, then (p)asting in the desired directory. 21 + This action can be (u)ndone and behind the scenes is a deletion. 22 + Currently this feature only supports files, folders, and symlinks. 23 + - Added force delete keybind. It's unbound by default. 24 + - Added keybind `v` to view additional information about the selected entry. 25 + - A huge audit of `try` usages was conducted. As a result of this, Jido is much 26 + more resiliant to errors and should crash less often in known cases. 27 + - Added `:h` command to view help / keybind menu. 28 + - Added config option `true_dir_size` to see the true size of directories. 29 + - Added [-v | --version] and [-h | --help] args. 30 + - File permissions are now displayed in the file information bar to the bottom 31 + of Jido. 32 + - Keybinds can now be unbound. Some keybinds are now unbound by default. 33 + See [Configuration](https://github.com/BrookJeynes/jido?tab=readme-ov-file#configuration) 34 + for more information. 35 + - Fixes: 36 + - fix: Scrolling command history now provides the correct values. 37 + - fix: Ensure complete Git branch is displayed. Previously if the branch 38 + contained slashes, it would only retrieve the ending split. 39 + - fix: Allow the cursor to be moved left and right on text input. 40 + - fix: The keybind " " (spacebar) is now accepted by the config. 41 + - fix: Multi-char keybinds now throw errors instead of crashing. 42 + - fix: Undoing a delete/rename wont overwrite an item with the same name now. 43 + 44 + ## v0.9.9 (2025-04-06) 45 + - feat: Added ability to copy folders. 46 + - fix: Scrolling command history now provides the correct values. 47 + 48 + ## v0.9.8 (2025-04-04) 49 + - fix: Ensure complete Git branch is displayed. 50 + - refactor: Audit try usage to improve system resiliance. 51 + - refactor: Removed need for enum based notifications. 52 + 53 + ## v0.9.7 (2025-04-01) 54 + - feat: Added ability to copy files. 55 + This is done by (y)anking the file, then (p)asting in the desired directory. 56 + This action can be (u)ndone and behind the scenes is a deletion. 57 + - fix: Allow the cursor to be moved left and right. 58 + - refactor: Changed action struct field names to be more clear. 59 + - refactor: Better ergonomics around writing to the log file. 60 + 61 + ## v0.9.6 (2025-03-31) 62 + - feat: Added ability to unbound keybinds. 63 + - feat: Added force delete keybind. It's unbound by default. 64 + 65 + ## v0.9.5 (2025-03-29) 66 + - feat: Added [-v | --version] and [-h | --help] args. 67 + 68 + ## v0.9.4 (2025-03-29) 69 + - feat: Added keybind `h` to view help / keybind menu. 70 + - refactor: `List` drawing logic is now handled by the `Drawer{}`. 71 + 72 + ## v0.9.3 (2025-03-27) 73 + - feat: The keybind " " is now accepted. This allows spacebar to be bound. 74 + - feat: Duplicate keybind notification now includes additional information. 75 + - fix: Multi-char keybinds now throw errors instead of crashing. 76 + - fix: Remove need to init notification handler. This fixes many issues with 77 + the places in the code notifications could be produced. 78 + 79 + ## v0.9.2 (2025-03-25) 80 + - feat: Added keybind `v` to view additional information about the selected entry. 81 + - feat: Added config option `true_dir_size` to see the true size of directories. 82 + - fix: Undoing a delete/rename wont overwrite an item with the same name now. 83 + 84 + ## v0.9.1 (2025-03-23) 85 + - feat: File permissions are now displayed in the file information bar to the 86 + bottom of Jido. 87 + 88 + ## v0.9.0 (2025-03-21) 89 + - New Keybinds: 90 + - Added keybind `<CTRL-r>` to reload config while Jido is running. 91 + - Added keybind `.` to hide/show hidden files at runtime. 92 + Default behaviour is still read from the config file if set. 93 + - Added keybind rebinding. 94 + Jido now allows you to rebind certain keys. These can be rebound via the config 95 + file. See [Configuration](https://github.com/BrookJeynes/jido?tab=readme-ov-file#configuration) 96 + for more information. 97 + - Added file logger. 98 + This file logger allows Jido to provide users with more detailed log messages 99 + the notification system cannot. The log file can be found within the config 100 + directory under the file `log.txt`. 101 + - Jido is now built with the latest stable version of Zig, v0.14.0. 102 + - Fixes: 103 + - Hiding/showing hidden files after cd would cause all the files to visually 104 + disappear. 105 + - Off by one error when traversing command history causing the list to skip 106 + some entries. 107 + - Empty commands are no longer added to the command history. This now means 108 + commands are whitespace trimmed. 109 + - Move logic to hide dot files from renderer to directory reader. 110 + This moves the logic to hide dot files out from the renderer to the 111 + directory reader. This means if hidden files are turned off, they aren't 112 + even stored. 113 + - Default styling didn't specify styling for notification box text. This 114 + would cause visual issues for light mode users. 115 + 116 + ## v0.8.3 (2025-03-19) 117 + - feat: Added keybind `<CTRL-r>` to reload config while Jido is running. 118 + - fix: Hiding/showing hidden files after cd would display no files. 119 + - fix: Off by one error when traversing command history... 120 + - fix: Dont add empty commands to command history. 121 + - docs: Updated readme to mention new keybind. 122 + - docs: Reordered keybinds section to add "Global" section. 123 + 124 + ## v0.8.2 (2025-03-18) 125 + - fix: Move logic to hide dot files from renderer to directory reader. 126 + this moves the logic to hide dot files out from the renderer to the 127 + directory reader. This means if hidden files are turned off, they aren't 128 + even stored. 129 + - feat: Added keybind `.` to hide/show hidden files at runtime. 130 + Default behaviour is still read from the config file if set. 131 + 132 + ## v0.8.1 (2025-03-11) 133 + - feat: Jido is now built with zig 0.14.0. 134 + - chore: Update packages. 135 + 136 + ## v0.8.0 (2025-01-07) 137 + - Rebrand from zfe to Jido by @BrookJeynes in #16 138 + I felt that I wanted this project to have more of its own identity so I 139 + decided now that this project is getting closer to a v1.0 release, it's time 140 + to give it a proper name. 141 + - Added command mode by @BrookJeynes in #14 142 + Command mode is a way for users to enter Jido commands. 143 + Currently supported commands: 144 + ``` 145 + Command mode: 146 + :q :Exit. 147 + :config :Navigate to config directory if it exists. 148 + :trash :Navigate to trash directory if it exists. 149 + :empty_trash :Empty trash if it exists. This action cannot be undone. 150 + ``` 151 + - Deletes are now sent to `<config>/trash` instead of `/tmp`. by @BrookJeynes in #15 152 + Previously, deletes were sent to `/tmp`. This made it convenient for cleanup 153 + however caused issues on certain distros. This was because the `/tmp` dir was 154 + on a separate mount point and therefore the file was unable to be moved there. 155 + Tying into this, there is now a new `empty_trash_on_exit` config option set to 156 + false by default. 157 + - Reworked the notification stylings. Notification stylings are now under the 158 + notification namespace within the config file. 159 + - The code used to detect the git branch no longer needs git installed on the 160 + system. 161 + - Displayed file size now shows the correct file size for files. 162 + 163 + ## v0.7.0 (2025-01-01) 164 + - Fix notification segfaults by @BrookJeynes in #9 165 + - Conform codebase styling by @BrookJeynes in #10 166 + - Create release action by @BrookJeynes in #11 167 + - Separate event and draw logic by @BrookJeynes in #12 168 + - Updated config location from `$HOME/.config/zfe` to `$HOME/.zfe` by @BrookJeynes in 3cb9bb2 169 + - This means that the config can be found at either `$HOME/.zfe/` or 170 + `$XDG_CONFIG_HOME/zfe/config/`. The old path will continue to work for 171 + the meantime but has been deprecated. 172 + - Show git branch when available by @BrookJeynes in #13 173 + 174 + ## v0.6.1 (2024-12-03) 175 + - Updated libvaxis and refactored build.zig by @BrookJeynes in #7 176 + - Notifications are now their own windows that appear to the right by @BrookJeynes in #8 177 + - Notifications are now their own windows that appear to the right of the 178 + screen. they disappear after 3 seconds but note that renders only occur 179 + after an action has been polled. this means that if you wait for 3 seconds 180 + without an action, the notification wont disappear until an action occurs. 181 + - Added info notifications on actions such as renaming, deleting, changing 182 + dir, etc. 183 + - Added notification_box colour setting to config. 184 + 185 + ## v0.5.0 (2024-06-05) 186 + - Updated libvaxis dependency. 187 + - Fixed an issue where viewing a PDF would freeze zfe. This fixes issue #5 188 + - Added additional "Optional Dependencies" section to README to specify optional 189 + dependencies for zfe (such as pdftotext for PDF viewing). 190 + - Updated the way images are streamed in. This should help with #4 but I don't 191 + think it ultimately fixes the issue at hand. 192 + 193 + ## v0.4.0 (2024-06-05) 194 + - Fixed bug where cursor would jump back to the top after deleting, renaming, 195 + creating, or undoing. 196 + - Added new keybind `c` to change directory via path. 197 + - Previous positions are saved when entering a new directory. 198 + - PDFs can now be read if `pdftotext` is installed. 199 + - Undo history can now only store the last 100 events. 200 + - List scrolling is now squeaky smooth. 201 + - Other general refactors and bug fixes. 202 + 203 + ## v0.3.0 (2024-05-30) 204 + - Moved render and event handling logic to their own functions. This will make 205 + it a more pleasant experience for contributors. 206 + - Added issue templates for easier and more concise bug reports and feature 207 + requests. 208 + - Fixed issue where images would stop rendering if an event was emitted without 209 + changing selected item. 210 + - Implemented ability to delete files and folders. 211 + - Implemented ability to rename files and folders. 212 + - Implemented ability to undo deletions and renames within a session. 213 + - Implemented ability to create folders and directories. 214 + - Updated README with new keybinds. 215 + - Added config option for styling info bar. 216 + 217 + ## v0.2.0 (2024-05-26) 218 + - Implemented fuzzy search for items in a directory. 219 + - Files can now be opened with `$EDITOR`. 220 + - Error messages now displayed in app. 221 + - Better errors when failing to read config. 222 + - Stopped supporting Windows. 223 + 224 + ## v0.1.1 (2024-05-25) 225 + - Added better error handling. 226 + - Added new config style for error bar. 227 + - Updated README to include config schema. 228 + - Added MIT license. 229 + 230 + ## v0.1.0 (2024-05-24)
+73 -25
README.md
··· 1 1 # 地圖 (Jido) 2 2 3 - ![Jido preview](./assets/preview.png) 4 - 5 - > **Note:** Previously known as **zfe**, this project has been renamed to 6 - **Jido** to better reflect its purpose and functionality. 3 + ![Jido preview](./assets/preview.gif) 7 4 8 5 **Jido** is a lightweight Unix TUI file explorer designed for speed and 9 6 simplicity. ··· 12 9 purpose: helping you navigate and explore your file system with ease. With 13 10 Vim-like bindings and a minimalist interface, Jido focuses on speed and 14 11 simplicity. 12 + 13 + Jido is built with Zig v`0.15.2`. 15 14 16 15 - [Installation](#installation) 17 16 - [Integrations](#integrations) ··· 28 27 - A terminal supporting the `kitty image protocol` to view images. 29 28 30 29 ## Key manual 30 + Below are the default keybinds. Keybinds can be overwritten via the `Keybinds` 31 + config option. Some keybinds are unbound by default, see [Configuration](#configuration) 32 + for more information. 33 + 31 34 ``` 35 + Global: 36 + <CTRL-c> :Exit. 37 + <CTRL-r> :Reload config. 38 + 32 39 Normal mode: 33 - <CTRL-c> :Exit. 34 40 j / <Down> :Go down. 35 41 k / <Up> :Go up. 36 42 h / <Left> / - :Go to the parent directory. ··· 44 50 d :Create directory. Will enter input mode. 45 51 % :Create file. Will enter input mode. 46 52 / :Fuzzy search directory. Will enter input mode. 53 + . :Toggle hidden files. 47 54 : :Allows for Jido commands to be entered. Please refer to the 48 55 "Command mode" section for available commands. Will enter 49 56 input mode. 57 + v :Verbose mode. Provides more information about selected entry. 58 + y :Yank selected item. 59 + p :Past yanked item. 60 + x :Extract archive to `<name>/`. 50 61 51 62 Input mode: 52 63 <Esc> :Cancel input. 53 64 <CR> :Confirm input. 54 65 55 66 Command mode: 67 + <Up> / <Down> :Cycle previous commands. 56 68 :q :Exit. 69 + :h :View available keybinds. 'q' to return to app. 57 70 :config :Navigate to config directory if it exists. 58 71 :trash :Navigate to trash directory if it exists. 59 72 :empty_trash :Empty trash if it exists. This action cannot be undone. 73 + :cd <path> :Change directory via path. Will enter input mode. 74 + :extract :Extract archive under cursor. 60 75 ``` 61 76 62 - 63 77 ## Configuration 64 78 Configure `jido` by editing the external configuration file located at either: 65 79 - `$HOME/.jido/config.json` 66 80 - `$XDG_CONFIG_HOME/jido/config.json`. 67 81 68 - Jido will look for these env variables specifically. If they are not set, Jido will 69 - not be able to find the config file. 82 + Jido will look for these env variables specifically. If they are not set, Jido 83 + will not be able to find the config file. 70 84 71 85 An example config file can be found [here](https://github.com/BrookJeynes/jido/blob/main/example-config.json). 72 86 73 87 Config schema: 74 88 ``` 75 89 Config = struct { 76 - .show_hidden: bool, 77 - .sort_dirs: bool, 78 - .show_images: bool, 79 - .preview_file: bool, 80 - .empty_trash_on_exit: bool, 81 - .styles: Styles, 90 + .show_hidden: bool = true, 91 + .sort_dirs: bool = true, 92 + .show_images: bool = true, -- Images are only supported in a terminal 93 + supporting the `kitty image protocol`. 94 + .preview_file: bool = true, 95 + .empty_trash_on_exit: bool = false, -- Emptying the trash permanently deletes 96 + all files within the trash. These 97 + files are not recoverable past this 98 + point. 99 + .true_dir_size: bool = false, -- Display size of directory including 100 + all its children. This can and will 101 + cause lag on deeply nested directories. 102 + .archive_traversal_limit: usize = 100, -- How many files to be traversed when reading 103 + an archive (zip, tar, etc.). 104 + .keep_partial_extraction: bool = false, -- If extraction fails, keep the partial 105 + extracted directory instead of cleaning up. 106 + .keybinds: Keybinds, 107 + .styles: Styles 108 + } 109 + 110 + Keybinds = struct { 111 + .toggle_hidden_files: ?Char = '.', 112 + .delete: ?Char = 'D', 113 + .rename: ?Char = 'R', 114 + .create_dir: ?Char = 'd', 115 + .create_file: ?Char = '%', 116 + .fuzzy_find: ?Char = '/', 117 + .change_dir: ?Char = 'c', 118 + .enter_command_mode: ?Char = ':', 119 + .jump_top: ?Char = 'g', 120 + .jump_bottom: ?Char = 'G', 121 + .toggle_verbose_file_information: ?Char = 'v', 122 + .force_delete: ?Char = null -- Files deleted this way are 123 + not recoverable 124 + .yank: ?Char = 'y' 125 + .paste: ?Char = 'p' 126 + .extract_archive: ?Char = 'x' 82 127 } 83 128 84 129 NotificationStyles = struct { 85 - box: vaxis.Style, 86 - err: vaxis.Style, 87 - warn: vaxis.Style, 88 - info: vaxis.Style, 89 - }; 130 + .box: vaxis.Style, 131 + .err: vaxis.Style, 132 + .warn: vaxis.Style, 133 + .info: vaxis.Style 134 + } 90 135 91 136 Styles = struct { 92 137 .selected_list_item: Style, ··· 94 139 .file_name: Style, 95 140 .file_information: Style 96 141 .notification: NotificationStyles, 97 - .git_branch: Style, 142 + .git_branch: Style 98 143 } 99 144 100 145 Style = struct { ··· 107 152 double, 108 153 curly, 109 154 dotted, 110 - dashed, 155 + dashed 111 156 } 112 157 .bold: bool, 113 158 .dim: bool, ··· 115 160 .blink: bool, 116 161 .reverse: bool, 117 162 .invisible: bool, 118 - .strikethrough: bool, 163 + .strikethrough: bool 119 164 } 120 165 121 166 Color = enum{ 122 167 default, 123 168 index: u8, 124 - rgb: [3]u8, 169 + rgb: [3]u8 125 170 } 171 + 172 + Char = enum(u21) 126 173 ``` 127 174 128 175 ## Contributing 129 - Contributions, issues, and feature requests are always welcome! This project is 130 - currently using the latest stable release of Zig (0.13.0). 176 + Contributions, issues, and feature requests are always welcome via 177 + [GitHub](https://github.com/brookjeynes/jido) or 178 + [tangled](https://tangled.sh/@brookjeynes.dev/jido).
assets/preview.gif

This is a binary file and will not be displayed.

+74 -12
build.zig
··· 1 1 const std = @import("std"); 2 2 const builtin = @import("builtin"); 3 3 4 + ///Must match the `version` in `build.zig.zon`. 5 + const version = std.SemanticVersion{ .major = 1, .minor = 4, .patch = 0 }; 6 + 4 7 const targets: []const std.Target.Query = &.{ 5 8 .{ .cpu_arch = .aarch64, .os_tag = .macos }, 6 9 .{ .cpu_arch = .aarch64, .os_tag = .linux }, ··· 8 11 .{ .cpu_arch = .x86_64, .os_tag = .macos }, 9 12 }; 10 13 11 - fn createExe(b: *std.Build, exe_name: []const u8, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode) !*std.Build.Step.Compile { 12 - const libvaxis = b.dependency("vaxis", .{ .target = target }).module("vaxis"); 13 - const fuzzig = b.dependency("fuzzig", .{ .target = target }).module("fuzzig"); 14 - const zuid = b.dependency("zuid", .{ .target = target }).module("zuid"); 14 + fn createExe( 15 + b: *std.Build, 16 + exe_name: []const u8, 17 + target: std.Build.ResolvedTarget, 18 + optimize: std.builtin.OptimizeMode, 19 + build_options: *std.Build.Module, 20 + ) !*std.Build.Step.Compile { 21 + const libvaxis = b.dependency("vaxis", .{ .target = target, .optimize = optimize }).module("vaxis"); 22 + const fuzzig = b.dependency("fuzzig", .{ .target = target, .optimize = optimize }).module("fuzzig"); 23 + const zeit = b.dependency("zeit", .{ .target = target, .optimize = optimize }).module("zeit"); 24 + const zuid = b.dependency("zuid", .{ .target = target, .optimize = optimize }).module("zuid"); 15 25 16 26 const exe = b.addExecutable(.{ 17 27 .name = exe_name, 18 - .root_source_file = b.path("src/main.zig"), 19 - .target = target, 20 - .optimize = optimize, 28 + .root_module = b.createModule(.{ 29 + .root_source_file = b.path("src/main.zig"), 30 + .target = target, 31 + .optimize = optimize, 32 + }), 21 33 }); 22 34 35 + exe.root_module.addImport("options", build_options); 23 36 exe.root_module.addImport("vaxis", libvaxis); 24 37 exe.root_module.addImport("fuzzig", fuzzig); 38 + exe.root_module.addImport("zeit", zeit); 25 39 exe.root_module.addImport("zuid", zuid); 26 40 27 41 return exe; ··· 31 45 const target = b.standardTargetOptions(.{}); 32 46 const optimize = b.standardOptimizeOption(.{}); 33 47 34 - // Building targets for release. 48 + const build_options = b.addOptions(); 49 + build_options.step.name = "build options"; 50 + build_options.addOption(std.SemanticVersion, "version", version); 51 + const build_options_module = build_options.createModule(); 52 + 35 53 const build_all = b.option(bool, "all-targets", "Build all targets in ReleaseSafe mode.") orelse false; 36 54 if (build_all) { 37 - try buildTargets(b); 55 + try buildTargets(b, build_options_module); 38 56 return; 39 57 } 40 58 41 - const exe = try createExe(b, "jido", target, optimize); 59 + const exe = try createExe(b, "jido", target, optimize, build_options_module); 42 60 b.installArtifact(exe); 43 61 44 62 const run_cmd = b.addRunArtifact(exe); ··· 48 66 } 49 67 const run_step = b.step("run", "Run the app"); 50 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 + } 51 113 } 52 114 53 - fn buildTargets(b: *std.Build) !void { 115 + fn buildTargets(b: *std.Build, build_options: *std.Build.Module) !void { 54 116 for (targets) |t| { 55 117 const target = b.resolveTargetQuery(t); 56 118 57 - const exe = try createExe(b, "jido", target, .ReleaseSafe); 119 + const exe = try createExe(b, "jido", target, .ReleaseSafe, build_options); 58 120 b.installArtifact(exe); 59 121 60 122 const target_output = b.addInstallArtifact(exe, .{
+25 -10
build.zig.zon
··· 1 1 .{ 2 - .name = "jido", 3 - .version = "0.8.0", 4 - .minimum_zig_version = "0.13.0", 2 + .name = .jido, 3 + .fingerprint = 0xee45eabe36cafb57, 4 + .version = "1.4.0", 5 + .minimum_zig_version = "0.15.2", 5 6 6 7 .dependencies = .{ 8 + // Replace with rockorager/libvaxis once https://github.com/rockorager/libvaxis/pull/293 is merged 7 9 .vaxis = .{ 8 - .url = "git+https://github.com/rockorager/libvaxis#77f5795892b08cd64ad6a103f0c53a7d1db50b18", 9 - .hash = "1220d587525255e734670ae74f38cb09d75df936c7889b07a6eab739c066dc736f85", 10 + .url = "git+https://github.com/rob9315/libvaxis.git#8d04cffd9137b4a8c56b356de98b32023ae752f3", 11 + .hash = "vaxis-0.5.1-BWNV_OA-CQDeFBHIx9ryyASogr2GE3FsAm-l5Ii5-HZT", 10 12 }, 11 13 .fuzzig = .{ 12 - .url = "git+https://github.com/fjebaker/fuzzig#0fd156d5097365151e85a85eef9d8cf0eebe7b00", 13 - .hash = "122019f077d09686b1ec47928ca2b4bf264422f3a27afc5b49dafb0129a4ceca0d01", 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", 14 20 }, 21 + // Replace with KeithBrown39423/zuid once https://github.com/KeithBrown39423/zuid/pull/4 is merged 15 22 .zuid = .{ 16 - .url = "git+https://github.com/KeithBrown39423/zuid#49e5980ba83f7d9ae967fa7ce4d54384c1c0f82b", 17 - .hash = "1220e05a3f459c0adbf2b09b4764838833e3e716a712852aec6ef1636f4d8e9f646e", 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", 25 + }, 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", 18 30 }, 19 31 }, 20 32 21 33 .paths = .{ 22 - "./src/", 34 + "LICENSE", 35 + "build.zig", 36 + "build.zig.zon", 37 + "src", 23 38 }, 24 39 }
+4
example-config.json
··· 3 3 "sort_dirs": false, 4 4 "show_images": true, 5 5 "preview_file": true, 6 + "keybinds": { 7 + "toggle_hidden_files": "h", 8 + "force_delete": null 9 + }, 6 10 "styles": { 7 11 "selected_list_item": { 8 12 "bg": {
+250 -53
src/app.zig
··· 1 1 const std = @import("std"); 2 2 const builtin = @import("builtin"); 3 - const environment = @import("./environment.zig"); 4 - const Drawer = @import("./drawer.zig"); 5 - const Notification = @import("./notification.zig"); 6 - const config = &@import("./config.zig").config; 7 - const List = @import("./list.zig").List; 8 - const Directories = @import("./directories.zig"); 9 - const CircStack = @import("./circ_stack.zig").CircularStack; 10 - const zuid = @import("zuid"); 3 + 11 4 const vaxis = @import("vaxis"); 12 5 const Key = vaxis.Key; 6 + const zuid = @import("zuid"); 7 + 8 + const Archive = @import("./archive.zig"); 9 + const CircStack = @import("./circ_stack.zig").CircularStack; 10 + const CommandHistory = @import("./commands.zig").CommandHistory; 11 + const Directories = @import("./directories.zig"); 12 + const Drawer = @import("./drawer.zig"); 13 + const environment = @import("./environment.zig"); 13 14 const EventHandlers = @import("./event_handlers.zig"); 15 + const FileLogger = @import("./file_logger.zig"); 16 + const Image = @import("./image.zig"); 17 + const List = @import("./list.zig").List; 18 + const Notification = @import("./notification.zig"); 19 + const Preview = @import("./preview.zig"); 20 + 21 + const config = &@import("./config.zig").config; 22 + const 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 + }; 14 64 15 65 pub const State = enum { 16 66 normal, ··· 20 70 change_dir, 21 71 rename, 22 72 command, 23 - }; 24 - 25 - const ActionPaths = struct { 26 - /// Allocated. 27 - old: []const u8, 28 - /// Allocated. 29 - new: []const u8, 73 + help_menu, 30 74 }; 31 75 32 76 pub const Action = union(enum) { 33 - delete: ActionPaths, 34 - rename: ActionPaths, 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, 35 80 }; 36 81 37 82 pub const Event = union(enum) { 83 + image_ready, 84 + notification, 38 85 key_press: Key, 39 86 winsize: vaxis.Winsize, 40 87 }; 41 88 42 89 const actions_len = 100; 90 + const image_cache_cap = 100; 43 91 44 92 const App = @This(); 45 93 46 94 alloc: std.mem.Allocator, 47 95 should_quit: bool, 48 96 vx: vaxis.Vaxis = undefined, 97 + tty_buffer: [1024]u8 = undefined, 49 98 tty: vaxis.Tty = undefined, 99 + loop: vaxis.Loop(Event) = undefined, 50 100 state: State = .normal, 51 101 actions: CircStack(Action, actions_len), 102 + command_history: CommandHistory = CommandHistory{}, 103 + drawer: Drawer = Drawer{}, 52 104 105 + help_menu: List([]const u8), 53 106 directories: Directories, 54 - notification: Notification, 107 + archive_files: ?Archive.ArchiveContents = null, 108 + notification: Notification = Notification{}, 109 + file_logger: ?FileLogger = null, 55 110 56 111 text_input: vaxis.widgets.TextInput, 57 112 text_input_buf: [std.fs.max_path_bytes]u8 = undefined, 58 113 59 - image: ?vaxis.Image = null, 114 + yanked: ?struct { dir: []const u8, entry: std.fs.Dir.Entry } = null, 60 115 last_known_height: usize, 61 116 62 - pub fn init(alloc: std.mem.Allocator) !App { 117 + // Used to detect whether to re-render an image. 118 + current_item_path_buf: [std.fs.max_path_bytes]u8 = undefined, 119 + current_item_path: []u8 = "", 120 + last_item_path_buf: [std.fs.max_path_bytes]u8 = undefined, 121 + last_item_path: []u8 = "", 122 + 123 + images: Image.Cache, 124 + preview_cache: Preview.PreviewCache, 125 + 126 + pub fn init(alloc: std.mem.Allocator, entry_dir: ?[]const u8) !App { 63 127 var vx = try vaxis.init(alloc, .{ 64 128 .kitty_keyboard_flags = .{ 65 129 .report_text = false, ··· 70 134 }, 71 135 }); 72 136 73 - var notification = Notification{}; 74 - notification.init(); 137 + var help_menu = List([]const u8).init(alloc); 138 + try help_menu.fromArray(&help_menu_items); 75 139 76 - return App{ 140 + var app: App = .{ 77 141 .alloc = alloc, 78 142 .should_quit = false, 79 143 .vx = vx, 80 - .tty = try vaxis.Tty.init(), 81 - .directories = try Directories.init(alloc), 82 - .text_input = vaxis.widgets.TextInput.init(alloc, &vx.unicode), 83 - .notification = notification, 144 + .directories = try Directories.init(alloc, entry_dir), 145 + .help_menu = help_menu, 146 + .text_input = vaxis.widgets.TextInput.init(alloc), 84 147 .actions = CircStack(Action, actions_len).init(), 85 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, 86 156 }; 157 + 158 + return app; 87 159 } 88 160 89 161 pub fn deinit(self: *App) void { 90 - for (self.actions.buf[0..self.actions.count]) |action| { 162 + while (self.actions.pop()) |action| { 91 163 switch (action) { 92 - .delete, .rename => |a| { 93 - self.alloc.free(a.new); 94 - self.alloc.free(a.old); 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); 95 171 }, 172 + .paste => |a| self.alloc.free(a), 96 173 } 97 174 } 98 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(); 99 184 self.directories.deinit(); 100 185 self.text_input.deinit(); 101 - self.vx.deinit(self.alloc, self.tty.anyWriter()); 186 + self.vx.deinit(self.alloc, self.tty.writer()); 102 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(); 103 197 } 104 198 105 - pub fn run(self: *App) !void { 106 - var drawer = Drawer{}; 107 - try self.directories.populateEntries(""); 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. 202 + pub 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; 108 206 109 - var loop: vaxis.Loop(Event) = .{ 110 - .vaxis = &self.vx, 111 - .tty = &self.tty, 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 + 218 + pub 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 {}; 112 232 }; 113 - try loop.start(); 114 - defer loop.stop(); 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 + } 115 243 116 - try self.vx.enterAltScreen(self.tty.anyWriter()); 117 - try self.vx.queryTerminal(self.tty.anyWriter(), 1 * std.time.ns_per_s); 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 + 255 + pub 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; 118 263 119 264 while (!self.should_quit) { 120 - loop.pollEvent(); 121 - while (loop.tryEvent()) |event| { 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. 122 295 switch (self.state) { 123 296 .normal => { 124 - try EventHandlers.handleNormalEvent(self, event, &loop); 297 + try EventHandlers.handleNormalEvent(self, event); 298 + }, 299 + .help_menu => { 300 + try EventHandlers.handleHelpMenuEvent(self, event); 125 301 }, 126 302 else => { 127 303 try EventHandlers.handleInputEvent(self, event); ··· 129 305 } 130 306 } 131 307 132 - try drawer.draw(self); 308 + try self.drawer.draw(self); 133 309 134 - var buffered = self.tty.bufferedWriter(); 135 - try self.vx.render(buffered.writer().any()); 136 - try buffered.flush(); 310 + const writer = self.tty.writer(); 311 + try self.vx.render(writer); 312 + try writer.flush(); 137 313 } 138 314 139 315 if (config.empty_trash_on_exit) { 140 - if (try config.trashDir()) |dir| { 141 - var trash_dir = dir; 142 - defer trash_dir.close(); 143 - _ = try environment.deleteContents(trash_dir); 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 + }; 144 341 } 145 342 } 146 343 }
+522
src/archive.zig
··· 1 + const ascii = @import("std").ascii; 2 + const std = @import("std"); 3 + 4 + const FileLogger = @import("./file_logger.zig"); 5 + 6 + const archive_buf_size = 8192; 7 + 8 + pub const ArchiveType = enum { 9 + tar, 10 + @"tar.gz", 11 + @"tar.xz", 12 + @"tar.zst", 13 + zip, 14 + 15 + pub fn fromPath(file_path: []const u8) ?ArchiveType { 16 + if (ascii.endsWithIgnoreCase(file_path, ".tar")) return .tar; 17 + if (ascii.endsWithIgnoreCase(file_path, ".tgz")) return .@"tar.gz"; 18 + if (ascii.endsWithIgnoreCase(file_path, ".tar.gz")) return .@"tar.gz"; 19 + if (ascii.endsWithIgnoreCase(file_path, ".txz")) return .@"tar.xz"; 20 + if (ascii.endsWithIgnoreCase(file_path, ".tar.xz")) return .@"tar.xz"; 21 + if (ascii.endsWithIgnoreCase(file_path, ".tzst")) return .@"tar.zst"; 22 + if (ascii.endsWithIgnoreCase(file_path, ".tar.zst")) return .@"tar.zst"; 23 + if (ascii.endsWithIgnoreCase(file_path, ".zip")) return .zip; 24 + if (ascii.endsWithIgnoreCase(file_path, ".jar")) return .zip; 25 + return null; 26 + } 27 + }; 28 + 29 + pub const ArchiveContents = struct { 30 + entries: std.ArrayList([]const u8), 31 + 32 + pub fn deinit(self: *ArchiveContents, alloc: std.mem.Allocator) void { 33 + for (self.entries.items) |entry| alloc.free(entry); 34 + self.entries.deinit(alloc); 35 + } 36 + }; 37 + 38 + pub const ExtractionResult = struct { 39 + files_extracted: usize, 40 + dirs_created: usize, 41 + files_skipped: usize, 42 + }; 43 + 44 + pub const PathValidationError = error{ 45 + PathContainsTraversal, 46 + PathTooLong, 47 + PathEmpty, 48 + }; 49 + 50 + pub const SkipReason = enum { 51 + path_contains_traversal, 52 + path_too_long, 53 + path_empty, 54 + }; 55 + 56 + const Operation = enum { list, extract }; 57 + 58 + const OperationArgs = union(Operation) { 59 + list: struct { 60 + traversal_limit: usize, 61 + }, 62 + extract: struct { 63 + dest_dir: std.fs.Dir, 64 + file_logger: ?FileLogger, 65 + }, 66 + }; 67 + 68 + const OperationResult = union(Operation) { 69 + list: ArchiveContents, 70 + extract: ExtractionResult, 71 + }; 72 + 73 + pub fn listArchiveContents( 74 + alloc: std.mem.Allocator, 75 + file: std.fs.File, 76 + archive_type: ArchiveType, 77 + traversal_limit: usize, 78 + ) !ArchiveContents { 79 + var buffer: [archive_buf_size]u8 = undefined; 80 + var reader = file.reader(&buffer); 81 + 82 + const list_args = OperationArgs{ .list = .{ 83 + .traversal_limit = traversal_limit, 84 + } }; 85 + 86 + const contents = switch (archive_type) { 87 + .tar => try listTar(alloc, &reader.interface, traversal_limit), 88 + .@"tar.gz" => (try processTarGz(alloc, &reader.interface, list_args)).list, 89 + .@"tar.xz" => (try processTarXz(alloc, &reader.interface, list_args)).list, 90 + .@"tar.zst" => (try processTarZst(alloc, &reader.interface, list_args)).list, 91 + .zip => try listZip(alloc, file, traversal_limit), 92 + }; 93 + 94 + return contents; 95 + } 96 + 97 + pub fn extractArchive( 98 + alloc: std.mem.Allocator, 99 + file: std.fs.File, 100 + archive_type: ArchiveType, 101 + dest_dir: std.fs.Dir, 102 + file_logger: ?FileLogger, 103 + ) !ExtractionResult { 104 + var buffer: [archive_buf_size]u8 = undefined; 105 + var reader = file.reader(&buffer); 106 + 107 + const extract_args = OperationArgs{ .extract = .{ 108 + .dest_dir = dest_dir, 109 + .file_logger = file_logger, 110 + } }; 111 + 112 + return switch (archive_type) { 113 + .tar => try extractTarImpl(alloc, &reader.interface, dest_dir, file_logger), 114 + .@"tar.gz" => (try processTarGz(alloc, &reader.interface, extract_args)).extract, 115 + .@"tar.xz" => (try processTarXz(alloc, &reader.interface, extract_args)).extract, 116 + .@"tar.zst" => (try processTarZst(alloc, &reader.interface, extract_args)).extract, 117 + .zip => try extractZipImpl(alloc, file, dest_dir, file_logger), 118 + }; 119 + } 120 + 121 + pub fn getExtractDirName(archive_path: []const u8) []const u8 { 122 + const basename = std.fs.path.basename(archive_path); 123 + 124 + return if (ascii.endsWithIgnoreCase(basename, ".tar.gz")) 125 + basename[0 .. basename.len - 7] 126 + else if (ascii.endsWithIgnoreCase(basename, ".tar.xz")) 127 + basename[0 .. basename.len - 7] 128 + else if (ascii.endsWithIgnoreCase(basename, ".tar.zst")) 129 + basename[0 .. basename.len - 8] 130 + else if (ascii.endsWithIgnoreCase(basename, ".tgz")) 131 + basename[0 .. basename.len - 4] 132 + else if (ascii.endsWithIgnoreCase(basename, ".txz")) 133 + basename[0 .. basename.len - 4] 134 + else if (ascii.endsWithIgnoreCase(basename, ".tzst")) 135 + basename[0 .. basename.len - 5] 136 + else if (ascii.endsWithIgnoreCase(basename, ".tar")) 137 + basename[0 .. basename.len - 4] 138 + else if (ascii.endsWithIgnoreCase(basename, ".zip")) 139 + basename[0 .. basename.len - 4] 140 + else if (ascii.endsWithIgnoreCase(basename, ".jar")) 141 + basename[0 .. basename.len - 4] 142 + else 143 + basename; 144 + } 145 + 146 + fn validateAndCleanPath( 147 + alloc: std.mem.Allocator, 148 + path: []const u8, 149 + ) (PathValidationError || error{OutOfMemory})![]const u8 { 150 + // Strip leading slashes (handles /, //, ///, etc.) 151 + var clean_path = path; 152 + while (std.mem.startsWith(u8, clean_path, "/")) { 153 + clean_path = clean_path[1..]; 154 + } 155 + 156 + if (clean_path.len == 0) return error.PathEmpty; 157 + if (clean_path.len >= std.fs.max_path_bytes) return error.PathTooLong; 158 + 159 + // Check for directory traversal by tracking depth 160 + var depth: i32 = 0; 161 + var iter = std.mem.splitScalar(u8, clean_path, '/'); 162 + while (iter.next()) |component| { 163 + if (component.len == 0) continue; 164 + 165 + if (std.mem.eql(u8, component, "..")) { 166 + depth -= 1; 167 + if (depth < 0) { 168 + return error.PathContainsTraversal; 169 + } 170 + } else if (!std.mem.eql(u8, component, ".")) { 171 + depth += 1; 172 + } 173 + } 174 + 175 + return try alloc.dupe(u8, clean_path); 176 + } 177 + 178 + fn extractTopLevelEntry( 179 + alloc: std.mem.Allocator, 180 + full_path: []const u8, 181 + is_directory: bool, 182 + truncated: bool, 183 + ) ![]const u8 { 184 + var is_directory_internal = is_directory; 185 + var path = full_path; 186 + 187 + if (std.mem.indexOfScalar(u8, full_path, '/')) |idx| { 188 + path = full_path[0..idx]; 189 + is_directory_internal = true; 190 + } 191 + 192 + return try std.fmt.allocPrint( 193 + alloc, 194 + "{s}{s}{s}", 195 + .{ path, if (truncated) "..." else "", if (is_directory_internal) "/" else "" }, 196 + ); 197 + } 198 + 199 + fn listTar( 200 + alloc: std.mem.Allocator, 201 + reader: anytype, 202 + traversal_limit: usize, 203 + ) !ArchiveContents { 204 + var entries: std.ArrayList([]const u8) = .empty; 205 + errdefer { 206 + for (entries.items) |e| alloc.free(e); 207 + entries.deinit(alloc); 208 + } 209 + 210 + var seen = std.StringHashMap(void).init(alloc); 211 + defer seen.deinit(); 212 + 213 + var diagnostics: std.tar.Diagnostics = .{ .allocator = alloc }; 214 + defer diagnostics.deinit(); 215 + 216 + var file_name_buffer: [std.fs.max_path_bytes]u8 = undefined; 217 + var link_name_buffer: [std.fs.max_path_bytes]u8 = undefined; 218 + var iter = std.tar.Iterator.init(reader, .{ 219 + .file_name_buffer = &file_name_buffer, 220 + .link_name_buffer = &link_name_buffer, 221 + }); 222 + iter.diagnostics = &diagnostics; 223 + 224 + for (0..traversal_limit) |_| { 225 + const tar_file = try iter.next(); 226 + if (tar_file == null) break; 227 + 228 + const is_dir = tar_file.?.kind == .directory; 229 + const truncated = tar_file.?.name.len >= std.fs.max_path_bytes; 230 + const entry = try extractTopLevelEntry(alloc, tar_file.?.name, is_dir, truncated); 231 + 232 + const gop = try seen.getOrPut(entry); 233 + if (gop.found_existing) { 234 + alloc.free(entry); 235 + continue; 236 + } 237 + 238 + try entries.append(alloc, entry); 239 + } 240 + 241 + return ArchiveContents{ 242 + .entries = entries, 243 + }; 244 + } 245 + 246 + fn processTarGz( 247 + alloc: std.mem.Allocator, 248 + reader: anytype, 249 + args: OperationArgs, 250 + ) !OperationResult { 251 + var flate_buffer: [std.compress.flate.max_window_len]u8 = undefined; 252 + var decompress = std.compress.flate.Decompress.init(reader, .gzip, &flate_buffer); 253 + 254 + return switch (args) { 255 + .list => |list_args| .{ 256 + .list = try listTar(alloc, &decompress.reader, list_args.traversal_limit), 257 + }, 258 + .extract => |extract_args| .{ 259 + .extract = try extractTarImpl(alloc, &decompress.reader, extract_args.dest_dir, extract_args.file_logger), 260 + }, 261 + }; 262 + } 263 + 264 + fn processTarXz( 265 + alloc: std.mem.Allocator, 266 + reader: anytype, 267 + args: OperationArgs, 268 + ) !OperationResult { 269 + var dcp = try std.compress.xz.decompress(alloc, reader.adaptToOldInterface()); 270 + defer dcp.deinit(); 271 + var adapter_buffer: [1024]u8 = undefined; 272 + var adapter = dcp.reader().adaptToNewApi(&adapter_buffer); 273 + 274 + return switch (args) { 275 + .list => |list_args| .{ 276 + .list = try listTar(alloc, &adapter.new_interface, list_args.traversal_limit), 277 + }, 278 + .extract => |extract_args| .{ 279 + .extract = try extractTarImpl(alloc, &adapter.new_interface, extract_args.dest_dir, extract_args.file_logger), 280 + }, 281 + }; 282 + } 283 + 284 + fn processTarZst( 285 + alloc: std.mem.Allocator, 286 + reader: anytype, 287 + args: OperationArgs, 288 + ) !OperationResult { 289 + const window_len = std.compress.zstd.default_window_len; 290 + const window_buffer = try alloc.alloc(u8, window_len + std.compress.zstd.block_size_max); 291 + defer alloc.free(window_buffer); 292 + var decompress: std.compress.zstd.Decompress = .init(reader, window_buffer, .{ 293 + .verify_checksum = false, 294 + .window_len = window_len, 295 + }); 296 + 297 + return switch (args) { 298 + .list => |list_args| .{ 299 + .list = try listTar(alloc, &decompress.reader, list_args.traversal_limit), 300 + }, 301 + .extract => |extract_args| .{ 302 + .extract = try extractTarImpl(alloc, &decompress.reader, extract_args.dest_dir, extract_args.file_logger), 303 + }, 304 + }; 305 + } 306 + 307 + fn listZip( 308 + alloc: std.mem.Allocator, 309 + file: std.fs.File, 310 + traversal_limit: usize, 311 + ) !ArchiveContents { 312 + var entries: std.ArrayList([]const u8) = .empty; 313 + errdefer { 314 + for (entries.items) |e| alloc.free(e); 315 + entries.deinit(alloc); 316 + } 317 + 318 + var seen = std.StringHashMap(void).init(alloc); 319 + defer seen.deinit(); 320 + 321 + var buffer: [archive_buf_size]u8 = undefined; 322 + var file_reader = file.reader(&buffer); 323 + 324 + var iter = try std.zip.Iterator.init(&file_reader); 325 + var file_name_buf: [std.fs.max_path_bytes]u8 = undefined; 326 + 327 + for (0..traversal_limit) |_| { 328 + const zip_file = try iter.next(); 329 + if (zip_file == null) break; 330 + 331 + const file_name_len = @min(zip_file.?.filename_len, file_name_buf.len); 332 + const truncated = zip_file.?.filename_len > file_name_buf.len; 333 + 334 + try file_reader.seekTo(zip_file.?.header_zip_offset + @sizeOf(std.zip.CentralDirectoryFileHeader)); 335 + const file_name = file_name_buf[0..file_name_len]; 336 + try file_reader.interface.readSliceAll(file_name); 337 + 338 + const is_dir = std.mem.endsWith(u8, file_name, "/"); 339 + const entry = try extractTopLevelEntry(alloc, file_name, is_dir, truncated); 340 + 341 + const gop = try seen.getOrPut(entry); 342 + if (gop.found_existing) { 343 + alloc.free(entry); 344 + continue; 345 + } 346 + 347 + try entries.append(alloc, entry); 348 + } 349 + 350 + return ArchiveContents{ 351 + .entries = entries, 352 + }; 353 + } 354 + 355 + fn extractTarImpl( 356 + alloc: std.mem.Allocator, 357 + reader: anytype, 358 + dest_dir: std.fs.Dir, 359 + file_logger: ?FileLogger, 360 + ) !ExtractionResult { 361 + var files_extracted: usize = 0; 362 + var dirs_created: usize = 0; 363 + var files_skipped: usize = 0; 364 + 365 + var diagnostics: std.tar.Diagnostics = .{ .allocator = alloc }; 366 + defer diagnostics.deinit(); 367 + 368 + var file_name_buffer: [std.fs.max_path_bytes]u8 = undefined; 369 + var link_name_buffer: [std.fs.max_path_bytes]u8 = undefined; 370 + var iter = std.tar.Iterator.init(reader, .{ 371 + .file_name_buffer = &file_name_buffer, 372 + .link_name_buffer = &link_name_buffer, 373 + }); 374 + iter.diagnostics = &diagnostics; 375 + 376 + while (try iter.next()) |tar_file| { 377 + const safe_path = validateAndCleanPath(alloc, tar_file.name) catch |err| { 378 + if (err == error.OutOfMemory) return err; 379 + 380 + files_skipped += 1; 381 + if (file_logger) |logger| { 382 + const reason: SkipReason = switch (err) { 383 + error.PathContainsTraversal => .path_contains_traversal, 384 + error.PathTooLong => .path_too_long, 385 + error.PathEmpty => .path_empty, 386 + error.OutOfMemory => unreachable, 387 + }; 388 + 389 + const message = try std.fmt.allocPrint(alloc, "Failed to extract file '{s}': {any}", .{ tar_file.name, reason }); 390 + defer alloc.free(message); 391 + logger.write(message, .err) catch {}; 392 + } 393 + continue; 394 + }; 395 + defer alloc.free(safe_path); 396 + 397 + if (tar_file.kind == .directory) { 398 + try dest_dir.makePath(safe_path); 399 + dirs_created += 1; 400 + } else if (tar_file.kind == .file or tar_file.kind == .sym_link) { 401 + if (std.fs.path.dirname(safe_path)) |parent| { 402 + try dest_dir.makePath(parent); 403 + } 404 + 405 + // TODO: Investigate preserving file permissions from archive 406 + const out_file = try dest_dir.createFile(safe_path, .{ .exclusive = true }); 407 + defer out_file.close(); 408 + 409 + var file_writer_buffer: [archive_buf_size]u8 = undefined; 410 + var file_writer = out_file.writer(&file_writer_buffer); 411 + try iter.streamRemaining(tar_file, &file_writer.interface); 412 + 413 + files_extracted += 1; 414 + } 415 + } 416 + 417 + return ExtractionResult{ 418 + .files_extracted = files_extracted, 419 + .dirs_created = dirs_created, 420 + .files_skipped = files_skipped, 421 + }; 422 + } 423 + 424 + fn extractZipImpl( 425 + alloc: std.mem.Allocator, 426 + file: std.fs.File, 427 + dest_dir: std.fs.Dir, 428 + file_logger: ?FileLogger, 429 + ) !ExtractionResult { 430 + var files_extracted: usize = 0; 431 + var dirs_created: usize = 0; 432 + var files_skipped: usize = 0; 433 + 434 + var buffer: [archive_buf_size]u8 = undefined; 435 + var file_reader = file.reader(&buffer); 436 + 437 + var iter = try std.zip.Iterator.init(&file_reader); 438 + var file_name_buf: [std.fs.max_path_bytes]u8 = undefined; 439 + 440 + while (try iter.next()) |entry| { 441 + const file_name_len = @min(entry.filename_len, file_name_buf.len); 442 + 443 + try file_reader.seekTo(entry.header_zip_offset + @sizeOf(std.zip.CentralDirectoryFileHeader)); 444 + const file_name = file_name_buf[0..file_name_len]; 445 + try file_reader.interface.readSliceAll(file_name); 446 + 447 + const safe_path = validateAndCleanPath(alloc, file_name) catch |err| { 448 + if (err == error.OutOfMemory) return err; 449 + 450 + files_skipped += 1; 451 + if (file_logger) |logger| { 452 + const reason: SkipReason = switch (err) { 453 + error.PathContainsTraversal => .path_contains_traversal, 454 + error.PathTooLong => .path_too_long, 455 + error.PathEmpty => .path_empty, 456 + error.OutOfMemory => unreachable, 457 + }; 458 + 459 + const message = try std.fmt.allocPrint(alloc, "Failed to extract file '{s}': {any}", .{ file_name, reason }); 460 + defer alloc.free(message); 461 + logger.write(message, .err) catch {}; 462 + } 463 + continue; 464 + }; 465 + defer alloc.free(safe_path); 466 + 467 + if (std.mem.endsWith(u8, file_name, "/")) { 468 + try dest_dir.makePath(safe_path); 469 + dirs_created += 1; 470 + } else { 471 + if (std.fs.path.dirname(safe_path)) |parent| { 472 + try dest_dir.makePath(parent); 473 + } 474 + 475 + // TODO: Investigate preserving file permissions from archive 476 + const out_file = try dest_dir.createFile(safe_path, .{ .exclusive = true }); 477 + defer out_file.close(); 478 + 479 + // Seek to local file header and read it to get to compressed data 480 + try file_reader.seekTo(entry.file_offset); 481 + const local_header = try file_reader.interface.takeStruct(std.zip.LocalFileHeader, .little); 482 + 483 + // Skip filename and extra field to get to compressed data 484 + _ = try file_reader.interface.discard(@enumFromInt(local_header.filename_len)); 485 + _ = try file_reader.interface.discard(@enumFromInt(local_header.extra_len)); 486 + 487 + var copy_buffer: [archive_buf_size]u8 = undefined; 488 + 489 + if (entry.compression_method == .store) { 490 + var total_read: usize = 0; 491 + while (total_read < entry.uncompressed_size) { 492 + const to_read = @min(copy_buffer.len, entry.uncompressed_size - total_read); 493 + const n = try file_reader.interface.readSliceShort(copy_buffer[0..to_read]); 494 + if (n == 0) break; 495 + try out_file.writeAll(copy_buffer[0..n]); 496 + total_read += n; 497 + } 498 + } else if (entry.compression_method == .deflate) { 499 + var limited_buffer: [archive_buf_size]u8 = undefined; 500 + var limited_reader = file_reader.interface.limited(@enumFromInt(entry.compressed_size), &limited_buffer); 501 + var flate_buffer: [std.compress.flate.max_window_len]u8 = undefined; 502 + var decompress = std.compress.flate.Decompress.init(&limited_reader.interface, .raw, &flate_buffer); 503 + 504 + while (true) { 505 + const n = try decompress.reader.readSliceShort(&copy_buffer); 506 + if (n == 0) break; 507 + try out_file.writeAll(copy_buffer[0..n]); 508 + } 509 + } else { 510 + return error.UnsupportedCompressionMethod; 511 + } 512 + 513 + files_extracted += 1; 514 + } 515 + } 516 + 517 + return ExtractionResult{ 518 + .files_extracted = files_extracted, 519 + .dirs_created = dirs_created, 520 + .files_skipped = files_skipped, 521 + }; 522 + }
+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 + }
+195 -35
src/commands.zig
··· 1 + const std = @import("std"); 2 + 1 3 const App = @import("app.zig"); 2 4 const environment = @import("environment.zig"); 3 - const _config = &@import("./config.zig").config; 5 + const Preview = @import("preview.zig"); 6 + 7 + const user_config = &@import("./config.zig").config; 8 + 9 + pub const CommandHistory = struct { 10 + const history_len = 10; 11 + 12 + history: [history_len][]const u8 = undefined, 13 + count: usize = 0, 14 + ///Points to the oldest entry. 15 + start: usize = 0, 16 + cursor: ?usize = null, 17 + 18 + pub fn deinit(self: *CommandHistory, allocator: std.mem.Allocator) void { 19 + for (self.history[0..self.count]) |entry| { 20 + allocator.free(entry); 21 + } 22 + } 23 + 24 + pub fn add(self: *CommandHistory, cmd: []const u8, allocator: std.mem.Allocator) error{OutOfMemory}!void { 25 + const index = (self.start + self.count) % history_len; 26 + 27 + if (self.count < history_len) { 28 + self.count += 1; 29 + } else { 30 + // Overwriting the oldest entry. 31 + allocator.free(self.history[self.start]); 32 + self.start = (self.start + 1) % history_len; 33 + } 34 + 35 + self.history[index] = try allocator.dupe(u8, cmd); 36 + self.cursor = null; 37 + } 38 + 39 + pub fn previous(self: *CommandHistory) ?[]const u8 { 40 + if (self.count == 0) return null; 41 + 42 + if (self.cursor == null) { 43 + self.cursor = self.count - 1; 44 + } else if (self.cursor.? > 0) { 45 + self.cursor.? -= 1; 46 + } 47 + 48 + return self.getAtCursor(); 49 + } 50 + 51 + pub fn next(self: *CommandHistory) ?[]const u8 { 52 + if (self.count == 0 or self.cursor == null) return null; 53 + 54 + if (self.cursor.? < self.count - 1) { 55 + self.cursor.? += 1; 56 + return self.getAtCursor(); 57 + } 58 + 59 + self.cursor = null; 60 + return null; 61 + } 62 + 63 + fn getAtCursor(self: *CommandHistory) ?[]const u8 { 64 + if (self.cursor == null) return null; 65 + const index = (self.start + self.cursor.?) % history_len; 66 + return self.history[index]; 67 + } 68 + }; 4 69 5 70 ///Navigate the user to the config dir. 6 - pub fn config(app: *App) !void { 71 + pub fn config(app: *App) error{OutOfMemory}!void { 7 72 const dir = dir: { 8 73 notfound: { 9 - break :dir (_config.configDir() catch break :notfound) orelse break :notfound; 74 + break :dir (user_config.configDir() catch break :notfound) orelse break :notfound; 10 75 } 11 - try app.notification.writeErr(.ConfigPathNotFound); 76 + const message = try std.fmt.allocPrint(app.alloc, "Failed to navigate to config directory - unable to retrieve config directory.", .{}); 77 + defer app.alloc.free(message); 78 + app.notification.write(message, .err) catch {}; 79 + if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 12 80 return; 13 81 }; 14 - app.directories.clearEntries(); 82 + 15 83 app.directories.dir.close(); 16 84 app.directories.dir = dir; 17 - app.directories.populateEntries("") catch |err| { 18 - switch (err) { 19 - error.AccessDenied => try app.notification.writeErr(.PermissionDenied), 20 - else => try app.notification.writeErr(.UnknownError), 21 - } 22 - }; 85 + try app.repopulateDirectory(""); 23 86 } 24 87 25 88 ///Navigate the user to the trash dir. 26 - pub fn trash(app: *App) !void { 89 + pub fn trash(app: *App) error{OutOfMemory}!void { 27 90 const dir = dir: { 28 91 notfound: { 29 - break :dir (_config.trashDir() catch break :notfound) orelse break :notfound; 92 + break :dir (user_config.trashDir() catch break :notfound) orelse break :notfound; 30 93 } 31 - try app.notification.writeErr(.ConfigPathNotFound); 94 + const message = try std.fmt.allocPrint(app.alloc, "Failed to navigate to trash directory - unable to retrieve trash directory.", .{}); 95 + defer app.alloc.free(message); 96 + app.notification.write(message, .err) catch {}; 97 + if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 32 98 return; 33 99 }; 34 - app.directories.clearEntries(); 100 + 35 101 app.directories.dir.close(); 36 102 app.directories.dir = dir; 37 - app.directories.populateEntries("") catch |err| { 38 - switch (err) { 39 - error.AccessDenied => try app.notification.writeErr(.PermissionDenied), 40 - else => try app.notification.writeErr(.UnknownError), 41 - } 42 - }; 103 + try app.repopulateDirectory(""); 43 104 } 44 105 45 106 ///Empty the trash. 46 - pub fn emptyTrash(app: *App) !void { 47 - const dir = dir: { 107 + pub fn emptyTrash(app: *App) error{OutOfMemory}!void { 108 + var message: ?[]const u8 = null; 109 + defer if (message) |msg| app.alloc.free(msg); 110 + 111 + var dir = dir: { 48 112 notfound: { 49 - break :dir (_config.trashDir() catch break :notfound) orelse break :notfound; 113 + break :dir (user_config.trashDir() catch break :notfound) orelse break :notfound; 50 114 } 51 - try app.notification.writeErr(.ConfigPathNotFound); 115 + message = try std.fmt.allocPrint(app.alloc, "Failed to navigate to trash directory - unable to retrieve trash directory.", .{}); 116 + app.notification.write(message.?, .err) catch {}; 117 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 52 118 return; 53 119 }; 120 + defer dir.close(); 54 121 55 - var trash_dir = dir; 56 - defer trash_dir.close(); 57 - const failed = try environment.deleteContents(trash_dir); 58 - if (failed > 0) try app.notification.writeErr(.FailedToDeleteSomeItems); 122 + const failed = environment.deleteContents(dir) catch |err| lbl: { 123 + message = try std.fmt.allocPrint(app.alloc, "Failed to empty trash - {}.", .{err}); 124 + app.notification.write(message.?, .err) catch {}; 125 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 126 + break :lbl 0; 127 + }; 128 + if (failed > 0) { 129 + message = try std.fmt.allocPrint(app.alloc, "Failed to empty {d} items from the trash.", .{failed}); 130 + app.notification.write(message.?, .err) catch {}; 131 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 132 + } 133 + 134 + try app.repopulateDirectory(""); 135 + } 59 136 60 - app.directories.clearEntries(); 61 - app.directories.populateEntries("") catch |err| { 62 - switch (err) { 63 - error.AccessDenied => try app.notification.writeErr(.PermissionDenied), 64 - else => try app.notification.writeErr(.UnknownError), 65 - } 137 + pub fn resolvePath(buf: *[std.fs.max_path_bytes]u8, path: []const u8, dir: std.fs.Dir) []const u8 { 138 + const resolved_path = if (std.mem.startsWith(u8, path, "~")) path: { 139 + var home_dir = (environment.getHomeDir() catch break :path path) orelse break :path path; 140 + defer home_dir.close(); 141 + const relative = std.mem.trim(u8, path[1..], std.fs.path.sep_str); 142 + return home_dir.realpath( 143 + if (relative.len == 0) "." else relative, 144 + buf, 145 + ) catch path; 146 + } else path; 147 + 148 + return dir.realpath(resolved_path, buf) catch path; 149 + } 150 + 151 + ///Change directory. 152 + pub fn cd(app: *App, path: []const u8) error{OutOfMemory}!void { 153 + var message: ?[]const u8 = null; 154 + defer if (message) |msg| app.alloc.free(msg); 155 + 156 + var path_buf: [std.fs.max_path_bytes]u8 = undefined; 157 + const resolved_path = resolvePath(&path_buf, path, app.directories.dir); 158 + 159 + const dir = app.directories.dir.openDir(resolved_path, .{ .iterate = true }) catch |err| { 160 + message = switch (err) { 161 + error.FileNotFound => try std.fmt.allocPrint(app.alloc, "Failed to navigate to '{s}' - directory does not exist.", .{resolved_path}), 162 + error.NotDir => try std.fmt.allocPrint(app.alloc, "Failed to navigate to '{s}' - item is not a directory.", .{resolved_path}), 163 + else => try std.fmt.allocPrint(app.alloc, "Failed to read directory entries - {}.", .{err}), 164 + }; 165 + app.notification.write(message.?, .err) catch {}; 166 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 167 + return; 66 168 }; 169 + app.directories.dir.close(); 170 + app.directories.dir = dir; 171 + 172 + message = try std.fmt.allocPrint(app.alloc, "Navigated to directory '{s}'.", .{resolved_path}); 173 + app.notification.write(message.?, .info) catch {}; 174 + 175 + try app.repopulateDirectory(""); 176 + app.directories.history.reset(); 177 + } 178 + 179 + const testing = std.testing; 180 + 181 + test "CommandHistory: add and retrieve commands" { 182 + var history = CommandHistory{}; 183 + defer history.deinit(testing.allocator); 184 + 185 + try history.add(":cd /tmp", testing.allocator); 186 + try history.add(":config", testing.allocator); 187 + 188 + try testing.expectEqual(@as(usize, 2), history.count); 189 + } 190 + 191 + test "CommandHistory: previous/next navigation" { 192 + var history = CommandHistory{}; 193 + defer history.deinit(testing.allocator); 194 + 195 + try history.add(":cmd1", testing.allocator); 196 + try history.add(":cmd2", testing.allocator); 197 + try history.add(":cmd3", testing.allocator); 198 + 199 + const cmd3 = history.previous(); 200 + try testing.expectEqualStrings(":cmd3", cmd3.?); 201 + 202 + const cmd2 = history.previous(); 203 + try testing.expectEqualStrings(":cmd2", cmd2.?); 204 + 205 + const cmd3_again = history.next(); 206 + try testing.expectEqualStrings(":cmd3", cmd3_again.?); 207 + 208 + const at_end = history.next(); 209 + try testing.expect(at_end == null); 210 + } 211 + 212 + test "CommandHistory: wraparound at capacity" { 213 + var history = CommandHistory{}; 214 + defer history.deinit(testing.allocator); 215 + 216 + var i: u32 = 0; 217 + while (i < 15) : (i += 1) { 218 + const cmd = try std.fmt.allocPrint(testing.allocator, ":cmd{}", .{i}); 219 + defer testing.allocator.free(cmd); 220 + try history.add(cmd, testing.allocator); 221 + } 222 + 223 + try testing.expectEqual(@as(usize, 10), history.count); 224 + 225 + const recent = history.previous(); 226 + try testing.expectEqualStrings(":cmd14", recent.?); 67 227 }
+140 -48
src/config.zig
··· 1 1 const std = @import("std"); 2 2 const builtin = @import("builtin"); 3 - const environment = @import("./environment.zig"); 3 + 4 4 const vaxis = @import("vaxis"); 5 + 6 + const App = @import("./app.zig"); 7 + const environment = @import("./environment.zig"); 5 8 const Notification = @import("./notification.zig"); 9 + const FileLogger = @import("file_logger.zig"); 6 10 7 - pub const ParseRes = struct { deprecated: bool }; 11 + const CONFIG_NAME = "config.json"; 12 + const TRASH_DIR_NAME = "trash"; 13 + const HOME_DIR_NAME = ".jido"; 14 + const XDG_CONFIG_HOME_DIR_NAME = "jido"; 8 15 9 16 const Config = struct { 10 17 show_hidden: bool = true, ··· 12 19 show_images: bool = true, 13 20 preview_file: bool = true, 14 21 empty_trash_on_exit: bool = false, 15 - styles: Styles, 22 + true_dir_size: bool = false, 23 + entry_dir: ?[]const u8 = null, 24 + archive_traversal_limit: usize = 100, 25 + keep_partial_extraction: bool = false, 26 + styles: Styles = .{}, 27 + keybinds: Keybinds = .{}, 16 28 17 - config_path_buf: [std.fs.max_path_bytes]u8 = undefined, 18 - config_path: ?[]u8 = null, 29 + config_dir: ?std.fs.Dir = null, 19 30 20 31 ///Returned dir needs to be closed by user. 21 32 pub fn configDir(self: Config) !?std.fs.Dir { 22 - if (self.config_path) |path| { 23 - return try std.fs.openDirAbsolute(std.mem.trimRight(u8, path, "config.json"), .{ .iterate = true }); 33 + if (self.config_dir) |dir| { 34 + return try dir.openDir(".", .{ .iterate = true }); 24 35 } else return null; 25 36 } 26 37 ··· 28 39 pub fn trashDir(self: Config) !?std.fs.Dir { 29 40 var parent = try self.configDir() orelse return null; 30 41 defer parent.close(); 31 - if (!environment.dirExists(parent, "trash")) { 32 - try parent.makeDir("trash"); 42 + if (!environment.dirExists(parent, TRASH_DIR_NAME)) { 43 + try parent.makeDir(TRASH_DIR_NAME); 33 44 } 34 45 35 - return try parent.openDir("trash", .{ .iterate = true }); 46 + return try parent.openDir(TRASH_DIR_NAME, .{ .iterate = true }); 36 47 } 37 48 38 - pub fn parse(self: *Config, alloc: std.mem.Allocator) !ParseRes { 39 - var deprecated = false; 40 - var config_location: struct { 41 - home_dir: std.fs.Dir, 42 - path: []const u8, 43 - } = lbl: { 49 + pub fn parse(self: *Config, alloc: std.mem.Allocator, app: *App) !void { 50 + var dir = lbl: { 44 51 if (try environment.getXdgConfigHomeDir()) |home_dir| { 45 - const path = "jido" ++ std.fs.path.sep_str ++ "config.json"; 46 - if (environment.fileExists(home_dir, path)) { 47 - break :lbl .{ 48 - .home_dir = home_dir, 49 - .path = path, 50 - }; 52 + defer { 53 + var dir = home_dir; 54 + dir.close(); 51 55 } 52 56 53 - var dir = home_dir; 54 - dir.close(); 57 + if (!environment.dirExists(home_dir, XDG_CONFIG_HOME_DIR_NAME)) { 58 + try home_dir.makeDir(XDG_CONFIG_HOME_DIR_NAME); 59 + } 60 + 61 + const jido_dir = try home_dir.openDir( 62 + XDG_CONFIG_HOME_DIR_NAME, 63 + .{ .iterate = true }, 64 + ); 65 + self.config_dir = jido_dir; 66 + 67 + if (environment.fileExists(jido_dir, CONFIG_NAME)) { 68 + break :lbl jido_dir; 69 + } 70 + return; 55 71 } 56 72 57 73 if (try environment.getHomeDir()) |home_dir| { 58 - const path = ".jido" ++ std.fs.path.sep_str ++ "config.json"; 59 - if (environment.fileExists(home_dir, path)) { 60 - break :lbl .{ 61 - .home_dir = home_dir, 62 - .path = path, 63 - }; 74 + defer { 75 + var dir = home_dir; 76 + dir.close(); 64 77 } 65 78 66 - const deprecated_path = ".config" ++ std.fs.path.sep_str ++ "jido" ++ std.fs.path.sep_str ++ "config.json"; 67 - if (environment.fileExists(home_dir, deprecated_path)) { 68 - deprecated = true; 69 - break :lbl .{ 70 - .home_dir = home_dir, 71 - .path = deprecated_path, 72 - }; 79 + if (!environment.dirExists(home_dir, HOME_DIR_NAME)) { 80 + try home_dir.makeDir(HOME_DIR_NAME); 73 81 } 74 82 75 - var dir = home_dir; 76 - dir.close(); 83 + const jido_dir = try home_dir.openDir( 84 + HOME_DIR_NAME, 85 + .{ .iterate = true }, 86 + ); 87 + self.config_dir = jido_dir; 88 + 89 + if (environment.fileExists(jido_dir, CONFIG_NAME)) { 90 + break :lbl jido_dir; 91 + } 92 + return; 77 93 } 78 94 79 - return .{ .deprecated = deprecated }; 95 + return; 80 96 }; 81 - defer config_location.home_dir.close(); 82 97 83 - const config_file = try config_location.home_dir.openFile(config_location.path, .{}); 98 + const config_file = try dir.openFile(CONFIG_NAME, .{}); 84 99 defer config_file.close(); 85 100 86 101 const config_str = try config_file.readToEndAlloc(alloc, 1024 * 1024 * 1024); ··· 90 105 defer parsed_config.deinit(); 91 106 92 107 self.* = parsed_config.value; 93 - self.config_path = config_location.home_dir.realpath( 94 - config_location.path, 95 - &self.config_path_buf, 96 - ) catch null; 97 - return .{ .deprecated = deprecated }; 108 + self.config_dir = dir; 109 + 110 + // Check duplicate keybinds 111 + { 112 + var file_logger = FileLogger.init(dir); 113 + defer file_logger.deinit(); 114 + 115 + var key_map = std.AutoHashMap(u21, []const u8).init(alloc); 116 + defer { 117 + var it = key_map.iterator(); 118 + while (it.next()) |entry| { 119 + alloc.free(entry.value_ptr.*); 120 + } 121 + key_map.deinit(); 122 + } 123 + 124 + inline for (std.meta.fields(Keybinds)) |field| { 125 + if (@field(self.keybinds, field.name)) |field_value| { 126 + const codepoint = @intFromEnum(field_value); 127 + 128 + const res = try key_map.getOrPut(codepoint); 129 + if (res.found_existing) { 130 + var keybind_str: [1024]u8 = undefined; 131 + const keybind_str_bytes = try std.unicode.utf8Encode(codepoint, &keybind_str); 132 + 133 + const message = try std.fmt.allocPrint( 134 + alloc, 135 + "'{s}' and '{s}' have the same keybind: '{s}'. This can cause undefined behaviour.", 136 + .{ res.value_ptr.*, field.name, keybind_str[0..keybind_str_bytes] }, 137 + ); 138 + defer alloc.free(message); 139 + 140 + app.notification.write(message, .err) catch {}; 141 + file_logger.write(message, .err) catch {}; 142 + 143 + return error.DuplicateKeybind; 144 + } 145 + res.value_ptr.* = try alloc.dupe(u8, field.name); 146 + } 147 + } 148 + } 149 + 150 + return; 98 151 } 99 152 }; 100 153 ··· 110 163 111 164 const NotificationStyles = struct { 112 165 box: vaxis.Style = vaxis.Style{ 166 + .fg = .{ .rgb = Colours.snow_white }, 113 167 .bg = .{ .rgb = Colours.grey }, 114 168 }, 115 169 err: vaxis.Style = vaxis.Style{ ··· 126 180 }, 127 181 }; 128 182 183 + pub const Keybinds = struct { 184 + pub const Char = enum(u21) { 185 + _, 186 + pub fn jsonParse(alloc: std.mem.Allocator, source: anytype, options: std.json.ParseOptions) !@This() { 187 + const parsed = try std.json.innerParse([]const u8, alloc, source, options); 188 + if (std.mem.eql(u8, parsed, "")) return error.InvalidCharacter; 189 + 190 + const utf8_byte_sequence_len = std.unicode.utf8ByteSequenceLength(parsed[0]) catch return error.InvalidCharacter; 191 + if (parsed.len != utf8_byte_sequence_len) return error.InvalidCharacter; 192 + const unicode = switch (utf8_byte_sequence_len) { 193 + 1 => parsed[0], 194 + 2 => std.unicode.utf8Decode2(parsed[0..2].*), 195 + 3 => std.unicode.utf8Decode3(parsed[0..3].*), 196 + 4 => std.unicode.utf8Decode4(parsed[0..4].*), 197 + else => return error.InvalidCharacter, 198 + } catch return error.InvalidCharacter; 199 + 200 + return @enumFromInt(unicode); 201 + } 202 + }; 203 + 204 + toggle_hidden_files: ?Char = @enumFromInt('.'), 205 + delete: ?Char = @enumFromInt('D'), 206 + rename: ?Char = @enumFromInt('R'), 207 + create_dir: ?Char = @enumFromInt('d'), 208 + create_file: ?Char = @enumFromInt('%'), 209 + fuzzy_find: ?Char = @enumFromInt('/'), 210 + change_dir: ?Char = @enumFromInt('c'), 211 + enter_command_mode: ?Char = @enumFromInt(':'), 212 + jump_top: ?Char = @enumFromInt('g'), 213 + jump_bottom: ?Char = @enumFromInt('G'), 214 + toggle_verbose_file_information: ?Char = @enumFromInt('v'), 215 + force_delete: ?Char = null, 216 + paste: ?Char = @enumFromInt('p'), 217 + yank: ?Char = @enumFromInt('y'), 218 + extract_archive: ?Char = @enumFromInt('x'), 219 + }; 220 + 129 221 const Styles = struct { 130 222 selected_list_item: vaxis.Style = vaxis.Style{ 131 223 .bg = .{ .rgb = Colours.grey }, ··· 145 237 }, 146 238 }; 147 239 148 - pub var config: Config = Config{ .styles = Styles{} }; 240 + pub var config: Config = Config{};
+150 -73
src/directories.zig
··· 1 1 const std = @import("std"); 2 - const List = @import("./list.zig").List; 3 - const CircStack = @import("./circ_stack.zig").CircularStack; 4 - const config = &@import("./config.zig").config; 5 - const vaxis = @import("vaxis"); 2 + 6 3 const fuzzig = @import("fuzzig"); 7 4 8 - const History = struct { 9 - selected: usize, 10 - offset: usize, 11 - }; 5 + const CircStack = @import("./circ_stack.zig").CircularStack; 6 + const List = @import("./list.zig").List; 12 7 8 + const sort = &@import("./sort.zig"); 9 + const config = &@import("./config.zig").config; 13 10 const history_len: usize = 100; 14 11 15 12 const Self = @This(); ··· 17 14 alloc: std.mem.Allocator, 18 15 dir: std.fs.Dir, 19 16 path_buf: [std.fs.max_path_bytes]u8 = undefined, 20 - file_contents: [4096]u8 = undefined, 17 + file: struct { 18 + handle: ?std.fs.File = null, 19 + data: [4096]u8 = undefined, 20 + bytes_read: usize = 0, 21 + } = .{}, 21 22 pdf_contents: ?[]u8 = null, 22 23 entries: List(std.fs.Dir.Entry), 23 - history: CircStack(History, history_len), 24 + history: CircStack(usize, history_len), 24 25 child_entries: List([]const u8), 25 26 searcher: fuzzig.Ascii, 26 27 27 - pub fn init(alloc: std.mem.Allocator) !Self { 28 + pub fn init(alloc: std.mem.Allocator, entry_dir: ?[]const u8) !Self { 29 + const dir_path = if (entry_dir) |dir| dir else "."; 30 + const dir = std.fs.cwd().openDir(dir_path, .{ .iterate = true }) catch |err| { 31 + switch (err) { 32 + error.FileNotFound => { 33 + std.log.err("path '{s}' could not be found.", .{dir_path}); 34 + return err; 35 + }, 36 + else => { 37 + std.log.err("{}", .{err}); 38 + return err; 39 + }, 40 + } 41 + }; 42 + 28 43 return Self{ 29 44 .alloc = alloc, 30 - .dir = try std.fs.cwd().openDir(".", .{ .iterate = true }), 45 + .dir = dir, 31 46 .entries = List(std.fs.Dir.Entry).init(alloc), 32 - .history = CircStack(History, history_len).init(), 47 + .history = CircStack(usize, history_len).init(), 33 48 .child_entries = List([]const u8).init(alloc), 34 49 .searcher = try fuzzig.Ascii.init( 35 50 alloc, ··· 71 86 return try self.dir.realpath(relative_path, &self.path_buf); 72 87 } 73 88 89 + pub fn getDirSize(self: Self, dir: std.fs.Dir) !usize { 90 + var total_size: usize = 0; 91 + 92 + var walker = try dir.walk(self.alloc); 93 + defer walker.deinit(); 94 + 95 + while (try walker.next()) |entry| { 96 + switch (entry.kind) { 97 + .file => { 98 + const stat = try entry.dir.statFile(entry.basename); 99 + total_size += stat.size; 100 + }, 101 + else => {}, 102 + } 103 + } 104 + 105 + return total_size; 106 + } 107 + 74 108 pub fn populateChildEntries( 75 109 self: *Self, 76 110 relative_path: []const u8, ··· 80 114 81 115 var it = dir.iterate(); 82 116 while (try it.next()) |entry| { 83 - try self.child_entries.append(try self.alloc.dupe(u8, entry.name)); 84 - } 85 - 86 - if (config.sort_dirs == true) { 87 - std.mem.sort([]const u8, self.child_entries.all(), {}, sortChildEntry); 88 - } 89 - } 90 - 91 - pub fn writeChildEntries( 92 - self: *Self, 93 - window: vaxis.Window, 94 - style: vaxis.Style, 95 - ) !void { 96 - for (self.child_entries.all(), 0..) |item, i| { 97 - if (std.mem.startsWith(u8, item, ".") and config.show_hidden == false) { 117 + if (std.mem.startsWith(u8, entry.name, ".") and config.show_hidden == false) { 98 118 continue; 99 119 } 100 120 101 - if (i > window.height) continue; 102 - 103 - const w = window.child(.{ .y_off = @intCast(i), .height = 1 }); 104 - w.fill(vaxis.Cell{ .style = style }); 121 + if (entry.kind == .directory) { 122 + try self.child_entries.append(try std.fmt.allocPrint(self.alloc, "{s}/", .{entry.name})); 123 + } else { 124 + try self.child_entries.append(try self.alloc.dupe(u8, entry.name)); 125 + } 126 + } 105 127 106 - _ = w.print(&.{.{ .text = item, .style = style }}, .{}); 128 + if (config.sort_dirs == true) { 129 + std.mem.sort([]const u8, self.child_entries.all(), {}, sort.string); 107 130 } 108 131 } 109 132 ··· 115 138 continue; 116 139 } 117 140 141 + if (std.mem.startsWith(u8, entry.name, ".") and config.show_hidden == false) { 142 + continue; 143 + } 144 + 118 145 try self.entries.append(.{ 119 146 .kind = entry.kind, 120 - .name = try self.alloc.dupe(u8, entry.name), 147 + .name = if (entry.kind == .directory) try std.fmt.allocPrint(self.alloc, "{s}/", .{entry.name}) else try self.alloc.dupe(u8, entry.name), 121 148 }); 122 149 } 123 150 124 151 if (config.sort_dirs == true) { 125 - std.mem.sort(std.fs.Dir.Entry, self.entries.all(), {}, sortEntry); 152 + std.mem.sort(std.fs.Dir.Entry, self.entries.all(), {}, sort.sortDirectoryEntry); 126 153 } 127 154 } 128 155 129 - pub fn writeEntries( 130 - self: *Self, 131 - window: vaxis.Window, 132 - selected_list_item_style: vaxis.Style, 133 - list_item_style: vaxis.Style, 134 - ) !void { 135 - for (self.entries.all()[self.entries.offset..], 0..) |item, i| { 136 - const selected = self.entries.selected - self.entries.offset; 137 - const is_selected = selected == i; 156 + pub fn clearEntries(self: *Self) void { 157 + for (self.entries.all()) |entry| { 158 + self.entries.alloc.free(entry.name); 159 + } 160 + self.entries.clear(); 161 + } 162 + 163 + pub fn clearChildEntries(self: *Self) void { 164 + for (self.child_entries.all()) |entry| { 165 + self.child_entries.alloc.free(entry); 166 + } 167 + self.child_entries.clear(); 168 + } 169 + 170 + const testing = std.testing; 138 171 139 - if (std.mem.startsWith(u8, item.name, ".") and config.show_hidden == false) { 140 - continue; 141 - } 172 + test "Directories: populateEntries respects show_hidden config" { 173 + const local_config = &@import("./config.zig").config; 142 174 143 - if (i > window.height) continue; 175 + var tmp = testing.tmpDir(.{}); 176 + defer tmp.cleanup(); 144 177 145 - const w = window.child(.{ .y_off = @intCast(i), .height = 1 }); 146 - w.fill(vaxis.Cell{ 147 - .style = if (is_selected) selected_list_item_style else list_item_style, 148 - }); 178 + { 179 + const visible = try tmp.dir.createFile("visible.txt", .{}); 180 + visible.close(); 181 + const hidden = try tmp.dir.createFile(".hidden.txt", .{}); 182 + hidden.close(); 183 + } 149 184 150 - _ = w.print(&.{ 151 - .{ 152 - .text = item.name, 153 - .style = if (is_selected) selected_list_item_style else list_item_style, 154 - }, 155 - }, .{}); 185 + var path_buf: [std.fs.max_path_bytes]u8 = undefined; 186 + const tmp_path = try tmp.dir.realpath(".", &path_buf); 187 + const iter_dir = try std.fs.openDirAbsolute(tmp_path, .{ .iterate = true }); 188 + 189 + var dirs = try Self.init(testing.allocator, null); 190 + defer { 191 + dirs.clearEntries(); 192 + dirs.clearChildEntries(); 193 + dirs.entries.deinit(); 194 + dirs.child_entries.deinit(); 195 + dirs.searcher.deinit(); 156 196 } 197 + dirs.dir.close(); 198 + dirs.dir = iter_dir; 199 + 200 + local_config.show_hidden = false; 201 + try dirs.populateEntries(""); 202 + try testing.expectEqual(@as(usize, 1), dirs.entries.len()); 203 + 204 + dirs.clearEntries(); 205 + local_config.show_hidden = true; 206 + try dirs.populateEntries(""); 207 + try testing.expectEqual(@as(usize, 2), dirs.entries.len()); 157 208 } 158 209 159 - fn sortEntry(_: void, lhs: std.fs.Dir.Entry, rhs: std.fs.Dir.Entry) bool { 160 - return std.mem.lessThan(u8, lhs.name, rhs.name); 161 - } 210 + test "Directories: fuzzy search filters entries" { 211 + var tmp = testing.tmpDir(.{}); 212 + defer tmp.cleanup(); 213 + 214 + { 215 + const f1 = try tmp.dir.createFile("test_file.txt", .{}); 216 + f1.close(); 217 + const f2 = try tmp.dir.createFile("other.txt", .{}); 218 + f2.close(); 219 + const f3 = try tmp.dir.createFile("test_another.txt", .{}); 220 + f3.close(); 221 + } 222 + 223 + var path_buf: [std.fs.max_path_bytes]u8 = undefined; 224 + const tmp_path = try tmp.dir.realpath(".", &path_buf); 225 + const iter_dir = try std.fs.openDirAbsolute(tmp_path, .{ .iterate = true }); 226 + 227 + var dirs = try Self.init(testing.allocator, null); 228 + defer { 229 + dirs.clearEntries(); 230 + dirs.clearChildEntries(); 231 + dirs.entries.deinit(); 232 + dirs.child_entries.deinit(); 233 + dirs.searcher.deinit(); 234 + } 235 + dirs.dir.close(); 236 + dirs.dir = iter_dir; 162 237 163 - fn sortChildEntry(_: void, lhs: []const u8, rhs: []const u8) bool { 164 - return std.mem.lessThan(u8, lhs, rhs); 165 - } 238 + try dirs.populateEntries("test"); 239 + // Should match test_* 240 + try testing.expect(dirs.entries.len() >= 2); 166 241 167 - pub fn clearEntries(self: *Self) void { 168 - for (self.entries.all()) |entry| { 169 - self.entries.alloc.free(entry.name); 242 + // Verify all entries contain "test" 243 + for (dirs.entries.all()) |entry| { 244 + try testing.expect(std.mem.indexOf(u8, entry.name, "test") != null); 170 245 } 171 - self.entries.clear(); 172 246 } 173 247 174 - pub fn clearChildEntries(self: *Self) void { 175 - for (self.child_entries.all()) |entry| { 176 - self.child_entries.alloc.free(entry); 177 - } 178 - self.child_entries.clear(); 248 + test "Directories: fullPath resolves relative paths" { 249 + var dirs = try Self.init(testing.allocator, "."); 250 + defer dirs.deinit(); 251 + 252 + const path = try dirs.fullPath("."); 253 + try testing.expect(path.len > 0); 254 + // Should be absolute 255 + try testing.expect(std.mem.indexOf(u8, path, "/") != null); 179 256 }
+311 -194
src/drawer.zig
··· 1 1 const std = @import("std"); 2 + 3 + const vaxis = @import("vaxis"); 4 + const zeit = @import("zeit"); 5 + 2 6 const App = @import("./app.zig"); 3 - const Notification = @import("./notification.zig"); 7 + const Archive = @import("./archive.zig"); 4 8 const Directories = @import("./directories.zig"); 5 - const config = &@import("./config.zig").config; 6 - const vaxis = @import("vaxis"); 9 + const FileLogger = @import("./file_logger.zig"); 7 10 const Git = @import("./git.zig"); 8 - const inputToSlice = @import("./event_handlers.zig").inputToSlice; 11 + const Image = @import("./image.zig"); 12 + const List = @import("./list.zig").List; 13 + const Notification = @import("./notification.zig"); 14 + const path_utils = @import("./path_utils.zig"); 15 + const Preview = @import("./preview.zig"); 16 + const sort = @import("./sort.zig"); 9 17 18 + const config = &@import("./config.zig").config; 10 19 const Drawer = @This(); 11 20 12 21 const top_div: u16 = 1; 13 22 const info_div: u16 = 1; 14 - const bottom_div: u16 = 1; 15 23 16 - // Used to detect whether to re-render an image. 17 - current_item_path_buf: [std.fs.max_path_bytes]u8 = undefined, 18 - current_item_path: []u8 = "", 19 - last_item_path_buf: [std.fs.max_path_bytes]u8 = undefined, 20 - last_item_path: []u8 = "", 21 24 file_info_buf: [std.fs.max_path_bytes]u8 = undefined, 22 25 file_name_buf: [std.fs.max_path_bytes + 2]u8 = undefined, // +2 to accomodate for [<file_name>] 23 26 git_branch: [1024]u8 = undefined, 27 + verbose: bool = false, 24 28 25 - pub fn draw(self: *Drawer, app: *App) !void { 29 + pub fn draw(self: *Drawer, app: *App) error{ OutOfMemory, NoSpaceLeft }!void { 26 30 const win = app.vx.window(); 27 31 win.clear(); 28 32 29 - const abs_file_path_bar = try self.drawAbsFilePath(app.alloc, &app.directories, win); 30 - const file_info_bar = try self.drawFileInfo(&app.directories, win); 31 - app.last_known_height = try drawDirList( 32 - &app.directories, 33 + if (app.state == .help_menu) { 34 + win.hideCursor(); 35 + const offset: usize = app.help_menu.selected; 36 + for (app.help_menu.all()[offset..], 0..) |item, i| { 37 + if (i > win.height) continue; 38 + 39 + const w = win.child(.{ .y_off = @intCast(i), .height = 1 }); 40 + w.fill(vaxis.Cell{ 41 + .style = config.styles.list_item, 42 + }); 43 + 44 + _ = w.print(&.{.{ 45 + .text = item, 46 + .style = config.styles.list_item, 47 + }}, .{}); 48 + } 49 + 50 + return; 51 + } 52 + 53 + const abs_file_path_bar = try self.drawAbsFilePath(app, win); 54 + const file_info_bar = try self.drawFileInfo(app.alloc, &app.directories, win); 55 + app.last_known_height = drawDirList( 33 56 win, 57 + app.directories.entries, 34 58 abs_file_path_bar, 35 59 file_info_bar, 36 60 ); 37 61 38 - if (config.preview_file == true) { 62 + if (config.preview_file) { 39 63 const file_name_bar = try self.drawFileName(&app.directories, win); 40 - try self.drawFilePreview(app, win, file_name_bar); 64 + try drawFilePreview(app, win, file_name_bar); 41 65 } 42 66 43 - const input = inputToSlice(app); 44 - try drawUserInput(app.state, &app.text_input, input, win); 45 - try drawNotification(&app.notification, win); 67 + const input = app.readInput(); 68 + drawUserInput(app.state, &app.text_input, input, win); 69 + 70 + // Notification should be drawn last. 71 + drawNotification(&app.notification, &app.file_logger, win); 46 72 } 47 73 48 74 fn drawFileName( 49 75 self: *Drawer, 50 76 directories: *Directories, 51 77 win: vaxis.Window, 52 - ) !vaxis.Window { 78 + ) error{NoSpaceLeft}!vaxis.Window { 53 79 const file_name_bar = win.child(.{ 54 80 .x_off = win.width / 2, 55 81 .y_off = 0, ··· 57 83 .height = top_div, 58 84 }); 59 85 60 - lbl: { 61 - const entry = directories.getSelected() catch break :lbl; 62 - if (entry) |e| { 63 - const file_name = try std.fmt.bufPrint( 64 - &self.file_name_buf, 65 - "[{s}]", 66 - .{e.name}, 67 - ); 68 - _ = file_name_bar.print(&.{vaxis.Segment{ 69 - .text = file_name, 70 - .style = config.styles.file_name, 71 - }}, .{}); 72 - } 73 - } 86 + const entry = lbl: { 87 + const entry = directories.getSelected() catch return file_name_bar; 88 + if (entry) |e| break :lbl e else return file_name_bar; 89 + }; 90 + 91 + const file_name = try std.fmt.bufPrint(&self.file_name_buf, "[{s}]", .{entry.name}); 92 + _ = file_name_bar.printSegment(.{ .text = file_name, .style = config.styles.file_name }, .{}); 74 93 75 94 return file_name_bar; 76 95 } 77 96 78 97 fn drawFilePreview( 79 - self: *Drawer, 80 98 app: *App, 81 99 win: vaxis.Window, 82 100 file_name_win: vaxis.Window, 83 - ) !void { 101 + ) error{ OutOfMemory, NoSpaceLeft }!void { 102 + const bottom_div: u16 = 1; 103 + 84 104 const preview_win = win.child(.{ 85 105 .x_off = win.width / 2, 86 106 .y_off = top_div + 1, ··· 95 115 if (entry) |e| break :lbl e else return; 96 116 }; 97 117 98 - @memcpy(&self.last_item_path_buf, &self.current_item_path_buf); 99 - self.last_item_path = self.last_item_path_buf[0..self.current_item_path.len]; 100 - self.current_item_path = try std.fmt.bufPrint( 101 - &self.current_item_path_buf, 102 - "{s}/{s}", 103 - .{ try app.directories.fullPath("."), entry.name }, 104 - ); 118 + const clean_name = path_utils.getCleanName(entry); 119 + const abs_path = app.directories.fullPath(clean_name) catch { 120 + _ = preview_win.print(&.{.{ .text = "Unable to get file path." }}, .{}); 121 + return; 122 + }; 123 + 124 + const preview_data = app.preview_cache.get(abs_path); 125 + if (preview_data == null) { 126 + _ = preview_win.print(&.{.{ .text = "Loading preview..." }}, .{}); 127 + return; 128 + } 105 129 106 - switch (entry.kind) { 107 - .directory => { 108 - app.directories.clearChildEntries(); 109 - if (app.directories.populateChildEntries(entry.name)) { 110 - try app.directories.writeChildEntries(preview_win, config.styles.list_item); 111 - } else |err| { 112 - switch (err) { 113 - error.AccessDenied => try app.notification.writeErr(.PermissionDenied), 114 - else => try app.notification.writeErr(.UnknownError), 130 + switch (preview_data.?.*) { 131 + .none => { 132 + _ = preview_win.print(&.{.{ .text = "No preview available." }}, .{}); 133 + }, 134 + .text, .pdf => |text| { 135 + _ = preview_win.print(&.{.{ .text = text }}, .{}); 136 + }, 137 + .directory => |entries| { 138 + for (entries.items, 0..) |item, i| { 139 + if (std.mem.startsWith(u8, item, ".") and config.show_hidden == false) { 140 + continue; 115 141 } 142 + if (i >= preview_win.height) break; 143 + const w = preview_win.child(.{ .y_off = @intCast(i), .height = 1 }); 144 + w.fill(vaxis.Cell{ .style = config.styles.list_item }); 145 + _ = w.print(&.{.{ .text = item, .style = config.styles.list_item }}, .{}); 116 146 } 117 147 }, 118 - .file => file: { 119 - var file = app.directories.dir.openFile(entry.name, .{ .mode = .read_only }) catch |err| { 120 - switch (err) { 121 - error.AccessDenied => try app.notification.writeErr(.PermissionDenied), 122 - else => try app.notification.writeErr(.UnknownError), 123 - } 124 - 125 - _ = preview_win.print(&.{ 126 - .{ .text = "No preview available." }, 127 - }, .{}); 128 - 129 - break :file; 130 - }; 131 - defer file.close(); 132 - const bytes = try file.readAll(&app.directories.file_contents); 133 - 134 - // Handle image. 135 - if (config.show_images == true) unsupported: { 136 - var match = false; 137 - inline for (@typeInfo(vaxis.zigimg.Image.Format).Enum.fields) |field| { 138 - const entry_ext = std.mem.trimLeft( 139 - u8, 140 - std.fs.path.extension(entry.name), 141 - ".", 142 - ); 143 - if (std.mem.eql(u8, entry_ext, field.name)) { 144 - match = true; 145 - } 146 - } 147 - if (!match) break :unsupported; 148 - 149 - if (std.mem.eql(u8, self.last_item_path, self.current_item_path)) break :unsupported; 150 - 151 - var image = vaxis.zigimg.Image.fromFilePath( 152 - app.alloc, 153 - self.current_item_path, 154 - ) catch { 155 - break :unsupported; 156 - }; 157 - defer image.deinit(); 158 - 159 - if (app.vx.transmitImage(app.alloc, app.tty.anyWriter(), &image, .rgba)) |img| { 160 - app.image = img; 161 - } else |_| { 162 - if (app.image) |img| { 163 - app.vx.freeImage(app.tty.anyWriter(), img.id); 164 - } 165 - app.image = null; 166 - break :unsupported; 167 - } 168 - 169 - if (app.image) |img| { 170 - try img.draw(preview_win, .{ .scale = .contain }); 171 - } 172 - 173 - break :file; 148 + .archive => |entries| { 149 + for (entries.items, 0..) |item, i| { 150 + if (i >= preview_win.height) break; 151 + const w = preview_win.child(.{ .y_off = @intCast(i), .height = 1 }); 152 + w.fill(vaxis.Cell{ .style = config.styles.list_item }); 153 + _ = w.print(&.{.{ .text = item, .style = config.styles.list_item }}, .{}); 154 + } 155 + }, 156 + .image => |img_info| { 157 + if (!config.show_images) { 158 + _ = preview_win.print(&.{.{ .text = "Image preview disabled." }}, .{}); 159 + return; 174 160 } 175 161 176 - // Handle pdf. 177 - if (std.mem.eql(u8, std.fs.path.extension(entry.name), ".pdf")) { 178 - const output = std.process.Child.run(.{ 179 - .allocator = app.alloc, 180 - .argv = &[_][]const u8{ 181 - "pdftotext", 182 - "-f", 183 - "0", 184 - "-l", 185 - "5", 186 - self.current_item_path, 187 - "-", 162 + app.images.mutex.lock(); 163 + defer app.images.mutex.unlock(); 164 + 165 + if (app.images.cache.getPtr(img_info.cache_path)) |cache_entry| { 166 + switch (cache_entry.status) { 167 + .processing => { 168 + _ = preview_win.print(&.{.{ .text = "Image still processing..." }}, .{}); 169 + }, 170 + .failed => { 171 + _ = preview_win.print(&.{.{ .text = "Failed to process image." }}, .{}); 172 + }, 173 + .ready => { 174 + if (cache_entry.image) |image| { 175 + image.draw(preview_win, .{ .scale = .contain }) catch { 176 + _ = preview_win.print(&.{.{ .text = "Failed to draw image." }}, .{}); 177 + return; 178 + }; 179 + } else if (cache_entry.data) |*data| { 180 + if (app.vx.transmitImage(app.alloc, app.tty.writer(), data, .rgba)) |image| { 181 + image.draw(preview_win, .{ .scale = .contain }) catch { 182 + _ = preview_win.print(&.{.{ .text = "Failed to draw image." }}, .{}); 183 + return; 184 + }; 185 + cache_entry.image = image; 186 + var d = data.*; 187 + d.deinit(app.alloc); 188 + cache_entry.data = null; 189 + } else |_| { 190 + _ = preview_win.print(&.{.{ .text = "Failed to transmit image." }}, .{}); 191 + } 192 + } else { 193 + _ = preview_win.print(&.{.{ .text = "Image processing..." }}, .{}); 194 + } 188 195 }, 189 - .cwd_dir = app.directories.dir, 190 - }) catch { 191 - _ = preview_win.print(&.{.{ 192 - .text = "No preview available. Install pdftotext to get PDF previews.", 193 - }}, .{}); 194 - break :file; 195 - }; 196 - defer app.alloc.free(output.stderr); 197 - defer app.alloc.free(output.stdout); 198 - 199 - if (output.term.Exited != 0) { 200 - _ = preview_win.print(&.{.{ 201 - .text = "No preview available. Install pdftotext to get PDF previews.", 202 - }}, .{}); 203 - break :file; 204 196 } 205 - 206 - if (app.directories.pdf_contents) |contents| app.alloc.free(contents); 207 - app.directories.pdf_contents = try app.alloc.dupe(u8, output.stdout); 208 - 209 - _ = preview_win.print(&.{ 210 - .{ .text = app.directories.pdf_contents.? }, 211 - }, .{}); 212 - break :file; 213 - } 214 - 215 - // Handle utf-8. 216 - if (std.unicode.utf8ValidateSlice(app.directories.file_contents[0..bytes])) { 217 - _ = preview_win.print(&.{ 218 - .{ .text = app.directories.file_contents[0..bytes] }, 219 - }, .{}); 220 - break :file; 197 + } else { 198 + _ = preview_win.print(&.{.{ .text = "Image not found in cache." }}, .{}); 221 199 } 222 - 223 - // Fallback to no preview. 224 - _ = preview_win.print(&.{.{ .text = "No preview available." }}, .{}); 225 - }, 226 - else => { 227 - _ = preview_win.print(&.{ 228 - vaxis.Segment{ .text = self.current_item_path }, 229 - }, .{}); 230 200 }, 231 201 } 232 202 } 233 203 234 204 fn drawFileInfo( 235 205 self: *Drawer, 206 + alloc: std.mem.Allocator, 236 207 directories: *Directories, 237 208 win: vaxis.Window, 238 - ) !vaxis.Window { 209 + ) error{NoSpaceLeft}!vaxis.Window { 210 + const bottom_div: u16 = if (self.verbose) 6 else 1; 211 + 239 212 const file_info_win = win.child(.{ 240 213 .x_off = 0, 241 214 .y_off = win.height - bottom_div, ··· 250 223 }; 251 224 252 225 var fbs = std.io.fixedBufferStream(&self.file_info_buf); 226 + 227 + // Selected entry. 253 228 try fbs.writer().print( 254 - "{d}/{d} ", 255 - .{ directories.entries.selected + 1, directories.entries.len() }, 229 + "{s}{d}/{d}{s}", 230 + .{ 231 + if (self.verbose) "Entry: " else "", 232 + directories.entries.selected + 1, 233 + directories.entries.len(), 234 + if (self.verbose) "\n" else " ", 235 + }, 256 236 ); 257 237 258 - if (entry.kind == .directory) { 259 - _ = file_info_win.printSegment(.{ 260 - .text = fbs.getWritten(), 261 - .style = config.styles.file_information, 262 - }, .{}); 263 - return file_info_win; 238 + // Time created / last modified 239 + if (self.verbose) lbl: { 240 + var maybe_meta: ?std.fs.File.Stat = null; 241 + if (entry.kind == .directory) { 242 + maybe_meta = directories.dir.stat() catch break :lbl; 243 + } else if (entry.kind == .file) { 244 + const clean_name = path_utils.getCleanName(entry); 245 + var file = directories.dir.openFile(clean_name, .{}) catch break :lbl; 246 + maybe_meta = file.stat() catch break :lbl; 247 + } 248 + 249 + const meta = maybe_meta orelse break :lbl; 250 + var env = std.process.getEnvMap(alloc) catch break :lbl; 251 + defer env.deinit(); 252 + const local = zeit.local(alloc, &env) catch break :lbl; 253 + defer local.deinit(); 254 + 255 + const ctime_instant = zeit.instant(.{ 256 + .source = .{ .unix_nano = meta.ctime }, 257 + .timezone = &local, 258 + }) catch break :lbl; 259 + const ctime = ctime_instant.time(); 260 + ctime.strftime(fbs.writer().any(), "Created: %Y-%m-%d %H:%M:%S\n") catch break :lbl; 261 + 262 + const mtime_instant = zeit.instant(.{ 263 + .source = .{ .unix_nano = meta.mtime }, 264 + .timezone = &local, 265 + }) catch break :lbl; 266 + const mtime = mtime_instant.time(); 267 + mtime.strftime(fbs.writer().any(), "Last modified: %Y-%m-%d %H:%M:%S\n") catch break :lbl; 264 268 } 265 269 266 - const file_size: u64 = lbl: { 267 - const formatted_size = directories.dir.statFile(entry.name) catch break :lbl 0; 268 - break :lbl formatted_size.size; 270 + // File permissions. 271 + var file_perm_buf: [11]u8 = undefined; 272 + const file_perms: usize = lbl: { 273 + if (self.verbose) try fbs.writer().writeAll("Permissions: "); 274 + var file_perm_fbs = std.io.fixedBufferStream(&file_perm_buf); 275 + 276 + if (entry.kind == .directory) { 277 + _ = try file_perm_fbs.write("d"); 278 + } 279 + 280 + const perm_strings = [_][]const u8{ 281 + "---", "--x", "-w-", "-wx", 282 + "r--", "r-x", "rw-", "rwx", 283 + }; 284 + 285 + const clean_name = path_utils.getCleanName(entry); 286 + const stat = directories.dir.statFile(clean_name) catch { 287 + _ = try file_perm_fbs.write("---------\n"); 288 + break :lbl 10; 289 + }; 290 + // Ignore upper bytes as they represent file type. 291 + const perms = @as(u9, @truncate(stat.mode)); 292 + 293 + for (0..3) |group| { 294 + const shift: u4 = @truncate((2 - group) * 3); // Extract from left to right 295 + const perm = @as(u3, @truncate((perms >> shift) & 0b111)); 296 + _ = try file_perm_fbs.write(perm_strings[perm]); 297 + } 298 + 299 + if (self.verbose) { 300 + _ = try file_perm_fbs.write("\n"); 301 + } else { 302 + _ = try file_perm_fbs.write(" "); 303 + } 304 + 305 + if (entry.kind == .directory) { 306 + break :lbl 11; 307 + } else { 308 + break :lbl 10; 309 + } 269 310 }; 311 + try fbs.writer().writeAll(file_perm_buf[0..file_perms]); 270 312 271 - const extension = std.fs.path.extension(entry.name); 272 - if (extension.len > 0) try fbs.writer().print("{s} ", .{extension}); 313 + // Size. 314 + const size: ?usize = lbl: { 315 + const clean_name = path_utils.getCleanName(entry); 316 + const stat = directories.dir.statFile(clean_name) catch break :lbl null; 317 + if (entry.kind == .file) { 318 + break :lbl stat.size; 319 + } else if (entry.kind == .directory) { 320 + if (config.true_dir_size) { 321 + var dir = directories.dir.openDir( 322 + clean_name, 323 + .{ .iterate = true }, 324 + ) catch break :lbl null; 325 + defer dir.close(); 326 + break :lbl directories.getDirSize(dir) catch break :lbl null; 327 + } else { 328 + break :lbl stat.size; 329 + } 330 + } 331 + 332 + break :lbl 0; 333 + }; 334 + if (size) |s| try fbs.writer().print("{s}{B:.2}\n", .{ 335 + if (self.verbose) "Size: " else "", 336 + s, 337 + }); 273 338 274 - try fbs.writer().print("{:.2}", .{std.fmt.fmtIntSizeDec(file_size)}); 339 + // Extension. 340 + const extension = std.fs.path.extension(entry.name); 341 + if (self.verbose) { 342 + try fbs.writer().print( 343 + "Extension: {s}\n", 344 + .{if (entry.kind == .directory) "Dir" else extension}, 345 + ); 346 + } else { 347 + try fbs.writer().print( 348 + "{s} ", 349 + .{if (entry.kind == .directory) "dir" else extension}, 350 + ); 351 + } 275 352 276 353 _ = file_info_win.printSegment(.{ 277 354 .text = fbs.getWritten(), ··· 282 359 } 283 360 284 361 fn drawDirList( 285 - directories: *Directories, 286 362 win: vaxis.Window, 363 + list: List(std.fs.Dir.Entry), 287 364 abs_file_path: vaxis.Window, 288 365 file_information: vaxis.Window, 289 - ) !u16 { 366 + ) u16 { 367 + const bottom_div: u16 = 1; 368 + 290 369 const current_dir_list_win = win.child(.{ 291 370 .x_off = 0, 292 371 .y_off = top_div + 1, 293 372 .width = if (config.preview_file) win.width / 2 else win.width, 294 373 .height = win.height - (abs_file_path.height + file_information.height + top_div + bottom_div), 295 374 }); 296 - try directories.writeEntries( 297 - current_dir_list_win, 298 - config.styles.selected_list_item, 299 - config.styles.list_item, 300 - ); 375 + 376 + const win_height = current_dir_list_win.height; 377 + var offset: usize = 0; 301 378 302 - return current_dir_list_win.height; 379 + while (list.all()[offset..].len > win_height and 380 + list.selected >= offset + (win_height / 2)) 381 + { 382 + offset += 1; 383 + } 384 + 385 + for (list.all()[offset..], 0..) |item, i| { 386 + const selected = list.selected - offset; 387 + const is_selected = selected == i; 388 + 389 + if (i > win_height) continue; 390 + 391 + const w = current_dir_list_win.child(.{ .y_off = @intCast(i), .height = 1 }); 392 + w.fill(vaxis.Cell{ 393 + .style = if (is_selected) config.styles.selected_list_item else config.styles.list_item, 394 + }); 395 + 396 + _ = w.print(&.{ 397 + .{ 398 + .text = item.name, 399 + .style = if (is_selected) config.styles.selected_list_item else config.styles.list_item, 400 + }, 401 + }, .{}); 402 + } 403 + 404 + return win_height; 303 405 } 304 406 305 407 fn drawAbsFilePath( 306 408 self: *Drawer, 307 - alloc: std.mem.Allocator, 308 - directories: *Directories, 409 + app: *App, 309 410 win: vaxis.Window, 310 - ) !vaxis.Window { 411 + ) error{ OutOfMemory, NoSpaceLeft }!vaxis.Window { 311 412 const abs_file_path_bar = win.child(.{ 312 413 .x_off = 0, 313 414 .y_off = 0, ··· 315 416 .height = top_div, 316 417 }); 317 418 318 - const branch_alloc = try Git.getGitBranch(alloc, directories.dir); 319 - defer if (branch_alloc) |b| alloc.free(b); 419 + const branch_alloc = Git.getGitBranch(app.alloc, app.directories.dir) catch null; 420 + defer if (branch_alloc) |b| app.alloc.free(b); 320 421 const branch = if (branch_alloc) |b| 321 422 try std.fmt.bufPrint( 322 423 &self.git_branch, ··· 327 428 ""; 328 429 329 430 _ = abs_file_path_bar.print(&.{ 330 - vaxis.Segment{ .text = try directories.fullPath(".") }, 431 + vaxis.Segment{ .text = app.directories.fullPath(".") catch { 432 + const message = try std.fmt.allocPrint(app.alloc, "Can not display absolute file path - unable to retrieve full path.", .{}); 433 + defer app.alloc.free(message); 434 + app.notification.write(message, .err) catch {}; 435 + if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 436 + return abs_file_path_bar; 437 + } }, 331 438 vaxis.Segment{ .text = if (branch_alloc != null) " on " else "" }, 332 439 vaxis.Segment{ .text = branch, .style = config.styles.git_branch }, 333 440 }, .{}); ··· 340 447 text_input: *vaxis.widgets.TextInput, 341 448 input: []const u8, 342 449 win: vaxis.Window, 343 - ) !void { 450 + ) void { 344 451 const user_input_win = win.child(.{ 345 452 .x_off = 0, 346 453 .y_off = top_div, ··· 351 458 352 459 switch (current_state) { 353 460 .fuzzy, .new_file, .new_dir, .rename, .change_dir, .command => { 354 - text_input.draw(user_input_win); 461 + text_input.drawWithStyle(user_input_win, config.styles.text_input); 355 462 }, 356 463 .normal => { 357 464 if (text_input.buf.realLength() > 0) { 358 465 text_input.drawWithStyle( 359 466 user_input_win, 360 - if (std.mem.eql(u8, input, ":UnsupportedCommand")) config.styles.text_input_err else .{}, 467 + if (std.mem.eql(u8, input, ":UnsupportedCommand")) 468 + config.styles.text_input_err 469 + else 470 + config.styles.text_input, 361 471 ); 362 472 } 363 473 364 474 win.hideCursor(); 365 475 }, 476 + .help_menu => { 477 + win.hideCursor(); 478 + }, 366 479 } 367 480 } 368 481 369 482 fn drawNotification( 370 483 notification: *Notification, 484 + file_logger: *?FileLogger, 371 485 win: vaxis.Window, 372 - ) !void { 486 + ) void { 373 487 if (notification.len() == 0) return; 374 488 if (notification.clearIfEnded()) return; 375 489 ··· 380 494 const max_width = win.width / 4; 381 495 const width = notification.len() + width_padding; 382 496 const calculated_width = if (width > max_width) max_width else width; 383 - const height = try std.math.divCeil(usize, notification.len(), calculated_width) + height_padding; 497 + const height = (std.math.divCeil(usize, notification.len(), calculated_width) catch { 498 + if (file_logger.*) |fl| fl.write("Unable to display notification - failed to calculate notification height.", .err) catch {}; 499 + return; 500 + }) + height_padding; 384 501 385 502 const notification_win = win.child(.{ 386 503 .x_off = @intCast(win.width - (calculated_width + screen_pos_padding)),
+27
src/environment.zig
··· 1 1 const std = @import("std"); 2 2 const builtin = @import("builtin"); 3 3 4 + const zuid = @import("zuid"); 5 + 4 6 pub fn getHomeDir() !?std.fs.Dir { 5 7 return try std.fs.openDirAbsolute(std.posix.getenv("HOME") orelse { 6 8 return null; ··· 21 23 } 22 24 } 23 25 return null; 26 + } 27 + 28 + pub fn checkDuplicatePath( 29 + buf: []u8, 30 + dir: std.fs.Dir, 31 + relative_path: []const u8, 32 + ) error{NoSpaceLeft}!struct { 33 + path: []const u8, 34 + had_duplicate: bool, 35 + } { 36 + var had_duplicate = false; 37 + const new_path = if (fileExists(dir, relative_path)) lbl: { 38 + had_duplicate = true; 39 + const extension = std.fs.path.extension(relative_path); 40 + break :lbl try std.fmt.bufPrint( 41 + buf, 42 + "{s}-{f}{s}", 43 + .{ relative_path[0 .. relative_path.len - extension.len], zuid.new.v4(), extension }, 44 + ); 45 + } else lbl: { 46 + break :lbl try std.fmt.bufPrint(buf, "{s}", .{relative_path}); 47 + }; 48 + 49 + return .{ .path = new_path, .had_duplicate = had_duplicate }; 24 50 } 25 51 26 52 pub fn openFile( ··· 68 94 return result; 69 95 } 70 96 97 + ///Deletes the contents of a directory but not the directory itself. 71 98 ///Returns the amount of files failed to be delete. 72 99 pub fn deleteContents(dir: std.fs.Dir) !usize { 73 100 var failed: usize = 0;
+225 -398
src/event_handlers.zig
··· 1 1 const std = @import("std"); 2 - const App = @import("./app.zig"); 3 - const environment = @import("./environment.zig"); 4 - const zuid = @import("zuid"); 2 + 5 3 const vaxis = @import("vaxis"); 6 4 const Key = vaxis.Key; 7 - const config = &@import("./config.zig").config; 5 + const zuid = @import("zuid"); 6 + 7 + const App = @import("./app.zig"); 8 8 const commands = @import("./commands.zig"); 9 + const Keybinds = @import("./config.zig").Keybinds; 10 + const environment = @import("./environment.zig"); 11 + const events = @import("./events.zig"); 12 + const Preview = @import("./preview.zig"); 9 13 10 - pub fn inputToSlice(self: *App) []const u8 { 11 - self.text_input.buf.cursor = self.text_input.buf.realLength(); 12 - return self.text_input.sliceToCursor(&self.text_input_buf); 13 - } 14 + const config = &@import("./config.zig").config; 14 15 15 - pub fn handleNormalEvent( 16 + pub fn handleGlobalEvent( 16 17 app: *App, 17 18 event: App.Event, 18 - loop: *vaxis.Loop(App.Event), 19 - ) !void { 19 + ) error{OutOfMemory}!void { 20 20 switch (event) { 21 21 .key_press => |key| { 22 22 if ((key.codepoint == 'c' and key.mods.ctrl)) { ··· 24 24 return; 25 25 } 26 26 27 - switch (key.codepoint) { 28 - '-', 'h', Key.left => { 29 - app.text_input.clearAndFree(); 30 - 31 - if (app.directories.dir.openDir("../", .{ .iterate = true })) |dir| { 32 - app.directories.dir.close(); 33 - app.directories.dir = dir; 34 - 35 - app.directories.clearEntries(); 36 - const fuzzy = inputToSlice(app); 37 - app.directories.populateEntries(fuzzy) catch |err| { 38 - switch (err) { 39 - error.AccessDenied => try app.notification.writeErr(.PermissionDenied), 40 - else => try app.notification.writeErr(.UnknownError), 41 - } 27 + if ((key.codepoint == 'r' and key.mods.ctrl)) { 28 + if (config.parse(app.alloc, app)) { 29 + app.notification.write("Reloaded configuration file.", .info) catch {}; 30 + } else |err| switch (err) { 31 + error.SyntaxError => { 32 + app.notification.write("Encountered a syntax error while parsing the config file.", .err) catch { 33 + std.log.err("Encountered a syntax error while parsing the config file.", .{}); 42 34 }; 43 - 44 - if (app.directories.history.pop()) |history| { 45 - app.directories.entries.selected = history.selected; 46 - app.directories.entries.offset = history.offset; 47 - } 48 - } else |err| { 49 - switch (err) { 50 - error.AccessDenied => try app.notification.writeErr(.PermissionDenied), 51 - else => try app.notification.writeErr(.UnknownError), 52 - } 53 - } 54 - }, 55 - Key.enter, 'l', Key.right => { 56 - const entry = lbl: { 57 - const entry = app.directories.getSelected() catch return; 58 - if (entry) |e| break :lbl e else return; 59 - }; 60 - 61 - switch (entry.kind) { 62 - .directory => { 63 - app.text_input.clearAndFree(); 64 - 65 - if (app.directories.dir.openDir(entry.name, .{ .iterate = true })) |dir| { 66 - app.directories.dir.close(); 67 - app.directories.dir = dir; 68 - 69 - _ = app.directories.history.push(.{ 70 - .selected = app.directories.entries.selected, 71 - .offset = app.directories.entries.offset, 72 - }); 73 - 74 - app.directories.clearEntries(); 75 - const fuzzy = inputToSlice(app); 76 - app.directories.populateEntries(fuzzy) catch |err| { 77 - switch (err) { 78 - error.AccessDenied => try app.notification.writeErr(.PermissionDenied), 79 - else => try app.notification.writeErr(.UnknownError), 80 - } 81 - }; 82 - } else |err| { 83 - switch (err) { 84 - error.AccessDenied => try app.notification.writeErr(.PermissionDenied), 85 - else => try app.notification.writeErr(.UnknownError), 86 - } 87 - } 88 - }, 89 - .file => { 90 - if (environment.getEditor()) |editor| { 91 - try app.vx.exitAltScreen(app.tty.anyWriter()); 92 - try app.vx.resetState(app.tty.anyWriter()); 93 - loop.stop(); 35 + }, 36 + error.InvalidCharacter => { 37 + app.notification.write("One or more overriden keybinds are invalid.", .err) catch { 38 + std.log.err("One or more overriden keybinds are invalid.", .{}); 39 + }; 40 + }, 41 + error.DuplicateKeybind => { 42 + // Error logged in function 43 + }, 44 + else => { 45 + const message = try std.fmt.allocPrint(app.alloc, "Encountend an unknown error while parsing the config file - {}", .{err}); 46 + defer app.alloc.free(message); 94 47 95 - environment.openFile(app.alloc, app.directories.dir, entry.name, editor) catch { 96 - try app.notification.writeErr(.UnableToOpenFile); 97 - }; 98 - 99 - try loop.start(); 100 - try app.vx.enterAltScreen(app.tty.anyWriter()); 101 - try app.vx.enableDetectedFeatures(app.tty.anyWriter()); 102 - app.vx.queueRefresh(); 103 - } else { 104 - try app.notification.writeErr(.EditorNotSet); 105 - } 106 - }, 107 - else => {}, 108 - } 109 - }, 110 - 'j', Key.down => { 111 - app.directories.entries.next(app.last_known_height); 112 - }, 113 - 'k', Key.up => { 114 - app.directories.entries.previous(app.last_known_height); 115 - }, 116 - 'G' => { 117 - app.directories.entries.selectLast(app.last_known_height); 118 - }, 119 - 'g' => app.directories.entries.selectFirst(), 120 - 'D' => { 121 - const entry = lbl: { 122 - const entry = app.directories.getSelected() catch { 123 - try app.notification.writeErr(.UnableToDelete); 124 - return; 48 + app.notification.write(message, .err) catch { 49 + std.log.err("Encountend an unknown error while parsing the config file - {}", .{err}); 125 50 }; 126 - if (entry) |e| break :lbl e else return; 127 - }; 51 + }, 52 + } 53 + } 54 + }, 55 + else => {}, 56 + } 57 + } 128 58 129 - var old_path_buf: [std.fs.max_path_bytes]u8 = undefined; 130 - const old_path = try app.alloc.dupe(u8, try app.directories.dir.realpath(entry.name, &old_path_buf)); 59 + pub fn handleNormalEvent( 60 + app: *App, 61 + event: App.Event, 62 + ) !void { 63 + switch (event) { 64 + .key_press => |key| { 65 + @setEvalBranchQuota( 66 + std.meta.fields(Keybinds).len * 1000, 67 + ); 131 68 132 - var trash_dir = dir: { 133 - notfound: { 134 - break :dir (config.trashDir() catch break :notfound) orelse break :notfound; 69 + const maybe_remap: ?std.meta.FieldEnum(Keybinds) = lbl: { 70 + inline for (std.meta.fields(Keybinds)) |field| { 71 + if (@field(config.keybinds, field.name)) |field_value| { 72 + if (key.codepoint == @intFromEnum(field_value)) { 73 + break :lbl comptime std.meta.stringToEnum(std.meta.FieldEnum(Keybinds), field.name) orelse unreachable; 135 74 } 136 - app.alloc.free(old_path); 137 - try app.notification.writeErr(.ConfigPathNotFound); 138 - return; 139 - }; 140 - defer trash_dir.close(); 141 - var trash_dir_path_buf: [std.fs.max_path_bytes]u8 = undefined; 142 - const trash_dir_path = try trash_dir.realpath(".", &trash_dir_path_buf); 143 - 144 - if (std.mem.eql(u8, old_path, trash_dir_path)) { 145 - try app.notification.writeErr(.CannotDeleteTrashDir); 146 - app.alloc.free(old_path); 147 - return; 148 75 } 149 - 150 - var tmp_path_buf: [std.fs.max_path_bytes]u8 = undefined; 151 - const tmp_path = try app.alloc.dupe(u8, try std.fmt.bufPrint(&tmp_path_buf, "{s}/{s}-{s}", .{ trash_dir_path, entry.name, zuid.new.v4().toString() })); 76 + } 77 + break :lbl null; 78 + }; 152 79 153 - if (app.directories.dir.rename(entry.name, tmp_path)) { 154 - if (app.actions.push(.{ 155 - .delete = .{ .old = old_path, .new = tmp_path }, 156 - })) |prev_elem| { 157 - app.alloc.free(prev_elem.delete.old); 158 - app.alloc.free(prev_elem.delete.new); 159 - } 160 - 161 - try app.notification.writeInfo(.Deleted); 162 - app.directories.removeSelected(); 163 - } else |err| { 164 - switch (err) { 165 - error.RenameAcrossMountPoints => try app.notification.writeErr(.UnableToDeleteAcrossMountPoints), 166 - else => try app.notification.writeErr(.UnableToDelete), 167 - } 168 - app.alloc.free(old_path); 169 - app.alloc.free(tmp_path); 170 - } 171 - }, 172 - 'd' => { 173 - app.text_input.clearAndFree(); 174 - app.directories.clearEntries(); 175 - app.directories.populateEntries("") catch |err| { 176 - switch (err) { 177 - error.AccessDenied => try app.notification.writeErr(.PermissionDenied), 178 - else => try app.notification.writeErr(.UnknownError), 179 - } 180 - }; 181 - app.state = .new_dir; 182 - }, 183 - '%' => { 184 - app.text_input.clearAndFree(); 185 - app.directories.clearEntries(); 186 - app.directories.populateEntries("") catch |err| { 187 - switch (err) { 188 - error.AccessDenied => try app.notification.writeErr(.PermissionDenied), 189 - else => try app.notification.writeErr(.UnknownError), 190 - } 191 - }; 192 - app.state = .new_file; 193 - }, 194 - 'u' => { 195 - if (app.actions.pop()) |action| { 196 - const selected = app.directories.entries.selected; 80 + if (maybe_remap) |action| { 81 + switch (action) { 82 + .toggle_hidden_files => try events.toggleHiddenFiles(app), 83 + .delete => try events.delete(app), 84 + .rename => { 85 + const entry = (app.directories.getSelected() catch { 86 + app.notification.write("Can not rename item - no item selected.", .warn) catch {}; 87 + return; 88 + }) orelse return; 197 89 198 - switch (action) { 199 - .delete => |a| { 200 - defer app.alloc.free(a.new); 201 - defer app.alloc.free(a.old); 90 + app.text_input.clearAndFree(); 202 91 203 - // TODO: Will overwrite an item if it has the same name. 204 - if (app.directories.dir.rename(a.new, a.old)) { 205 - app.directories.clearEntries(); 206 - const fuzzy = inputToSlice(app); 207 - app.directories.populateEntries(fuzzy) catch |err| { 208 - switch (err) { 209 - error.AccessDenied => try app.notification.writeErr(.PermissionDenied), 210 - else => try app.notification.writeErr(.UnknownError), 211 - } 212 - }; 213 - try app.notification.writeInfo(.RestoredDelete); 214 - } else |_| { 215 - try app.notification.writeErr(.UnableToUndo); 216 - } 217 - }, 218 - .rename => |a| { 219 - defer app.alloc.free(a.new); 220 - defer app.alloc.free(a.old); 221 - 222 - // TODO: Will overwrite an item if it has the same name. 223 - if (app.directories.dir.rename(a.new, a.old)) { 224 - app.directories.clearEntries(); 225 - const fuzzy = inputToSlice(app); 226 - app.directories.populateEntries(fuzzy) catch |err| { 227 - switch (err) { 228 - error.AccessDenied => try app.notification.writeErr(.PermissionDenied), 229 - else => try app.notification.writeErr(.UnknownError), 230 - } 231 - }; 232 - try app.notification.writeInfo(.RestoredRename); 233 - } else |_| { 234 - try app.notification.writeErr(.UnableToUndo); 235 - } 236 - }, 237 - } 238 - 239 - app.directories.entries.selected = selected; 240 - } else { 241 - try app.notification.writeInfo(.EmptyUndo); 242 - } 243 - }, 244 - '/' => { 245 - app.text_input.clearAndFree(); 246 - app.state = .fuzzy; 247 - }, 248 - 'R' => { 249 - app.text_input.clearAndFree(); 250 - app.state = .rename; 251 - 252 - const entry = lbl: { 253 - const entry = app.directories.getSelected() catch { 254 - app.state = .normal; 255 - try app.notification.writeErr(.UnableToRename); 256 - return; 257 - }; 258 - if (entry) |e| break :lbl e else { 259 - app.state = .normal; 260 - return; 261 - } 262 - }; 263 - 264 - app.text_input.insertSliceAtCursor(entry.name) catch { 265 - app.state = .normal; 266 - try app.notification.writeErr(.UnableToRename); 267 - return; 268 - }; 269 - }, 270 - 'c' => { 271 - app.text_input.clearAndFree(); 272 - app.state = .change_dir; 273 - }, 274 - ':' => { 275 - app.text_input.clearAndFree(); 276 - app.text_input.insertSliceAtCursor(":") catch {}; 277 - app.state = .command; 278 - }, 279 - else => {}, 92 + // Try insert entry name into text input for a nicer experience. 93 + // This failing shouldn't stop the user from entering a new name. 94 + app.text_input.insertSliceAtCursor(entry.name) catch {}; 95 + app.state = .rename; 96 + }, 97 + .create_dir => { 98 + try app.repopulateDirectory(""); 99 + app.text_input.clearAndFree(); 100 + app.state = .new_dir; 101 + }, 102 + .create_file => { 103 + try app.repopulateDirectory(""); 104 + app.text_input.clearAndFree(); 105 + app.state = .new_file; 106 + }, 107 + .fuzzy_find => { 108 + app.text_input.clearAndFree(); 109 + app.state = .fuzzy; 110 + }, 111 + .change_dir => { 112 + app.text_input.clearAndFree(); 113 + app.state = .change_dir; 114 + }, 115 + .enter_command_mode => { 116 + app.text_input.clearAndFree(); 117 + app.text_input.insertSliceAtCursor(":") catch {}; 118 + app.state = .command; 119 + }, 120 + .jump_bottom => { 121 + app.directories.entries.selectLast(); 122 + app.preview_cache.invalidate(); 123 + Preview.loadPreviewForCurrentEntry(app) catch {}; 124 + }, 125 + .jump_top => { 126 + app.directories.entries.selectFirst(); 127 + app.preview_cache.invalidate(); 128 + Preview.loadPreviewForCurrentEntry(app) catch {}; 129 + }, 130 + .toggle_verbose_file_information => app.drawer.verbose = !app.drawer.verbose, 131 + .force_delete => try events.forceDelete(app), 132 + .yank => try events.yank(app), 133 + .paste => try events.paste(app), 134 + .extract_archive => try events.extractArchive(app), 135 + } 136 + } else { 137 + switch (key.codepoint) { 138 + '-', 'h', Key.left => try events.traverseLeft(app), 139 + Key.enter, 'l', Key.right => try events.traverseRight(app), 140 + 'j', Key.down => app.directories.entries.next(), 141 + 'k', Key.up => app.directories.entries.previous(), 142 + 'u' => try events.undo(app), 143 + else => {}, 144 + } 145 + app.preview_cache.invalidate(); 146 + Preview.loadPreviewForCurrentEntry(app) catch {}; 280 147 } 281 148 }, 282 - .winsize => |ws| try app.vx.resize(app.alloc, app.tty.anyWriter(), ws), 149 + .image_ready => {}, 150 + .notification => {}, 151 + .winsize => |ws| try app.vx.resize(app.alloc, app.tty.writer(), ws), 283 152 } 284 153 } 285 154 286 155 pub fn handleInputEvent(app: *App, event: App.Event) !void { 287 156 switch (event) { 288 157 .key_press => |key| { 289 - if ((key.codepoint == 'c' and key.mods.ctrl)) { 290 - app.should_quit = true; 291 - return; 292 - } 293 - 294 158 switch (key.codepoint) { 295 159 Key.escape => { 296 160 switch (app.state) { 297 161 .fuzzy => { 298 - app.directories.clearEntries(); 299 - app.directories.populateEntries("") catch |err| { 300 - switch (err) { 301 - error.AccessDenied => try app.notification.writeErr(.PermissionDenied), 302 - else => try app.notification.writeErr(.UnknownError), 303 - } 304 - }; 162 + try app.repopulateDirectory(""); 163 + app.text_input.clearAndFree(); 305 164 }, 165 + .command => app.command_history.cursor = null, 306 166 else => {}, 307 167 } 308 168 ··· 310 170 app.state = .normal; 311 171 }, 312 172 Key.enter => { 313 - const selected = app.directories.entries.selected; 314 173 switch (app.state) { 315 - .new_dir => { 316 - const dir = inputToSlice(app); 317 - if (app.directories.dir.makeDir(dir)) { 318 - try app.notification.writeInfo(.CreatedFolder); 319 - 320 - app.directories.clearEntries(); 321 - app.directories.populateEntries("") catch |err| { 322 - switch (err) { 323 - error.AccessDenied => try app.notification.writeErr(.PermissionDenied), 324 - else => try app.notification.writeErr(.UnknownError), 325 - } 326 - }; 327 - } else |err| { 328 - switch (err) { 329 - error.AccessDenied => try app.notification.writeErr(.PermissionDenied), 330 - error.PathAlreadyExists => try app.notification.writeErr(.ItemAlreadyExists), 331 - else => try app.notification.writeErr(.UnknownError), 332 - } 333 - } 334 - app.text_input.clearAndFree(); 335 - }, 336 - .new_file => { 337 - const file = inputToSlice(app); 338 - if (environment.fileExists(app.directories.dir, file)) { 339 - try app.notification.writeErr(.ItemAlreadyExists); 340 - } else { 341 - if (app.directories.dir.createFile(file, .{})) |f| { 342 - f.close(); 343 - 344 - try app.notification.writeInfo(.CreatedFile); 345 - 346 - app.directories.clearEntries(); 347 - app.directories.populateEntries("") catch |err| { 348 - switch (err) { 349 - error.AccessDenied => try app.notification.writeErr(.PermissionDenied), 350 - else => try app.notification.writeErr(.UnknownError), 351 - } 352 - }; 353 - } else |err| { 354 - switch (err) { 355 - error.AccessDenied => try app.notification.writeErr(.PermissionDenied), 356 - else => try app.notification.writeErr(.UnknownError), 357 - } 358 - } 359 - } 360 - app.text_input.clearAndFree(); 174 + .new_dir => try events.createNewDir(app), 175 + .new_file => try events.createNewFile(app), 176 + .rename => try events.rename(app), 177 + .change_dir => { 178 + const path = try app.text_input.toOwnedSlice(); 179 + defer app.alloc.free(path); 180 + try commands.cd(app, path); 361 181 }, 362 - .rename => { 363 - var dir_prefix_buf: [std.fs.max_path_bytes]u8 = undefined; 364 - const dir_prefix = try app.directories.dir.realpath(".", &dir_prefix_buf); 182 + .command => { 183 + const command = try app.text_input.toOwnedSlice(); 184 + defer app.alloc.free(command); 365 185 366 - const old = lbl: { 367 - const entry = app.directories.getSelected() catch { 368 - try app.notification.writeErr(.UnableToRename); 369 - return; 370 - }; 371 - if (entry) |e| break :lbl e else return; 372 - }; 373 - const new = inputToSlice(app); 374 - 375 - if (environment.fileExists(app.directories.dir, new)) { 376 - try app.notification.writeErr(.ItemAlreadyExists); 377 - } else { 378 - app.directories.dir.rename(old.name, new) catch |err| switch (err) { 379 - error.AccessDenied => try app.notification.writeErr(.PermissionDenied), 380 - error.PathAlreadyExists => try app.notification.writeErr(.ItemAlreadyExists), 381 - else => try app.notification.writeErr(.UnknownError), 382 - }; 383 - if (app.actions.push(.{ 384 - .rename = .{ 385 - .old = try std.fs.path.join(app.alloc, &.{ dir_prefix, old.name }), 386 - .new = try std.fs.path.join(app.alloc, &.{ dir_prefix, new }), 387 - }, 388 - })) |prev_elem| { 389 - app.alloc.free(prev_elem.rename.old); 390 - app.alloc.free(prev_elem.rename.new); 391 - } 392 - 393 - try app.notification.writeInfo(.Renamed); 394 - 395 - app.directories.clearEntries(); 396 - app.directories.populateEntries("") catch |err| { 397 - switch (err) { 398 - error.AccessDenied => try app.notification.writeErr(.PermissionDenied), 399 - else => try app.notification.writeErr(.UnknownError), 400 - } 186 + // Push command to history if it's not empty. 187 + if (!std.mem.eql(u8, std.mem.trim(u8, command, " "), ":")) { 188 + app.command_history.add(command, app.alloc) catch |err| { 189 + const message = try std.fmt.allocPrint(app.alloc, "Failed to add command to history - {}.", .{err}); 190 + defer app.alloc.free(message); 191 + if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 401 192 }; 402 193 } 403 - app.text_input.clearAndFree(); 404 - }, 405 - .change_dir => { 406 - const path = inputToSlice(app); 407 - if (app.directories.dir.openDir(path, .{ .iterate = true })) |dir| { 408 - app.directories.dir.close(); 409 - app.directories.dir = dir; 410 - 411 - try app.notification.writeInfo(.ChangedDir); 412 - 413 - app.directories.clearEntries(); 414 - app.directories.populateEntries("") catch |err| { 415 - switch (err) { 416 - error.AccessDenied => try app.notification.writeErr(.PermissionDenied), 417 - else => try app.notification.writeErr(.UnknownError), 418 - } 419 - }; 420 - app.directories.history.reset(); 421 - } else |err| { 422 - switch (err) { 423 - error.AccessDenied => try app.notification.writeErr(.PermissionDenied), 424 - error.FileNotFound => try app.notification.writeErr(.IncorrectPath), 425 - error.NotDir => try app.notification.writeErr(.IncorrectPath), 426 - else => try app.notification.writeErr(.UnknownError), 427 - } 428 - } 429 - 430 - app.text_input.clearAndFree(); 431 - }, 432 - .command => { 433 - const command = inputToSlice(app); 434 194 435 195 supported: { 436 196 if (std.mem.eql(u8, command, ":q")) { ··· 448 208 break :supported; 449 209 } 450 210 451 - // TODO(06-01-25): Add a confirmation for this. 211 + if (std.mem.startsWith(u8, command, ":cd ")) { 212 + try commands.cd(app, command[":cd ".len..]); 213 + break :supported; 214 + } 215 + 452 216 if (std.mem.eql(u8, command, ":empty_trash")) { 453 217 try commands.emptyTrash(app); 454 218 break :supported; 455 219 } 456 220 457 - app.text_input.clearAndFree(); 221 + if (std.mem.eql(u8, command, ":h")) { 222 + app.state = .help_menu; 223 + break :supported; 224 + } 225 + 226 + if (std.mem.eql(u8, command, ":extract")) { 227 + try events.extractArchive(app); 228 + break :supported; 229 + } 230 + 458 231 try app.text_input.insertSliceAtCursor(":UnsupportedCommand"); 459 232 } 233 + 234 + app.command_history.cursor = null; 460 235 }, 461 236 else => {}, 462 237 } 463 - app.state = .normal; 464 - app.directories.entries.selected = selected; 238 + 239 + if (app.state != .help_menu) app.state = .normal; 240 + }, 241 + Key.up => { 242 + if (app.state == .command) { 243 + if (app.command_history.previous()) |command| { 244 + app.text_input.clearAndFree(); 245 + app.text_input.insertSliceAtCursor(command) catch |err| { 246 + const message = try std.fmt.allocPrint(app.alloc, "Failed to get previous command history - {}.", .{err}); 247 + defer app.alloc.free(message); 248 + app.notification.write(message, .err) catch {}; 249 + if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 250 + }; 251 + } 252 + } 253 + }, 254 + Key.down => { 255 + if (app.state == .command) { 256 + app.text_input.clearAndFree(); 257 + if (app.command_history.next()) |command| { 258 + app.text_input.insertSliceAtCursor(command) catch |err| { 259 + const message = try std.fmt.allocPrint(app.alloc, "Failed to get next command history - {}.", .{err}); 260 + defer app.alloc.free(message); 261 + app.notification.write(message, .err) catch {}; 262 + if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 263 + }; 264 + } else { 265 + app.text_input.insertSliceAtCursor(":") catch |err| { 266 + const message = try std.fmt.allocPrint(app.alloc, "Failed to get next command history - {}.", .{err}); 267 + defer app.alloc.free(message); 268 + app.notification.write(message, .err) catch {}; 269 + if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 270 + }; 271 + } 272 + } 465 273 }, 466 274 else => { 467 275 try app.text_input.update(.{ .key_press = key }); 468 276 469 277 switch (app.state) { 470 278 .fuzzy => { 471 - app.directories.clearEntries(); 472 - const fuzzy = inputToSlice(app); 473 - app.directories.populateEntries(fuzzy) catch |err| { 474 - switch (err) { 475 - error.AccessDenied => try app.notification.writeErr(.PermissionDenied), 476 - else => try app.notification.writeErr(.UnknownError), 477 - } 478 - }; 279 + const fuzzy = app.readInput(); 280 + try app.repopulateDirectory(fuzzy); 479 281 }, 480 282 .command => { 481 - const command = inputToSlice(app); 283 + const command = app.readInput(); 482 284 if (!std.mem.startsWith(u8, command, ":")) { 483 285 app.text_input.clearAndFree(); 484 - try app.text_input.insertSliceAtCursor(":"); 286 + app.text_input.insertSliceAtCursor(":") catch |err| { 287 + app.state = .normal; 288 + 289 + const message = try std.fmt.allocPrint(app.alloc, "An input error occurred while attempting to enter a command - {}.", .{err}); 290 + defer app.alloc.free(message); 291 + app.notification.write(message, .err) catch {}; 292 + if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 293 + }; 485 294 } 486 295 }, 487 296 else => {}, ··· 489 298 }, 490 299 } 491 300 }, 492 - .winsize => |ws| try app.vx.resize(app.alloc, app.tty.anyWriter(), ws), 301 + .image_ready => {}, 302 + .notification => {}, 303 + .winsize => |ws| try app.vx.resize(app.alloc, app.tty.writer(), ws), 304 + } 305 + } 306 + 307 + pub fn handleHelpMenuEvent(app: *App, event: App.Event) !void { 308 + switch (event) { 309 + .key_press => |key| { 310 + switch (key.codepoint) { 311 + Key.escape, 'q' => app.state = .normal, 312 + 'j', Key.down => app.help_menu.next(), 313 + 'k', Key.up => app.help_menu.previous(), 314 + else => {}, 315 + } 316 + }, 317 + .image_ready => {}, 318 + .notification => {}, 319 + .winsize => |ws| try app.vx.resize(app.alloc, app.tty.writer(), ws), 493 320 } 494 321 }
+663
src/events.zig
··· 1 + const std = @import("std"); 2 + 3 + const vaxis = @import("vaxis"); 4 + const zuid = @import("zuid"); 5 + 6 + const App = @import("./app.zig"); 7 + const Archive = @import("./archive.zig"); 8 + const environment = @import("./environment.zig"); 9 + const path_utils = @import("./path_utils.zig"); 10 + const Preview = @import("./preview.zig"); 11 + 12 + const config = &@import("./config.zig").config; 13 + 14 + pub fn delete(app: *App) error{OutOfMemory}!void { 15 + var message: ?[]const u8 = null; 16 + defer if (message) |msg| app.alloc.free(msg); 17 + 18 + const entry = (app.directories.getSelected() catch { 19 + app.notification.write("Can not to delete item - no item selected.", .warn) catch {}; 20 + return; 21 + }) orelse return; 22 + const clean_name = path_utils.getCleanName(entry); 23 + 24 + var prev_path_buf: [std.fs.max_path_bytes]u8 = undefined; 25 + const prev_path = app.directories.dir.realpath(clean_name, &prev_path_buf) catch { 26 + message = try std.fmt.allocPrint(app.alloc, "Failed to delete '{s}' - unable to retrieve absolute path.", .{entry.name}); 27 + app.notification.write(message.?, .err) catch {}; 28 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 29 + return; 30 + }; 31 + const prev_path_alloc = try app.alloc.dupe(u8, prev_path); 32 + 33 + var trash_dir = dir: { 34 + notfound: { 35 + break :dir (config.trashDir() catch break :notfound) orelse break :notfound; 36 + } 37 + app.alloc.free(prev_path_alloc); 38 + message = try std.fmt.allocPrint(app.alloc, "Failed to delete '{s}' - unable to retrieve trash directory.", .{entry.name}); 39 + app.notification.write(message.?, .err) catch {}; 40 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 41 + return; 42 + }; 43 + defer trash_dir.close(); 44 + 45 + var trash_dir_path_buf: [std.fs.max_path_bytes]u8 = undefined; 46 + const trash_dir_path = trash_dir.realpath(".", &trash_dir_path_buf) catch { 47 + message = try std.fmt.allocPrint(app.alloc, "Failed to delete '{s}' - unable to retrieve absolute path for trash directory.", .{entry.name}); 48 + app.notification.write(message.?, .err) catch {}; 49 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 50 + return; 51 + }; 52 + 53 + if (std.mem.eql(u8, prev_path_alloc, trash_dir_path)) { 54 + app.notification.write("Can not delete trash directory.", .warn) catch {}; 55 + app.alloc.free(prev_path_alloc); 56 + return; 57 + } 58 + 59 + const tmp_path = try std.fmt.allocPrint(app.alloc, "{s}/{s}-{f}", .{ trash_dir_path, clean_name, zuid.new.v4() }); 60 + if (app.directories.dir.rename(clean_name, tmp_path)) { 61 + if (app.actions.push(.{ 62 + .delete = .{ .prev_path = prev_path_alloc, .new_path = tmp_path }, 63 + })) |prev_elem| { 64 + app.alloc.free(prev_elem.delete.prev_path); 65 + app.alloc.free(prev_elem.delete.new_path); 66 + } 67 + message = try std.fmt.allocPrint(app.alloc, "Deleted '{s}'.", .{entry.name}); 68 + app.notification.write(message.?, .info) catch {}; 69 + 70 + app.directories.removeSelected(); 71 + Preview.loadPreviewForCurrentEntry(app) catch {}; 72 + } else |err| { 73 + app.alloc.free(prev_path_alloc); 74 + app.alloc.free(tmp_path); 75 + 76 + message = try std.fmt.allocPrint(app.alloc, "Failed to delete '{s}' - {}.", .{ entry.name, err }); 77 + app.notification.write(message.?, .err) catch {}; 78 + } 79 + } 80 + 81 + pub fn rename(app: *App) error{OutOfMemory}!void { 82 + var message: ?[]const u8 = null; 83 + defer if (message) |msg| app.alloc.free(msg); 84 + 85 + const entry = (app.directories.getSelected() catch { 86 + app.notification.write("Can not to rename item - no item selected.", .warn) catch {}; 87 + return; 88 + }) orelse return; 89 + 90 + var dir_prefix_buf: [std.fs.max_path_bytes]u8 = undefined; 91 + const dir_prefix = app.directories.dir.realpath(".", &dir_prefix_buf) catch { 92 + message = try std.fmt.allocPrint(app.alloc, "Failed to rename '{s}' - unable to retrieve absolute path.", .{entry.name}); 93 + app.notification.write(message.?, .err) catch {}; 94 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 95 + return; 96 + }; 97 + 98 + const new_path = try app.text_input.toOwnedSlice(); 99 + defer app.alloc.free(new_path); 100 + 101 + if (environment.fileExists(app.directories.dir, new_path)) { 102 + message = try std.fmt.allocPrint(app.alloc, "Can not rename file - '{s}' already exists.", .{new_path}); 103 + app.notification.write(message.?, .warn) catch {}; 104 + } else { 105 + app.directories.dir.rename(entry.name, new_path) catch |err| { 106 + message = try std.fmt.allocPrint(app.alloc, "Failed to rename '{s}' - {}.", .{ new_path, err }); 107 + app.notification.write(message.?, .err) catch {}; 108 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 109 + return; 110 + }; 111 + 112 + if (app.actions.push(.{ 113 + .rename = .{ 114 + .prev_path = try std.fs.path.join(app.alloc, &.{ dir_prefix, entry.name }), 115 + .new_path = try std.fs.path.join(app.alloc, &.{ dir_prefix, new_path }), 116 + }, 117 + })) |prev_elem| { 118 + app.alloc.free(prev_elem.rename.prev_path); 119 + app.alloc.free(prev_elem.rename.new_path); 120 + } 121 + 122 + app.directories.clearEntries(); 123 + app.directories.populateEntries("") catch |err| { 124 + const m = try std.fmt.allocPrint(app.alloc, "Failed to read directory entries - {}.", .{err}); 125 + defer app.alloc.free(m); 126 + app.notification.write(m, .err) catch {}; 127 + if (app.file_logger) |file_logger| file_logger.write(m, .err) catch {}; 128 + }; 129 + 130 + const target_name = if (entry.kind == .directory) 131 + try std.fmt.allocPrint(app.alloc, "{s}/", .{new_path}) 132 + else 133 + new_path; 134 + defer if (entry.kind == .directory) app.alloc.free(target_name); 135 + 136 + for (app.directories.entries.all(), 0..) |e, i| { 137 + if (std.mem.eql(u8, e.name, target_name)) { 138 + app.directories.entries.selected = i; 139 + break; 140 + } 141 + } 142 + 143 + // No need to revalidate cache as we're viewing the same file 144 + Preview.loadPreviewForCurrentEntry(app) catch |err| { 145 + if (app.file_logger) |file_logger| { 146 + const msg = std.fmt.allocPrint(app.alloc, "Failed to load preview after repopulate: {}", .{err}) catch return; 147 + defer app.alloc.free(msg); 148 + file_logger.write(msg, .err) catch {}; 149 + } 150 + }; 151 + 152 + message = try std.fmt.allocPrint(app.alloc, "Renamed '{s}' to '{s}'.", .{ entry.name, new_path }); 153 + app.notification.write(message.?, .info) catch {}; 154 + } 155 + } 156 + 157 + pub fn forceDelete(app: *App) error{OutOfMemory}!void { 158 + const entry = (app.directories.getSelected() catch { 159 + app.notification.write("Can not force delete item - no item selected.", .warn) catch {}; 160 + return; 161 + }) orelse return; 162 + 163 + app.directories.dir.deleteTree(entry.name) catch |err| { 164 + const error_message = try std.fmt.allocPrint(app.alloc, "Failed to force delete '{s}' - {}.", .{ entry.name, err }); 165 + app.notification.write(error_message, .err) catch {}; 166 + return; 167 + }; 168 + 169 + app.directories.removeSelected(); 170 + } 171 + 172 + pub fn toggleHiddenFiles(app: *App) error{OutOfMemory}!void { 173 + config.show_hidden = !config.show_hidden; 174 + 175 + try app.repopulateDirectory(""); 176 + app.text_input.clearAndFree(); 177 + } 178 + 179 + pub fn yank(app: *App) error{OutOfMemory}!void { 180 + var message: ?[]const u8 = null; 181 + defer if (message) |msg| app.alloc.free(msg); 182 + 183 + if (app.yanked) |yanked| { 184 + app.alloc.free(yanked.dir); 185 + app.alloc.free(yanked.entry.name); 186 + } 187 + 188 + app.yanked = lbl: { 189 + const entry = (app.directories.getSelected() catch { 190 + app.notification.write("Can not yank item - no item selected.", .warn) catch {}; 191 + break :lbl null; 192 + }) orelse break :lbl null; 193 + 194 + switch (entry.kind) { 195 + .file, .directory, .sym_link => { 196 + break :lbl .{ 197 + .dir = try app.alloc.dupe(u8, app.directories.fullPath(".") catch { 198 + message = try std.fmt.allocPrint( 199 + app.alloc, 200 + "Failed to yank '{s}' - unable to retrieve directory path.", 201 + .{entry.name}, 202 + ); 203 + app.notification.write(message.?, .err) catch {}; 204 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 205 + break :lbl null; 206 + }), 207 + .entry = .{ 208 + .kind = entry.kind, 209 + .name = try app.alloc.dupe(u8, entry.name), 210 + }, 211 + }; 212 + }, 213 + else => { 214 + message = try std.fmt.allocPrint(app.alloc, "Can not yank '{s}' - unsupported file type '{s}'.", .{ entry.name, @tagName(entry.kind) }); 215 + app.notification.write(message.?, .warn) catch {}; 216 + break :lbl null; 217 + }, 218 + } 219 + }; 220 + 221 + if (app.yanked) |y| { 222 + message = try std.fmt.allocPrint(app.alloc, "Yanked '{s}'.", .{y.entry.name}); 223 + app.notification.write(message.?, .info) catch {}; 224 + } 225 + } 226 + 227 + pub fn paste(app: *App) error{ OutOfMemory, NoSpaceLeft }!void { 228 + var message: ?[]const u8 = null; 229 + defer if (message) |msg| app.alloc.free(msg); 230 + 231 + const yanked = if (app.yanked) |y| y else return; 232 + 233 + var new_path_buf: [std.fs.max_path_bytes]u8 = undefined; 234 + const new_path_res = environment.checkDuplicatePath(&new_path_buf, app.directories.dir, yanked.entry.name) catch { 235 + message = try std.fmt.allocPrint(app.alloc, "Failed to copy '{s}' - path too long.", .{yanked.entry.name}); 236 + app.notification.write(message.?, .err) catch {}; 237 + return; 238 + }; 239 + 240 + switch (yanked.entry.kind) { 241 + .directory => { 242 + var source_dir = std.fs.openDirAbsolute(yanked.dir, .{ .iterate = true }) catch { 243 + message = try std.fmt.allocPrint(app.alloc, "Failed to copy '{s}' - unable to open directory '{s}'.", .{ yanked.entry.name, yanked.dir }); 244 + app.notification.write(message.?, .err) catch {}; 245 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 246 + return; 247 + }; 248 + defer source_dir.close(); 249 + 250 + var selected_dir = source_dir.openDir(yanked.entry.name, .{ .iterate = true }) catch { 251 + message = try std.fmt.allocPrint(app.alloc, "Failed to copy '{s}' - unable to open directory '{s}'.", .{ yanked.entry.name, yanked.entry.name }); 252 + app.notification.write(message.?, .err) catch {}; 253 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 254 + return; 255 + }; 256 + defer selected_dir.close(); 257 + 258 + var walker = selected_dir.walk(app.alloc) catch |err| { 259 + message = try std.fmt.allocPrint(app.alloc, "Failed to copy '{s}' - unable to walk directory tree due to {}.", .{ yanked.entry.name, err }); 260 + app.notification.write(message.?, .err) catch {}; 261 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 262 + return; 263 + }; 264 + defer walker.deinit(); 265 + 266 + // Make initial dir. 267 + app.directories.dir.makeDir(new_path_res.path) catch |err| { 268 + message = try std.fmt.allocPrint(app.alloc, "Failed to copy '{s}' - unable to create new directory due to {}.", .{ yanked.entry.name, err }); 269 + app.notification.write(message.?, .err) catch {}; 270 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 271 + return; 272 + }; 273 + 274 + var errored = false; 275 + var inner_path_buf: [std.fs.max_path_bytes]u8 = undefined; 276 + while (walker.next() catch |err| { 277 + message = try std.fmt.allocPrint(app.alloc, "Failed to copy one or more files - {}. A partial copy may have taken place.", .{err}); 278 + app.notification.write(message.?, .err) catch {}; 279 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 280 + return; 281 + }) |entry| { 282 + const path = try std.fmt.bufPrint(&inner_path_buf, "{s}{s}{s}", .{ new_path_res.path, std.fs.path.sep_str, entry.path }); 283 + switch (entry.kind) { 284 + .directory => { 285 + app.directories.dir.makeDir(path) catch { 286 + message = try std.fmt.allocPrint(app.alloc, "Failed to copy '{s}' - unable to create containing directory '{s}'.", .{ entry.basename, path }); 287 + app.notification.write(message.?, .err) catch {}; 288 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 289 + errored = true; 290 + }; 291 + }, 292 + .file, .sym_link => { 293 + entry.dir.copyFile(entry.basename, app.directories.dir, path, .{}) catch |err| switch (err) { 294 + error.FileNotFound => { 295 + message = try std.fmt.allocPrint(app.alloc, "Failed to copy '{s}' - the original file was deleted or moved.", .{entry.path}); 296 + app.notification.write(message.?, .err) catch {}; 297 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 298 + errored = true; 299 + }, 300 + else => { 301 + message = try std.fmt.allocPrint(app.alloc, "Failed to copy '{s}' - {}.", .{ entry.path, err }); 302 + app.notification.write(message.?, .err) catch {}; 303 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 304 + errored = true; 305 + }, 306 + }; 307 + }, 308 + else => { 309 + message = try std.fmt.allocPrint(app.alloc, "Failed to copy '{s}' - unsupported file type '{s}'.", .{ entry.path, @tagName(entry.kind) }); 310 + app.notification.write(message.?, .err) catch {}; 311 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 312 + errored = true; 313 + }, 314 + } 315 + } 316 + 317 + if (errored) { 318 + app.notification.write("Failed to copy some items, check the log file for more details.", .err) catch {}; 319 + } else { 320 + message = try std.fmt.allocPrint(app.alloc, "Copied '{s}'.", .{yanked.entry.name}); 321 + app.notification.write(message.?, .info) catch {}; 322 + } 323 + }, 324 + .file, .sym_link => { 325 + var source_dir = std.fs.openDirAbsolute(yanked.dir, .{ .iterate = true }) catch { 326 + message = try std.fmt.allocPrint(app.alloc, "Failed to copy '{s}' - unable to open directory '{s}'.", .{ yanked.entry.name, yanked.dir }); 327 + app.notification.write(message.?, .err) catch {}; 328 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 329 + return; 330 + }; 331 + defer source_dir.close(); 332 + 333 + std.fs.Dir.copyFile( 334 + source_dir, 335 + yanked.entry.name, 336 + app.directories.dir, 337 + new_path_res.path, 338 + .{}, 339 + ) catch |err| switch (err) { 340 + error.FileNotFound => { 341 + message = try std.fmt.allocPrint(app.alloc, "Failed to copy '{s}' - the original file was deleted or moved.", .{yanked.entry.name}); 342 + app.notification.write(message.?, .err) catch {}; 343 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 344 + return; 345 + }, 346 + else => { 347 + message = try std.fmt.allocPrint(app.alloc, "Failed to copy '{s}' - {}.", .{ yanked.entry.name, err }); 348 + app.notification.write(message.?, .err) catch {}; 349 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 350 + return; 351 + }, 352 + }; 353 + 354 + message = try std.fmt.allocPrint(app.alloc, "Copied '{s}'.", .{yanked.entry.name}); 355 + app.notification.write(message.?, .info) catch {}; 356 + }, 357 + else => { 358 + message = try std.fmt.allocPrint(app.alloc, "Can not copy '{s}' - unsupported file type '{s}'.", .{ yanked.entry.name, @tagName(yanked.entry.kind) }); 359 + app.notification.write(message.?, .warn) catch {}; 360 + return; 361 + }, 362 + } 363 + 364 + // Append action to undo history. 365 + var new_path_abs_buf: [std.fs.max_path_bytes]u8 = undefined; 366 + const new_path_abs = app.directories.dir.realpath(new_path_res.path, &new_path_abs_buf) catch { 367 + message = try std.fmt.allocPrint( 368 + app.alloc, 369 + "Failed to push copy action for '{s}' to undo history - unable to retrieve absolute directory path for '{s}'. This action will not be able to be undone via the `undo` keybind.", 370 + .{ new_path_res.path, yanked.entry.name }, 371 + ); 372 + app.notification.write(message.?, .err) catch {}; 373 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 374 + return; 375 + }; 376 + 377 + if (app.actions.push(.{ 378 + .paste = try app.alloc.dupe(u8, new_path_abs), 379 + })) |prev_elem| { 380 + app.alloc.free(prev_elem.delete.prev_path); 381 + app.alloc.free(prev_elem.delete.new_path); 382 + } 383 + 384 + try app.repopulateDirectory(""); 385 + app.text_input.clearAndFree(); 386 + } 387 + 388 + pub fn traverseLeft(app: *App) error{OutOfMemory}!void { 389 + app.text_input.clearAndFree(); 390 + 391 + const dir = app.directories.dir.openDir("../", .{ .iterate = true }) catch |err| { 392 + const message = try std.fmt.allocPrint(app.alloc, "Failed to read directory entries - {}.", .{err}); 393 + defer app.alloc.free(message); 394 + app.notification.write(message, .err) catch {}; 395 + if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 396 + return; 397 + }; 398 + 399 + app.directories.dir.close(); 400 + app.directories.dir = dir; 401 + 402 + try app.repopulateDirectory(""); 403 + app.text_input.clearAndFree(); 404 + 405 + if (app.directories.history.pop()) |history| { 406 + if (history < app.directories.entries.len()) { 407 + app.directories.entries.selected = history; 408 + } 409 + } 410 + } 411 + 412 + pub fn traverseRight(app: *App) !void { 413 + var message: ?[]const u8 = null; 414 + defer if (message) |msg| app.alloc.free(msg); 415 + 416 + const entry = (app.directories.getSelected() catch { 417 + app.notification.write("Can not rename item - no item selected.", .warn) catch {}; 418 + return; 419 + }) orelse return; 420 + 421 + switch (entry.kind) { 422 + .directory => { 423 + app.text_input.clearAndFree(); 424 + 425 + const dir = app.directories.dir.openDir(entry.name, .{ .iterate = true }) catch |err| { 426 + message = try std.fmt.allocPrint(app.alloc, "Failed to read directory entries - {}.", .{err}); 427 + app.notification.write(message.?, .err) catch {}; 428 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 429 + return; 430 + }; 431 + 432 + app.directories.dir.close(); 433 + app.directories.dir = dir; 434 + _ = app.directories.history.push(app.directories.entries.selected); 435 + try app.repopulateDirectory(""); 436 + app.text_input.clearAndFree(); 437 + }, 438 + .file => { 439 + if (environment.getEditor()) |editor| { 440 + try app.vx.exitAltScreen(app.tty.writer()); 441 + try app.vx.resetState(app.tty.writer()); 442 + app.loop.stop(); 443 + 444 + environment.openFile(app.alloc, app.directories.dir, entry.name, editor) catch |err| { 445 + message = try std.fmt.allocPrint(app.alloc, "Failed to open file '{s}' - {}.", .{ entry.name, err }); 446 + app.notification.write(message.?, .err) catch {}; 447 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 448 + }; 449 + 450 + try app.loop.start(); 451 + try app.vx.enterAltScreen(app.tty.writer()); 452 + try app.vx.enableDetectedFeatures(app.tty.writer()); 453 + app.vx.queueRefresh(); 454 + } else { 455 + app.notification.write("Can not open file - $EDITOR not set.", .warn) catch {}; 456 + } 457 + }, 458 + else => {}, 459 + } 460 + } 461 + 462 + pub fn createNewDir(app: *App) error{OutOfMemory}!void { 463 + var message: ?[]const u8 = null; 464 + defer if (message) |msg| app.alloc.free(msg); 465 + 466 + const dir = try app.text_input.toOwnedSlice(); 467 + defer app.alloc.free(dir); 468 + 469 + app.directories.dir.makeDir(dir) catch |err| { 470 + message = try std.fmt.allocPrint(app.alloc, "Failed to create directory '{s}' - {}", .{ dir, err }); 471 + app.notification.write(message.?, .err) catch {}; 472 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 473 + return; 474 + }; 475 + 476 + try app.repopulateDirectory(""); 477 + 478 + message = try std.fmt.allocPrint(app.alloc, "Created new directory '{s}'.", .{dir}); 479 + app.notification.write(message.?, .info) catch {}; 480 + } 481 + 482 + pub fn createNewFile(app: *App) error{OutOfMemory}!void { 483 + var message: ?[]const u8 = null; 484 + defer if (message) |msg| app.alloc.free(msg); 485 + 486 + const file = try app.text_input.toOwnedSlice(); 487 + defer app.alloc.free(file); 488 + 489 + if (environment.fileExists(app.directories.dir, file)) { 490 + message = try std.fmt.allocPrint(app.alloc, "Can not create file - '{s}' already exists.", .{file}); 491 + app.notification.write(message.?, .warn) catch {}; 492 + } else { 493 + _ = app.directories.dir.createFile(file, .{}) catch |err| { 494 + message = try std.fmt.allocPrint(app.alloc, "Failed to create file '{s}' - {}", .{ file, err }); 495 + app.notification.write(message.?, .err) catch {}; 496 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 497 + return; 498 + }; 499 + 500 + try app.repopulateDirectory(""); 501 + 502 + message = try std.fmt.allocPrint(app.alloc, "Created new file '{s}'.", .{file}); 503 + app.notification.write(message.?, .info) catch {}; 504 + } 505 + } 506 + 507 + pub fn undo(app: *App) error{OutOfMemory}!void { 508 + var message: ?[]const u8 = null; 509 + defer if (message) |msg| app.alloc.free(msg); 510 + 511 + const action = app.actions.pop() orelse { 512 + app.notification.write("There is nothing to undo.", .info) catch {}; 513 + return; 514 + }; 515 + 516 + switch (action) { 517 + .delete => |a| { 518 + defer app.alloc.free(a.new_path); 519 + defer app.alloc.free(a.prev_path); 520 + 521 + var new_path_buf: [std.fs.max_path_bytes]u8 = undefined; 522 + const new_path_res = environment.checkDuplicatePath(&new_path_buf, app.directories.dir, a.prev_path) catch { 523 + message = try std.fmt.allocPrint(app.alloc, "Failed to undo delete '{s}' - path too long.", .{a.prev_path}); 524 + app.notification.write(message.?, .err) catch {}; 525 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 526 + return; 527 + }; 528 + 529 + app.directories.dir.rename(a.new_path, new_path_res.path) catch |err| { 530 + message = try std.fmt.allocPrint(app.alloc, "Failed to undo delete for '{s}' - {}.", .{ a.prev_path, err }); 531 + app.notification.write(message.?, .err) catch {}; 532 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 533 + return; 534 + }; 535 + 536 + message = try std.fmt.allocPrint(app.alloc, "Restored '{s}' as '{s}'.", .{ a.prev_path, new_path_res.path }); 537 + app.notification.write(message.?, .info) catch {}; 538 + }, 539 + .rename => |a| { 540 + defer app.alloc.free(a.new_path); 541 + defer app.alloc.free(a.prev_path); 542 + 543 + var new_path_buf: [std.fs.max_path_bytes]u8 = undefined; 544 + const new_path_res = environment.checkDuplicatePath(&new_path_buf, app.directories.dir, a.prev_path) catch { 545 + message = try std.fmt.allocPrint(app.alloc, "Failed to undo rename '{s}' - path too long.", .{a.prev_path}); 546 + app.notification.write(message.?, .err) catch {}; 547 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 548 + return; 549 + }; 550 + 551 + app.directories.dir.rename(a.new_path, new_path_res.path) catch |err| { 552 + message = try std.fmt.allocPrint(app.alloc, "Failed to undo rename for '{s}' - {}.", .{ a.new_path, err }); 553 + app.notification.write(message.?, .err) catch {}; 554 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 555 + return; 556 + }; 557 + 558 + message = try std.fmt.allocPrint(app.alloc, "Reverted renaming of '{s}', now '{s}'.", .{ a.new_path, new_path_res.path }); 559 + app.notification.write(message.?, .info) catch {}; 560 + }, 561 + .paste => |path| { 562 + defer app.alloc.free(path); 563 + 564 + app.directories.dir.deleteTree(path) catch |err| { 565 + message = try std.fmt.allocPrint(app.alloc, "Failed to delete '{s}' - {}.", .{ path, err }); 566 + app.notification.write(message.?, .err) catch {}; 567 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 568 + return; 569 + }; 570 + }, 571 + } 572 + 573 + try app.repopulateDirectory(""); 574 + app.text_input.clearAndFree(); 575 + } 576 + 577 + pub fn extractArchive(app: *App) error{OutOfMemory}!void { 578 + var message: ?[]const u8 = null; 579 + defer if (message) |msg| app.alloc.free(msg); 580 + 581 + const entry = (app.directories.getSelected() catch { 582 + app.notification.write("Can not extract - no item selected.", .warn) catch {}; 583 + return; 584 + }) orelse return; 585 + 586 + const archive_type = Archive.ArchiveType.fromPath(entry.name) orelse { 587 + app.notification.write("Not an archive file.", .warn) catch {}; 588 + return; 589 + }; 590 + 591 + const extract_dir_name = Archive.getExtractDirName(entry.name); 592 + 593 + if (environment.fileExists(app.directories.dir, extract_dir_name)) { 594 + message = try std.fmt.allocPrint(app.alloc, "Can not extract file(s) - '{s}' already exists.", .{extract_dir_name}); 595 + app.notification.write(message.?, .warn) catch {}; 596 + return; 597 + } 598 + 599 + var dest_dir = app.directories.dir.makeOpenPath(extract_dir_name, .{}) catch |err| { 600 + message = try std.fmt.allocPrint(app.alloc, "Failed to extract archive '{s}' - {}.", .{ extract_dir_name, err }); 601 + app.notification.write(message.?, .err) catch {}; 602 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 603 + return; 604 + }; 605 + defer dest_dir.close(); 606 + 607 + const archive_file = app.directories.dir.openFile(entry.name, .{}) catch |err| { 608 + message = try std.fmt.allocPrint( 609 + app.alloc, 610 + "Failed to open archive '{s}' - {}.", 611 + .{ entry.name, err }, 612 + ); 613 + app.notification.write(message.?, .err) catch {}; 614 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 615 + 616 + if (!config.keep_partial_extraction) { 617 + app.directories.dir.deleteTree(extract_dir_name) catch {}; 618 + } 619 + return; 620 + }; 621 + defer archive_file.close(); 622 + 623 + const result = Archive.extractArchive( 624 + app.alloc, 625 + archive_file, 626 + archive_type, 627 + dest_dir, 628 + app.file_logger, 629 + ) catch |err| { 630 + message = try std.fmt.allocPrint( 631 + app.alloc, 632 + "Failed to extract '{s}' - {s}.", 633 + .{ entry.name, @errorName(err) }, 634 + ); 635 + app.notification.write(message.?, .err) catch {}; 636 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 637 + 638 + if (!config.keep_partial_extraction) { 639 + app.directories.dir.deleteTree(extract_dir_name) catch {}; 640 + } 641 + return; 642 + }; 643 + 644 + if (result.files_skipped > 0) { 645 + message = try std.fmt.allocPrint( 646 + app.alloc, 647 + "Extracted {d} files, {d} directories to './{s}{s}'. Failed to extract {d} files, check the log file for more details.", 648 + .{ result.files_extracted, result.dirs_created, std.fs.path.sep_str, extract_dir_name, result.files_skipped }, 649 + ); 650 + app.notification.write(message.?, .err) catch {}; 651 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 652 + } else { 653 + message = try std.fmt.allocPrint( 654 + app.alloc, 655 + "Extracted {d} files, {d} directories to './{s}{s}'.", 656 + .{ result.files_extracted, result.dirs_created, std.fs.path.sep_str, extract_dir_name }, 657 + ); 658 + app.notification.write(message.?, .info) catch {}; 659 + if (app.file_logger) |file_logger| file_logger.write(message.?, .info) catch {}; 660 + } 661 + 662 + try app.repopulateDirectory(""); 663 + }
+61
src/file_logger.zig
··· 1 + const std = @import("std"); 2 + 3 + const environment = @import("environment.zig"); 4 + 5 + const config = &@import("./config.zig").config; 6 + 7 + pub const LOG_PATH = "log.txt"; 8 + 9 + const LogLevel = enum { 10 + err, 11 + info, 12 + warn, 13 + 14 + pub fn toString(level: LogLevel) []const u8 { 15 + return switch (level) { 16 + .err => "ERROR", 17 + .info => "INFO", 18 + .warn => "WARN", 19 + }; 20 + } 21 + }; 22 + 23 + const FileLogger = @This(); 24 + 25 + dir: std.fs.Dir, 26 + file: ?std.fs.File, 27 + 28 + pub fn init(dir: std.fs.Dir) FileLogger { 29 + const file = dir.createFile(LOG_PATH, .{ .truncate = false, .read = true }) catch |err| { 30 + std.log.err("Failed to create/open log file: {s}", .{@errorName(err)}); 31 + return .{ .dir = dir, .file = null }; 32 + }; 33 + 34 + return .{ .dir = dir, .file = file }; 35 + } 36 + 37 + pub fn deinit(self: FileLogger) void { 38 + if (self.file) |file| { 39 + var f = file; 40 + f.close(); 41 + } 42 + } 43 + 44 + pub fn write(self: FileLogger, msg: []const u8, level: LogLevel) !void { 45 + const file = if (self.file) |file| file else return error.NoLogFile; 46 + 47 + if (try file.tryLock(.exclusive)) { 48 + defer file.unlock(); 49 + 50 + var buffer: [1024]u8 = undefined; 51 + var file_writer_impl = file.writer(&buffer); 52 + const file_writer = &file_writer_impl.interface; 53 + try file_writer_impl.seekTo(file.getEndPos() catch 0); 54 + 55 + try file_writer.print( 56 + "({d}) {s}: {s}\n", 57 + .{ std.time.timestamp(), LogLevel.toString(level), msg }, 58 + ); 59 + try file_writer.flush(); 60 + } 61 + }
+5 -6
src/git.zig
··· 2 2 3 3 /// Callers owns memory returned. 4 4 pub fn getGitBranch(alloc: std.mem.Allocator, dir: std.fs.Dir) !?[]const u8 { 5 - var file = dir.openFile(".git/HEAD", .{}) catch return null; 5 + var file = try dir.openFile(".git/HEAD", .{}); 6 6 defer file.close(); 7 7 8 8 var buf: [1024]u8 = undefined; 9 - const bytes = file.readAll(&buf) catch return null; 9 + const bytes = try file.readAll(&buf); 10 + if (bytes == 0) return null; 10 11 11 - var it = std.mem.splitBackwardsSequence(u8, buf[0..bytes], "/"); 12 - const branch = it.next() orelse return null; 13 - if (std.mem.eql(u8, branch, "")) return null; 12 + const preamble = "ref: refs/heads/"; 14 13 15 - return try alloc.dupe(u8, branch); 14 + return try alloc.dupe(u8, buf[preamble.len..]); 16 15 }
+100
src/image.zig
··· 1 + const std = @import("std"); 2 + 3 + const vaxis = @import("vaxis"); 4 + 5 + const App = @import("app.zig"); 6 + 7 + pub const Cache = struct { 8 + mutex: std.Thread.Mutex = .{}, 9 + cache: std.StringHashMap(Image), 10 + }; 11 + 12 + const Status = enum { 13 + ready, 14 + processing, 15 + failed, 16 + }; 17 + 18 + const Image = @This(); 19 + 20 + ///Only use on first transmission. Subsequent draws should use 21 + ///`Image.image`. 22 + data: ?vaxis.zigimg.Image = null, 23 + image: ?vaxis.Image = null, 24 + path: ?[]const u8 = null, 25 + status: Status = .processing, 26 + 27 + pub fn deinit(self: Image, alloc: std.mem.Allocator, vx: vaxis.Vaxis, tty: *vaxis.Tty) void { 28 + if (self.image) |image| { 29 + vx.freeImage(tty.writer(), image.id); 30 + } 31 + if (self.data) |data| { 32 + var d = data; 33 + d.deinit(alloc); 34 + } 35 + if (self.path) |path| alloc.free(path); 36 + } 37 + 38 + pub fn processImage(alloc: std.mem.Allocator, app: *App, path: []const u8) error{ Unsupported, OutOfMemory }!void { 39 + app.images.cache.put(path, .{ .path = path, .status = .processing }) catch { 40 + const message = try std.fmt.allocPrint(alloc, "Failed to load image '{s}' - error occurred while attempting to add image to cache.", .{path}); 41 + defer alloc.free(message); 42 + app.notification.write(message, .err) catch {}; 43 + if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 44 + return error.Unsupported; 45 + }; 46 + 47 + const load_img_thread = std.Thread.spawn(.{}, loadImage, .{ 48 + alloc, 49 + app, 50 + path, 51 + }) catch { 52 + app.images.mutex.lock(); 53 + if (app.images.cache.getPtr(path)) |entry| { 54 + entry.status = .failed; 55 + } 56 + app.images.mutex.unlock(); 57 + 58 + const message = try std.fmt.allocPrint(alloc, "Failed to load image '{s}' - error occurred while attempting to spawn processing thread.", .{path}); 59 + defer alloc.free(message); 60 + app.notification.write(message, .err) catch {}; 61 + if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 62 + 63 + return error.Unsupported; 64 + }; 65 + load_img_thread.detach(); 66 + } 67 + 68 + fn loadImage(alloc: std.mem.Allocator, app: *App, path: []const u8) error{OutOfMemory}!void { 69 + var buf: [(1024 * 1024) * 5]u8 = undefined; // 5mb 70 + const data = vaxis.zigimg.Image.fromFilePath(alloc, path, &buf) catch { 71 + app.images.mutex.lock(); 72 + if (app.images.cache.getPtr(path)) |entry| { 73 + entry.status = .failed; 74 + } 75 + app.images.mutex.unlock(); 76 + 77 + const message = try std.fmt.allocPrint(alloc, "Failed to load image '{s}' - error occurred while attempting to read image from path.", .{path}); 78 + defer alloc.free(message); 79 + app.notification.write(message, .err) catch {}; 80 + if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 81 + 82 + return; 83 + }; 84 + 85 + app.images.mutex.lock(); 86 + if (app.images.cache.getPtr(path)) |entry| { 87 + entry.status = .ready; 88 + entry.data = data; 89 + entry.path = path; 90 + } else { 91 + const message = try std.fmt.allocPrint(alloc, "Failed to load image '{s}' - error occurred while attempting to add image to cache.", .{path}); 92 + defer alloc.free(message); 93 + app.notification.write(message, .err) catch {}; 94 + if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 95 + return; 96 + } 97 + app.images.mutex.unlock(); 98 + 99 + app.loop.postEvent(.image_ready); 100 + }
+81 -22
src/list.zig
··· 1 1 const std = @import("std"); 2 + 2 3 const vaxis = @import("vaxis"); 3 4 4 5 pub fn List(comptime T: type) type { ··· 8 9 alloc: std.mem.Allocator, 9 10 items: std.ArrayList(T), 10 11 selected: usize, 11 - offset: usize, 12 12 13 13 pub fn init(alloc: std.mem.Allocator) Self { 14 14 return Self{ 15 15 .alloc = alloc, 16 - .items = std.ArrayList(T).init(alloc), 16 + .items = .empty, 17 17 .selected = 0, 18 - .offset = 0, 19 18 }; 20 19 } 21 20 22 21 pub fn deinit(self: *Self) void { 23 - self.items.deinit(); 22 + self.items.deinit(self.alloc); 24 23 } 25 24 26 25 pub fn append(self: *Self, item: T) !void { 27 - try self.items.append(item); 26 + try self.items.append(self.alloc, item); 28 27 } 29 28 30 29 pub fn clear(self: *Self) void { 31 - self.items.clearAndFree(); 30 + self.items.clearAndFree(self.alloc); 32 31 self.selected = 0; 33 - self.offset = 0; 32 + } 33 + 34 + pub fn fromArray(self: *Self, array: []const T) !void { 35 + for (array) |item| { 36 + try self.append(item); 37 + } 34 38 } 35 39 36 40 pub fn get(self: Self, index: usize) !T { ··· 61 65 return self.items.items.len; 62 66 } 63 67 64 - pub fn next(self: *Self, win_height: usize) void { 68 + pub fn next(self: *Self) void { 65 69 if (self.selected + 1 < self.len()) { 66 70 self.selected += 1; 67 - 68 - if (self.all()[self.offset..].len > win_height and self.selected >= self.offset + (win_height / 2)) { 69 - self.offset += 1; 70 - } 71 71 } 72 72 } 73 73 74 - pub fn previous(self: *Self, win_height: usize) void { 74 + pub fn previous(self: *Self) void { 75 75 if (self.selected > 0) { 76 76 self.selected -= 1; 77 - 78 - if (self.offset > 0 and self.selected < self.offset + (win_height / 2)) { 79 - self.offset -= 1; 80 - } 81 77 } 82 78 } 83 79 84 - pub fn selectLast(self: *Self, win_height: usize) void { 80 + pub fn selectLast(self: *Self) void { 85 81 self.selected = self.len() - 1; 86 - if (self.selected >= win_height) { 87 - self.offset = self.selected - (win_height - 1); 88 - } 89 82 } 90 83 91 84 pub fn selectFirst(self: *Self) void { 92 85 self.selected = 0; 93 - self.offset = 0; 94 86 } 95 87 }; 96 88 } 89 + 90 + const testing = std.testing; 91 + 92 + test "List: navigation respects bounds" { 93 + var list = List(u32).init(testing.allocator); 94 + defer list.deinit(); 95 + 96 + try list.append(1); 97 + try list.append(2); 98 + try list.append(3); 99 + 100 + try testing.expectEqual(@as(usize, 0), list.selected); 101 + 102 + list.next(); 103 + try testing.expectEqual(@as(usize, 1), list.selected); 104 + 105 + list.next(); 106 + list.next(); 107 + // Try to go past end 108 + list.next(); 109 + // Should stay at last 110 + try testing.expectEqual(@as(usize, 2), list.selected); 111 + 112 + list.previous(); 113 + try testing.expectEqual(@as(usize, 1), list.selected); 114 + 115 + list.previous(); 116 + // Try to go before start 117 + list.previous(); 118 + // Should stay at first 119 + try testing.expectEqual(@as(usize, 0), list.selected); 120 + } 121 + 122 + test "List: getSelected handles empty list" { 123 + var list = List(u32).init(testing.allocator); 124 + defer list.deinit(); 125 + 126 + const result = try list.getSelected(); 127 + try testing.expect(result == null); 128 + } 129 + 130 + test "List: append and get operations" { 131 + var list = List(u32).init(testing.allocator); 132 + defer list.deinit(); 133 + 134 + try list.append(42); 135 + try list.append(84); 136 + 137 + try testing.expectEqual(@as(usize, 2), list.len()); 138 + try testing.expectEqual(@as(u32, 42), try list.get(0)); 139 + try testing.expectEqual(@as(u32, 84), try list.get(1)); 140 + } 141 + 142 + test "List: selectFirst and selectLast" { 143 + var list = List(u32).init(testing.allocator); 144 + defer list.deinit(); 145 + 146 + try list.append(1); 147 + try list.append(2); 148 + try list.append(3); 149 + 150 + list.selectLast(); 151 + try testing.expectEqual(@as(usize, 2), list.selected); 152 + 153 + list.selectFirst(); 154 + try testing.expectEqual(@as(usize, 0), list.selected); 155 + }
+137 -14
src/main.zig
··· 1 1 const std = @import("std"); 2 2 const builtin = @import("builtin"); 3 - const App = @import("app.zig"); 4 - const vaxis = @import("vaxis"); 5 - const ConfigParseRes = @import("./config.zig").ParseRes; 6 - const config = &@import("./config.zig").config; 7 3 4 + const options = @import("options"); 5 + const vaxis = @import("vaxis"); 8 6 pub const panic = vaxis.panic_handler; 9 7 8 + const resolvePath = @import("./commands.zig").resolvePath; 9 + const App = @import("app.zig"); 10 + const FileLogger = @import("file_logger.zig"); 11 + 12 + const config = &@import("./config.zig").config; 13 + const help_menu = 14 + \\Usage: jido 15 + \\ 16 + \\a lightweight Unix TUI file explorer 17 + \\ 18 + \\Flags: 19 + \\ -h, --help Show help information and exit. 20 + \\ -v, --version Print version information and exit. 21 + \\ --entry-dir=PATH Open jido at chosen dir. 22 + \\ --choose-dir Makes jido act like a directory chooser. When jido 23 + \\ quits, it will write the name of the last visited 24 + \\ directory to STDOUT. 25 + \\ 26 + ; 27 + 10 28 pub const std_options: std.Options = .{ 11 29 .log_scope_levels = &.{ 12 30 .{ .scope = .vaxis, .level = .warn }, ··· 14 32 }, 15 33 }; 16 34 35 + const Options = struct { 36 + help: bool = false, 37 + version: bool = false, 38 + @"choose-dir": bool = false, 39 + @"entry-path": []const u8 = ".", 40 + 41 + fn optKind(a: []const u8) enum { short, long, positional } { 42 + if (std.mem.startsWith(u8, a, "--")) return .long; 43 + if (std.mem.startsWith(u8, a, "-")) return .short; 44 + return .positional; 45 + } 46 + }; 47 + 17 48 pub fn main() !void { 18 49 var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 19 50 defer { ··· 24 55 } 25 56 const alloc = gpa.allocator(); 26 57 27 - var app = try App.init(alloc); 28 - defer app.deinit(); 58 + var last_dir: ?[]const u8 = null; 59 + var entry_path_buf: [std.fs.max_path_bytes]u8 = undefined; 60 + 61 + var opts = Options{}; 62 + var args = std.process.args(); 63 + _ = args.skip(); 64 + while (args.next()) |arg| { 65 + switch (Options.optKind(arg)) { 66 + .short => { 67 + const str = arg[1..]; 68 + for (str) |b| { 69 + switch (b) { 70 + 'v' => opts.version = true, 71 + 'h' => opts.help = true, 72 + else => { 73 + std.log.err("Invalid opt: '{c}'", .{b}); 74 + std.process.exit(1); 75 + }, 76 + } 77 + } 78 + }, 79 + .long => { 80 + var split = std.mem.splitScalar(u8, arg[2..], '='); 81 + const opt = split.first(); 82 + const val = split.rest(); 83 + if (std.mem.eql(u8, opt, "version")) { 84 + opts.version = true; 85 + } else if (std.mem.eql(u8, opt, "help")) { 86 + opts.help = true; 87 + } else if (std.mem.eql(u8, opt, "choose-dir")) { 88 + opts.@"choose-dir" = true; 89 + } else if (std.mem.eql(u8, opt, "entry-dir")) { 90 + const path = if (std.mem.eql(u8, val, "")) "." else val; 91 + var dir = try std.fs.cwd().openDir(".", .{ .iterate = true }); 92 + defer dir.close(); 93 + opts.@"entry-path" = resolvePath(&entry_path_buf, path, dir); 94 + } 95 + }, 96 + .positional => { 97 + std.log.err("Invalid opt: '{s}'. Jido does not take positional arguments.", .{arg}); 98 + std.process.exit(1); 99 + }, 100 + } 101 + } 102 + 103 + if (opts.help) { 104 + std.debug.print(help_menu, .{}); 105 + return; 106 + } 29 107 30 - const config_parse_res = config.parse(alloc) catch |err| lbl: { 31 - switch (err) { 108 + if (opts.version) { 109 + std.debug.print("jido v{f}\n", .{options.version}); 110 + return; 111 + } 112 + 113 + { 114 + var app = App.init(alloc, opts.@"entry-path") catch { 115 + vaxis.recover(); 116 + std.process.exit(1); 117 + }; 118 + defer app.deinit(); 119 + 120 + config.parse(alloc, &app) catch |err| switch (err) { 32 121 error.SyntaxError => { 33 - try app.notification.writeErr(.ConfigSyntaxError); 122 + app.notification.write("Encountered a syntax error while parsing the config file.", .err) catch { 123 + std.log.err("Encountered a syntax error while parsing the config file.", .{}); 124 + }; 125 + }, 126 + error.InvalidCharacter => { 127 + app.notification.write("One or more overriden keybinds are invalid.", .err) catch { 128 + std.log.err("One or more overriden keybinds are invalid.", .{}); 129 + }; 130 + }, 131 + error.DuplicateKeybind => { 132 + // Error logged in function 34 133 }, 35 134 else => { 36 - try app.notification.writeErr(.ConfigUnknownError); 135 + const message = try std.fmt.allocPrint(alloc, "Encountend an unknown error while parsing the config file - {}", .{err}); 136 + defer alloc.free(message); 137 + 138 + app.notification.write(message, .err) catch { 139 + std.log.err("Encountend an unknown error while parsing the config file - {}", .{err}); 140 + }; 37 141 }, 142 + }; 143 + 144 + app.file_logger = if (config.config_dir) |dir| FileLogger.init(dir) else logger: { 145 + std.log.err("Failed to initialise file logger - no config directory found", .{}); 146 + break :logger null; 147 + }; 148 + app.notification.loop = &app.loop; 149 + 150 + try app.run(); 151 + 152 + if (opts.@"choose-dir") { 153 + last_dir = alloc.dupe(u8, try app.directories.fullPath(".")) catch null; 38 154 } 155 + } 39 156 40 - break :lbl ConfigParseRes{ .deprecated = false }; 41 - }; 42 - if (config_parse_res.deprecated) try app.notification.writeWarn(.DeprecatedConfigPath); 157 + // Must be printed after app has deinit as part of that process clears 158 + // the screen. 159 + if (last_dir) |path| { 160 + var stdout_buffer: [std.fs.max_path_bytes]u8 = undefined; 161 + var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer); 162 + const stdout = &stdout_writer.interface; 163 + stdout.print("{s}\n", .{path}) catch {}; 164 + stdout.flush() catch {}; 43 165 44 - try app.run(); 166 + alloc.free(path); 167 + } 45 168 }
+11 -77
src/notification.zig
··· 1 1 const std = @import("std"); 2 2 3 + const vaxis = @import("vaxis"); 4 + 5 + const Event = @import("app.zig").Event; 6 + const FileLogger = @import("file_logger.zig"); 7 + 3 8 const Self = @This(); 4 9 5 10 /// Seconds. ··· 11 16 warn, 12 17 }; 13 18 14 - const Error = enum { 15 - PermissionDenied, 16 - UnknownError, 17 - UnableToUndo, 18 - UnableToOpenFile, 19 - UnableToDelete, 20 - FailedToDeleteSomeItems, 21 - UnableToDeleteAcrossMountPoints, 22 - UnsupportedImageFormat, 23 - EditorNotSet, 24 - ItemAlreadyExists, 25 - UnableToRename, 26 - IncorrectPath, 27 - ConfigSyntaxError, 28 - ConfigUnknownError, 29 - ConfigPathNotFound, 30 - CannotDeleteTrashDir, 31 - }; 32 - 33 - const Info = enum { 34 - CreatedFile, 35 - CreatedFolder, 36 - Deleted, 37 - Renamed, 38 - RestoredDelete, 39 - RestoredRename, 40 - EmptyUndo, 41 - ChangedDir, 42 - }; 43 - 44 - const Warn = enum { DeprecatedConfigPath }; 19 + var buf: [1024]u8 = undefined; 45 20 46 - buf: [1024]u8 = undefined, 47 21 style: Style = Style.info, 48 - fbs: std.io.FixedBufferStream([]u8) = undefined, 22 + fbs: std.io.FixedBufferStream([]u8) = std.io.fixedBufferStream(&buf), 49 23 /// How long until the notification disappears in seconds. 50 24 timer: i64 = 0, 51 - 52 - pub fn init(self: *Self) void { 53 - self.fbs = std.io.fixedBufferStream(&self.buf); 54 - self.timer = std.time.timestamp(); 55 - } 25 + loop: ?*vaxis.Loop(Event) = null, 56 26 57 27 pub fn write(self: *Self, text: []const u8, style: Style) !void { 58 28 self.fbs.reset(); 59 29 _ = try self.fbs.write(text); 60 30 self.timer = std.time.timestamp(); 61 31 self.style = style; 62 - } 63 32 64 - pub fn writeErr(self: *Self, err: Error) !void { 65 - try switch (err) { 66 - .PermissionDenied => self.write("Permission denied.", .err), 67 - .UnknownError => self.write("An unknown error occurred.", .err), 68 - .UnableToOpenFile => self.write("Unable to open file.", .err), 69 - .UnableToDelete => self.write("Unable to delete item.", .err), 70 - .FailedToDeleteSomeItems => self.write("Failed to delete some items..", .err), 71 - .UnableToDeleteAcrossMountPoints => self.write("Unable to move item to /tmp. Failed to delete.", .err), 72 - .UnableToUndo => self.write("Unable to undo previous action.", .err), 73 - .ItemAlreadyExists => self.write("Item already exists.", .err), 74 - .UnableToRename => self.write("Unable to rename item.", .err), 75 - .IncorrectPath => self.write("Unable to find path.", .err), 76 - .EditorNotSet => self.write("$EDITOR is not set.", .err), 77 - .UnsupportedImageFormat => self.write("Unsupported image format.", .err), 78 - .ConfigSyntaxError => self.write("Could not read config due to a syntax error.", .err), 79 - .ConfigUnknownError => self.write("Could not read config due to an unknown error.", .err), 80 - .ConfigPathNotFound => self.write("Could not read config due to unset env variables. Please set either $HOME or $XDG_CONFIG_HOME.", .err), 81 - .CannotDeleteTrashDir => self.write("Cannot delete trash directory.", .err), 82 - }; 83 - } 84 - 85 - pub fn writeInfo(self: *Self, info: Info) !void { 86 - try switch (info) { 87 - .CreatedFile => self.write("Successfully created file.", .info), 88 - .CreatedFolder => self.write("Successfully created folder.", .info), 89 - .Deleted => self.write("Successfully deleted item.", .info), 90 - .Renamed => self.write("Successfully renamed item.", .info), 91 - .RestoredDelete => self.write("Successfully restored deleted item.", .info), 92 - .RestoredRename => self.write("Successfully restored renamed item.", .info), 93 - .EmptyUndo => self.write("Nothing to undo.", .info), 94 - .ChangedDir => self.write("Successfully changed directory.", .info), 95 - }; 96 - } 97 - 98 - pub fn writeWarn(self: *Self, warning: Warn) !void { 99 - try switch (warning) { 100 - .DeprecatedConfigPath => self.write("You are using a deprecated config path. Please move your config to either `$XDG_CONFIG_HOME/jido` or `$HOME/.jido`", .warn), 101 - }; 33 + if (self.loop) |loop| { 34 + loop.postEvent(.notification); 35 + } 102 36 } 103 37 104 38 pub fn reset(self: *Self) void {
+8
src/path_utils.zig
··· 1 + const std = @import("std"); 2 + 3 + pub fn getCleanName(entry: std.fs.Dir.Entry) []const u8 { 4 + if (entry.kind == .directory and entry.name.len > 0 and entry.name[entry.name.len - 1] == '/') { 5 + return entry.name[0 .. entry.name.len - 1]; 6 + } 7 + return entry.name; 8 + }
+388
src/preview.zig
··· 1 + const std = @import("std"); 2 + 3 + const App = @import("./app.zig"); 4 + const Archive = @import("./archive.zig"); 5 + const Image = @import("./image.zig"); 6 + const path_utils = @import("./path_utils.zig"); 7 + const config = &@import("./config.zig").config; 8 + 9 + pub const PreviewType = enum { 10 + none, 11 + text, 12 + image, 13 + pdf, 14 + archive, 15 + directory, 16 + }; 17 + 18 + pub const PreviewData = union(PreviewType) { 19 + none: void, 20 + text: []const u8, 21 + image: ImageInfo, 22 + pdf: []const u8, 23 + archive: std.ArrayList([]const u8), 24 + directory: std.ArrayList([]const u8), 25 + }; 26 + 27 + pub const ImageInfo = struct { 28 + cache_path: []const u8, 29 + }; 30 + 31 + pub const CacheEntry = struct { 32 + file_path: []const u8, 33 + preview: PreviewData, 34 + is_valid: bool, 35 + 36 + pub fn deinit(self: *CacheEntry, alloc: std.mem.Allocator) void { 37 + alloc.free(self.file_path); 38 + switch (self.preview) { 39 + .text, .pdf => |data| alloc.free(data), 40 + .archive, .directory => |*list| { 41 + for (list.items) |item| alloc.free(item); 42 + list.deinit(alloc); 43 + }, 44 + .image => |img| alloc.free(img.cache_path), 45 + .none => {}, 46 + } 47 + } 48 + }; 49 + 50 + pub const PreviewCache = struct { 51 + alloc: std.mem.Allocator, 52 + current: ?CacheEntry, 53 + 54 + pub fn init(alloc: std.mem.Allocator) PreviewCache { 55 + return .{ 56 + .alloc = alloc, 57 + .current = null, 58 + }; 59 + } 60 + 61 + pub fn deinit(self: *PreviewCache) void { 62 + if (self.current) |*entry| { 63 + entry.deinit(self.alloc); 64 + } 65 + } 66 + 67 + pub fn invalidate(self: *PreviewCache) void { 68 + if (self.current) |*entry| { 69 + entry.is_valid = false; 70 + } 71 + } 72 + 73 + pub fn clear(self: *PreviewCache) void { 74 + if (self.current) |*entry| { 75 + entry.deinit(self.alloc); 76 + } 77 + self.current = null; 78 + } 79 + 80 + pub fn updatePath(self: *PreviewCache, app: *App, old_path: []const u8, new_path: []const u8) error{OutOfMemory}!void { 81 + if (self.current) |*entry| { 82 + if (std.mem.eql(u8, entry.file_path, old_path)) { 83 + if (entry.preview == .image) { 84 + app.images.mutex.lock(); 85 + defer app.images.mutex.unlock(); 86 + 87 + if (app.images.cache.fetchRemove(old_path)) |kv| { 88 + app.images.cache.put(new_path, kv.value) catch |err| { 89 + kv.value.deinit(app.alloc, app.vx, &app.tty); 90 + self.clear(); 91 + return err; 92 + }; 93 + } 94 + 95 + self.alloc.free(entry.preview.image.cache_path); 96 + entry.preview.image.cache_path = try self.alloc.dupe(u8, new_path); 97 + } 98 + 99 + self.alloc.free(entry.file_path); 100 + entry.file_path = try self.alloc.dupe(u8, new_path); 101 + } 102 + } 103 + } 104 + 105 + pub fn get(self: *PreviewCache, path: []const u8) ?*const PreviewData { 106 + if (self.current) |*entry| { 107 + if (entry.is_valid and std.mem.eql(u8, entry.file_path, path)) { 108 + return &entry.preview; 109 + } 110 + } 111 + return null; 112 + } 113 + 114 + pub fn set(self: *PreviewCache, path: []const u8, preview: PreviewData) !void { 115 + self.clear(); 116 + 117 + self.current = .{ 118 + .file_path = try self.alloc.dupe(u8, path), 119 + .preview = preview, 120 + .is_valid = true, 121 + }; 122 + } 123 + }; 124 + 125 + pub fn loadPreviewForCurrentEntry(app: *App) !void { 126 + if (!config.preview_file) return; 127 + 128 + const entry = (try app.directories.getSelected()) orelse return; 129 + 130 + const clean_name = path_utils.getCleanName(entry); 131 + const path = try app.directories.dir.realpathAlloc( 132 + app.alloc, 133 + clean_name, 134 + ); 135 + defer app.alloc.free(path); 136 + 137 + if (app.preview_cache.get(path)) |_| { 138 + return; 139 + } 140 + 141 + const preview = switch (entry.kind) { 142 + .directory => try loadDirectoryPreview(app, entry), 143 + .file => try loadFilePreview(app, entry), 144 + else => PreviewData{ .none = {} }, 145 + }; 146 + 147 + try app.preview_cache.set(path, preview); 148 + } 149 + 150 + fn loadDirectoryPreview(app: *App, entry: std.fs.Dir.Entry) !PreviewData { 151 + app.directories.clearChildEntries(); 152 + 153 + const clean_name = path_utils.getCleanName(entry); 154 + app.directories.populateChildEntries(clean_name) catch |err| { 155 + const message = try std.fmt.allocPrint( 156 + app.alloc, 157 + "Failed to read directory entries - {}.", 158 + .{err}, 159 + ); 160 + defer app.alloc.free(message); 161 + app.notification.write(message, .err) catch {}; 162 + if (app.file_logger) |file_logger| { 163 + file_logger.write(message, .err) catch {}; 164 + } 165 + return PreviewData{ .none = {} }; 166 + }; 167 + 168 + var list: std.ArrayList([]const u8) = .empty; 169 + for (app.directories.child_entries.all()) |child| { 170 + const owned = try app.alloc.dupe(u8, child); 171 + try list.append(app.alloc, owned); 172 + } 173 + 174 + return PreviewData{ .directory = list }; 175 + } 176 + 177 + fn loadFilePreview(app: *App, entry: std.fs.Dir.Entry) !PreviewData { 178 + const file_ext = std.fs.path.extension(entry.name); 179 + 180 + if (config.show_images) { 181 + if (isImageExtension(file_ext)) { 182 + return try loadImagePreview(app, entry); 183 + } 184 + } 185 + 186 + if (std.mem.eql(u8, file_ext, ".pdf")) { 187 + return try loadPdfPreview(app, entry); 188 + } 189 + 190 + if (Archive.ArchiveType.fromPath(entry.name)) |archive_type| { 191 + return try loadArchivePreview(app, entry, archive_type); 192 + } 193 + 194 + return try loadTextPreview(app, entry); 195 + } 196 + 197 + fn loadTextPreview(app: *App, entry: std.fs.Dir.Entry) !PreviewData { 198 + const clean_name = path_utils.getCleanName(entry); 199 + var file = app.directories.dir.openFile( 200 + clean_name, 201 + .{ .mode = .read_only }, 202 + ) catch |err| { 203 + const message = try std.fmt.allocPrint( 204 + app.alloc, 205 + "Failed to open file - {}.", 206 + .{err}, 207 + ); 208 + defer app.alloc.free(message); 209 + app.notification.write(message, .err) catch {}; 210 + if (app.file_logger) |file_logger| { 211 + file_logger.write(message, .err) catch {}; 212 + } 213 + return PreviewData{ .none = {} }; 214 + }; 215 + defer file.close(); 216 + 217 + var buffer: [4096]u8 = undefined; 218 + const bytes = file.readAll(&buffer) catch |err| { 219 + const message = try std.fmt.allocPrint( 220 + app.alloc, 221 + "Failed to read file contents - {}.", 222 + .{err}, 223 + ); 224 + defer app.alloc.free(message); 225 + app.notification.write(message, .err) catch {}; 226 + if (app.file_logger) |file_logger| { 227 + file_logger.write(message, .err) catch {}; 228 + } 229 + return PreviewData{ .none = {} }; 230 + }; 231 + 232 + if (std.unicode.utf8ValidateSlice(buffer[0..bytes])) { 233 + const text = try app.alloc.dupe(u8, buffer[0..bytes]); 234 + return PreviewData{ .text = text }; 235 + } 236 + 237 + return PreviewData{ .none = {} }; 238 + } 239 + 240 + fn loadImagePreview(app: *App, entry: std.fs.Dir.Entry) !PreviewData { 241 + const clean_name = path_utils.getCleanName(entry); 242 + const path = try app.directories.dir.realpathAlloc( 243 + app.alloc, 244 + clean_name, 245 + ); 246 + defer app.alloc.free(path); 247 + 248 + app.images.mutex.lock(); 249 + const exists = app.images.cache.contains(path); 250 + app.images.mutex.unlock(); 251 + 252 + if (!exists) { 253 + const owned_path = try app.alloc.dupe(u8, path); 254 + Image.processImage(app.alloc, app, owned_path) catch { 255 + app.alloc.free(owned_path); 256 + return PreviewData{ .none = {} }; 257 + }; 258 + } 259 + 260 + return PreviewData{ 261 + .image = .{ 262 + .cache_path = try app.alloc.dupe(u8, path), 263 + }, 264 + }; 265 + } 266 + 267 + fn loadPdfPreview(app: *App, entry: std.fs.Dir.Entry) !PreviewData { 268 + const clean_name = path_utils.getCleanName(entry); 269 + const path = try app.directories.dir.realpathAlloc( 270 + app.alloc, 271 + clean_name, 272 + ); 273 + defer app.alloc.free(path); 274 + 275 + const result = std.process.Child.run(.{ 276 + .allocator = app.alloc, 277 + .argv = &[_][]const u8{ 278 + "pdftotext", 279 + "-f", 280 + "0", 281 + "-l", 282 + "5", 283 + path, 284 + "-", 285 + }, 286 + .cwd_dir = app.directories.dir, 287 + }) catch { 288 + app.notification.write("No preview available. Install pdftotext to get PDF previews.", .err) catch {}; 289 + return PreviewData{ .none = {} }; 290 + }; 291 + defer app.alloc.free(result.stdout); 292 + defer app.alloc.free(result.stderr); 293 + 294 + if (result.term.Exited != 0) { 295 + app.notification.write("No preview available. Install pdftotext to get PDF previews.", .err) catch {}; 296 + return PreviewData{ .none = {} }; 297 + } 298 + 299 + const text = try app.alloc.dupe(u8, result.stdout); 300 + return PreviewData{ .pdf = text }; 301 + } 302 + 303 + fn loadArchivePreview( 304 + app: *App, 305 + entry: std.fs.Dir.Entry, 306 + archive_type: Archive.ArchiveType, 307 + ) !PreviewData { 308 + const clean_name = path_utils.getCleanName(entry); 309 + var file = app.directories.dir.openFile( 310 + clean_name, 311 + .{ .mode = .read_only }, 312 + ) catch |err| { 313 + const message = try std.fmt.allocPrint( 314 + app.alloc, 315 + "Failed to open archive - {}.", 316 + .{err}, 317 + ); 318 + defer app.alloc.free(message); 319 + app.notification.write(message, .err) catch {}; 320 + if (app.file_logger) |file_logger| { 321 + file_logger.write(message, .err) catch {}; 322 + } 323 + return PreviewData{ .none = {} }; 324 + }; 325 + defer file.close(); 326 + 327 + const archive_contents = Archive.listArchiveContents( 328 + app.alloc, 329 + file, 330 + archive_type, 331 + config.archive_traversal_limit, 332 + ) catch |err| { 333 + const message = try std.fmt.allocPrint( 334 + app.alloc, 335 + "Failed to read archive: {s}", 336 + .{@errorName(err)}, 337 + ); 338 + defer app.alloc.free(message); 339 + app.notification.write(message, .err) catch {}; 340 + if (app.file_logger) |file_logger| { 341 + file_logger.write(message, .err) catch {}; 342 + } 343 + return PreviewData{ .none = {} }; 344 + }; 345 + 346 + if (config.sort_dirs) { 347 + const sort_mod = @import("./sort.zig"); 348 + std.mem.sort( 349 + []const u8, 350 + archive_contents.entries.items, 351 + {}, 352 + sort_mod.string, 353 + ); 354 + } 355 + 356 + return PreviewData{ .archive = archive_contents.entries }; 357 + } 358 + 359 + fn isImageExtension(ext: []const u8) bool { 360 + const supported = [_][]const u8{ 361 + ".bmp", 362 + ".farbfeld", 363 + ".gif", 364 + ".iff", 365 + ".ilbm", 366 + ".jpeg", 367 + ".jpg", 368 + ".pam", 369 + ".pbm", 370 + ".pcx", 371 + ".pgm", 372 + ".png", 373 + ".ppm", 374 + ".qoi", 375 + ".ras", 376 + ".sgi", 377 + ".tga", 378 + ".tif", 379 + ".tiff", 380 + }; 381 + 382 + for (supported) |supported_ext| { 383 + if (std.ascii.eqlIgnoreCase(ext, supported_ext)) { 384 + return true; 385 + } 386 + } 387 + return false; 388 + }
+9
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 + } 6 + 7 + pub fn sortDirectoryEntry(_: void, lhs: std.fs.Dir.Entry, rhs: std.fs.Dir.Entry) bool { 8 + return std.mem.lessThan(u8, lhs.name, rhs.name); 9 + }
+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 + }