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

Compare changes

Choose any two refs to compare.

+3923 -1089
+11
.gila/done/creamy_ogre_ggk/creamy_ogre_ggk.md
··· 1 + --- 2 + title: feat: display archive contents 3 + status: done 4 + priority_value: 50 5 + priority: medium 6 + owner: brookjeynes 7 + created: 2026-01-14T21:48:51Z 8 + completed: 2026-01-14T21:49:06Z 9 + --- 10 + When a user scrolls past a compressed file (.tar, .zip, etc.), they should be 11 + able to see the top level contents.
+11
.gila/done/grave_zone_233/grave_zone_233.md
··· 1 + --- 2 + title: feat: support cursor placement in input 3 + status: done 4 + priority_value: 50 5 + priority: high 6 + owner: brookjeynes 7 + created: 2026-01-11T21:53:52Z 8 + completed: 2026-01-14T21:48:27Z 9 + --- 10 + Currently the user cannot move their cursor left or right when typing in the 11 + input field. This is painful when renaming files or changing directories.
+11
.gila/todo/helpful_heat_b1b/helpful_heat_b1b.md
··· 1 + --- 2 + title: feat: add keybind to compress item 3 + status: todo 4 + priority_value: 50 5 + priority: low 6 + owner: brookjeynes 7 + created: 2026-01-14T21:46:27Z 8 + --- 9 + Allow users to compress files/folders via a keybind 10 + 11 + This should have a config option as to what file format to compress as.
+9
.gila/todo/intelligent_dino_17y/intelligent_dino_17y.md
··· 1 + --- 2 + title: feat: add keybind to extraction archive 3 + status: todo 4 + priority_value: 50 5 + priority: low 6 + owner: brookjeynes 7 + created: 2026-01-11T21:52:59Z 8 + --- 9 + Allow users to extract archives via a keybind
+67
.github/workflows/create-draft-release.yml
··· 1 + name: Create draft release 2 + 3 + on: 4 + workflow_dispatch: 5 + 6 + env: 7 + # https://github.com/cli/cli/issues/9514#issuecomment-2311517523 8 + GH_TOKEN: ${{ secrets.TOKEN }} 9 + 10 + jobs: 11 + build: 12 + runs-on: ubuntu-latest 13 + 14 + steps: 15 + - name: Checkout code 16 + uses: actions/checkout@v4 17 + 18 + - name: Set up Zig 19 + uses: korandoru/setup-zig@v1 20 + with: 21 + zig-version: "0.14.0" 22 + 23 + - name: Build application 24 + run: | 25 + zig build -Dall-targets 26 + 27 + - name: Upload artifact 28 + uses: actions/upload-artifact@v4 29 + with: 30 + name: builds 31 + path: zig-out/ 32 + 33 + release: 34 + needs: build 35 + runs-on: ubuntu-latest 36 + 37 + steps: 38 + - name: Checkout code 39 + uses: actions/checkout@v4 40 + 41 + - name: Download artifacts 42 + uses: actions/download-artifact@v4 43 + with: 44 + path: artifacts 45 + 46 + - name: Create tarballs 47 + run: | 48 + mkdir -p tarballs 49 + for dir in artifacts/builds/*; do 50 + if [ -d "$dir" ] && [ $(basename "$dir") != "bin" ]; then 51 + tar -czf "tarballs/$(basename "$dir").tar.gz" -C "$dir" . 52 + fi 53 + done 54 + 55 + - name: gh log 56 + run: | 57 + gh --version 58 + gh auth status 59 + 60 + - name: Create release 61 + run: | 62 + gh release create ${{ github.ref_name }} tarballs/* \ 63 + --title "Release ${{ github.ref_name }}" \ 64 + --notes "Automated release with build artifacts." \ 65 + --draft 66 + env: 67 + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+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)
+124 -61
README.md
··· 1 - <h1 align="center"> 2 - zfe 3 - </h1> 1 + # 地圖 (Jido) 2 + 3 + ![Jido preview](./assets/preview.gif) 4 + 5 + **Jido** is a lightweight Unix TUI file explorer designed for speed and 6 + simplicity. 7 + 8 + The name 地圖 (지도) translates to "map" in English, reflecting Jido's 9 + purpose: helping you navigate and explore your file system with ease. With 10 + Vim-like bindings and a minimalist interface, Jido focuses on speed and 11 + simplicity. 12 + 13 + Jido is built with Zig v`0.15.2`. 14 + 15 + - [Installation](#installation) 16 + - [Integrations](#integrations) 17 + - [Key manual](#key-manual) 18 + - [Configuration](#configuration) 19 + - [Contributing](#contributing) 20 + 21 + ## Installation 22 + To install Jido, check the "Releases" page or build locally 23 + via `zig build --release=safe`. 4 24 5 - <div align="center">Unix terminal file explorer, written in Zig</div> 25 + ## Integrations 26 + - `pdftotext` to view PDF text previews. 27 + - A terminal supporting the `kitty image protocol` to view images. 6 28 7 - <br> 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. 8 33 9 - **zfe** is a small unix terminal file explorer written in Zig. 34 + ``` 35 + Global: 36 + <CTRL-c> :Exit. 37 + <CTRL-r> :Reload config. 10 38 11 - ![image](https://github.com/BrookJeynes/zfe/assets/25432120/811956b1-9819-4213-9bd8-67700d901ddd) 39 + Normal mode: 40 + j / <Down> :Go down. 41 + k / <Up> :Go up. 42 + h / <Left> / - :Go to the parent directory. 43 + l / <Right> :Open item or change directory. 44 + g :Go to the top. 45 + G :Go to the bottom. 46 + c :Change directory via path. Will enter input mode. 47 + R :Rename item. Will enter input mode. 48 + D :Delete item. 49 + u :Undo delete/rename. 50 + d :Create directory. Will enter input mode. 51 + % :Create file. Will enter input mode. 52 + / :Fuzzy search directory. Will enter input mode. 53 + . :Toggle hidden files. 54 + : :Allows for Jido commands to be entered. Please refer to the 55 + "Command mode" section for available commands. Will enter 56 + input mode. 57 + v :Verbose mode. Provides more information about selected entry. 58 + y :Yank selected item. 59 + p :Past yanked item. 12 60 13 - ## Features 14 - - **Simple to use**: Minimal and customizable keymaps with vim binding support. 15 - - **Image Previews**: Preview images with Kitty terminal. 16 - - **File Previews**: Preview contents of files directly in the terminal. 17 - - **Configurable Options**: Customize settings via an external configuration file. 18 - - **Fuzzy Search**: Fuzzy search within directories. 61 + Input mode: 62 + <Esc> :Cancel input. 63 + <CR> :Confirm input. 19 64 20 - ## Install 21 - To install zfe, check the "Releases" section in Github and download the 22 - appropriate version or build locally via `zig build -Doptimize=ReleaseSafe`. 65 + Command mode: 66 + <Up> / <Down> :Cycle previous commands. 67 + :q :Exit. 68 + :h :View available keybinds. 'q' to return to app. 69 + :config :Navigate to config directory if it exists. 70 + :trash :Navigate to trash directory if it exists. 71 + :empty_trash :Empty trash if it exists. This action cannot be undone. 72 + :cd <path> :Change directory via path. Will enter input mode. 73 + ``` 23 74 24 75 ## Configuration 25 - Configure `zfe` by editing the external configuration file located at either: 26 - - `$HOME/.config/zfe/config.json` 27 - - `$XDG_CONFIG_HOME/zfe/config.json`. 76 + Configure `jido` by editing the external configuration file located at either: 77 + - `$HOME/.jido/config.json` 78 + - `$XDG_CONFIG_HOME/jido/config.json`. 79 + 80 + Jido will look for these env variables specifically. If they are not set, Jido 81 + will not be able to find the config file. 28 82 29 - An example config file can be found [here](https://github.com/BrookJeynes/zfe/blob/main/example-config.json). 83 + An example config file can be found [here](https://github.com/BrookJeynes/jido/blob/main/example-config.json). 30 84 31 85 Config schema: 32 86 ``` 33 87 Config = struct { 34 - .show_hidden: bool, 35 - .sort_dirs: bool, 36 - .show_images: bool, 37 - .preview_file: bool, 38 - .styles: Styles, 88 + .show_hidden: bool = true, 89 + .sort_dirs: bool = true, 90 + .show_images: bool = true, -- Images are only supported in a terminal 91 + supporting the `kitty image protocol`. 92 + .preview_file: bool = true, 93 + .empty_trash_on_exit: bool = false, -- Emptying the trash permanently deletes 94 + all files within the trash. These 95 + files are not recoverable past this 96 + point. 97 + .true_dir_size: bool = false, -- Display size of directory including 98 + all its children. This can and will 99 + cause lag on deeply nested directories. 100 + .archive_traversal_limit: usize = 100, -- How many files to be traversed when reading 101 + an archive (zip, tar, etc.). 102 + .keybinds: Keybinds, 103 + .styles: Styles 104 + } 105 + 106 + Keybinds = struct { 107 + .toggle_hidden_files: ?Char = '.', 108 + .delete: ?Char = 'D', 109 + .rename: ?Char = 'R', 110 + .create_dir: ?Char = 'd', 111 + .create_file: ?Char = '%', 112 + .fuzzy_find: ?Char = '/', 113 + .change_dir: ?Char = 'c', 114 + .enter_command_mode: ?Char = ':', 115 + .jump_top: ?Char = 'g', 116 + .jump_bottom: ?Char = 'G', 117 + .toggle_verbose_file_information: ?Char = 'v', 118 + .force_delete: ?Char = null -- Files deleted this way are 119 + not recoverable 120 + .yank: ?Char = 'y' 121 + .paste: ?Char = 'p' 122 + } 123 + 124 + NotificationStyles = struct { 125 + .box: vaxis.Style, 126 + .err: vaxis.Style, 127 + .warn: vaxis.Style, 128 + .info: vaxis.Style 39 129 } 40 130 41 131 Styles = struct { ··· 43 133 .list_item: Style, 44 134 .file_name: Style, 45 135 .file_information: Style 46 - .error_bar: Style, 47 - .info_bar: Style, 48 - .notification_box: Style, 136 + .notification: NotificationStyles, 137 + .git_branch: Style 49 138 } 50 139 51 140 Style = struct { ··· 58 147 double, 59 148 curly, 60 149 dotted, 61 - dashed, 150 + dashed 62 151 } 63 152 .bold: bool, 64 153 .dim: bool, ··· 66 155 .blink: bool, 67 156 .reverse: bool, 68 157 .invisible: bool, 69 - .strikethrough: bool, 158 + .strikethrough: bool 70 159 } 71 160 72 161 Color = enum{ 73 162 default, 74 163 index: u8, 75 - rgb: [3]u8, 164 + rgb: [3]u8 76 165 } 77 - ``` 78 166 79 - ## Keybinds 80 - ``` 81 - Normal mode: 82 - q / <CTRL-c> :Exit. 83 - 84 - j / <Down> :Go down. 85 - k / <Up> :Go up. 86 - h / <Left> / - :Go to the parent directory if exists. 87 - l / <Right> :Open item or change directory. 88 - gg :Go to the top. 89 - G :Go to the bottom. 90 - c :Change directory via path. Will enter input mode. 91 - 92 - R :Rename item. Will enter input mode. 93 - D :Delete item. 94 - u :Undo delete/rename. 95 - 96 - d :Create directory. Will enter input mode. 97 - % :Create file. Will enter input mode. 98 - / :Fuzzy search directory. Will enter input mode. 99 - 100 - Input mode: 101 - <Esc> :Cancel input. 102 - <Enter> :Confirm input. 167 + Char = enum(u21) 103 168 ``` 104 - 105 - ## Optional Dependencies 106 - - `pdftotext` to view PDF previews. 107 169 108 170 ## Contributing 109 - Contributions, issues, and feature requests are always welcome! This project is 110 - currently using the latest stable release of Zig (0.13.0). 171 + Contributions, issues, and feature requests are always welcome via 172 + [GitHub](https://github.com/brookjeynes/jido) or 173 + [tangled](https://tangled.sh/@brookjeynes.dev/jido).
assets/preview.gif

This is a binary file and will not be displayed.

assets/preview.png

This is a binary file and will not be displayed.

assets/style_guide.png

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 build_targets(b); 55 + try buildTargets(b, build_options_module); 38 56 return; 39 57 } 40 58 41 - const exe = try createExe(b, "zfe", 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 build_targets(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, "zfe", 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 = "zfe", 3 - .version = "0.6.1", 4 - .minimum_zig_version = "0.13.0", 2 + .name = .jido, 3 + .fingerprint = 0xee45eabe36cafb57, 4 + .version = "1.3.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 }
+14 -6
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": { ··· 10 14 }, 11 15 "bold": true 12 16 }, 13 - "file_information": { 14 - "fg": { 15 - "rgb": [0, 0, 0] 16 - }, 17 - "bg": { 18 - "rgb": [255, 255, 255] 17 + "notification": { 18 + "info": { 19 + "fg": { 20 + "rgb": [127, 255, 0] 21 + } 19 22 } 23 + }, 24 + "git_branch": { 25 + "fg": { 26 + "rgb": [127, 255, 0] 27 + } 20 28 } 21 29 } 22 30 }
-6
release_zip.sh
··· 1 - cd ./zig-out 2 - zip -r ./x86_64-linux.zip ./x86_64-linux/ 3 - zip -r ./x86_64-macos.zip ./x86_64-macos/ 4 - zip -r ./aarch64-linux.zip ./aarch64-linux/ 5 - zip -r ./aarch64-macos.zip ./aarch64-macos/ 6 - cd -
+181 -721
src/app.zig
··· 1 1 const std = @import("std"); 2 2 const builtin = @import("builtin"); 3 - 4 - const Logger = @import("./log.zig").Logger; 5 3 const environment = @import("./environment.zig"); 4 + const Drawer = @import("./drawer.zig"); 6 5 const Notification = @import("./notification.zig"); 7 6 const config = &@import("./config.zig").config; 8 7 const List = @import("./list.zig").List; 9 8 const Directories = @import("./directories.zig"); 9 + const FileLogger = @import("./file_logger.zig"); 10 10 const CircStack = @import("./circ_stack.zig").CircularStack; 11 - 11 + const Image = @import("./image.zig"); 12 + const Archive = @import("./archive.zig"); 12 13 const zuid = @import("zuid"); 13 - 14 14 const vaxis = @import("vaxis"); 15 - const TextInput = @import("vaxis").widgets.TextInput; 16 - const Cell = vaxis.Cell; 17 15 const Key = vaxis.Key; 16 + const EventHandlers = @import("./event_handlers.zig"); 17 + const CommandHistory = @import("./commands.zig").CommandHistory; 18 + 19 + const help_menu_items = [_][]const u8{ 20 + "Global:", 21 + "<CTRL-c> :Exit.", 22 + "<CTRL-r> :Reload config.", 23 + "", 24 + "Normal mode:", 25 + "j / <Down> :Go down.", 26 + "k / <Up> :Go up.", 27 + "h / <Left> / - :Go to the parent directory.", 28 + "l / <Right> :Open item or change directory.", 29 + "g :Go to the top.", 30 + "G :Go to the bottom.", 31 + "c :Change directory via path. Will enter input mode.", 32 + "R :Rename item. Will enter input mode.", 33 + "D :Delete item.", 34 + "u :Undo delete/rename.", 35 + "d :Create directory. Will enter input mode.", 36 + "% :Create file. Will enter input mode.", 37 + "/ :Fuzzy search directory. Will enter input mode.", 38 + ". :Toggle hidden files.", 39 + ": :Allows for Jido commands to be entered. Please refer to the ", 40 + " \"Command mode\" section for available commands. Will enter ", 41 + " input mode.", 42 + "v :Verbose mode. Provides more information about selected entry. ", 43 + "y :Yank selected item.", 44 + "p :Past yanked item.", 45 + "", 46 + "Input mode:", 47 + "<Esc> :Cancel input.", 48 + "<CR> :Confirm input.", 49 + "", 50 + "Command mode:", 51 + "<Up> / <Down> :Cycle previous commands.", 52 + ":q :Exit.", 53 + ":h :View available keybinds. 'q' to return to app.", 54 + ":config :Navigate to config directory if it exists.", 55 + ":trash :Navigate to trash directory if it exists.", 56 + ":empty_trash :Empty trash if it exists. This action cannot be undone.", 57 + ":cd <path> :Change directory via path. Will enter input mode.", 58 + }; 18 59 19 60 pub const State = enum { 20 61 normal, ··· 23 64 new_file, 24 65 change_dir, 25 66 rename, 26 - }; 27 - 28 - const ActionPaths = struct { 29 - /// Allocated. 30 - old: []const u8, 31 - /// Allocated. 32 - new: []const u8, 67 + command, 68 + help_menu, 33 69 }; 34 70 35 71 pub const Action = union(enum) { 36 - delete: ActionPaths, 37 - rename: ActionPaths, 72 + delete: struct { prev_path: []const u8, new_path: []const u8 }, 73 + rename: struct { prev_path: []const u8, new_path: []const u8 }, 74 + paste: []const u8, 38 75 }; 39 76 40 - const Event = union(enum) { 41 - key_press: vaxis.Key, 77 + pub const Event = union(enum) { 78 + image_ready, 79 + notification, 80 + key_press: Key, 42 81 winsize: vaxis.Winsize, 43 82 }; 44 83 45 - const top_div: u16 = 1; 46 - const info_div: u16 = 1; 47 - const bottom_div: u16 = 1; 48 84 const actions_len = 100; 85 + const image_cache_cap = 100; 49 86 50 87 const App = @This(); 51 88 52 89 alloc: std.mem.Allocator, 53 90 should_quit: bool, 54 91 vx: vaxis.Vaxis = undefined, 92 + tty_buffer: [1024]u8 = undefined, 55 93 tty: vaxis.Tty = undefined, 56 - logger: Logger, 94 + loop: vaxis.Loop(Event) = undefined, 57 95 state: State = .normal, 58 96 actions: CircStack(Action, actions_len), 59 - 60 - // Used to detect whether to re-render an image. 61 - current_item_path_buf: [std.fs.max_path_bytes]u8 = undefined, 62 - current_item_path: []u8 = "", 63 - last_item_path_buf: [std.fs.max_path_bytes]u8 = undefined, 64 - last_item_path: []u8 = "", 65 - file_info_buf: [std.fs.max_path_bytes]u8 = undefined, 66 - file_name_buf: [std.fs.max_path_bytes + 2]u8 = undefined, // +2 to accomodate for [<file_name>] 97 + command_history: CommandHistory = CommandHistory{}, 98 + drawer: Drawer = Drawer{}, 67 99 100 + help_menu: List([]const u8), 68 101 directories: Directories, 69 - notification: Notification, 102 + archive_files: ?Archive.ArchiveContents = null, 103 + notification: Notification = Notification{}, 104 + file_logger: ?FileLogger = null, 70 105 71 - text_input: TextInput, 106 + text_input: vaxis.widgets.TextInput, 72 107 text_input_buf: [std.fs.max_path_bytes]u8 = undefined, 73 108 74 - image: ?vaxis.Image = null, 109 + yanked: ?struct { dir: []const u8, entry: std.fs.Dir.Entry } = null, 75 110 last_known_height: usize, 76 111 77 - pub fn init(alloc: std.mem.Allocator) !App { 112 + images: Image.Cache, 113 + 114 + pub fn init(alloc: std.mem.Allocator, entry_dir: ?[]const u8) !App { 78 115 var vx = try vaxis.init(alloc, .{ 79 116 .kitty_keyboard_flags = .{ 80 117 .report_text = false, ··· 85 122 }, 86 123 }); 87 124 88 - return App{ 125 + var help_menu = List([]const u8).init(alloc); 126 + try help_menu.fromArray(&help_menu_items); 127 + 128 + var app: App = .{ 89 129 .alloc = alloc, 90 130 .should_quit = false, 91 131 .vx = vx, 92 - .tty = try vaxis.Tty.init(), 93 - .directories = try Directories.init(alloc), 94 - .logger = Logger{}, 95 - .text_input = TextInput.init(alloc, &vx.unicode), 96 - .notification = Notification{}, 132 + .directories = try Directories.init(alloc, entry_dir), 133 + .help_menu = help_menu, 134 + .text_input = vaxis.widgets.TextInput.init(alloc), 97 135 .actions = CircStack(Action, actions_len).init(), 98 136 .last_known_height = vx.window().height, 137 + .images = .{ .cache = .init(alloc) }, 99 138 }; 139 + app.tty = try vaxis.Tty.init(&app.tty_buffer); 140 + app.loop = vaxis.Loop(Event){ 141 + .vaxis = &app.vx, 142 + .tty = &app.tty, 143 + }; 144 + 145 + return app; 100 146 } 101 147 102 148 pub fn deinit(self: *App) void { 103 - for (self.actions.buf[0..self.actions.count]) |action| { 149 + while (self.actions.pop()) |action| { 104 150 switch (action) { 105 - .delete, .rename => |a| { 106 - self.alloc.free(a.new); 107 - self.alloc.free(a.old); 151 + .delete => |a| { 152 + self.alloc.free(a.new_path); 153 + self.alloc.free(a.prev_path); 108 154 }, 155 + .rename => |a| { 156 + self.alloc.free(a.new_path); 157 + self.alloc.free(a.prev_path); 158 + }, 159 + .paste => |a| self.alloc.free(a), 109 160 } 110 161 } 111 162 163 + if (self.yanked) |yanked| { 164 + self.alloc.free(yanked.dir); 165 + self.alloc.free(yanked.entry.name); 166 + } 167 + 168 + self.command_history.deinit(self.alloc); 169 + 170 + self.help_menu.deinit(); 112 171 self.directories.deinit(); 113 172 self.text_input.deinit(); 114 - self.vx.deinit(self.alloc, self.tty.anyWriter()); 173 + self.vx.deinit(self.alloc, self.tty.writer()); 115 174 self.tty.deinit(); 116 - } 117 - 118 - pub fn run(self: *App) !void { 119 - self.logger.init(); 120 - self.notification.init(); 121 - 122 - try self.directories.populate_entries(""); 123 - 124 - var loop: vaxis.Loop(Event) = .{ 125 - .vaxis = &self.vx, 126 - .tty = &self.tty, 127 - }; 128 - try loop.start(); 129 - defer loop.stop(); 130 - 131 - try self.vx.enterAltScreen(self.tty.anyWriter()); 132 - try self.vx.queryTerminal(self.tty.anyWriter(), 1 * std.time.ns_per_s); 175 + if (self.file_logger) |file_logger| file_logger.deinit(); 176 + if (self.archive_files) |*archive_files| archive_files.deinit(self.alloc); 133 177 134 - while (!self.should_quit) { 135 - loop.pollEvent(); 136 - while (loop.tryEvent()) |event| { 137 - switch (self.state) { 138 - .normal => { 139 - try self.handle_normal_event(event, &loop); 140 - }, 141 - .fuzzy, .new_file, .new_dir, .rename, .change_dir => { 142 - try self.handle_input_event(event); 143 - }, 144 - } 145 - } 146 - 147 - try self.draw(); 148 - 149 - var buffered = self.tty.bufferedWriter(); 150 - try self.vx.render(buffered.writer().any()); 151 - try buffered.flush(); 178 + var image_iter = self.images.cache.iterator(); 179 + while (image_iter.next()) |img| { 180 + img.value_ptr.deinit(self.alloc, self.vx, &self.tty); 152 181 } 153 - } 154 - 155 - pub fn inputToSlice(self: *App) []const u8 { 156 - self.text_input.buf.cursor = self.text_input.buf.realLength(); 157 - return self.text_input.sliceToCursor(&self.text_input_buf); 182 + self.images.cache.deinit(); 158 183 } 159 184 160 - pub fn handle_normal_event(self: *App, event: Event, loop: *vaxis.Loop(Event)) !void { 161 - switch (event) { 162 - .key_press => |key| { 163 - if ((key.codepoint == 'c' and key.mods.ctrl) or key.codepoint == 'q') { 164 - self.should_quit = true; 165 - } 185 + /// Reads the current text input without consuming it. 186 + /// The returned slice is valid until the next call to readInput() or until 187 + /// the text_input buffer is modified. 188 + pub fn readInput(self: *App) []const u8 { 189 + const first = self.text_input.buf.firstHalf(); 190 + const second = self.text_input.buf.secondHalf(); 191 + var dest_idx: usize = 0; 166 192 167 - switch (key.codepoint) { 168 - '-', 'h', Key.left => { 169 - self.text_input.clearAndFree(); 193 + const first_len = @min(first.len, self.text_input_buf.len - dest_idx); 194 + @memcpy(self.text_input_buf[dest_idx .. dest_idx + first_len], first[0..first_len]); 195 + dest_idx += first_len; 170 196 171 - if (self.directories.dir.openDir("../", .{ .iterate = true })) |dir| { 172 - self.directories.dir = dir; 197 + const second_len = @min(second.len, self.text_input_buf.len - dest_idx); 198 + @memcpy(self.text_input_buf[dest_idx .. dest_idx + second_len], second[0..second_len]); 199 + dest_idx += second_len; 173 200 174 - self.directories.cleanup(); 175 - const fuzzy = self.inputToSlice(); 176 - self.directories.populate_entries(fuzzy) catch |err| { 177 - switch (err) { 178 - error.AccessDenied => try self.notification.write_err(.PermissionDenied), 179 - else => try self.notification.write_err(.UnknownError), 180 - } 181 - }; 182 - 183 - if (self.directories.history.pop()) |history| { 184 - self.directories.entries.selected = history.selected; 185 - self.directories.entries.offset = history.offset; 186 - } 187 - } else |err| { 188 - switch (err) { 189 - error.AccessDenied => try self.notification.write_err(.PermissionDenied), 190 - else => try self.notification.write_err(.UnknownError), 191 - } 192 - } 193 - }, 194 - Key.enter, 'l', Key.right => { 195 - const entry = self.directories.get_selected() catch return; 196 - 197 - switch (entry.kind) { 198 - .directory => { 199 - self.text_input.clearAndFree(); 200 - 201 - if (self.directories.dir.openDir(entry.name, .{ .iterate = true })) |dir| { 202 - self.directories.dir = dir; 203 - 204 - _ = self.directories.history.push(.{ 205 - .selected = self.directories.entries.selected, 206 - .offset = self.directories.entries.offset, 207 - }); 208 - 209 - self.directories.cleanup(); 210 - const fuzzy = self.inputToSlice(); 211 - self.directories.populate_entries(fuzzy) catch |err| { 212 - switch (err) { 213 - error.AccessDenied => try self.notification.write_err(.PermissionDenied), 214 - else => try self.notification.write_err(.UnknownError), 215 - } 216 - }; 217 - } else |err| { 218 - switch (err) { 219 - error.AccessDenied => try self.notification.write_err(.PermissionDenied), 220 - else => try self.notification.write_err(.UnknownError), 221 - } 222 - } 223 - }, 224 - .file => { 225 - if (environment.get_editor()) |editor| { 226 - try self.vx.exitAltScreen(self.tty.anyWriter()); 227 - try self.vx.resetState(self.tty.anyWriter()); 228 - loop.stop(); 229 - 230 - environment.open_file(self.alloc, self.directories.dir, entry.name, editor) catch { 231 - try self.notification.write_err(.UnableToOpenFile); 232 - }; 233 - 234 - try loop.start(); 235 - try self.vx.enterAltScreen(self.tty.anyWriter()); 236 - try self.vx.enableDetectedFeatures(self.tty.anyWriter()); 237 - self.vx.queueRefresh(); 238 - } else { 239 - try self.notification.write_err(.EditorNotSet); 240 - } 241 - }, 242 - else => {}, 243 - } 244 - }, 245 - 'j', Key.down => { 246 - self.directories.entries.next(self.last_known_height); 247 - }, 248 - 'k', Key.up => { 249 - self.directories.entries.previous(self.last_known_height); 250 - }, 251 - 'G' => { 252 - self.directories.entries.select_last(self.last_known_height); 253 - }, 254 - 'g' => { 255 - self.directories.entries.select_first(); 256 - }, 257 - 'D' => { 258 - const entry = self.directories.get_selected() catch { 259 - try self.notification.write_err(.UnableToDelete); 260 - return; 261 - }; 262 - 263 - var old_path_buf: [std.fs.max_path_bytes]u8 = undefined; 264 - const old_path = try self.alloc.dupe(u8, try self.directories.dir.realpath(entry.name, &old_path_buf)); 265 - var tmp_path_buf: [std.fs.max_path_bytes]u8 = undefined; 266 - const tmp_path = try self.alloc.dupe(u8, try std.fmt.bufPrint(&tmp_path_buf, "/tmp/{s}-{s}", .{ entry.name, zuid.new.v4().toString() })); 267 - 268 - if (self.directories.dir.rename(entry.name, tmp_path)) { 269 - if (self.actions.push(.{ 270 - .delete = .{ .old = old_path, .new = tmp_path }, 271 - })) |prev_elem| { 272 - self.alloc.free(prev_elem.delete.old); 273 - self.alloc.free(prev_elem.delete.new); 274 - } 275 - 276 - try self.notification.write_info(.Deleted); 277 - self.directories.remove_selected(); 278 - } else |err| { 279 - switch (err) { 280 - error.RenameAcrossMountPoints => try self.notification.write_err(.UnableToDeleteAcrossMountPoints), 281 - else => try self.notification.write_err(.UnableToDelete), 282 - } 283 - self.alloc.free(old_path); 284 - self.alloc.free(tmp_path); 285 - } 286 - }, 287 - 'd' => { 288 - self.text_input.clearAndFree(); 289 - self.directories.cleanup(); 290 - self.directories.populate_entries("") catch |err| { 291 - switch (err) { 292 - error.AccessDenied => try self.notification.write_err(.PermissionDenied), 293 - else => try self.notification.write_err(.UnknownError), 294 - } 295 - }; 296 - self.state = .new_dir; 297 - }, 298 - '%' => { 299 - self.text_input.clearAndFree(); 300 - self.directories.cleanup(); 301 - self.directories.populate_entries("") catch |err| { 302 - switch (err) { 303 - error.AccessDenied => try self.notification.write_err(.PermissionDenied), 304 - else => try self.notification.write_err(.UnknownError), 305 - } 306 - }; 307 - self.state = .new_file; 308 - }, 309 - 'u' => { 310 - if (self.actions.pop()) |action| { 311 - const selected = self.directories.entries.selected; 312 - 313 - switch (action) { 314 - .delete => |a| { 315 - // TODO: Will overwrite an item if it has the same name. 316 - if (self.directories.dir.rename(a.new, a.old)) { 317 - defer self.alloc.free(a.new); 318 - defer self.alloc.free(a.old); 319 - 320 - self.directories.cleanup(); 321 - const fuzzy = self.inputToSlice(); 322 - self.directories.populate_entries(fuzzy) catch |err| { 323 - switch (err) { 324 - error.AccessDenied => try self.notification.write_err(.PermissionDenied), 325 - else => try self.notification.write_err(.UnknownError), 326 - } 327 - }; 328 - try self.notification.write_info(.RestoredDelete); 329 - } else |_| { 330 - try self.notification.write_err(.UnableToUndo); 331 - } 332 - }, 333 - .rename => |a| { 334 - // TODO: Will overwrite an item if it has the same name. 335 - if (self.directories.dir.rename(a.new, a.old)) { 336 - defer self.alloc.free(a.new); 337 - defer self.alloc.free(a.old); 338 - 339 - self.directories.cleanup(); 340 - const fuzzy = self.inputToSlice(); 341 - self.directories.populate_entries(fuzzy) catch |err| { 342 - switch (err) { 343 - error.AccessDenied => try self.notification.write_err(.PermissionDenied), 344 - else => try self.notification.write_err(.UnknownError), 345 - } 346 - }; 347 - try self.notification.write_info(.RestoredRename); 348 - } else |_| { 349 - try self.notification.write_err(.UnableToUndo); 350 - } 351 - }, 352 - } 353 - 354 - self.directories.entries.selected = selected; 355 - } else { 356 - try self.notification.write_info(.EmptyUndo); 357 - } 358 - }, 359 - '/' => { 360 - self.state = .fuzzy; 361 - }, 362 - 'R' => { 363 - self.state = .rename; 364 - 365 - const entry = self.directories.get_selected() catch { 366 - self.state = .normal; 367 - try self.notification.write_err(.UnableToRename); 368 - return; 369 - }; 370 - 371 - self.text_input.insertSliceAtCursor(entry.name) catch { 372 - self.state = .normal; 373 - try self.notification.write_err(.UnableToRename); 374 - return; 375 - }; 376 - }, 377 - 'c' => { 378 - self.state = .change_dir; 379 - }, 380 - else => {}, 381 - } 382 - }, 383 - .winsize => |ws| try self.vx.resize(self.alloc, self.tty.anyWriter(), ws), 384 - } 201 + return self.text_input_buf[0..dest_idx]; 385 202 } 386 203 387 - pub fn handle_input_event(self: *App, event: Event) !void { 388 - switch (event) { 389 - .key_press => |key| { 390 - if ((key.codepoint == 'c' and key.mods.ctrl)) { 391 - self.should_quit = true; 392 - return; 393 - } 394 - 395 - switch (key.codepoint) { 396 - Key.escape => { 397 - switch (self.state) { 398 - .fuzzy => { 399 - self.directories.cleanup(); 400 - self.directories.populate_entries("") catch |err| { 401 - switch (err) { 402 - error.AccessDenied => try self.notification.write_err(.PermissionDenied), 403 - else => try self.notification.write_err(.UnknownError), 404 - } 405 - }; 406 - }, 407 - else => {}, 408 - } 204 + pub fn repopulateDirectory(self: *App, fuzzy: []const u8) error{OutOfMemory}!void { 205 + self.directories.clearEntries(); 206 + self.directories.populateEntries(fuzzy) catch |err| { 207 + const message = try std.fmt.allocPrint(self.alloc, "Failed to read directory entries - {}.", .{err}); 208 + defer self.alloc.free(message); 209 + self.notification.write(message, .err) catch {}; 210 + if (self.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 211 + }; 212 + } 409 213 410 - self.text_input.clearAndFree(); 411 - self.state = .normal; 412 - }, 413 - Key.enter => { 414 - const selected = self.directories.entries.selected; 415 - switch (self.state) { 416 - .new_dir => { 417 - const dir = self.inputToSlice(); 418 - if (self.directories.dir.makeDir(dir)) { 419 - try self.notification.write_info(.CreatedFolder); 214 + pub fn run(self: *App) !void { 215 + try self.repopulateDirectory(""); 216 + try self.loop.start(); 217 + defer self.loop.stop(); 420 218 421 - self.directories.cleanup(); 422 - self.directories.populate_entries("") catch |err| { 423 - switch (err) { 424 - error.AccessDenied => try self.notification.write_err(.PermissionDenied), 425 - else => try self.notification.write_err(.UnknownError), 426 - } 427 - }; 428 - } else |err| { 429 - switch (err) { 430 - error.AccessDenied => try self.notification.write_err(.PermissionDenied), 431 - error.PathAlreadyExists => try self.notification.write_err(.ItemAlreadyExists), 432 - else => try self.notification.write_err(.UnknownError), 433 - } 434 - } 435 - self.text_input.clearAndFree(); 436 - }, 437 - .new_file => { 438 - const file = self.inputToSlice(); 439 - if (environment.file_exists(self.directories.dir, file)) { 440 - try self.notification.write_err(.ItemAlreadyExists); 441 - } else { 442 - if (self.directories.dir.createFile(file, .{})) |f| { 443 - f.close(); 219 + try self.vx.enterAltScreen(self.tty.writer()); 220 + try self.vx.queryTerminal(self.tty.writer(), 1 * std.time.ns_per_s); 221 + self.vx.caps.kitty_graphics = true; 444 222 445 - try self.notification.write_info(.CreatedFile); 446 - 447 - self.directories.cleanup(); 448 - self.directories.populate_entries("") catch |err| { 449 - switch (err) { 450 - error.AccessDenied => try self.notification.write_err(.PermissionDenied), 451 - else => try self.notification.write_err(.UnknownError), 452 - } 453 - }; 454 - } else |err| { 455 - switch (err) { 456 - error.AccessDenied => try self.notification.write_err(.PermissionDenied), 457 - else => try self.notification.write_err(.UnknownError), 458 - } 459 - } 460 - } 461 - self.text_input.clearAndFree(); 462 - }, 463 - .rename => { 464 - var dir_prefix_buf: [std.fs.max_path_bytes]u8 = undefined; 465 - const dir_prefix = try self.directories.dir.realpath(".", &dir_prefix_buf); 466 - 467 - const old = try self.directories.get_selected(); 468 - const new = self.inputToSlice(); 469 - 470 - if (environment.file_exists(self.directories.dir, new)) { 471 - try self.notification.write_err(.ItemAlreadyExists); 472 - } else { 473 - self.directories.dir.rename(old.name, new) catch |err| switch (err) { 474 - error.AccessDenied => try self.notification.write_err(.PermissionDenied), 475 - error.PathAlreadyExists => try self.notification.write_err(.ItemAlreadyExists), 476 - else => try self.notification.write_err(.UnknownError), 477 - }; 478 - if (self.actions.push(.{ 479 - .rename = .{ 480 - .old = try std.fs.path.join(self.alloc, &.{ dir_prefix, old.name }), 481 - .new = try std.fs.path.join(self.alloc, &.{ dir_prefix, new }), 482 - }, 483 - })) |prev_elem| { 484 - self.alloc.free(prev_elem.rename.old); 485 - self.alloc.free(prev_elem.rename.new); 486 - } 487 - 488 - try self.notification.write_info(.Renamed); 489 - 490 - self.directories.cleanup(); 491 - self.directories.populate_entries("") catch |err| { 492 - switch (err) { 493 - error.AccessDenied => try self.notification.write_err(.PermissionDenied), 494 - else => try self.notification.write_err(.UnknownError), 495 - } 496 - }; 497 - } 498 - self.text_input.clearAndFree(); 499 - }, 500 - .change_dir => { 501 - const path = self.inputToSlice(); 502 - if (self.directories.dir.openDir(path, .{ .iterate = true })) |dir| { 503 - self.directories.dir = dir; 504 - 505 - try self.notification.write_info(.ChangedDir); 506 - 507 - self.directories.cleanup(); 508 - self.directories.populate_entries("") catch |err| { 509 - switch (err) { 510 - error.AccessDenied => try self.notification.write_err(.PermissionDenied), 511 - else => try self.notification.write_err(.UnknownError), 512 - } 513 - }; 514 - self.directories.history.reset(); 515 - } else |err| { 516 - switch (err) { 517 - error.AccessDenied => try self.notification.write_err(.PermissionDenied), 518 - error.FileNotFound => try self.notification.write_err(.IncorrectPath), 519 - error.NotDir => try self.notification.write_err(.IncorrectPath), 520 - else => try self.notification.write_err(.UnknownError), 521 - } 522 - } 223 + while (!self.should_quit) { 224 + self.loop.pollEvent(); 225 + while (self.loop.tryEvent()) |event| { 226 + // Global keybinds. 227 + try EventHandlers.handleGlobalEvent(self, event); 523 228 524 - self.text_input.clearAndFree(); 525 - }, 526 - else => {}, 527 - } 528 - self.state = .normal; 529 - self.directories.entries.selected = selected; 229 + // State specific keybinds. 230 + switch (self.state) { 231 + .normal => { 232 + try EventHandlers.handleNormalEvent(self, event); 233 + }, 234 + .help_menu => { 235 + try EventHandlers.handleHelpMenuEvent(self, event); 530 236 }, 531 237 else => { 532 - try self.text_input.update(.{ .key_press = key }); 533 - 534 - switch (self.state) { 535 - .fuzzy => { 536 - self.directories.cleanup(); 537 - const fuzzy = self.inputToSlice(); 538 - self.directories.populate_entries(fuzzy) catch |err| { 539 - switch (err) { 540 - error.AccessDenied => try self.notification.write_err(.PermissionDenied), 541 - else => try self.notification.write_err(.UnknownError), 542 - } 543 - }; 544 - }, 545 - else => {}, 546 - } 238 + try EventHandlers.handleInputEvent(self, event); 547 239 }, 548 240 } 549 - }, 550 - .winsize => |ws| try self.vx.resize(self.alloc, self.tty.anyWriter(), ws), 551 - } 552 - } 241 + } 553 242 554 - pub fn draw(self: *App) !void { 555 - const win = self.vx.window(); 556 - win.clear(); 243 + try self.drawer.draw(self); 557 244 558 - const abs_file_path_bar = try self.draw_abs_file_path(win); 559 - const file_info_bar = try self.draw_file_info(win); 560 - try self.draw_current_dir_list(win, abs_file_path_bar, file_info_bar); 561 - 562 - if (config.preview_file == true) { 563 - const file_name_bar = try self.draw_file_name(win); 564 - try self.draw_preview(win, file_name_bar); 245 + const writer = self.tty.writer(); 246 + try self.vx.render(writer); 247 + try writer.flush(); 565 248 } 566 249 567 - try self.draw_user_input(win); 568 - try self.draw_notification(win); 569 - } 570 - 571 - fn draw_file_name(self: *App, win: vaxis.Window) !vaxis.Window { 572 - const file_name_bar = win.child(.{ 573 - .x_off = win.width / 2, 574 - .y_off = 0, 575 - .width = win.width, 576 - .height = top_div, 577 - }); 578 - 579 - if (self.directories.get_selected()) |entry| { 580 - const file_name = try std.fmt.bufPrint(&self.file_name_buf, "[{s}]", .{entry.name}); 581 - _ = file_name_bar.print(&.{vaxis.Segment{ 582 - .text = file_name, 583 - .style = config.styles.file_name, 584 - }}, .{}); 585 - } else |_| {} 586 - 587 - return file_name_bar; 588 - } 589 - 590 - fn draw_preview(self: *App, win: vaxis.Window, file_name_win: vaxis.Window) !void { 591 - const preview_win = win.child(.{ 592 - .x_off = win.width / 2, 593 - .y_off = top_div + 1, 594 - .width = win.width / 2, 595 - .height = win.height - (file_name_win.height + top_div + bottom_div), 596 - }); 597 - 598 - // Populate preview bar 599 - if (self.directories.entries.len() > 0 and config.preview_file == true) { 600 - const entry = try self.directories.get_selected(); 601 - 602 - @memcpy(&self.last_item_path_buf, &self.current_item_path_buf); 603 - self.last_item_path = self.last_item_path_buf[0..self.current_item_path.len]; 604 - self.current_item_path = try std.fmt.bufPrint(&self.current_item_path_buf, "{s}/{s}", .{ try self.directories.full_path("."), entry.name }); 605 - 606 - switch (entry.kind) { 607 - .directory => { 608 - self.directories.cleanup_sub(); 609 - if (self.directories.populate_sub_entries(entry.name)) { 610 - try self.directories.write_sub_entries(preview_win, config.styles.list_item); 611 - } else |err| { 612 - switch (err) { 613 - error.AccessDenied => try self.notification.write_err(.PermissionDenied), 614 - else => try self.notification.write_err(.UnknownError), 615 - } 616 - } 617 - }, 618 - .file => file: { 619 - var file = self.directories.dir.openFile(entry.name, .{ .mode = .read_only }) catch |err| { 620 - switch (err) { 621 - error.AccessDenied => try self.notification.write_err(.PermissionDenied), 622 - else => try self.notification.write_err(.UnknownError), 623 - } 624 - 625 - _ = preview_win.print(&.{.{ .text = "No preview available." }}, .{}); 626 - 627 - break :file; 628 - }; 629 - defer file.close(); 630 - const bytes = try file.readAll(&self.directories.file_contents); 631 - 632 - // Handle image. 633 - if (config.show_images == true) unsupported: { 634 - var match = false; 635 - inline for (@typeInfo(vaxis.zigimg.Image.Format).Enum.fields) |field| { 636 - const entry_ext = std.mem.trimLeft(u8, std.fs.path.extension(entry.name), "."); 637 - if (std.mem.eql(u8, entry_ext, field.name)) { 638 - match = true; 639 - } 640 - } 641 - if (!match) break :unsupported; 642 - 643 - if (std.mem.eql(u8, self.last_item_path, self.current_item_path)) break :unsupported; 644 - 645 - var image = vaxis.zigimg.Image.fromFilePath(self.alloc, self.current_item_path) catch { 646 - break :unsupported; 647 - }; 648 - defer image.deinit(); 649 - if (self.vx.transmitImage(self.alloc, self.tty.anyWriter(), &image, .rgba)) |img| { 650 - self.image = img; 651 - } else |_| { 652 - if (self.image) |img| { 653 - self.vx.freeImage(self.tty.anyWriter(), img.id); 654 - } 655 - self.image = null; 656 - break :unsupported; 657 - } 658 - 659 - if (self.image) |img| { 660 - try img.draw(preview_win, .{ .scale = .contain }); 661 - } 662 - 663 - break :file; 664 - } 665 - 666 - // Handle pdf. 667 - if (std.mem.eql(u8, std.fs.path.extension(entry.name), ".pdf")) { 668 - const output = std.process.Child.run(.{ 669 - .allocator = self.alloc, 670 - .argv = &[_][]const u8{ "pdftotext", "-f", "0", "-l", "5", self.current_item_path, "-" }, 671 - .cwd_dir = self.directories.dir, 672 - }) catch { 673 - _ = preview_win.print(&.{.{ 674 - .text = "No preview available. Install pdftotext to get PDF previews.", 675 - }}, .{}); 676 - break :file; 677 - }; 678 - defer self.alloc.free(output.stderr); 679 - defer self.alloc.free(output.stdout); 680 - 681 - if (output.term.Exited != 0) { 682 - _ = preview_win.print(&.{.{ 683 - .text = "No preview available. Install pdftotext to get PDF previews.", 684 - }}, .{}); 685 - break :file; 686 - } 687 - 688 - if (self.directories.pdf_contents) |contents| self.alloc.free(contents); 689 - self.directories.pdf_contents = try self.alloc.dupe(u8, output.stdout); 690 - 691 - _ = preview_win.print(&.{.{ .text = self.directories.pdf_contents.? }}, .{}); 692 - break :file; 693 - } 694 - 695 - // Handle utf-8. 696 - if (std.unicode.utf8ValidateSlice(self.directories.file_contents[0..bytes])) { 697 - _ = preview_win.print(&.{.{ .text = self.directories.file_contents[0..bytes] }}, .{}); 698 - break :file; 699 - } 700 - 701 - // Fallback to no preview. 702 - _ = preview_win.print(&.{.{ .text = "No preview available." }}, .{}); 703 - }, 704 - else => { 705 - _ = preview_win.print(&.{vaxis.Segment{ .text = self.current_item_path }}, .{}); 706 - }, 707 - } 708 - } 709 - } 710 - 711 - fn draw_file_info(self: *App, win: vaxis.Window) !vaxis.Window { 712 - const file_info = try std.fmt.bufPrint(&self.file_info_buf, "{d}/{d} {s} {s}", .{ 713 - self.directories.entries.selected + 1, 714 - self.directories.entries.len(), 715 - std.fs.path.extension(if (self.directories.get_selected()) |entry| entry.name else |_| ""), 716 - // TODO: This should be the file size, not dir. 717 - std.fmt.fmtIntSizeDec((try self.directories.dir.metadata()).size()), 718 - }); 719 - 720 - const file_info_win = win.child(.{ 721 - .x_off = 0, 722 - .y_off = win.height - bottom_div, 723 - .width = if (config.preview_file) win.width / 2 else win.width, 724 - .height = bottom_div, 725 - }); 726 - file_info_win.fill(vaxis.Cell{ .style = config.styles.file_information }); 727 - _ = file_info_win.print(&.{vaxis.Segment{ .text = file_info, .style = config.styles.file_information }}, .{}); 728 - 729 - return file_info_win; 730 - } 731 - 732 - fn draw_current_dir_list(self: *App, win: vaxis.Window, abs_file_path: vaxis.Window, file_information: vaxis.Window) !void { 733 - const current_dir_list_win = win.child(.{ 734 - .x_off = 0, 735 - .y_off = top_div + 1, 736 - .width = if (config.preview_file) win.width / 2 else win.width, 737 - .height = win.height - (abs_file_path.height + file_information.height + top_div + bottom_div), 738 - }); 739 - try self.directories.write_entries(current_dir_list_win, config.styles.selected_list_item, config.styles.list_item); 740 - 741 - self.last_known_height = current_dir_list_win.height; 742 - } 743 - 744 - fn draw_abs_file_path(self: *App, win: vaxis.Window) !vaxis.Window { 745 - const abs_file_path_bar = win.child(.{ 746 - .x_off = 0, 747 - .y_off = 0, 748 - .width = win.width, 749 - .height = top_div, 750 - }); 751 - _ = abs_file_path_bar.print(&.{vaxis.Segment{ .text = try self.directories.full_path(".") }}, .{}); 752 - 753 - return abs_file_path_bar; 754 - } 755 - 756 - fn draw_user_input(self: *App, win: vaxis.Window) !void { 757 - const user_input_win = win.child(.{ 758 - .x_off = 0, 759 - .y_off = top_div, 760 - .width = win.width, 761 - .height = info_div, 762 - }); 763 - 764 - switch (self.state) { 765 - .fuzzy, .new_file, .new_dir, .rename, .change_dir => { 766 - // TODO: Investigate why removing this causes a segfault when 767 - // entering user input while a notification is being rendered. 768 - self.notification.reset(); 769 - 770 - self.text_input.draw(user_input_win); 771 - }, 772 - .normal => { 773 - if (self.text_input.buf.realLength() > 0) { 774 - self.text_input.draw(user_input_win); 250 + if (config.empty_trash_on_exit) { 251 + var trash_dir = dir: { 252 + notfound: { 253 + break :dir (config.trashDir() catch break :notfound) orelse break :notfound; 775 254 } 776 - 777 - win.hideCursor(); 778 - }, 779 - } 780 - } 781 - 782 - fn draw_notification(self: *App, win: vaxis.Window) !void { 783 - if (self.notification.len > 0) { 784 - const notification_width_padding = 4; 785 - const notification_height_padding = 3; 786 - const notification_screen_pos_padding = 10; 787 - 788 - const max_notification_width = win.width / 4; 789 - const notification_width = self.notification.len + notification_width_padding; 790 - const abs_notification_width = if (notification_width > max_notification_width) max_notification_width else notification_width; 791 - const notification_height = try std.math.divCeil(usize, self.notification.len, abs_notification_width) + notification_height_padding; 792 - 793 - const notification_win = win.child(.{ 794 - .x_off = @intCast(win.width - (abs_notification_width + notification_screen_pos_padding)), 795 - .y_off = top_div, 796 - .width = @intCast(abs_notification_width), 797 - .height = @intCast(notification_height), 798 - .border = .{ .where = .all }, 799 - }); 800 - 801 - if (self.text_input.buf.realLength() > 0) { 802 - self.text_input.clearAndFree(); 803 - } 804 - 805 - notification_win.fill(.{ .style = config.styles.notification_box }); 806 - _ = notification_win.printSegment(.{ 807 - .text = self.notification.slice(), 808 - .style = switch (self.notification.style) { 809 - .info => config.styles.info_bar, 810 - .err => config.styles.error_bar, 811 - }, 812 - }, .{ .wrap = .word }); 255 + if (self.file_logger) |file_logger| file_logger.write("Failed to open trash directory.", .err) catch { 256 + std.log.err("Failed to open trash directory.", .{}); 257 + }; 258 + return; 259 + }; 260 + defer trash_dir.close(); 813 261 814 - if (std.time.timestamp() - self.notification.timer > Notification.notification_timeout) { 815 - self.notification.reset(); 262 + const failed = environment.deleteContents(trash_dir) catch |err| { 263 + const message = try std.fmt.allocPrint(self.alloc, "Failed to empty trash - {}.", .{err}); 264 + defer self.alloc.free(message); 265 + if (self.file_logger) |file_logger| file_logger.write(message, .err) catch { 266 + std.log.err("Failed to empty trash - {}.", .{err}); 267 + }; 268 + return; 269 + }; 270 + if (failed > 0) { 271 + const message = try std.fmt.allocPrint(self.alloc, "Failed to empty {d} items from the trash.", .{failed}); 272 + defer self.alloc.free(message); 273 + if (self.file_logger) |file_logger| file_logger.write(message, .err) catch { 274 + std.log.err("Failed to empty {d} items from the trash.", .{failed}); 275 + }; 816 276 } 817 277 } 818 278 }
+206
src/archive.zig
··· 1 + const std = @import("std"); 2 + const ascii = @import("std").ascii; 3 + 4 + const archive_buf_size = 8192; 5 + 6 + pub const ArchiveType = enum { 7 + tar, 8 + @"tar.gz", 9 + @"tar.xz", 10 + @"tar.zst", 11 + zip, 12 + 13 + pub fn fromPath(file_path: []const u8) ?ArchiveType { 14 + if (ascii.endsWithIgnoreCase(file_path, ".tar")) return .tar; 15 + if (ascii.endsWithIgnoreCase(file_path, ".tgz")) return .@"tar.gz"; 16 + if (ascii.endsWithIgnoreCase(file_path, ".tar.gz")) return .@"tar.gz"; 17 + if (ascii.endsWithIgnoreCase(file_path, ".txz")) return .@"tar.xz"; 18 + if (ascii.endsWithIgnoreCase(file_path, ".tar.xz")) return .@"tar.xz"; 19 + if (ascii.endsWithIgnoreCase(file_path, ".tzst")) return .@"tar.zst"; 20 + if (ascii.endsWithIgnoreCase(file_path, ".tar.zst")) return .@"tar.zst"; 21 + if (ascii.endsWithIgnoreCase(file_path, ".zip")) return .zip; 22 + if (ascii.endsWithIgnoreCase(file_path, ".jar")) return .zip; 23 + return null; 24 + } 25 + }; 26 + 27 + pub const ArchiveContents = struct { 28 + entries: std.ArrayList([]const u8), 29 + 30 + pub fn deinit(self: *ArchiveContents, alloc: std.mem.Allocator) void { 31 + for (self.entries.items) |entry| alloc.free(entry); 32 + self.entries.deinit(alloc); 33 + } 34 + }; 35 + 36 + pub fn listArchiveContents( 37 + alloc: std.mem.Allocator, 38 + file: std.fs.File, 39 + archive_type: ArchiveType, 40 + traversal_limit: usize, 41 + ) !ArchiveContents { 42 + var buffer: [archive_buf_size]u8 = undefined; 43 + var reader = file.reader(&buffer); 44 + 45 + const contents = switch (archive_type) { 46 + .tar => try listTar(alloc, &reader.interface, traversal_limit), 47 + .@"tar.gz" => try listTarGz(alloc, &reader.interface, traversal_limit), 48 + .@"tar.xz" => try listTarXz(alloc, &reader.interface, traversal_limit), 49 + .@"tar.zst" => try listTarZst(alloc, &reader.interface, traversal_limit), 50 + .zip => try listZip(alloc, file, traversal_limit), 51 + }; 52 + 53 + return contents; 54 + } 55 + 56 + fn extractTopLevelEntry( 57 + alloc: std.mem.Allocator, 58 + full_path: []const u8, 59 + is_directory: bool, 60 + truncated: bool, 61 + ) ![]const u8 { 62 + var is_directory_internal = is_directory; 63 + var path = full_path; 64 + 65 + if (std.mem.indexOfScalar(u8, full_path, '/')) |idx| { 66 + path = full_path[0..idx]; 67 + is_directory_internal = true; 68 + } 69 + 70 + return try std.fmt.allocPrint( 71 + alloc, 72 + "{s}{s}{s}", 73 + .{ path, if (truncated) "..." else "", if (is_directory_internal) "/" else "" }, 74 + ); 75 + } 76 + 77 + fn listTar( 78 + alloc: std.mem.Allocator, 79 + reader: anytype, 80 + traversal_limit: usize, 81 + ) !ArchiveContents { 82 + var entries: std.ArrayList([]const u8) = .empty; 83 + errdefer { 84 + for (entries.items) |e| alloc.free(e); 85 + entries.deinit(alloc); 86 + } 87 + 88 + var seen = std.StringHashMap(void).init(alloc); 89 + defer seen.deinit(); 90 + 91 + var diagnostics: std.tar.Diagnostics = .{ .allocator = alloc }; 92 + defer diagnostics.deinit(); 93 + 94 + var file_name_buffer: [std.fs.max_path_bytes]u8 = undefined; 95 + var link_name_buffer: [std.fs.max_path_bytes]u8 = undefined; 96 + var iter = std.tar.Iterator.init(reader, .{ 97 + .file_name_buffer = &file_name_buffer, 98 + .link_name_buffer = &link_name_buffer, 99 + }); 100 + iter.diagnostics = &diagnostics; 101 + 102 + for (0..traversal_limit) |_| { 103 + const tar_file = try iter.next(); 104 + if (tar_file == null) break; 105 + 106 + const is_dir = tar_file.?.kind == .directory; 107 + const truncated = tar_file.?.name.len >= std.fs.max_path_bytes; 108 + const entry = try extractTopLevelEntry(alloc, tar_file.?.name, is_dir, truncated); 109 + 110 + const gop = try seen.getOrPut(entry); 111 + if (gop.found_existing) { 112 + alloc.free(entry); 113 + continue; 114 + } 115 + 116 + try entries.append(alloc, entry); 117 + } 118 + 119 + return ArchiveContents{ 120 + .entries = entries, 121 + }; 122 + } 123 + 124 + fn listTarGz( 125 + alloc: std.mem.Allocator, 126 + reader: anytype, 127 + limit: usize, 128 + ) !ArchiveContents { 129 + var flate_buffer: [std.compress.flate.max_window_len]u8 = undefined; 130 + var decompress = std.compress.flate.Decompress.init(reader, .gzip, &flate_buffer); 131 + return try listTar(alloc, &decompress.reader, limit); 132 + } 133 + 134 + fn listTarXz( 135 + alloc: std.mem.Allocator, 136 + reader: anytype, 137 + limit: usize, 138 + ) !ArchiveContents { 139 + var dcp = try std.compress.xz.decompress(alloc, reader.adaptToOldInterface()); 140 + defer dcp.deinit(); 141 + var adapter_buffer: [1024]u8 = undefined; 142 + var adapter = dcp.reader().adaptToNewApi(&adapter_buffer); 143 + return try listTar(alloc, &adapter.new_interface, limit); 144 + } 145 + 146 + fn listTarZst( 147 + alloc: std.mem.Allocator, 148 + reader: anytype, 149 + limit: usize, 150 + ) !ArchiveContents { 151 + const window_len = std.compress.zstd.default_window_len; 152 + const window_buffer = try alloc.alloc(u8, window_len + std.compress.zstd.block_size_max); 153 + var decompress: std.compress.zstd.Decompress = .init(reader, window_buffer, .{ 154 + .verify_checksum = false, 155 + .window_len = window_len, 156 + }); 157 + return try listTar(alloc, &decompress.reader, limit); 158 + } 159 + 160 + fn listZip( 161 + alloc: std.mem.Allocator, 162 + file: std.fs.File, 163 + traversal_limit: usize, 164 + ) !ArchiveContents { 165 + var entries: std.ArrayList([]const u8) = .empty; 166 + errdefer { 167 + for (entries.items) |e| alloc.free(e); 168 + entries.deinit(alloc); 169 + } 170 + 171 + var seen = std.StringHashMap(void).init(alloc); 172 + defer seen.deinit(); 173 + 174 + var buffer: [archive_buf_size]u8 = undefined; 175 + var file_reader = file.reader(&buffer); 176 + 177 + var iter = try std.zip.Iterator.init(&file_reader); 178 + var file_name_buf: [std.fs.max_path_bytes]u8 = undefined; 179 + 180 + for (0..traversal_limit) |_| { 181 + const zip_file = try iter.next(); 182 + if (zip_file == null) break; 183 + 184 + const file_name_len = @min(zip_file.?.filename_len, file_name_buf.len); 185 + const truncated = zip_file.?.filename_len > file_name_buf.len; 186 + 187 + try file_reader.seekTo(zip_file.?.header_zip_offset + @sizeOf(std.zip.CentralDirectoryFileHeader)); 188 + const file_name = file_name_buf[0..file_name_len]; 189 + try file_reader.interface.readSliceAll(file_name); 190 + 191 + const is_dir = std.mem.endsWith(u8, file_name, "/"); 192 + const entry = try extractTopLevelEntry(alloc, file_name, is_dir, truncated); 193 + 194 + const gop = try seen.getOrPut(entry); 195 + if (gop.found_existing) { 196 + alloc.free(entry); 197 + continue; 198 + } 199 + 200 + try entries.append(alloc, entry); 201 + } 202 + 203 + return ArchiveContents{ 204 + .entries = entries, 205 + }; 206 + }
+44 -1
src/circ_stack.zig
··· 30 30 pub fn pop(self: *Self) ?T { 31 31 if (self.count == 0) return null; 32 32 33 - self.head = (self.head - 1) % capacity; 33 + self.head = if (self.head == 0) capacity - 1 else self.head - 1; 34 34 const value = self.buf[self.head]; 35 35 self.count -= 1; 36 36 return value; 37 37 } 38 38 }; 39 39 } 40 + 41 + const testing = std.testing; 42 + 43 + test "CircularStack: push and pop basic operations" { 44 + var stack = CircularStack(u32, 5).init(); 45 + 46 + _ = stack.push(1); 47 + _ = stack.push(2); 48 + _ = stack.push(3); 49 + 50 + try testing.expectEqual(@as(?u32, 3), stack.pop()); 51 + try testing.expectEqual(@as(?u32, 2), stack.pop()); 52 + try testing.expectEqual(@as(?u32, 1), stack.pop()); 53 + try testing.expectEqual(@as(?u32, null), stack.pop()); 54 + } 55 + 56 + test "CircularStack: wraparound behavior at capacity" { 57 + var stack = CircularStack(u32, 3).init(); 58 + 59 + _ = stack.push(1); 60 + _ = stack.push(2); 61 + _ = stack.push(3); 62 + 63 + const evicted = stack.push(4); 64 + try testing.expectEqual(@as(?u32, 1), evicted); 65 + 66 + try testing.expectEqual(@as(?u32, 4), stack.pop()); 67 + try testing.expectEqual(@as(?u32, 3), stack.pop()); 68 + try testing.expectEqual(@as(?u32, 2), stack.pop()); 69 + } 70 + 71 + test "CircularStack: reset clears all entries" { 72 + var stack = CircularStack(u32, 5).init(); 73 + 74 + _ = stack.push(1); 75 + _ = stack.push(2); 76 + _ = stack.push(3); 77 + 78 + stack.reset(); 79 + 80 + try testing.expectEqual(@as(?u32, null), stack.pop()); 81 + try testing.expectEqual(@as(usize, 0), stack.count); 82 + }
+224
src/commands.zig
··· 1 + const std = @import("std"); 2 + const App = @import("app.zig"); 3 + const environment = @import("environment.zig"); 4 + const user_config = &@import("./config.zig").config; 5 + 6 + pub const CommandHistory = struct { 7 + const history_len = 10; 8 + 9 + history: [history_len][]const u8 = undefined, 10 + count: usize = 0, 11 + ///Points to the oldest entry. 12 + start: usize = 0, 13 + cursor: ?usize = null, 14 + 15 + pub fn deinit(self: *CommandHistory, allocator: std.mem.Allocator) void { 16 + for (self.history[0..self.count]) |entry| { 17 + allocator.free(entry); 18 + } 19 + } 20 + 21 + pub fn add(self: *CommandHistory, cmd: []const u8, allocator: std.mem.Allocator) error{OutOfMemory}!void { 22 + const index = (self.start + self.count) % history_len; 23 + 24 + if (self.count < history_len) { 25 + self.count += 1; 26 + } else { 27 + // Overwriting the oldest entry. 28 + allocator.free(self.history[self.start]); 29 + self.start = (self.start + 1) % history_len; 30 + } 31 + 32 + self.history[index] = try allocator.dupe(u8, cmd); 33 + self.cursor = null; 34 + } 35 + 36 + pub fn previous(self: *CommandHistory) ?[]const u8 { 37 + if (self.count == 0) return null; 38 + 39 + if (self.cursor == null) { 40 + self.cursor = self.count - 1; 41 + } else if (self.cursor.? > 0) { 42 + self.cursor.? -= 1; 43 + } 44 + 45 + return self.getAtCursor(); 46 + } 47 + 48 + pub fn next(self: *CommandHistory) ?[]const u8 { 49 + if (self.count == 0 or self.cursor == null) return null; 50 + 51 + if (self.cursor.? < self.count - 1) { 52 + self.cursor.? += 1; 53 + return self.getAtCursor(); 54 + } 55 + 56 + self.cursor = null; 57 + return null; 58 + } 59 + 60 + fn getAtCursor(self: *CommandHistory) ?[]const u8 { 61 + if (self.cursor == null) return null; 62 + const index = (self.start + self.cursor.?) % history_len; 63 + return self.history[index]; 64 + } 65 + }; 66 + 67 + ///Navigate the user to the config dir. 68 + pub fn config(app: *App) error{OutOfMemory}!void { 69 + const dir = dir: { 70 + notfound: { 71 + break :dir (user_config.configDir() catch break :notfound) orelse break :notfound; 72 + } 73 + const message = try std.fmt.allocPrint(app.alloc, "Failed to navigate to config directory - unable to retrieve config directory.", .{}); 74 + defer app.alloc.free(message); 75 + app.notification.write(message, .err) catch {}; 76 + if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 77 + return; 78 + }; 79 + 80 + app.directories.dir.close(); 81 + app.directories.dir = dir; 82 + try app.repopulateDirectory(""); 83 + } 84 + 85 + ///Navigate the user to the trash dir. 86 + pub fn trash(app: *App) error{OutOfMemory}!void { 87 + const dir = dir: { 88 + notfound: { 89 + break :dir (user_config.trashDir() catch break :notfound) orelse break :notfound; 90 + } 91 + const message = try std.fmt.allocPrint(app.alloc, "Failed to navigate to trash directory - unable to retrieve trash directory.", .{}); 92 + defer app.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 + 98 + app.directories.dir.close(); 99 + app.directories.dir = dir; 100 + try app.repopulateDirectory(""); 101 + } 102 + 103 + ///Empty the trash. 104 + pub fn emptyTrash(app: *App) error{OutOfMemory}!void { 105 + var message: ?[]const u8 = null; 106 + defer if (message) |msg| app.alloc.free(msg); 107 + 108 + var dir = dir: { 109 + notfound: { 110 + break :dir (user_config.trashDir() catch break :notfound) orelse break :notfound; 111 + } 112 + message = try std.fmt.allocPrint(app.alloc, "Failed to navigate to trash directory - unable to retrieve trash directory.", .{}); 113 + app.notification.write(message.?, .err) catch {}; 114 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 115 + return; 116 + }; 117 + defer dir.close(); 118 + 119 + const failed = environment.deleteContents(dir) catch |err| lbl: { 120 + message = try std.fmt.allocPrint(app.alloc, "Failed to empty trash - {}.", .{err}); 121 + app.notification.write(message.?, .err) catch {}; 122 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 123 + break :lbl 0; 124 + }; 125 + if (failed > 0) { 126 + message = try std.fmt.allocPrint(app.alloc, "Failed to empty {d} items from the trash.", .{failed}); 127 + app.notification.write(message.?, .err) catch {}; 128 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 129 + } 130 + 131 + try app.repopulateDirectory(""); 132 + } 133 + 134 + pub fn resolvePath(buf: *[std.fs.max_path_bytes]u8, path: []const u8, dir: std.fs.Dir) []const u8 { 135 + const resolved_path = if (std.mem.startsWith(u8, path, "~")) path: { 136 + var home_dir = (environment.getHomeDir() catch break :path path) orelse break :path path; 137 + defer home_dir.close(); 138 + const relative = std.mem.trim(u8, path[1..], std.fs.path.sep_str); 139 + return home_dir.realpath( 140 + if (relative.len == 0) "." else relative, 141 + buf, 142 + ) catch path; 143 + } else path; 144 + 145 + return dir.realpath(resolved_path, buf) catch path; 146 + } 147 + 148 + ///Change directory. 149 + pub fn cd(app: *App, path: []const u8) error{OutOfMemory}!void { 150 + var message: ?[]const u8 = null; 151 + defer if (message) |msg| app.alloc.free(msg); 152 + 153 + var path_buf: [std.fs.max_path_bytes]u8 = undefined; 154 + const resolved_path = resolvePath(&path_buf, path, app.directories.dir); 155 + 156 + const dir = app.directories.dir.openDir(resolved_path, .{ .iterate = true }) catch |err| { 157 + message = switch (err) { 158 + error.FileNotFound => try std.fmt.allocPrint(app.alloc, "Failed to navigate to '{s}' - directory does not exist.", .{resolved_path}), 159 + error.NotDir => try std.fmt.allocPrint(app.alloc, "Failed to navigate to '{s}' - item is not a directory.", .{resolved_path}), 160 + else => try std.fmt.allocPrint(app.alloc, "Failed to read directory entries - {}.", .{err}), 161 + }; 162 + app.notification.write(message.?, .err) catch {}; 163 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 164 + return; 165 + }; 166 + app.directories.dir.close(); 167 + app.directories.dir = dir; 168 + 169 + message = try std.fmt.allocPrint(app.alloc, "Navigated to directory '{s}'.", .{resolved_path}); 170 + app.notification.write(message.?, .info) catch {}; 171 + 172 + try app.repopulateDirectory(""); 173 + app.directories.history.reset(); 174 + } 175 + 176 + const testing = std.testing; 177 + 178 + test "CommandHistory: add and retrieve commands" { 179 + var history = CommandHistory{}; 180 + defer history.deinit(testing.allocator); 181 + 182 + try history.add(":cd /tmp", testing.allocator); 183 + try history.add(":config", testing.allocator); 184 + 185 + try testing.expectEqual(@as(usize, 2), history.count); 186 + } 187 + 188 + test "CommandHistory: previous/next navigation" { 189 + var history = CommandHistory{}; 190 + defer history.deinit(testing.allocator); 191 + 192 + try history.add(":cmd1", testing.allocator); 193 + try history.add(":cmd2", testing.allocator); 194 + try history.add(":cmd3", testing.allocator); 195 + 196 + const cmd3 = history.previous(); 197 + try testing.expectEqualStrings(":cmd3", cmd3.?); 198 + 199 + const cmd2 = history.previous(); 200 + try testing.expectEqualStrings(":cmd2", cmd2.?); 201 + 202 + const cmd3_again = history.next(); 203 + try testing.expectEqualStrings(":cmd3", cmd3_again.?); 204 + 205 + const at_end = history.next(); 206 + try testing.expect(at_end == null); 207 + } 208 + 209 + test "CommandHistory: wraparound at capacity" { 210 + var history = CommandHistory{}; 211 + defer history.deinit(testing.allocator); 212 + 213 + var i: u32 = 0; 214 + while (i < 15) : (i += 1) { 215 + const cmd = try std.fmt.allocPrint(testing.allocator, ":cmd{}", .{i}); 216 + defer testing.allocator.free(cmd); 217 + try history.add(cmd, testing.allocator); 218 + } 219 + 220 + try testing.expectEqual(@as(usize, 10), history.count); 221 + 222 + const recent = history.previous(); 223 + try testing.expectEqualStrings(":cmd14", recent.?); 224 + }
+202 -36
src/config.zig
··· 1 1 const std = @import("std"); 2 2 const builtin = @import("builtin"); 3 + const environment = @import("./environment.zig"); 3 4 const vaxis = @import("vaxis"); 4 - const environment = @import("./environment.zig"); 5 + const FileLogger = @import("file_logger.zig"); 6 + const Notification = @import("./notification.zig"); 7 + const App = @import("./app.zig"); 8 + 9 + const CONFIG_NAME = "config.json"; 10 + const TRASH_DIR_NAME = "trash"; 11 + const HOME_DIR_NAME = ".jido"; 12 + const XDG_CONFIG_HOME_DIR_NAME = "jido"; 5 13 6 14 const Config = struct { 7 15 show_hidden: bool = true, 8 16 sort_dirs: bool = true, 9 17 show_images: bool = true, 10 18 preview_file: bool = true, 11 - styles: Styles, 19 + empty_trash_on_exit: bool = false, 20 + true_dir_size: bool = false, 21 + entry_dir: ?[]const u8 = null, 22 + archive_traversal_limit: usize = 100, 23 + styles: Styles = .{}, 24 + keybinds: Keybinds = .{}, 25 + 26 + config_dir: ?std.fs.Dir = null, 27 + 28 + ///Returned dir needs to be closed by user. 29 + pub fn configDir(self: Config) !?std.fs.Dir { 30 + if (self.config_dir) |dir| { 31 + return try dir.openDir(".", .{ .iterate = true }); 32 + } else return null; 33 + } 34 + 35 + ///Returned dir needs to be closed by user. 36 + pub fn trashDir(self: Config) !?std.fs.Dir { 37 + var parent = try self.configDir() orelse return null; 38 + defer parent.close(); 39 + if (!environment.dirExists(parent, TRASH_DIR_NAME)) { 40 + try parent.makeDir(TRASH_DIR_NAME); 41 + } 42 + 43 + return try parent.openDir(TRASH_DIR_NAME, .{ .iterate = true }); 44 + } 45 + 46 + pub fn parse(self: *Config, alloc: std.mem.Allocator, app: *App) !void { 47 + var dir = lbl: { 48 + if (try environment.getXdgConfigHomeDir()) |home_dir| { 49 + defer { 50 + var dir = home_dir; 51 + dir.close(); 52 + } 53 + 54 + if (!environment.dirExists(home_dir, XDG_CONFIG_HOME_DIR_NAME)) { 55 + try home_dir.makeDir(XDG_CONFIG_HOME_DIR_NAME); 56 + } 57 + 58 + const jido_dir = try home_dir.openDir( 59 + XDG_CONFIG_HOME_DIR_NAME, 60 + .{ .iterate = true }, 61 + ); 62 + self.config_dir = jido_dir; 63 + 64 + if (environment.fileExists(jido_dir, CONFIG_NAME)) { 65 + break :lbl jido_dir; 66 + } 67 + return; 68 + } 12 69 13 - pub fn parse(self: *Config, alloc: std.mem.Allocator) !void { 14 - var config_path: []u8 = undefined; 15 - defer alloc.free(config_path); 70 + if (try environment.getHomeDir()) |home_dir| { 71 + defer { 72 + var dir = home_dir; 73 + dir.close(); 74 + } 16 75 17 - var config_home: std.fs.Dir = undefined; 18 - defer config_home.close(); 19 - if (try environment.get_xdg_config_home_dir()) |path| { 20 - config_home = path; 21 - config_path = try std.fs.path.join(alloc, &.{ "zfe", "config.json" }); 22 - } else { 23 - if (try environment.get_home_dir()) |path| { 24 - config_home = path; 25 - config_path = try std.fs.path.join(alloc, &.{ ".config", "zfe", "config.json" }); 26 - } else { 27 - return error.MissingConfigHomeEnvironmentVariable; 76 + if (!environment.dirExists(home_dir, HOME_DIR_NAME)) { 77 + try home_dir.makeDir(HOME_DIR_NAME); 78 + } 79 + 80 + const jido_dir = try home_dir.openDir( 81 + HOME_DIR_NAME, 82 + .{ .iterate = true }, 83 + ); 84 + self.config_dir = jido_dir; 85 + 86 + if (environment.fileExists(jido_dir, CONFIG_NAME)) { 87 + break :lbl jido_dir; 88 + } 89 + return; 28 90 } 29 - } 30 91 31 - if (!environment.file_exists(config_home, config_path)) { 32 - return error.ConfigNotFound; 33 - } 92 + return; 93 + }; 34 94 35 - const config_file = try config_home.openFile(config_path, .{}); 95 + const config_file = try dir.openFile(CONFIG_NAME, .{}); 36 96 defer config_file.close(); 37 97 38 98 const config_str = try config_file.readToEndAlloc(alloc, 1024 * 1024 * 1024); 39 99 defer alloc.free(config_str); 40 100 41 - const c = try std.json.parseFromSlice(Config, alloc, config_str, .{}); 42 - defer c.deinit(); 101 + const parsed_config = try std.json.parseFromSlice(Config, alloc, config_str, .{}); 102 + defer parsed_config.deinit(); 103 + 104 + self.* = parsed_config.value; 105 + self.config_dir = dir; 43 106 44 - self.* = c.value; 107 + // Check duplicate keybinds 108 + { 109 + var file_logger = FileLogger.init(dir); 110 + defer file_logger.deinit(); 111 + 112 + var key_map = std.AutoHashMap(u21, []const u8).init(alloc); 113 + defer { 114 + var it = key_map.iterator(); 115 + while (it.next()) |entry| { 116 + alloc.free(entry.value_ptr.*); 117 + } 118 + key_map.deinit(); 119 + } 120 + 121 + inline for (std.meta.fields(Keybinds)) |field| { 122 + if (@field(self.keybinds, field.name)) |field_value| { 123 + const codepoint = @intFromEnum(field_value); 124 + 125 + const res = try key_map.getOrPut(codepoint); 126 + if (res.found_existing) { 127 + var keybind_str: [1024]u8 = undefined; 128 + const keybind_str_bytes = try std.unicode.utf8Encode(codepoint, &keybind_str); 129 + 130 + const message = try std.fmt.allocPrint( 131 + alloc, 132 + "'{s}' and '{s}' have the same keybind: '{s}'. This can cause undefined behaviour.", 133 + .{ res.value_ptr.*, field.name, keybind_str[0..keybind_str_bytes] }, 134 + ); 135 + defer alloc.free(message); 136 + 137 + app.notification.write(message, .err) catch {}; 138 + file_logger.write(message, .err) catch {}; 139 + 140 + return error.DuplicateKeybind; 141 + } 142 + res.value_ptr.* = try alloc.dupe(u8, field.name); 143 + } 144 + } 145 + } 146 + 147 + return; 45 148 } 46 149 }; 47 150 151 + const Colours = struct { 152 + const RGB = [3]u8; 153 + const red: RGB = .{ 227, 23, 10 }; 154 + const orange: RGB = .{ 251, 139, 36 }; 155 + const blue: RGB = .{ 82, 209, 220 }; 156 + const grey: RGB = .{ 39, 39, 39 }; 157 + const black: RGB = .{ 0, 0, 0 }; 158 + const snow_white: RGB = .{ 254, 252, 253 }; 159 + }; 160 + 161 + const NotificationStyles = struct { 162 + box: vaxis.Style = vaxis.Style{ 163 + .fg = .{ .rgb = Colours.snow_white }, 164 + .bg = .{ .rgb = Colours.grey }, 165 + }, 166 + err: vaxis.Style = vaxis.Style{ 167 + .fg = .{ .rgb = Colours.red }, 168 + .bg = .{ .rgb = Colours.grey }, 169 + }, 170 + warn: vaxis.Style = vaxis.Style{ 171 + .fg = .{ .rgb = Colours.orange }, 172 + .bg = .{ .rgb = Colours.grey }, 173 + }, 174 + info: vaxis.Style = vaxis.Style{ 175 + .fg = .{ .rgb = Colours.blue }, 176 + .bg = .{ .rgb = Colours.grey }, 177 + }, 178 + }; 179 + 180 + pub const Keybinds = struct { 181 + pub const Char = enum(u21) { 182 + _, 183 + pub fn jsonParse(alloc: std.mem.Allocator, source: anytype, options: std.json.ParseOptions) !@This() { 184 + const parsed = try std.json.innerParse([]const u8, alloc, source, options); 185 + if (std.mem.eql(u8, parsed, "")) return error.InvalidCharacter; 186 + 187 + const utf8_byte_sequence_len = std.unicode.utf8ByteSequenceLength(parsed[0]) catch return error.InvalidCharacter; 188 + if (parsed.len != utf8_byte_sequence_len) return error.InvalidCharacter; 189 + const unicode = switch (utf8_byte_sequence_len) { 190 + 1 => parsed[0], 191 + 2 => std.unicode.utf8Decode2(parsed[0..2].*), 192 + 3 => std.unicode.utf8Decode3(parsed[0..3].*), 193 + 4 => std.unicode.utf8Decode4(parsed[0..4].*), 194 + else => return error.InvalidCharacter, 195 + } catch return error.InvalidCharacter; 196 + 197 + return @enumFromInt(unicode); 198 + } 199 + }; 200 + 201 + toggle_hidden_files: ?Char = @enumFromInt('.'), 202 + delete: ?Char = @enumFromInt('D'), 203 + rename: ?Char = @enumFromInt('R'), 204 + create_dir: ?Char = @enumFromInt('d'), 205 + create_file: ?Char = @enumFromInt('%'), 206 + fuzzy_find: ?Char = @enumFromInt('/'), 207 + change_dir: ?Char = @enumFromInt('c'), 208 + enter_command_mode: ?Char = @enumFromInt(':'), 209 + jump_top: ?Char = @enumFromInt('g'), 210 + jump_bottom: ?Char = @enumFromInt('G'), 211 + toggle_verbose_file_information: ?Char = @enumFromInt('v'), 212 + force_delete: ?Char = null, 213 + paste: ?Char = @enumFromInt('p'), 214 + yank: ?Char = @enumFromInt('y'), 215 + }; 216 + 48 217 const Styles = struct { 49 218 selected_list_item: vaxis.Style = vaxis.Style{ 50 - .bg = .{ .rgb = .{ 45, 45, 45 } }, 219 + .bg = .{ .rgb = Colours.grey }, 51 220 .bold = true, 52 221 }, 53 - notification_box: vaxis.Style = vaxis.Style{ 54 - .bg = .{ .rgb = .{ 45, 45, 45 } }, 55 - }, 222 + notification: NotificationStyles = NotificationStyles{}, 223 + text_input: vaxis.Style = vaxis.Style{}, 224 + text_input_err: vaxis.Style = vaxis.Style{ .bg = .{ .rgb = Colours.red } }, 56 225 list_item: vaxis.Style = vaxis.Style{}, 57 226 file_name: vaxis.Style = vaxis.Style{}, 58 227 file_information: vaxis.Style = vaxis.Style{ 59 - .fg = .{ .rgb = .{ 0, 0, 0 } }, 60 - .bg = .{ .rgb = .{ 255, 255, 255 } }, 61 - }, 62 - error_bar: vaxis.Style = vaxis.Style{ 63 - .bg = .{ .rgb = .{ 216, 74, 74 } }, 228 + .fg = .{ .rgb = Colours.black }, 229 + .bg = .{ .rgb = Colours.snow_white }, 64 230 }, 65 - info_bar: vaxis.Style = vaxis.Style{ 66 - .bg = .{ .rgb = .{ 0, 140, 200 } }, 231 + git_branch: vaxis.Style = vaxis.Style{ 232 + .fg = .{ .rgb = Colours.blue }, 67 233 }, 68 234 }; 69 235 70 - pub var config: Config = Config{ .styles = Styles{} }; 236 + pub var config: Config = Config{};
+162 -81
src/directories.zig
··· 2 2 const List = @import("./list.zig").List; 3 3 const CircStack = @import("./circ_stack.zig").CircularStack; 4 4 const config = &@import("./config.zig").config; 5 - const vaxis = @import("vaxis"); 6 5 const fuzzig = @import("fuzzig"); 7 - 8 - const History = struct { 9 - selected: usize, 10 - offset: usize, 11 - }; 12 6 13 7 const history_len: usize = 100; 14 8 ··· 20 14 file_contents: [4096]u8 = undefined, 21 15 pdf_contents: ?[]u8 = null, 22 16 entries: List(std.fs.Dir.Entry), 23 - history: CircStack(History, history_len), 24 - sub_entries: List([]const u8), 17 + history: CircStack(usize, history_len), 18 + child_entries: List([]const u8), 25 19 searcher: fuzzig.Ascii, 26 20 27 - pub fn init(alloc: std.mem.Allocator) !Self { 21 + pub fn init(alloc: std.mem.Allocator, entry_dir: ?[]const u8) !Self { 22 + const dir_path = if (entry_dir) |dir| dir else "."; 23 + const dir = std.fs.cwd().openDir(dir_path, .{ .iterate = true }) catch |err| { 24 + switch (err) { 25 + error.FileNotFound => { 26 + std.log.err("path '{s}' could not be found.", .{dir_path}); 27 + return err; 28 + }, 29 + else => { 30 + std.log.err("{}", .{err}); 31 + return err; 32 + }, 33 + } 34 + }; 35 + 28 36 return Self{ 29 37 .alloc = alloc, 30 - .dir = try std.fs.cwd().openDir(".", .{ .iterate = true }), 38 + .dir = dir, 31 39 .entries = List(std.fs.Dir.Entry).init(alloc), 32 - .history = CircStack(History, history_len).init(), 33 - .sub_entries = List([]const u8).init(alloc), 40 + .history = CircStack(usize, history_len).init(), 41 + .child_entries = List([]const u8).init(alloc), 34 42 .searcher = try fuzzig.Ascii.init( 35 43 alloc, 36 44 std.fs.max_path_bytes, ··· 41 49 } 42 50 43 51 pub fn deinit(self: *Self) void { 44 - self.cleanup(); 45 - self.cleanup_sub(); 52 + self.clearEntries(); 53 + self.clearChildEntries(); 46 54 47 55 self.entries.deinit(); 48 - self.sub_entries.deinit(); 56 + self.child_entries.deinit(); 49 57 50 58 self.dir.close(); 51 59 self.searcher.deinit(); ··· 53 61 if (self.pdf_contents) |contents| self.alloc.free(contents); 54 62 } 55 63 56 - pub fn get_selected(self: *Self) !std.fs.Dir.Entry { 57 - return self.entries.get_selected(); 64 + pub fn getSelected(self: *Self) !?std.fs.Dir.Entry { 65 + return self.entries.getSelected(); 58 66 } 59 67 60 68 /// Asserts there is a selected item. 61 - pub fn remove_selected(self: *Self) void { 62 - const entry = self.get_selected() catch return; 69 + pub fn removeSelected(self: *Self) void { 70 + const entry = lbl: { 71 + const entry = self.getSelected() catch return std.debug.assert(false); 72 + if (entry) |e| break :lbl e else return std.debug.assert(false); 73 + }; 63 74 self.alloc.free(entry.name); 64 75 _ = self.entries.items.orderedRemove(self.entries.selected); 65 76 } 66 77 67 - pub fn full_path(self: *Self, relative_path: []const u8) ![]const u8 { 78 + pub fn fullPath(self: *Self, relative_path: []const u8) ![]const u8 { 68 79 return try self.dir.realpath(relative_path, &self.path_buf); 69 80 } 70 81 71 - pub fn populate_sub_entries( 82 + pub fn getDirSize(self: Self, dir: std.fs.Dir) !usize { 83 + var total_size: usize = 0; 84 + 85 + var walker = try dir.walk(self.alloc); 86 + defer walker.deinit(); 87 + 88 + while (try walker.next()) |entry| { 89 + switch (entry.kind) { 90 + .file => { 91 + const stat = try entry.dir.statFile(entry.basename); 92 + total_size += stat.size; 93 + }, 94 + else => {}, 95 + } 96 + } 97 + 98 + return total_size; 99 + } 100 + 101 + pub fn populateChildEntries( 72 102 self: *Self, 73 103 relative_path: []const u8, 74 104 ) !void { ··· 77 107 78 108 var it = dir.iterate(); 79 109 while (try it.next()) |entry| { 80 - try self.sub_entries.append(try self.alloc.dupe(u8, entry.name)); 81 - } 82 - 83 - if (config.sort_dirs == true) { 84 - std.mem.sort([]const u8, self.sub_entries.all(), {}, sort_sub_entry); 85 - } 86 - } 87 - 88 - pub fn write_sub_entries( 89 - self: *Self, 90 - window: vaxis.Window, 91 - style: vaxis.Style, 92 - ) !void { 93 - for (self.sub_entries.all(), 0..) |item, i| { 94 - if (std.mem.startsWith(u8, item, ".") and config.show_hidden == false) { 110 + if (std.mem.startsWith(u8, entry.name, ".") and config.show_hidden == false) { 95 111 continue; 96 112 } 97 113 98 - if (i > window.height) continue; 99 - 100 - const w = window.child(.{ .y_off = @intCast(i), .height = 1 }); 101 - w.fill(vaxis.Cell{ .style = style }); 114 + if (entry.kind == .directory) { 115 + try self.child_entries.append(try std.fmt.allocPrint(self.alloc, "{s}/", .{entry.name})); 116 + } else { 117 + try self.child_entries.append(try self.alloc.dupe(u8, entry.name)); 118 + } 119 + } 102 120 103 - _ = w.print(&.{.{ .text = item, .style = style }}, .{}); 121 + if (config.sort_dirs == true) { 122 + std.mem.sort([]const u8, self.child_entries.all(), {}, sortChildEntry); 104 123 } 105 124 } 106 125 107 - pub fn populate_entries(self: *Self, fuzzy_search: []const u8) !void { 126 + pub fn populateEntries(self: *Self, fuzzy_search: []const u8) !void { 108 127 var it = self.dir.iterate(); 109 128 while (try it.next()) |entry| { 110 129 const score = self.searcher.score(entry.name, fuzzy_search) orelse 0; 111 130 if (fuzzy_search.len > 0 and score < 1) { 131 + continue; 132 + } 133 + 134 + if (std.mem.startsWith(u8, entry.name, ".") and config.show_hidden == false) { 112 135 continue; 113 136 } 114 137 115 138 try self.entries.append(.{ 116 139 .kind = entry.kind, 117 - .name = try self.alloc.dupe(u8, entry.name), 140 + .name = if (entry.kind == .directory) try std.fmt.allocPrint(self.alloc, "{s}/", .{entry.name}) else try self.alloc.dupe(u8, entry.name), 118 141 }); 119 142 } 120 143 121 144 if (config.sort_dirs == true) { 122 - std.mem.sort(std.fs.Dir.Entry, self.entries.all(), {}, sort_entry); 145 + std.mem.sort(std.fs.Dir.Entry, self.entries.all(), {}, sortEntry); 123 146 } 124 147 } 125 148 126 - pub fn write_entries( 127 - self: *Self, 128 - window: vaxis.Window, 129 - selected_list_item_style: vaxis.Style, 130 - list_item_style: vaxis.Style, 131 - ) !void { 132 - for (self.entries.all()[self.entries.offset..], 0..) |item, i| { 133 - const selected = self.entries.selected - self.entries.offset; 134 - const is_selected = selected == i; 135 - 136 - if (std.mem.startsWith(u8, item.name, ".") and config.show_hidden == false) { 137 - continue; 138 - } 139 - 140 - if (i > window.height) continue; 141 - 142 - const w = window.child(.{ .y_off = @intCast(i), .height = 1 }); 143 - w.fill(vaxis.Cell{ 144 - .style = if (is_selected) selected_list_item_style else list_item_style, 145 - }); 146 - 147 - _ = w.print(&.{ 148 - .{ 149 - .text = item.name, 150 - .style = if (is_selected) selected_list_item_style else list_item_style, 151 - }, 152 - }, .{}); 153 - } 154 - } 155 - 156 - fn sort_entry(_: void, lhs: std.fs.Dir.Entry, rhs: std.fs.Dir.Entry) bool { 149 + fn sortEntry(_: void, lhs: std.fs.Dir.Entry, rhs: std.fs.Dir.Entry) bool { 157 150 return std.mem.lessThan(u8, lhs.name, rhs.name); 158 151 } 159 152 160 - fn sort_sub_entry(_: void, lhs: []const u8, rhs: []const u8) bool { 153 + fn sortChildEntry(_: void, lhs: []const u8, rhs: []const u8) bool { 161 154 return std.mem.lessThan(u8, lhs, rhs); 162 155 } 163 156 164 - pub fn cleanup(self: *Self) void { 157 + pub fn clearEntries(self: *Self) void { 165 158 for (self.entries.all()) |entry| { 166 159 self.entries.alloc.free(entry.name); 167 160 } 168 161 self.entries.clear(); 169 162 } 170 163 171 - pub fn cleanup_sub(self: *Self) void { 172 - for (self.sub_entries.all()) |entry| { 173 - self.sub_entries.alloc.free(entry); 164 + pub fn clearChildEntries(self: *Self) void { 165 + for (self.child_entries.all()) |entry| { 166 + self.child_entries.alloc.free(entry); 167 + } 168 + self.child_entries.clear(); 169 + } 170 + 171 + const testing = std.testing; 172 + 173 + test "Directories: populateEntries respects show_hidden config" { 174 + const local_config = &@import("./config.zig").config; 175 + 176 + var tmp = testing.tmpDir(.{}); 177 + defer tmp.cleanup(); 178 + 179 + { 180 + const visible = try tmp.dir.createFile("visible.txt", .{}); 181 + visible.close(); 182 + const hidden = try tmp.dir.createFile(".hidden.txt", .{}); 183 + hidden.close(); 184 + } 185 + 186 + var path_buf: [std.fs.max_path_bytes]u8 = undefined; 187 + const tmp_path = try tmp.dir.realpath(".", &path_buf); 188 + const iter_dir = try std.fs.openDirAbsolute(tmp_path, .{ .iterate = true }); 189 + 190 + var dirs = try Self.init(testing.allocator, null); 191 + defer { 192 + dirs.clearEntries(); 193 + dirs.clearChildEntries(); 194 + dirs.entries.deinit(); 195 + dirs.child_entries.deinit(); 196 + dirs.searcher.deinit(); 197 + } 198 + dirs.dir.close(); 199 + dirs.dir = iter_dir; 200 + 201 + local_config.show_hidden = false; 202 + try dirs.populateEntries(""); 203 + try testing.expectEqual(@as(usize, 1), dirs.entries.len()); 204 + 205 + dirs.clearEntries(); 206 + local_config.show_hidden = true; 207 + try dirs.populateEntries(""); 208 + try testing.expectEqual(@as(usize, 2), dirs.entries.len()); 209 + } 210 + 211 + test "Directories: fuzzy search filters entries" { 212 + var tmp = testing.tmpDir(.{}); 213 + defer tmp.cleanup(); 214 + 215 + { 216 + const f1 = try tmp.dir.createFile("test_file.txt", .{}); 217 + f1.close(); 218 + const f2 = try tmp.dir.createFile("other.txt", .{}); 219 + f2.close(); 220 + const f3 = try tmp.dir.createFile("test_another.txt", .{}); 221 + f3.close(); 174 222 } 175 - self.sub_entries.clear(); 223 + 224 + var path_buf: [std.fs.max_path_bytes]u8 = undefined; 225 + const tmp_path = try tmp.dir.realpath(".", &path_buf); 226 + const iter_dir = try std.fs.openDirAbsolute(tmp_path, .{ .iterate = true }); 227 + 228 + var dirs = try Self.init(testing.allocator, null); 229 + defer { 230 + dirs.clearEntries(); 231 + dirs.clearChildEntries(); 232 + dirs.entries.deinit(); 233 + dirs.child_entries.deinit(); 234 + dirs.searcher.deinit(); 235 + } 236 + dirs.dir.close(); 237 + dirs.dir = iter_dir; 238 + 239 + try dirs.populateEntries("test"); 240 + // Should match test_* 241 + try testing.expect(dirs.entries.len() >= 2); 242 + 243 + // Verify all entries contain "test" 244 + for (dirs.entries.all()) |entry| { 245 + try testing.expect(std.mem.indexOf(u8, entry.name, "test") != null); 246 + } 247 + } 248 + 249 + test "Directories: fullPath resolves relative paths" { 250 + var dirs = try Self.init(testing.allocator, "."); 251 + defer dirs.deinit(); 252 + 253 + const path = try dirs.fullPath("."); 254 + try testing.expect(path.len > 0); 255 + // Should be absolute 256 + try testing.expect(std.mem.indexOf(u8, path, "/") != null); 176 257 }
+690
src/drawer.zig
··· 1 + const std = @import("std"); 2 + const App = @import("./app.zig"); 3 + const FileLogger = @import("./file_logger.zig"); 4 + const Notification = @import("./notification.zig"); 5 + const Directories = @import("./directories.zig"); 6 + const config = &@import("./config.zig").config; 7 + const vaxis = @import("vaxis"); 8 + const sort = @import("./sort.zig"); 9 + const Git = @import("./git.zig"); 10 + const List = @import("./list.zig").List; 11 + const zeit = @import("zeit"); 12 + const Image = @import("./image.zig"); 13 + const Archive = @import("./archive.zig"); 14 + 15 + const Drawer = @This(); 16 + 17 + const top_div: u16 = 1; 18 + const info_div: u16 = 1; 19 + 20 + // Used to detect whether to re-render an image. 21 + current_item_path_buf: [std.fs.max_path_bytes]u8 = undefined, 22 + current_item_path: []u8 = "", 23 + last_item_path_buf: [std.fs.max_path_bytes]u8 = undefined, 24 + last_item_path: []u8 = "", 25 + file_info_buf: [std.fs.max_path_bytes]u8 = undefined, 26 + file_name_buf: [std.fs.max_path_bytes + 2]u8 = undefined, // +2 to accomodate for [<file_name>] 27 + git_branch: [1024]u8 = undefined, 28 + verbose: bool = false, 29 + 30 + pub fn draw(self: *Drawer, app: *App) error{ OutOfMemory, NoSpaceLeft }!void { 31 + const win = app.vx.window(); 32 + win.clear(); 33 + 34 + if (app.state == .help_menu) { 35 + win.hideCursor(); 36 + const offset: usize = app.help_menu.selected; 37 + for (app.help_menu.all()[offset..], 0..) |item, i| { 38 + if (i > win.height) continue; 39 + 40 + const w = win.child(.{ .y_off = @intCast(i), .height = 1 }); 41 + w.fill(vaxis.Cell{ 42 + .style = config.styles.list_item, 43 + }); 44 + 45 + _ = w.print(&.{.{ 46 + .text = item, 47 + .style = config.styles.list_item, 48 + }}, .{}); 49 + } 50 + 51 + return; 52 + } 53 + 54 + const abs_file_path_bar = try self.drawAbsFilePath(app, win); 55 + const file_info_bar = try self.drawFileInfo(app.alloc, &app.directories, win); 56 + app.last_known_height = drawDirList( 57 + win, 58 + app.directories.entries, 59 + abs_file_path_bar, 60 + file_info_bar, 61 + ); 62 + 63 + if (config.preview_file) { 64 + const file_name_bar = try self.drawFileName(&app.directories, win); 65 + try self.drawFilePreview(app, win, file_name_bar); 66 + } 67 + 68 + const input = app.readInput(); 69 + drawUserInput(app.state, &app.text_input, input, win); 70 + 71 + // Notification should be drawn last. 72 + drawNotification(&app.notification, &app.file_logger, win); 73 + } 74 + 75 + fn drawFileName( 76 + self: *Drawer, 77 + directories: *Directories, 78 + win: vaxis.Window, 79 + ) error{NoSpaceLeft}!vaxis.Window { 80 + const file_name_bar = win.child(.{ 81 + .x_off = win.width / 2, 82 + .y_off = 0, 83 + .width = win.width, 84 + .height = top_div, 85 + }); 86 + 87 + const entry = lbl: { 88 + const entry = directories.getSelected() catch return file_name_bar; 89 + if (entry) |e| break :lbl e else return file_name_bar; 90 + }; 91 + 92 + const file_name = try std.fmt.bufPrint(&self.file_name_buf, "[{s}]", .{entry.name}); 93 + _ = file_name_bar.printSegment(.{ .text = file_name, .style = config.styles.file_name }, .{}); 94 + 95 + return file_name_bar; 96 + } 97 + 98 + fn drawFilePreview( 99 + self: *Drawer, 100 + app: *App, 101 + win: vaxis.Window, 102 + file_name_win: vaxis.Window, 103 + ) error{ OutOfMemory, NoSpaceLeft }!void { 104 + const bottom_div: u16 = 1; 105 + 106 + const preview_win = win.child(.{ 107 + .x_off = win.width / 2, 108 + .y_off = top_div + 1, 109 + .width = win.width / 2, 110 + .height = win.height - (file_name_win.height + top_div + bottom_div), 111 + }); 112 + 113 + if (app.directories.entries.len() == 0 or !config.preview_file) return; 114 + 115 + const entry = lbl: { 116 + const entry = app.directories.getSelected() catch return; 117 + if (entry) |e| break :lbl e else return; 118 + }; 119 + 120 + @memcpy(&self.last_item_path_buf, &self.current_item_path_buf); 121 + self.last_item_path = self.last_item_path_buf[0..self.current_item_path.len]; 122 + self.current_item_path = try std.fmt.bufPrint( 123 + &self.current_item_path_buf, 124 + "{s}/{s}", 125 + .{ app.directories.fullPath(".") catch { 126 + const message = try std.fmt.allocPrint(app.alloc, "Can not display file - unable to retrieve directory path.", .{}); 127 + defer app.alloc.free(message); 128 + app.notification.write(message, .err) catch {}; 129 + if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 130 + 131 + _ = preview_win.print(&.{ 132 + .{ .text = "Can not display file - unable to retrieve directory path. No preview available." }, 133 + }, .{}); 134 + return; 135 + }, entry.name }, 136 + ); 137 + 138 + switch (entry.kind) { 139 + .directory => { 140 + app.directories.clearChildEntries(); 141 + app.directories.populateChildEntries(entry.name) catch |err| { 142 + const message = try std.fmt.allocPrint(app.alloc, "Failed to populate child directory entries - {}.", .{err}); 143 + defer app.alloc.free(message); 144 + app.notification.write(message, .err) catch {}; 145 + if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 146 + 147 + _ = preview_win.print(&.{ 148 + .{ .text = "Failed to populate child directory entries. No preview available." }, 149 + }, .{}); 150 + 151 + return; 152 + }; 153 + 154 + for (app.directories.child_entries.all(), 0..) |item, i| { 155 + if (std.mem.startsWith(u8, item, ".") and config.show_hidden == false) { 156 + continue; 157 + } 158 + if (i > preview_win.height) continue; 159 + const w = preview_win.child(.{ .y_off = @intCast(i), .height = 1 }); 160 + w.fill(vaxis.Cell{ .style = config.styles.list_item }); 161 + _ = w.print(&.{.{ .text = item, .style = config.styles.list_item }}, .{}); 162 + } 163 + }, 164 + .file => file: { 165 + var file = app.directories.dir.openFile( 166 + entry.name, 167 + .{ .mode = .read_only }, 168 + ) catch |err| { 169 + const message = try std.fmt.allocPrint(app.alloc, "Failed to open file - {}.", .{err}); 170 + defer app.alloc.free(message); 171 + app.notification.write(message, .err) catch {}; 172 + if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 173 + 174 + _ = preview_win.print(&.{ 175 + .{ .text = "Failed to open file. No preview available." }, 176 + }, .{}); 177 + 178 + break :file; 179 + }; 180 + defer file.close(); 181 + const bytes = file.readAll(&app.directories.file_contents) catch |err| { 182 + const message = try std.fmt.allocPrint(app.alloc, "Failed to read file contents - {}.", .{err}); 183 + defer app.alloc.free(message); 184 + app.notification.write(message, .err) catch {}; 185 + if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 186 + 187 + _ = preview_win.print(&.{ 188 + .{ .text = "Failed to read file contents. No preview available." }, 189 + }, .{}); 190 + 191 + break :file; 192 + }; 193 + 194 + // Handle image. 195 + if (config.show_images == true) unsupported: { 196 + var match = false; 197 + inline for (@typeInfo(vaxis.zigimg.Image.Format).@"enum".fields) |field| { 198 + const entry_ext = std.mem.trimLeft(u8, std.fs.path.extension(entry.name), "."); 199 + if (std.mem.eql(u8, entry_ext, field.name)) match = true; 200 + } 201 + if (!match) break :unsupported; 202 + 203 + app.images.mutex.lock(); 204 + defer app.images.mutex.unlock(); 205 + 206 + if (app.images.cache.getPtr(self.current_item_path)) |cache_entry| { 207 + if (cache_entry.status == .processing) { 208 + _ = preview_win.print(&.{ 209 + .{ .text = "Image still processing." }, 210 + }, .{}); 211 + break :file; 212 + } 213 + 214 + if (cache_entry.status == .failed) { 215 + _ = preview_win.print(&.{ 216 + .{ .text = "Failed to process image." }, 217 + }, .{}); 218 + break :file; 219 + } 220 + 221 + if (cache_entry.image) |img| { 222 + img.draw(preview_win, .{ .scale = .contain }) catch |err| { 223 + const message = try std.fmt.allocPrint(app.alloc, "Failed to draw image to screen - {}.", .{err}); 224 + defer app.alloc.free(message); 225 + app.notification.write(message, .err) catch {}; 226 + if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 227 + 228 + _ = preview_win.print(&.{ 229 + .{ .text = "Failed to draw image to screen. No preview available." }, 230 + }, .{}); 231 + cache_entry.image = null; 232 + break :file; 233 + }; 234 + } else { 235 + if (cache_entry.data == null) { 236 + const path = try app.alloc.dupe(u8, self.current_item_path); 237 + Image.processImage(app.alloc, app, path) catch { 238 + app.alloc.free(path); 239 + break :unsupported; 240 + }; 241 + _ = preview_win.print(&.{ 242 + .{ .text = "Image still processing." }, 243 + }, .{}); 244 + break :file; 245 + } 246 + 247 + if (app.vx.transmitImage(app.alloc, app.tty.writer(), &cache_entry.data.?, .rgba)) |img| { 248 + img.draw(preview_win, .{ .scale = .contain }) catch |err| { 249 + const message = try std.fmt.allocPrint(app.alloc, "Failed to draw image to screen - {}.", .{err}); 250 + defer app.alloc.free(message); 251 + app.notification.write(message, .err) catch {}; 252 + if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 253 + 254 + _ = preview_win.print(&.{ 255 + .{ .text = "Failed to draw image to screen. No preview available." }, 256 + }, .{}); 257 + break :file; 258 + }; 259 + cache_entry.image = img; 260 + if (cache_entry.data) |data| { 261 + var d = data; 262 + d.deinit(app.alloc); 263 + } 264 + cache_entry.data = null; 265 + } else |_| { 266 + break :unsupported; 267 + } 268 + } 269 + 270 + break :file; 271 + } else { 272 + _ = preview_win.print(&.{ 273 + .{ .text = "Processing image." }, 274 + }, .{}); 275 + 276 + const path = try app.alloc.dupe(u8, self.current_item_path); 277 + Image.processImage(app.alloc, app, path) catch { 278 + app.alloc.free(path); 279 + break :unsupported; 280 + }; 281 + } 282 + 283 + break :file; 284 + } 285 + 286 + // Handle pdf. 287 + if (std.mem.eql(u8, std.fs.path.extension(entry.name), ".pdf")) { 288 + const output = std.process.Child.run(.{ 289 + .allocator = app.alloc, 290 + .argv = &[_][]const u8{ 291 + "pdftotext", 292 + "-f", 293 + "0", 294 + "-l", 295 + "5", 296 + self.current_item_path, 297 + "-", 298 + }, 299 + .cwd_dir = app.directories.dir, 300 + }) catch { 301 + _ = preview_win.print(&.{.{ 302 + .text = "No preview available. Install pdftotext to get PDF previews.", 303 + }}, .{}); 304 + break :file; 305 + }; 306 + defer app.alloc.free(output.stderr); 307 + defer app.alloc.free(output.stdout); 308 + 309 + if (output.term.Exited != 0) { 310 + _ = preview_win.print(&.{.{ 311 + .text = "No preview available. Install pdftotext to get PDF previews.", 312 + }}, .{}); 313 + break :file; 314 + } 315 + 316 + if (app.directories.pdf_contents) |contents| app.alloc.free(contents); 317 + app.directories.pdf_contents = try app.alloc.dupe(u8, output.stdout); 318 + 319 + _ = preview_win.print(&.{ 320 + .{ .text = app.directories.pdf_contents.? }, 321 + }, .{}); 322 + break :file; 323 + } 324 + 325 + // Handle archives 326 + if (Archive.ArchiveType.fromPath(entry.name)) |archive_type| { 327 + if (app.archive_files) |*files| { 328 + files.deinit(app.alloc); 329 + app.archive_files = null; 330 + } 331 + 332 + app.archive_files = Archive.listArchiveContents( 333 + app.alloc, 334 + file, 335 + archive_type, 336 + config.archive_traversal_limit, 337 + ) catch |err| { 338 + const message = try std.fmt.allocPrint(app.alloc, "Failed to read archive: {s}", .{@errorName(err)}); 339 + defer app.alloc.free(message); 340 + app.notification.write(message, .err) catch {}; 341 + if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 342 + _ = preview_win.print(&.{.{ .text = "Failed to read archive." }}, .{}); 343 + break :file; 344 + }; 345 + 346 + if (config.sort_dirs) { 347 + std.mem.sort([]const u8, app.archive_files.?.entries.items, {}, sort.string); 348 + } 349 + 350 + for (app.archive_files.?.entries.items, 0..) |path, i| { 351 + if (i >= preview_win.height) break; 352 + const w = preview_win.child(.{ .y_off = @intCast(i), .height = 1 }); 353 + w.fill(vaxis.Cell{ .style = config.styles.list_item }); 354 + _ = w.print(&.{.{ .text = path, .style = config.styles.list_item }}, .{}); 355 + } 356 + break :file; 357 + } 358 + 359 + // Handle utf-8. 360 + if (std.unicode.utf8ValidateSlice(app.directories.file_contents[0..bytes])) { 361 + _ = preview_win.print(&.{ 362 + .{ .text = app.directories.file_contents[0..bytes] }, 363 + }, .{}); 364 + break :file; 365 + } 366 + 367 + // Fallback to no preview. 368 + _ = preview_win.print(&.{.{ .text = "No preview available." }}, .{}); 369 + }, 370 + else => { 371 + _ = preview_win.print(&.{ 372 + vaxis.Segment{ .text = self.current_item_path }, 373 + }, .{}); 374 + }, 375 + } 376 + } 377 + 378 + fn drawFileInfo( 379 + self: *Drawer, 380 + alloc: std.mem.Allocator, 381 + directories: *Directories, 382 + win: vaxis.Window, 383 + ) error{NoSpaceLeft}!vaxis.Window { 384 + const bottom_div: u16 = if (self.verbose) 6 else 1; 385 + 386 + const file_info_win = win.child(.{ 387 + .x_off = 0, 388 + .y_off = win.height - bottom_div, 389 + .width = if (config.preview_file) win.width / 2 else win.width, 390 + .height = bottom_div, 391 + }); 392 + file_info_win.fill(.{ .style = config.styles.file_information }); 393 + 394 + const entry = lbl: { 395 + const entry = directories.getSelected() catch return file_info_win; 396 + if (entry) |e| break :lbl e else return file_info_win; 397 + }; 398 + 399 + var fbs = std.io.fixedBufferStream(&self.file_info_buf); 400 + 401 + // Selected entry. 402 + try fbs.writer().print( 403 + "{s}{d}/{d}{s}", 404 + .{ 405 + if (self.verbose) "Entry: " else "", 406 + directories.entries.selected + 1, 407 + directories.entries.len(), 408 + if (self.verbose) "\n" else " ", 409 + }, 410 + ); 411 + 412 + // Time created / last modified 413 + if (self.verbose) lbl: { 414 + var maybe_meta: ?std.fs.File.Stat = null; 415 + if (entry.kind == .directory) { 416 + maybe_meta = directories.dir.stat() catch break :lbl; 417 + } else if (entry.kind == .file) { 418 + var file = directories.dir.openFile(entry.name, .{}) catch break :lbl; 419 + maybe_meta = file.stat() catch break :lbl; 420 + } 421 + 422 + const meta = maybe_meta orelse break :lbl; 423 + var env = std.process.getEnvMap(alloc) catch break :lbl; 424 + defer env.deinit(); 425 + const local = zeit.local(alloc, &env) catch break :lbl; 426 + defer local.deinit(); 427 + 428 + const ctime_instant = zeit.instant(.{ 429 + .source = .{ .unix_nano = meta.ctime }, 430 + .timezone = &local, 431 + }) catch break :lbl; 432 + const ctime = ctime_instant.time(); 433 + ctime.strftime(fbs.writer().any(), "Created: %Y-%m-%d %H:%M:%S\n") catch break :lbl; 434 + 435 + const mtime_instant = zeit.instant(.{ 436 + .source = .{ .unix_nano = meta.mtime }, 437 + .timezone = &local, 438 + }) catch break :lbl; 439 + const mtime = mtime_instant.time(); 440 + mtime.strftime(fbs.writer().any(), "Last modified: %Y-%m-%d %H:%M:%S\n") catch break :lbl; 441 + } 442 + 443 + // File permissions. 444 + var file_perm_buf: [11]u8 = undefined; 445 + const file_perms: usize = lbl: { 446 + if (self.verbose) try fbs.writer().writeAll("Permissions: "); 447 + var file_perm_fbs = std.io.fixedBufferStream(&file_perm_buf); 448 + 449 + if (entry.kind == .directory) { 450 + _ = try file_perm_fbs.write("d"); 451 + } 452 + 453 + const perm_strings = [_][]const u8{ 454 + "---", "--x", "-w-", "-wx", 455 + "r--", "r-x", "rw-", "rwx", 456 + }; 457 + 458 + const stat = directories.dir.statFile(entry.name) catch { 459 + _ = try file_perm_fbs.write("---------\n"); 460 + break :lbl 10; 461 + }; 462 + // Ignore upper bytes as they represent file type. 463 + const perms = @as(u9, @truncate(stat.mode)); 464 + 465 + for (0..3) |group| { 466 + const shift: u4 = @truncate((2 - group) * 3); // Extract from left to right 467 + const perm = @as(u3, @truncate((perms >> shift) & 0b111)); 468 + _ = try file_perm_fbs.write(perm_strings[perm]); 469 + } 470 + 471 + if (self.verbose) { 472 + _ = try file_perm_fbs.write("\n"); 473 + } else { 474 + _ = try file_perm_fbs.write(" "); 475 + } 476 + 477 + if (entry.kind == .directory) { 478 + break :lbl 11; 479 + } else { 480 + break :lbl 10; 481 + } 482 + }; 483 + try fbs.writer().writeAll(file_perm_buf[0..file_perms]); 484 + 485 + // Size. 486 + const size: ?usize = lbl: { 487 + const stat = directories.dir.statFile(entry.name) catch break :lbl null; 488 + if (entry.kind == .file) { 489 + break :lbl stat.size; 490 + } else if (entry.kind == .directory) { 491 + if (config.true_dir_size) { 492 + var dir = directories.dir.openDir( 493 + entry.name, 494 + .{ .iterate = true }, 495 + ) catch break :lbl null; 496 + defer dir.close(); 497 + break :lbl directories.getDirSize(dir) catch break :lbl null; 498 + } else { 499 + break :lbl stat.size; 500 + } 501 + } 502 + 503 + break :lbl 0; 504 + }; 505 + if (size) |s| try fbs.writer().print("{s}{B:.2}\n", .{ 506 + if (self.verbose) "Size: " else "", 507 + s, 508 + }); 509 + 510 + // Extension. 511 + const extension = std.fs.path.extension(entry.name); 512 + if (self.verbose) { 513 + try fbs.writer().print( 514 + "Extension: {s}\n", 515 + .{if (entry.kind == .directory) "Dir" else extension}, 516 + ); 517 + } else { 518 + try fbs.writer().print( 519 + "{s} ", 520 + .{if (entry.kind == .directory) "dir" else extension}, 521 + ); 522 + } 523 + 524 + _ = file_info_win.printSegment(.{ 525 + .text = fbs.getWritten(), 526 + .style = config.styles.file_information, 527 + }, .{}); 528 + 529 + return file_info_win; 530 + } 531 + 532 + fn drawDirList( 533 + win: vaxis.Window, 534 + list: List(std.fs.Dir.Entry), 535 + abs_file_path: vaxis.Window, 536 + file_information: vaxis.Window, 537 + ) u16 { 538 + const bottom_div: u16 = 1; 539 + 540 + const current_dir_list_win = win.child(.{ 541 + .x_off = 0, 542 + .y_off = top_div + 1, 543 + .width = if (config.preview_file) win.width / 2 else win.width, 544 + .height = win.height - (abs_file_path.height + file_information.height + top_div + bottom_div), 545 + }); 546 + 547 + const win_height = current_dir_list_win.height; 548 + var offset: usize = 0; 549 + 550 + while (list.all()[offset..].len > win_height and 551 + list.selected >= offset + (win_height / 2)) 552 + { 553 + offset += 1; 554 + } 555 + 556 + for (list.all()[offset..], 0..) |item, i| { 557 + const selected = list.selected - offset; 558 + const is_selected = selected == i; 559 + 560 + if (i > win_height) continue; 561 + 562 + const w = current_dir_list_win.child(.{ .y_off = @intCast(i), .height = 1 }); 563 + w.fill(vaxis.Cell{ 564 + .style = if (is_selected) config.styles.selected_list_item else config.styles.list_item, 565 + }); 566 + 567 + _ = w.print(&.{ 568 + .{ 569 + .text = item.name, 570 + .style = if (is_selected) config.styles.selected_list_item else config.styles.list_item, 571 + }, 572 + }, .{}); 573 + } 574 + 575 + return win_height; 576 + } 577 + 578 + fn drawAbsFilePath( 579 + self: *Drawer, 580 + app: *App, 581 + win: vaxis.Window, 582 + ) error{ OutOfMemory, NoSpaceLeft }!vaxis.Window { 583 + const abs_file_path_bar = win.child(.{ 584 + .x_off = 0, 585 + .y_off = 0, 586 + .width = win.width, 587 + .height = top_div, 588 + }); 589 + 590 + const branch_alloc = Git.getGitBranch(app.alloc, app.directories.dir) catch null; 591 + defer if (branch_alloc) |b| app.alloc.free(b); 592 + const branch = if (branch_alloc) |b| 593 + try std.fmt.bufPrint( 594 + &self.git_branch, 595 + "{s}", 596 + .{std.mem.trim(u8, b, " \n\r")}, 597 + ) 598 + else 599 + ""; 600 + 601 + _ = abs_file_path_bar.print(&.{ 602 + vaxis.Segment{ .text = app.directories.fullPath(".") catch { 603 + const message = try std.fmt.allocPrint(app.alloc, "Can not display absolute file path - unable to retrieve full path.", .{}); 604 + defer app.alloc.free(message); 605 + app.notification.write(message, .err) catch {}; 606 + if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 607 + return abs_file_path_bar; 608 + } }, 609 + vaxis.Segment{ .text = if (branch_alloc != null) " on " else "" }, 610 + vaxis.Segment{ .text = branch, .style = config.styles.git_branch }, 611 + }, .{}); 612 + 613 + return abs_file_path_bar; 614 + } 615 + 616 + fn drawUserInput( 617 + current_state: App.State, 618 + text_input: *vaxis.widgets.TextInput, 619 + input: []const u8, 620 + win: vaxis.Window, 621 + ) void { 622 + const user_input_win = win.child(.{ 623 + .x_off = 0, 624 + .y_off = top_div, 625 + .width = win.width / 2, 626 + .height = info_div, 627 + }); 628 + user_input_win.fill(.{ .style = config.styles.text_input }); 629 + 630 + switch (current_state) { 631 + .fuzzy, .new_file, .new_dir, .rename, .change_dir, .command => { 632 + text_input.drawWithStyle(user_input_win, config.styles.text_input); 633 + }, 634 + .normal => { 635 + if (text_input.buf.realLength() > 0) { 636 + text_input.drawWithStyle( 637 + user_input_win, 638 + if (std.mem.eql(u8, input, ":UnsupportedCommand")) 639 + config.styles.text_input_err 640 + else 641 + config.styles.text_input, 642 + ); 643 + } 644 + 645 + win.hideCursor(); 646 + }, 647 + .help_menu => { 648 + win.hideCursor(); 649 + }, 650 + } 651 + } 652 + 653 + fn drawNotification( 654 + notification: *Notification, 655 + file_logger: *?FileLogger, 656 + win: vaxis.Window, 657 + ) void { 658 + if (notification.len() == 0) return; 659 + if (notification.clearIfEnded()) return; 660 + 661 + const width_padding = 4; 662 + const height_padding = 3; 663 + const screen_pos_padding = 10; 664 + 665 + const max_width = win.width / 4; 666 + const width = notification.len() + width_padding; 667 + const calculated_width = if (width > max_width) max_width else width; 668 + const height = (std.math.divCeil(usize, notification.len(), calculated_width) catch { 669 + if (file_logger.*) |fl| fl.write("Unable to display notification - failed to calculate notification height.", .err) catch {}; 670 + return; 671 + }) + height_padding; 672 + 673 + const notification_win = win.child(.{ 674 + .x_off = @intCast(win.width - (calculated_width + screen_pos_padding)), 675 + .y_off = top_div, 676 + .width = @intCast(calculated_width), 677 + .height = @intCast(height), 678 + .border = .{ .where = .all, .style = switch (notification.style) { 679 + .info => config.styles.notification.info, 680 + .err => config.styles.notification.err, 681 + .warn => config.styles.notification.warn, 682 + } }, 683 + }); 684 + 685 + notification_win.fill(.{ .style = config.styles.notification.box }); 686 + _ = notification_win.printSegment(.{ 687 + .text = notification.slice(), 688 + .style = config.styles.notification.box, 689 + }, .{ .wrap = .word }); 690 + }
+51 -10
src/environment.zig
··· 1 1 const std = @import("std"); 2 + const zuid = @import("zuid"); 2 3 const builtin = @import("builtin"); 3 4 4 - const log = &@import("./log.zig").log; 5 - 6 - pub fn get_home_dir() !?std.fs.Dir { 5 + pub fn getHomeDir() !?std.fs.Dir { 7 6 return try std.fs.openDirAbsolute(std.posix.getenv("HOME") orelse { 8 7 return null; 9 8 }, .{ .iterate = true }); 10 9 } 11 10 12 - pub fn get_xdg_config_home_dir() !?std.fs.Dir { 11 + pub fn getXdgConfigHomeDir() !?std.fs.Dir { 13 12 return try std.fs.openDirAbsolute(std.posix.getenv("XDG_CONFIG_HOME") orelse { 14 13 return null; 15 14 }, .{ .iterate = true }); 16 15 } 17 16 18 - pub fn get_editor() ?[]const u8 { 17 + pub fn getEditor() ?[]const u8 { 19 18 const editor = std.posix.getenv("EDITOR"); 20 19 if (editor) |e| { 21 20 if (std.mem.trim(u8, e, " ").len > 0) { ··· 25 24 return null; 26 25 } 27 26 28 - pub fn open_file(alloc: std.mem.Allocator, dir: std.fs.Dir, file: []const u8, editor: []const u8) !void { 27 + pub fn checkDuplicatePath( 28 + buf: []u8, 29 + dir: std.fs.Dir, 30 + relative_path: []const u8, 31 + ) error{NoSpaceLeft}!struct { 32 + path: []const u8, 33 + had_duplicate: bool, 34 + } { 35 + var had_duplicate = false; 36 + const new_path = if (fileExists(dir, relative_path)) lbl: { 37 + had_duplicate = true; 38 + const extension = std.fs.path.extension(relative_path); 39 + break :lbl try std.fmt.bufPrint( 40 + buf, 41 + "{s}-{f}{s}", 42 + .{ relative_path[0 .. relative_path.len - extension.len], zuid.new.v4(), extension }, 43 + ); 44 + } else lbl: { 45 + break :lbl try std.fmt.bufPrint(buf, "{s}", .{relative_path}); 46 + }; 47 + 48 + return .{ .path = new_path, .had_duplicate = had_duplicate }; 49 + } 50 + 51 + pub fn openFile( 52 + alloc: std.mem.Allocator, 53 + dir: std.fs.Dir, 54 + file: []const u8, 55 + editor: []const u8, 56 + ) !void { 29 57 var path_buf: [std.fs.max_path_bytes]u8 = undefined; 30 58 const path = try dir.realpath(file, &path_buf); 31 59 ··· 33 61 _ = try child.spawnAndWait(); 34 62 } 35 63 36 - pub fn file_exists(dir: std.fs.Dir, path: []const u8) bool { 64 + pub fn fileExists(dir: std.fs.Dir, path: []const u8) bool { 37 65 const result = blk: { 38 66 _ = dir.openFile(path, .{}) catch |err| { 39 67 switch (err) { 40 68 error.FileNotFound => break :blk false, 41 69 else => { 42 - log.info("{}", .{err}); 70 + std.log.info("{}", .{err}); 43 71 break :blk true; 44 72 }, 45 73 } ··· 49 77 return result; 50 78 } 51 79 52 - pub fn dir_exists(dir: std.fs.Dir, path: []const u8) bool { 80 + pub fn dirExists(dir: std.fs.Dir, path: []const u8) bool { 53 81 const result = blk: { 54 82 _ = dir.openDir(path, .{}) catch |err| { 55 83 switch (err) { 56 84 error.FileNotFound => break :blk false, 57 85 else => { 58 - log.info("{}", .{err}); 86 + std.log.info("{}", .{err}); 59 87 break :blk true; 60 88 }, 61 89 } ··· 64 92 }; 65 93 return result; 66 94 } 95 + 96 + ///Deletes the contents of a directory but not the directory itself. 97 + ///Returns the amount of files failed to be delete. 98 + pub fn deleteContents(dir: std.fs.Dir) !usize { 99 + var failed: usize = 0; 100 + var it = dir.iterate(); 101 + while (try it.next()) |entry| { 102 + dir.deleteTree(entry.name) catch { 103 + failed += 1; 104 + }; 105 + } 106 + return failed; 107 + }
+304
src/event_handlers.zig
··· 1 + const std = @import("std"); 2 + const App = @import("./app.zig"); 3 + const environment = @import("./environment.zig"); 4 + const zuid = @import("zuid"); 5 + const vaxis = @import("vaxis"); 6 + const Key = vaxis.Key; 7 + const config = &@import("./config.zig").config; 8 + const commands = @import("./commands.zig"); 9 + const Keybinds = @import("./config.zig").Keybinds; 10 + const events = @import("./events.zig"); 11 + 12 + pub fn handleGlobalEvent( 13 + app: *App, 14 + event: App.Event, 15 + ) error{OutOfMemory}!void { 16 + switch (event) { 17 + .key_press => |key| { 18 + if ((key.codepoint == 'c' and key.mods.ctrl)) { 19 + app.should_quit = true; 20 + return; 21 + } 22 + 23 + if ((key.codepoint == 'r' and key.mods.ctrl)) { 24 + if (config.parse(app.alloc, app)) { 25 + app.notification.write("Reloaded configuration file.", .info) catch {}; 26 + } else |err| switch (err) { 27 + error.SyntaxError => { 28 + app.notification.write("Encountered a syntax error while parsing the config file.", .err) catch { 29 + std.log.err("Encountered a syntax error while parsing the config file.", .{}); 30 + }; 31 + }, 32 + error.InvalidCharacter => { 33 + app.notification.write("One or more overriden keybinds are invalid.", .err) catch { 34 + std.log.err("One or more overriden keybinds are invalid.", .{}); 35 + }; 36 + }, 37 + error.DuplicateKeybind => { 38 + // Error logged in function 39 + }, 40 + else => { 41 + const message = try std.fmt.allocPrint(app.alloc, "Encountend an unknown error while parsing the config file - {}", .{err}); 42 + defer app.alloc.free(message); 43 + 44 + app.notification.write(message, .err) catch { 45 + std.log.err("Encountend an unknown error while parsing the config file - {}", .{err}); 46 + }; 47 + }, 48 + } 49 + } 50 + }, 51 + else => {}, 52 + } 53 + } 54 + 55 + pub fn handleNormalEvent( 56 + app: *App, 57 + event: App.Event, 58 + ) !void { 59 + switch (event) { 60 + .key_press => |key| { 61 + @setEvalBranchQuota( 62 + std.meta.fields(Keybinds).len * 1000, 63 + ); 64 + 65 + const maybe_remap: ?std.meta.FieldEnum(Keybinds) = lbl: { 66 + inline for (std.meta.fields(Keybinds)) |field| { 67 + if (@field(config.keybinds, field.name)) |field_value| { 68 + if (key.codepoint == @intFromEnum(field_value)) { 69 + break :lbl comptime std.meta.stringToEnum(std.meta.FieldEnum(Keybinds), field.name) orelse unreachable; 70 + } 71 + } 72 + } 73 + break :lbl null; 74 + }; 75 + 76 + if (maybe_remap) |action| { 77 + switch (action) { 78 + .toggle_hidden_files => try events.toggleHiddenFiles(app), 79 + .delete => try events.delete(app), 80 + .rename => { 81 + const entry = (app.directories.getSelected() catch { 82 + app.notification.write("Can not rename item - no item selected.", .warn) catch {}; 83 + return; 84 + }) orelse return; 85 + 86 + app.text_input.clearAndFree(); 87 + 88 + // Try insert entry name into text input for a nicer experience. 89 + // This failing shouldn't stop the user from entering a new name. 90 + app.text_input.insertSliceAtCursor(entry.name) catch {}; 91 + app.state = .rename; 92 + }, 93 + 94 + .create_dir => { 95 + try app.repopulateDirectory(""); 96 + app.text_input.clearAndFree(); 97 + app.state = .new_dir; 98 + }, 99 + .create_file => { 100 + try app.repopulateDirectory(""); 101 + app.text_input.clearAndFree(); 102 + app.state = .new_file; 103 + }, 104 + .fuzzy_find => { 105 + app.text_input.clearAndFree(); 106 + app.state = .fuzzy; 107 + }, 108 + .change_dir => { 109 + app.text_input.clearAndFree(); 110 + app.state = .change_dir; 111 + }, 112 + .enter_command_mode => { 113 + app.text_input.clearAndFree(); 114 + app.text_input.insertSliceAtCursor(":") catch {}; 115 + app.state = .command; 116 + }, 117 + .jump_bottom => app.directories.entries.selectLast(), 118 + .jump_top => app.directories.entries.selectFirst(), 119 + .toggle_verbose_file_information => app.drawer.verbose = !app.drawer.verbose, 120 + .force_delete => try events.forceDelete(app), 121 + .yank => try events.yank(app), 122 + .paste => try events.paste(app), 123 + } 124 + } else { 125 + switch (key.codepoint) { 126 + '-', 'h', Key.left => try events.traverseLeft(app), 127 + Key.enter, 'l', Key.right => try events.traverseRight(app), 128 + 'j', Key.down => app.directories.entries.next(), 129 + 'k', Key.up => app.directories.entries.previous(), 130 + 'u' => try events.undo(app), 131 + else => {}, 132 + } 133 + } 134 + }, 135 + .image_ready => {}, 136 + .notification => {}, 137 + .winsize => |ws| try app.vx.resize(app.alloc, app.tty.writer(), ws), 138 + } 139 + } 140 + 141 + pub fn handleInputEvent(app: *App, event: App.Event) !void { 142 + switch (event) { 143 + .key_press => |key| { 144 + switch (key.codepoint) { 145 + Key.escape => { 146 + switch (app.state) { 147 + .fuzzy => { 148 + try app.repopulateDirectory(""); 149 + app.text_input.clearAndFree(); 150 + }, 151 + .command => app.command_history.cursor = null, 152 + else => {}, 153 + } 154 + 155 + app.text_input.clearAndFree(); 156 + app.state = .normal; 157 + }, 158 + Key.enter => { 159 + const selected = app.directories.entries.selected; 160 + switch (app.state) { 161 + .new_dir => try events.createNewDir(app), 162 + .new_file => try events.createNewFile(app), 163 + .rename => try events.rename(app), 164 + .change_dir => { 165 + const path = try app.text_input.toOwnedSlice(); 166 + defer app.alloc.free(path); 167 + try commands.cd(app, path); 168 + }, 169 + .command => { 170 + const command = try app.text_input.toOwnedSlice(); 171 + defer app.alloc.free(command); 172 + 173 + // Push command to history if it's not empty. 174 + if (!std.mem.eql(u8, std.mem.trim(u8, command, " "), ":")) { 175 + app.command_history.add(command, app.alloc) catch |err| { 176 + const message = try std.fmt.allocPrint(app.alloc, "Failed to add command to history - {}.", .{err}); 177 + defer app.alloc.free(message); 178 + if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 179 + }; 180 + } 181 + 182 + supported: { 183 + if (std.mem.eql(u8, command, ":q")) { 184 + app.should_quit = true; 185 + return; 186 + } 187 + 188 + if (std.mem.eql(u8, command, ":config")) { 189 + try commands.config(app); 190 + break :supported; 191 + } 192 + 193 + if (std.mem.eql(u8, command, ":trash")) { 194 + try commands.trash(app); 195 + break :supported; 196 + } 197 + 198 + if (std.mem.startsWith(u8, command, ":cd ")) { 199 + try commands.cd(app, command[":cd ".len..]); 200 + break :supported; 201 + } 202 + 203 + if (std.mem.eql(u8, command, ":empty_trash")) { 204 + try commands.emptyTrash(app); 205 + break :supported; 206 + } 207 + 208 + if (std.mem.eql(u8, command, ":h")) { 209 + app.state = .help_menu; 210 + break :supported; 211 + } 212 + 213 + try app.text_input.insertSliceAtCursor(":UnsupportedCommand"); 214 + } 215 + 216 + app.command_history.cursor = null; 217 + }, 218 + else => {}, 219 + } 220 + 221 + if (app.state != .help_menu) app.state = .normal; 222 + app.directories.entries.selected = selected; 223 + }, 224 + Key.up => { 225 + if (app.state == .command) { 226 + if (app.command_history.previous()) |command| { 227 + app.text_input.clearAndFree(); 228 + app.text_input.insertSliceAtCursor(command) catch |err| { 229 + const message = try std.fmt.allocPrint(app.alloc, "Failed to get previous command history - {}.", .{err}); 230 + defer app.alloc.free(message); 231 + app.notification.write(message, .err) catch {}; 232 + if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 233 + }; 234 + } 235 + } 236 + }, 237 + Key.down => { 238 + if (app.state == .command) { 239 + app.text_input.clearAndFree(); 240 + if (app.command_history.next()) |command| { 241 + app.text_input.insertSliceAtCursor(command) catch |err| { 242 + const message = try std.fmt.allocPrint(app.alloc, "Failed to get next command history - {}.", .{err}); 243 + defer app.alloc.free(message); 244 + app.notification.write(message, .err) catch {}; 245 + if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 246 + }; 247 + } else { 248 + app.text_input.insertSliceAtCursor(":") catch |err| { 249 + const message = try std.fmt.allocPrint(app.alloc, "Failed to get next command history - {}.", .{err}); 250 + defer app.alloc.free(message); 251 + app.notification.write(message, .err) catch {}; 252 + if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 253 + }; 254 + } 255 + } 256 + }, 257 + else => { 258 + try app.text_input.update(.{ .key_press = key }); 259 + 260 + switch (app.state) { 261 + .fuzzy => { 262 + const fuzzy = app.readInput(); 263 + try app.repopulateDirectory(fuzzy); 264 + }, 265 + .command => { 266 + const command = app.readInput(); 267 + if (!std.mem.startsWith(u8, command, ":")) { 268 + app.text_input.clearAndFree(); 269 + app.text_input.insertSliceAtCursor(":") catch |err| { 270 + app.state = .normal; 271 + 272 + const message = try std.fmt.allocPrint(app.alloc, "An input error occurred while attempting to enter a command - {}.", .{err}); 273 + defer app.alloc.free(message); 274 + app.notification.write(message, .err) catch {}; 275 + if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 276 + }; 277 + } 278 + }, 279 + else => {}, 280 + } 281 + }, 282 + } 283 + }, 284 + .image_ready => {}, 285 + .notification => {}, 286 + .winsize => |ws| try app.vx.resize(app.alloc, app.tty.writer(), ws), 287 + } 288 + } 289 + 290 + pub fn handleHelpMenuEvent(app: *App, event: App.Event) !void { 291 + switch (event) { 292 + .key_press => |key| { 293 + switch (key.codepoint) { 294 + Key.escape, 'q' => app.state = .normal, 295 + 'j', Key.down => app.help_menu.next(), 296 + 'k', Key.up => app.help_menu.previous(), 297 + else => {}, 298 + } 299 + }, 300 + .image_ready => {}, 301 + .notification => {}, 302 + .winsize => |ws| try app.vx.resize(app.alloc, app.tty.writer(), ws), 303 + } 304 + }
+565
src/events.zig
··· 1 + const std = @import("std"); 2 + const App = @import("./app.zig"); 3 + const config = &@import("./config.zig").config; 4 + const zuid = @import("zuid"); 5 + const environment = @import("./environment.zig"); 6 + const vaxis = @import("vaxis"); 7 + 8 + pub fn delete(app: *App) error{OutOfMemory}!void { 9 + var message: ?[]const u8 = null; 10 + defer if (message) |msg| app.alloc.free(msg); 11 + 12 + const entry = (app.directories.getSelected() catch { 13 + app.notification.write("Can not to delete item - no item selected.", .warn) catch {}; 14 + return; 15 + }) orelse return; 16 + 17 + var prev_path_buf: [std.fs.max_path_bytes]u8 = undefined; 18 + const prev_path = app.directories.dir.realpath(entry.name, &prev_path_buf) catch { 19 + message = try std.fmt.allocPrint(app.alloc, "Failed to delete '{s}' - unable to retrieve absolute path.", .{entry.name}); 20 + app.notification.write(message.?, .err) catch {}; 21 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 22 + return; 23 + }; 24 + const prev_path_alloc = try app.alloc.dupe(u8, prev_path); 25 + 26 + var trash_dir = dir: { 27 + notfound: { 28 + break :dir (config.trashDir() catch break :notfound) orelse break :notfound; 29 + } 30 + app.alloc.free(prev_path_alloc); 31 + message = try std.fmt.allocPrint(app.alloc, "Failed to delete '{s}' - unable to retrieve trash directory.", .{entry.name}); 32 + app.notification.write(message.?, .err) catch {}; 33 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 34 + return; 35 + }; 36 + defer trash_dir.close(); 37 + 38 + var trash_dir_path_buf: [std.fs.max_path_bytes]u8 = undefined; 39 + const trash_dir_path = trash_dir.realpath(".", &trash_dir_path_buf) catch { 40 + message = try std.fmt.allocPrint(app.alloc, "Failed to delete '{s}' - unable to retrieve absolute path for trash directory.", .{entry.name}); 41 + app.notification.write(message.?, .err) catch {}; 42 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 43 + return; 44 + }; 45 + 46 + if (std.mem.eql(u8, prev_path_alloc, trash_dir_path)) { 47 + app.notification.write("Can not delete trash directory.", .warn) catch {}; 48 + app.alloc.free(prev_path_alloc); 49 + return; 50 + } 51 + 52 + const tmp_path = try std.fmt.allocPrint(app.alloc, "{s}/{s}-{f}", .{ trash_dir_path, entry.name, zuid.new.v4() }); 53 + if (app.directories.dir.rename(entry.name, tmp_path)) { 54 + if (app.actions.push(.{ 55 + .delete = .{ .prev_path = prev_path_alloc, .new_path = tmp_path }, 56 + })) |prev_elem| { 57 + app.alloc.free(prev_elem.delete.prev_path); 58 + app.alloc.free(prev_elem.delete.new_path); 59 + } 60 + message = try std.fmt.allocPrint(app.alloc, "Deleted '{s}'.", .{entry.name}); 61 + app.notification.write(message.?, .info) catch {}; 62 + 63 + app.directories.removeSelected(); 64 + } else |err| { 65 + app.alloc.free(prev_path_alloc); 66 + app.alloc.free(tmp_path); 67 + 68 + message = try std.fmt.allocPrint(app.alloc, "Failed to delete '{s}' - {}.", .{ entry.name, err }); 69 + app.notification.write(message.?, .err) catch {}; 70 + } 71 + } 72 + 73 + pub fn rename(app: *App) error{OutOfMemory}!void { 74 + var message: ?[]const u8 = null; 75 + defer if (message) |msg| app.alloc.free(msg); 76 + 77 + const entry = (app.directories.getSelected() catch { 78 + app.notification.write("Can not to rename item - no item selected.", .warn) catch {}; 79 + return; 80 + }) orelse return; 81 + 82 + var dir_prefix_buf: [std.fs.max_path_bytes]u8 = undefined; 83 + const dir_prefix = app.directories.dir.realpath(".", &dir_prefix_buf) catch { 84 + message = try std.fmt.allocPrint(app.alloc, "Failed to rename '{s}' - unable to retrieve absolute path.", .{entry.name}); 85 + app.notification.write(message.?, .err) catch {}; 86 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 87 + return; 88 + }; 89 + 90 + const new_path = try app.text_input.toOwnedSlice(); 91 + defer app.alloc.free(new_path); 92 + 93 + if (environment.fileExists(app.directories.dir, new_path)) { 94 + message = try std.fmt.allocPrint(app.alloc, "Can not rename file - '{s}' already exists.", .{new_path}); 95 + app.notification.write(message.?, .warn) catch {}; 96 + } else { 97 + app.directories.dir.rename(entry.name, new_path) catch |err| { 98 + message = try std.fmt.allocPrint(app.alloc, "Failed to rename '{s}' - {}.", .{ new_path, err }); 99 + app.notification.write(message.?, .err) catch {}; 100 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 101 + return; 102 + }; 103 + 104 + if (app.actions.push(.{ 105 + .rename = .{ 106 + .prev_path = try std.fs.path.join(app.alloc, &.{ dir_prefix, entry.name }), 107 + .new_path = try std.fs.path.join(app.alloc, &.{ dir_prefix, new_path }), 108 + }, 109 + })) |prev_elem| { 110 + app.alloc.free(prev_elem.rename.prev_path); 111 + app.alloc.free(prev_elem.rename.new_path); 112 + } 113 + 114 + try app.repopulateDirectory(""); 115 + 116 + message = try std.fmt.allocPrint(app.alloc, "Renamed '{s}' to '{s}'.", .{ entry.name, new_path }); 117 + app.notification.write(message.?, .info) catch {}; 118 + } 119 + } 120 + 121 + pub fn forceDelete(app: *App) error{OutOfMemory}!void { 122 + const entry = (app.directories.getSelected() catch { 123 + app.notification.write("Can not force delete item - no item selected.", .warn) catch {}; 124 + return; 125 + }) orelse return; 126 + 127 + app.directories.dir.deleteTree(entry.name) catch |err| { 128 + const error_message = try std.fmt.allocPrint(app.alloc, "Failed to force delete '{s}' - {}.", .{ entry.name, err }); 129 + app.notification.write(error_message, .err) catch {}; 130 + return; 131 + }; 132 + 133 + app.directories.removeSelected(); 134 + } 135 + 136 + pub fn toggleHiddenFiles(app: *App) error{OutOfMemory}!void { 137 + config.show_hidden = !config.show_hidden; 138 + 139 + const prev_selected_name: []const u8, const prev_selected_err: bool = lbl: { 140 + const selected = app.directories.getSelected() catch break :lbl .{ "", true }; 141 + if (selected == null) break :lbl .{ "", true }; 142 + 143 + break :lbl .{ try app.alloc.dupe(u8, selected.?.name), false }; 144 + }; 145 + defer if (!prev_selected_err) app.alloc.free(prev_selected_name); 146 + 147 + try app.repopulateDirectory(""); 148 + app.text_input.clearAndFree(); 149 + 150 + for (app.directories.entries.all()) |entry| { 151 + if (std.mem.eql(u8, entry.name, prev_selected_name)) return; 152 + app.directories.entries.selected += 1; 153 + } 154 + 155 + // If it didn't find entry, reset selected. 156 + app.directories.entries.selected = 0; 157 + } 158 + 159 + pub fn yank(app: *App) error{OutOfMemory}!void { 160 + var message: ?[]const u8 = null; 161 + defer if (message) |msg| app.alloc.free(msg); 162 + 163 + if (app.yanked) |yanked| { 164 + app.alloc.free(yanked.dir); 165 + app.alloc.free(yanked.entry.name); 166 + } 167 + 168 + app.yanked = lbl: { 169 + const entry = (app.directories.getSelected() catch { 170 + app.notification.write("Can not yank item - no item selected.", .warn) catch {}; 171 + break :lbl null; 172 + }) orelse break :lbl null; 173 + 174 + switch (entry.kind) { 175 + .file, .directory, .sym_link => { 176 + break :lbl .{ 177 + .dir = try app.alloc.dupe(u8, app.directories.fullPath(".") catch { 178 + message = try std.fmt.allocPrint( 179 + app.alloc, 180 + "Failed to yank '{s}' - unable to retrieve directory path.", 181 + .{entry.name}, 182 + ); 183 + app.notification.write(message.?, .err) catch {}; 184 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 185 + break :lbl null; 186 + }), 187 + .entry = .{ 188 + .kind = entry.kind, 189 + .name = try app.alloc.dupe(u8, entry.name), 190 + }, 191 + }; 192 + }, 193 + else => { 194 + message = try std.fmt.allocPrint(app.alloc, "Can not yank '{s}' - unsupported file type '{s}'.", .{ entry.name, @tagName(entry.kind) }); 195 + app.notification.write(message.?, .warn) catch {}; 196 + break :lbl null; 197 + }, 198 + } 199 + }; 200 + 201 + if (app.yanked) |y| { 202 + message = try std.fmt.allocPrint(app.alloc, "Yanked '{s}'.", .{y.entry.name}); 203 + app.notification.write(message.?, .info) catch {}; 204 + } 205 + } 206 + 207 + pub fn paste(app: *App) error{ OutOfMemory, NoSpaceLeft }!void { 208 + var message: ?[]const u8 = null; 209 + defer if (message) |msg| app.alloc.free(msg); 210 + 211 + const yanked = if (app.yanked) |y| y else return; 212 + 213 + var new_path_buf: [std.fs.max_path_bytes]u8 = undefined; 214 + const new_path_res = environment.checkDuplicatePath(&new_path_buf, app.directories.dir, yanked.entry.name) catch { 215 + message = try std.fmt.allocPrint(app.alloc, "Failed to copy '{s}' - path too long.", .{yanked.entry.name}); 216 + app.notification.write(message.?, .err) catch {}; 217 + return; 218 + }; 219 + 220 + switch (yanked.entry.kind) { 221 + .directory => { 222 + var source_dir = std.fs.openDirAbsolute(yanked.dir, .{ .iterate = true }) catch { 223 + message = try std.fmt.allocPrint(app.alloc, "Failed to copy '{s}' - unable to open directory '{s}'.", .{ yanked.entry.name, yanked.dir }); 224 + app.notification.write(message.?, .err) catch {}; 225 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 226 + return; 227 + }; 228 + defer source_dir.close(); 229 + 230 + var selected_dir = source_dir.openDir(yanked.entry.name, .{ .iterate = true }) catch { 231 + message = try std.fmt.allocPrint(app.alloc, "Failed to copy '{s}' - unable to open directory '{s}'.", .{ yanked.entry.name, yanked.entry.name }); 232 + app.notification.write(message.?, .err) catch {}; 233 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 234 + return; 235 + }; 236 + defer selected_dir.close(); 237 + 238 + var walker = selected_dir.walk(app.alloc) catch |err| { 239 + message = try std.fmt.allocPrint(app.alloc, "Failed to copy '{s}' - unable to walk directory tree due to {}.", .{ yanked.entry.name, err }); 240 + app.notification.write(message.?, .err) catch {}; 241 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 242 + return; 243 + }; 244 + defer walker.deinit(); 245 + 246 + // Make initial dir. 247 + app.directories.dir.makeDir(new_path_res.path) catch |err| { 248 + message = try std.fmt.allocPrint(app.alloc, "Failed to copy '{s}' - unable to create new directory due to {}.", .{ yanked.entry.name, err }); 249 + app.notification.write(message.?, .err) catch {}; 250 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 251 + return; 252 + }; 253 + 254 + var errored = false; 255 + var inner_path_buf: [std.fs.max_path_bytes]u8 = undefined; 256 + while (walker.next() catch |err| { 257 + message = try std.fmt.allocPrint(app.alloc, "Failed to copy one or more files - {}. A partial copy may have taken place.", .{err}); 258 + app.notification.write(message.?, .err) catch {}; 259 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 260 + return; 261 + }) |entry| { 262 + const path = try std.fmt.bufPrint(&inner_path_buf, "{s}{s}{s}", .{ new_path_res.path, std.fs.path.sep_str, entry.path }); 263 + switch (entry.kind) { 264 + .directory => { 265 + app.directories.dir.makeDir(path) catch { 266 + message = try std.fmt.allocPrint(app.alloc, "Failed to copy '{s}' - unable to create containing directory '{s}'.", .{ entry.basename, path }); 267 + app.notification.write(message.?, .err) catch {}; 268 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 269 + errored = true; 270 + }; 271 + }, 272 + .file, .sym_link => { 273 + entry.dir.copyFile(entry.basename, app.directories.dir, path, .{}) catch |err| switch (err) { 274 + error.FileNotFound => { 275 + message = try std.fmt.allocPrint(app.alloc, "Failed to copy '{s}' - the original file was deleted or moved.", .{entry.path}); 276 + app.notification.write(message.?, .err) catch {}; 277 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 278 + errored = true; 279 + }, 280 + else => { 281 + message = try std.fmt.allocPrint(app.alloc, "Failed to copy '{s}' - {}.", .{ entry.path, err }); 282 + app.notification.write(message.?, .err) catch {}; 283 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 284 + errored = true; 285 + }, 286 + }; 287 + }, 288 + else => { 289 + message = try std.fmt.allocPrint(app.alloc, "Failed to copy '{s}' - unsupported file type '{s}'.", .{ entry.path, @tagName(entry.kind) }); 290 + app.notification.write(message.?, .err) catch {}; 291 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 292 + errored = true; 293 + }, 294 + } 295 + } 296 + 297 + if (errored) { 298 + app.notification.write("Failed to copy some items, check the log file for more details.", .err) catch {}; 299 + } else { 300 + message = try std.fmt.allocPrint(app.alloc, "Copied '{s}'.", .{yanked.entry.name}); 301 + app.notification.write(message.?, .info) catch {}; 302 + } 303 + }, 304 + .file, .sym_link => { 305 + var source_dir = std.fs.openDirAbsolute(yanked.dir, .{ .iterate = true }) catch { 306 + message = try std.fmt.allocPrint(app.alloc, "Failed to copy '{s}' - unable to open directory '{s}'.", .{ yanked.entry.name, yanked.dir }); 307 + app.notification.write(message.?, .err) catch {}; 308 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 309 + return; 310 + }; 311 + defer source_dir.close(); 312 + 313 + std.fs.Dir.copyFile( 314 + source_dir, 315 + yanked.entry.name, 316 + app.directories.dir, 317 + new_path_res.path, 318 + .{}, 319 + ) catch |err| switch (err) { 320 + error.FileNotFound => { 321 + message = try std.fmt.allocPrint(app.alloc, "Failed to copy '{s}' - the original file was deleted or moved.", .{yanked.entry.name}); 322 + app.notification.write(message.?, .err) catch {}; 323 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 324 + return; 325 + }, 326 + else => { 327 + message = try std.fmt.allocPrint(app.alloc, "Failed to copy '{s}' - {}.", .{ yanked.entry.name, err }); 328 + app.notification.write(message.?, .err) catch {}; 329 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 330 + return; 331 + }, 332 + }; 333 + 334 + message = try std.fmt.allocPrint(app.alloc, "Copied '{s}'.", .{yanked.entry.name}); 335 + app.notification.write(message.?, .info) catch {}; 336 + }, 337 + else => { 338 + message = try std.fmt.allocPrint(app.alloc, "Can not copy '{s}' - unsupported file type '{s}'.", .{ yanked.entry.name, @tagName(yanked.entry.kind) }); 339 + app.notification.write(message.?, .warn) catch {}; 340 + return; 341 + }, 342 + } 343 + 344 + // Append action to undo history. 345 + var new_path_abs_buf: [std.fs.max_path_bytes]u8 = undefined; 346 + const new_path_abs = app.directories.dir.realpath(new_path_res.path, &new_path_abs_buf) catch { 347 + message = try std.fmt.allocPrint( 348 + app.alloc, 349 + "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.", 350 + .{ new_path_res.path, yanked.entry.name }, 351 + ); 352 + app.notification.write(message.?, .err) catch {}; 353 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 354 + return; 355 + }; 356 + 357 + if (app.actions.push(.{ 358 + .paste = try app.alloc.dupe(u8, new_path_abs), 359 + })) |prev_elem| { 360 + app.alloc.free(prev_elem.delete.prev_path); 361 + app.alloc.free(prev_elem.delete.new_path); 362 + } 363 + 364 + try app.repopulateDirectory(""); 365 + app.text_input.clearAndFree(); 366 + } 367 + 368 + pub fn traverseLeft(app: *App) error{OutOfMemory}!void { 369 + app.text_input.clearAndFree(); 370 + 371 + const dir = app.directories.dir.openDir("../", .{ .iterate = true }) catch |err| { 372 + const message = try std.fmt.allocPrint(app.alloc, "Failed to read directory entries - {}.", .{err}); 373 + defer app.alloc.free(message); 374 + app.notification.write(message, .err) catch {}; 375 + if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 376 + return; 377 + }; 378 + 379 + app.directories.dir.close(); 380 + app.directories.dir = dir; 381 + 382 + try app.repopulateDirectory(""); 383 + app.text_input.clearAndFree(); 384 + 385 + if (app.directories.history.pop()) |history| { 386 + if (history < app.directories.entries.len()) { 387 + app.directories.entries.selected = history; 388 + } 389 + } 390 + } 391 + 392 + pub fn traverseRight(app: *App) !void { 393 + var message: ?[]const u8 = null; 394 + defer if (message) |msg| app.alloc.free(msg); 395 + 396 + const entry = (app.directories.getSelected() catch { 397 + app.notification.write("Can not rename item - no item selected.", .warn) catch {}; 398 + return; 399 + }) orelse return; 400 + 401 + switch (entry.kind) { 402 + .directory => { 403 + app.text_input.clearAndFree(); 404 + 405 + const dir = app.directories.dir.openDir(entry.name, .{ .iterate = true }) catch |err| { 406 + message = try std.fmt.allocPrint(app.alloc, "Failed to read directory entries - {}.", .{err}); 407 + app.notification.write(message.?, .err) catch {}; 408 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 409 + return; 410 + }; 411 + 412 + app.directories.dir.close(); 413 + app.directories.dir = dir; 414 + _ = app.directories.history.push(app.directories.entries.selected); 415 + try app.repopulateDirectory(""); 416 + app.text_input.clearAndFree(); 417 + }, 418 + .file => { 419 + if (environment.getEditor()) |editor| { 420 + try app.vx.exitAltScreen(app.tty.writer()); 421 + try app.vx.resetState(app.tty.writer()); 422 + app.loop.stop(); 423 + 424 + environment.openFile(app.alloc, app.directories.dir, entry.name, editor) catch |err| { 425 + message = try std.fmt.allocPrint(app.alloc, "Failed to open file '{s}' - {}.", .{ entry.name, err }); 426 + app.notification.write(message.?, .err) catch {}; 427 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 428 + }; 429 + 430 + try app.loop.start(); 431 + try app.vx.enterAltScreen(app.tty.writer()); 432 + try app.vx.enableDetectedFeatures(app.tty.writer()); 433 + app.vx.queueRefresh(); 434 + } else { 435 + app.notification.write("Can not open file - $EDITOR not set.", .warn) catch {}; 436 + } 437 + }, 438 + else => {}, 439 + } 440 + } 441 + 442 + pub fn createNewDir(app: *App) error{OutOfMemory}!void { 443 + var message: ?[]const u8 = null; 444 + defer if (message) |msg| app.alloc.free(msg); 445 + 446 + const dir = try app.text_input.toOwnedSlice(); 447 + defer app.alloc.free(dir); 448 + 449 + app.directories.dir.makeDir(dir) catch |err| { 450 + message = try std.fmt.allocPrint(app.alloc, "Failed to create directory '{s}' - {}", .{ dir, err }); 451 + app.notification.write(message.?, .err) catch {}; 452 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 453 + return; 454 + }; 455 + 456 + try app.repopulateDirectory(""); 457 + 458 + message = try std.fmt.allocPrint(app.alloc, "Created new directory '{s}'.", .{dir}); 459 + app.notification.write(message.?, .info) catch {}; 460 + } 461 + 462 + pub fn createNewFile(app: *App) error{OutOfMemory}!void { 463 + var message: ?[]const u8 = null; 464 + defer if (message) |msg| app.alloc.free(msg); 465 + 466 + const file = try app.text_input.toOwnedSlice(); 467 + defer app.alloc.free(file); 468 + 469 + if (environment.fileExists(app.directories.dir, file)) { 470 + message = try std.fmt.allocPrint(app.alloc, "Can not create file - '{s}' already exists.", .{file}); 471 + app.notification.write(message.?, .warn) catch {}; 472 + } else { 473 + _ = app.directories.dir.createFile(file, .{}) catch |err| { 474 + message = try std.fmt.allocPrint(app.alloc, "Failed to create file '{s}' - {}", .{ file, err }); 475 + app.notification.write(message.?, .err) catch {}; 476 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 477 + return; 478 + }; 479 + 480 + try app.repopulateDirectory(""); 481 + 482 + message = try std.fmt.allocPrint(app.alloc, "Created new file '{s}'.", .{file}); 483 + app.notification.write(message.?, .info) catch {}; 484 + } 485 + } 486 + 487 + pub fn undo(app: *App) error{OutOfMemory}!void { 488 + var message: ?[]const u8 = null; 489 + defer if (message) |msg| app.alloc.free(msg); 490 + 491 + const action = app.actions.pop() orelse { 492 + app.notification.write("There is nothing to undo.", .info) catch {}; 493 + return; 494 + }; 495 + 496 + const selected = app.directories.entries.selected; 497 + 498 + switch (action) { 499 + .delete => |a| { 500 + defer app.alloc.free(a.new_path); 501 + defer app.alloc.free(a.prev_path); 502 + 503 + var new_path_buf: [std.fs.max_path_bytes]u8 = undefined; 504 + const new_path_res = environment.checkDuplicatePath(&new_path_buf, app.directories.dir, a.prev_path) catch { 505 + message = try std.fmt.allocPrint(app.alloc, "Failed to undo delete '{s}' - path too long.", .{a.prev_path}); 506 + app.notification.write(message.?, .err) catch {}; 507 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 508 + return; 509 + }; 510 + 511 + app.directories.dir.rename(a.new_path, new_path_res.path) catch |err| { 512 + message = try std.fmt.allocPrint(app.alloc, "Failed to undo delete for '{s}' - {}.", .{ a.prev_path, err }); 513 + app.notification.write(message.?, .err) catch {}; 514 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 515 + return; 516 + }; 517 + 518 + try app.repopulateDirectory(""); 519 + app.text_input.clearAndFree(); 520 + 521 + message = try std.fmt.allocPrint(app.alloc, "Restored '{s}' as '{s}'.", .{ a.prev_path, new_path_res.path }); 522 + app.notification.write(message.?, .info) catch {}; 523 + }, 524 + .rename => |a| { 525 + defer app.alloc.free(a.new_path); 526 + defer app.alloc.free(a.prev_path); 527 + 528 + var new_path_buf: [std.fs.max_path_bytes]u8 = undefined; 529 + const new_path_res = environment.checkDuplicatePath(&new_path_buf, app.directories.dir, a.prev_path) catch { 530 + message = try std.fmt.allocPrint(app.alloc, "Failed to undo rename '{s}' - path too long.", .{a.prev_path}); 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 + app.directories.dir.rename(a.new_path, new_path_res.path) catch |err| { 537 + message = try std.fmt.allocPrint(app.alloc, "Failed to undo rename for '{s}' - {}.", .{ a.new_path, err }); 538 + app.notification.write(message.?, .err) catch {}; 539 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 540 + return; 541 + }; 542 + 543 + try app.repopulateDirectory(""); 544 + app.text_input.clearAndFree(); 545 + 546 + message = try std.fmt.allocPrint(app.alloc, "Reverted renaming of '{s}', now '{s}'.", .{ a.new_path, new_path_res.path }); 547 + app.notification.write(message.?, .info) catch {}; 548 + }, 549 + .paste => |path| { 550 + defer app.alloc.free(path); 551 + 552 + app.directories.dir.deleteTree(path) catch |err| { 553 + message = try std.fmt.allocPrint(app.alloc, "Failed to delete '{s}' - {}.", .{ path, err }); 554 + app.notification.write(message.?, .err) catch {}; 555 + if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 556 + return; 557 + }; 558 + 559 + try app.repopulateDirectory(""); 560 + app.text_input.clearAndFree(); 561 + }, 562 + } 563 + 564 + app.directories.entries.selected = selected; 565 + }
+59
src/file_logger.zig
··· 1 + const std = @import("std"); 2 + const environment = @import("environment.zig"); 3 + const config = &@import("./config.zig").config; 4 + 5 + pub const LOG_PATH = "log.txt"; 6 + 7 + const LogLevel = enum { 8 + err, 9 + info, 10 + warn, 11 + 12 + pub fn toString(level: LogLevel) []const u8 { 13 + return switch (level) { 14 + .err => "ERROR", 15 + .info => "INFO", 16 + .warn => "WARN", 17 + }; 18 + } 19 + }; 20 + 21 + const FileLogger = @This(); 22 + 23 + dir: std.fs.Dir, 24 + file: ?std.fs.File, 25 + 26 + pub fn init(dir: std.fs.Dir) FileLogger { 27 + const file = dir.createFile(LOG_PATH, .{ .truncate = false, .read = true }) catch |err| { 28 + std.log.err("Failed to create/open log file: {s}", .{@errorName(err)}); 29 + return .{ .dir = dir, .file = null }; 30 + }; 31 + 32 + return .{ .dir = dir, .file = file }; 33 + } 34 + 35 + pub fn deinit(self: FileLogger) void { 36 + if (self.file) |file| { 37 + var f = file; 38 + f.close(); 39 + } 40 + } 41 + 42 + pub fn write(self: FileLogger, msg: []const u8, level: LogLevel) !void { 43 + const file = if (self.file) |file| file else return error.NoLogFile; 44 + 45 + if (try file.tryLock(.exclusive)) { 46 + defer file.unlock(); 47 + 48 + var buffer: [1024]u8 = undefined; 49 + var file_writer_impl = file.writer(&buffer); 50 + const file_writer = &file_writer_impl.interface; 51 + try file_writer_impl.seekTo(file.getEndPos() catch 0); 52 + 53 + try file_writer.print( 54 + "({d}) {s}: {s}\n", 55 + .{ std.time.timestamp(), LogLevel.toString(level), msg }, 56 + ); 57 + try file_writer.flush(); 58 + } 59 + }
+15
src/git.zig
··· 1 + const std = @import("std"); 2 + 3 + /// Callers owns memory returned. 4 + pub fn getGitBranch(alloc: std.mem.Allocator, dir: std.fs.Dir) !?[]const u8 { 5 + var file = try dir.openFile(".git/HEAD", .{}); 6 + defer file.close(); 7 + 8 + var buf: [1024]u8 = undefined; 9 + const bytes = try file.readAll(&buf); 10 + if (bytes == 0) return null; 11 + 12 + const preamble = "ref: refs/heads/"; 13 + 14 + return try alloc.dupe(u8, buf[preamble.len..]); 15 + }
+98
src/image.zig
··· 1 + const std = @import("std"); 2 + const vaxis = @import("vaxis"); 3 + const App = @import("app.zig"); 4 + 5 + pub const Cache = struct { 6 + mutex: std.Thread.Mutex = .{}, 7 + cache: std.StringHashMap(Image), 8 + }; 9 + 10 + const Status = enum { 11 + ready, 12 + processing, 13 + failed, 14 + }; 15 + 16 + const Image = @This(); 17 + 18 + ///Only use on first transmission. Subsequent draws should use 19 + ///`Image.image`. 20 + data: ?vaxis.zigimg.Image = null, 21 + image: ?vaxis.Image = null, 22 + path: ?[]const u8 = null, 23 + status: Status = .processing, 24 + 25 + pub fn deinit(self: Image, alloc: std.mem.Allocator, vx: vaxis.Vaxis, tty: *vaxis.Tty) void { 26 + if (self.image) |image| { 27 + vx.freeImage(tty.writer(), image.id); 28 + } 29 + if (self.data) |data| { 30 + var d = data; 31 + d.deinit(alloc); 32 + } 33 + if (self.path) |path| alloc.free(path); 34 + } 35 + 36 + pub fn processImage(alloc: std.mem.Allocator, app: *App, path: []const u8) error{ Unsupported, OutOfMemory }!void { 37 + app.images.cache.put(path, .{ .path = path, .status = .processing }) catch { 38 + const message = try std.fmt.allocPrint(alloc, "Failed to load image '{s}' - error occurred while attempting to add image to cache.", .{path}); 39 + defer alloc.free(message); 40 + app.notification.write(message, .err) catch {}; 41 + if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 42 + return error.Unsupported; 43 + }; 44 + 45 + const load_img_thread = std.Thread.spawn(.{}, loadImage, .{ 46 + alloc, 47 + app, 48 + path, 49 + }) catch { 50 + app.images.mutex.lock(); 51 + if (app.images.cache.getPtr(path)) |entry| { 52 + entry.status = .failed; 53 + } 54 + app.images.mutex.unlock(); 55 + 56 + const message = try std.fmt.allocPrint(alloc, "Failed to load image '{s}' - error occurred while attempting to spawn processing thread.", .{path}); 57 + defer alloc.free(message); 58 + app.notification.write(message, .err) catch {}; 59 + if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 60 + 61 + return error.Unsupported; 62 + }; 63 + load_img_thread.detach(); 64 + } 65 + 66 + fn loadImage(alloc: std.mem.Allocator, app: *App, path: []const u8) error{OutOfMemory}!void { 67 + var buf: [(1024 * 1024) * 5]u8 = undefined; // 5mb 68 + const data = vaxis.zigimg.Image.fromFilePath(alloc, path, &buf) catch { 69 + app.images.mutex.lock(); 70 + if (app.images.cache.getPtr(path)) |entry| { 71 + entry.status = .failed; 72 + } 73 + app.images.mutex.unlock(); 74 + 75 + const message = try std.fmt.allocPrint(alloc, "Failed to load image '{s}' - error occurred while attempting to read image from path.", .{path}); 76 + defer alloc.free(message); 77 + app.notification.write(message, .err) catch {}; 78 + if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 79 + 80 + return; 81 + }; 82 + 83 + app.images.mutex.lock(); 84 + if (app.images.cache.getPtr(path)) |entry| { 85 + entry.status = .ready; 86 + entry.data = data; 87 + entry.path = path; 88 + } else { 89 + const message = try std.fmt.allocPrint(alloc, "Failed to load image '{s}' - error occurred while attempting to add image to cache.", .{path}); 90 + defer alloc.free(message); 91 + app.notification.write(message, .err) catch {}; 92 + if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 93 + return; 94 + } 95 + app.images.mutex.unlock(); 96 + 97 + app.loop.postEvent(.image_ready); 98 + }
+86 -28
src/list.zig
··· 8 8 alloc: std.mem.Allocator, 9 9 items: std.ArrayList(T), 10 10 selected: usize, 11 - offset: usize, 12 11 13 12 pub fn init(alloc: std.mem.Allocator) Self { 14 13 return Self{ 15 14 .alloc = alloc, 16 - .items = std.ArrayList(T).init(alloc), 15 + .items = .empty, 17 16 .selected = 0, 18 - .offset = 0, 19 17 }; 20 18 } 21 19 22 20 pub fn deinit(self: *Self) void { 23 - self.items.deinit(); 21 + self.items.deinit(self.alloc); 24 22 } 25 23 26 24 pub fn append(self: *Self, item: T) !void { 27 - try self.items.append(item); 25 + try self.items.append(self.alloc, item); 28 26 } 29 27 30 28 pub fn clear(self: *Self) void { 31 - self.items.clearAndFree(); 29 + self.items.clearAndFree(self.alloc); 32 30 self.selected = 0; 33 - self.offset = 0; 31 + } 32 + 33 + pub fn fromArray(self: *Self, array: []const T) !void { 34 + for (array) |item| { 35 + try self.append(item); 36 + } 34 37 } 35 38 36 - pub fn get(self: *Self, index: usize) !T { 39 + pub fn get(self: Self, index: usize) !T { 37 40 if (index + 1 > self.len()) { 38 41 return error.OutOfBounds; 39 42 } ··· 41 44 return self.all()[index]; 42 45 } 43 46 44 - pub fn get_selected(self: *Self) !T { 47 + pub fn getSelected(self: *Self) !?T { 45 48 if (self.len() > 0) { 46 49 if (self.selected >= self.len()) { 47 50 self.selected = self.len() - 1; ··· 50 53 return try self.get(self.selected); 51 54 } 52 55 53 - return error.EmptyList; 56 + return null; 54 57 } 55 58 56 - pub fn all(self: *Self) []T { 59 + pub fn all(self: Self) []T { 57 60 return self.items.items; 58 61 } 59 62 60 - pub fn len(self: *Self) usize { 63 + pub fn len(self: Self) usize { 61 64 return self.items.items.len; 62 65 } 63 66 64 - pub fn next(self: *Self, win_height: usize) void { 67 + pub fn next(self: *Self) void { 65 68 if (self.selected + 1 < self.len()) { 66 69 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 70 } 72 71 } 73 72 74 - pub fn previous(self: *Self, win_height: usize) void { 73 + pub fn previous(self: *Self) void { 75 74 if (self.selected > 0) { 76 75 self.selected -= 1; 77 - 78 - if (self.offset > 0 and self.selected < self.offset + (win_height / 2)) { 79 - self.offset -= 1; 80 - } 81 76 } 82 77 } 83 78 84 - pub fn select_last(self: *Self, win_height: usize) void { 79 + pub fn selectLast(self: *Self) void { 85 80 self.selected = self.len() - 1; 86 - if (self.selected >= win_height) { 87 - self.offset = self.selected - (win_height - 1); 88 - } 89 81 } 90 82 91 - pub fn select_first(self: *Self) void { 83 + pub fn selectFirst(self: *Self) void { 92 84 self.selected = 0; 93 - self.offset = 0; 94 85 } 95 86 }; 96 87 } 88 + 89 + const testing = std.testing; 90 + 91 + test "List: navigation respects bounds" { 92 + var list = List(u32).init(testing.allocator); 93 + defer list.deinit(); 94 + 95 + try list.append(1); 96 + try list.append(2); 97 + try list.append(3); 98 + 99 + try testing.expectEqual(@as(usize, 0), list.selected); 100 + 101 + list.next(); 102 + try testing.expectEqual(@as(usize, 1), list.selected); 103 + 104 + list.next(); 105 + list.next(); 106 + // Try to go past end 107 + list.next(); 108 + // Should stay at last 109 + try testing.expectEqual(@as(usize, 2), list.selected); 110 + 111 + list.previous(); 112 + try testing.expectEqual(@as(usize, 1), list.selected); 113 + 114 + list.previous(); 115 + // Try to go before start 116 + list.previous(); 117 + // Should stay at first 118 + try testing.expectEqual(@as(usize, 0), list.selected); 119 + } 120 + 121 + test "List: getSelected handles empty list" { 122 + var list = List(u32).init(testing.allocator); 123 + defer list.deinit(); 124 + 125 + const result = try list.getSelected(); 126 + try testing.expect(result == null); 127 + } 128 + 129 + test "List: append and get operations" { 130 + var list = List(u32).init(testing.allocator); 131 + defer list.deinit(); 132 + 133 + try list.append(42); 134 + try list.append(84); 135 + 136 + try testing.expectEqual(@as(usize, 2), list.len()); 137 + try testing.expectEqual(@as(u32, 42), try list.get(0)); 138 + try testing.expectEqual(@as(u32, 84), try list.get(1)); 139 + } 140 + 141 + test "List: selectFirst and selectLast" { 142 + var list = List(u32).init(testing.allocator); 143 + defer list.deinit(); 144 + 145 + try list.append(1); 146 + try list.append(2); 147 + try list.append(3); 148 + 149 + list.selectLast(); 150 + try testing.expectEqual(@as(usize, 2), list.selected); 151 + 152 + list.selectFirst(); 153 + try testing.expectEqual(@as(usize, 0), list.selected); 154 + }
-31
src/log.zig
··· 1 - const std = @import("std"); 2 - 3 - pub const Logger = struct { 4 - const Self = @This(); 5 - const BufferedFileWriter = std.io.BufferedWriter(4096, std.fs.File.Writer); 6 - 7 - stdout: BufferedFileWriter = undefined, 8 - stderr: BufferedFileWriter = undefined, 9 - 10 - pub fn init(self: *Self) void { 11 - self.stdout = BufferedFileWriter{ .unbuffered_writer = std.io.getStdOut().writer() }; 12 - self.stderr = BufferedFileWriter{ .unbuffered_writer = std.io.getStdErr().writer() }; 13 - } 14 - 15 - pub fn info(self: *Self, comptime format: []const u8, args: anytype) void { 16 - self.stdout.writer().print(format ++ "\n", args) catch return; 17 - self.stdout.flush() catch return; 18 - } 19 - 20 - pub fn debug(self: *Self, comptime format: []const u8, args: anytype) void { 21 - self.stdout.writer().print("[DEBUG] " ++ format ++ "\n", args) catch return; 22 - self.stdout.flush() catch return; 23 - } 24 - 25 - pub fn err(self: *Self, comptime format: []const u8, args: anytype) void { 26 - self.stderr.writer().print(format ++ "\n", args) catch return; 27 - self.stderr.flush() catch return; 28 - } 29 - }; 30 - 31 - pub var log: Logger = Logger{};
+139 -21
src/main.zig
··· 1 1 const std = @import("std"); 2 2 const builtin = @import("builtin"); 3 - 3 + const options = @import("options"); 4 4 const App = @import("app.zig"); 5 - 5 + const FileLogger = @import("file_logger.zig"); 6 6 const vaxis = @import("vaxis"); 7 - 8 7 const config = &@import("./config.zig").config; 8 + const resolvePath = @import("./commands.zig").resolvePath; 9 + 9 10 pub const panic = vaxis.panic_handler; 11 + const help_menu = 12 + \\Usage: jido 13 + \\ 14 + \\a lightweight Unix TUI file explorer 15 + \\ 16 + \\Flags: 17 + \\ -h, --help Show help information and exit. 18 + \\ -v, --version Print version information and exit. 19 + \\ --entry-dir=PATH Open jido at chosen dir. 20 + \\ --choose-dir Makes jido act like a directory chooser. When jido 21 + \\ quits, it will write the name of the last visited 22 + \\ directory to STDOUT. 23 + \\ 24 + ; 10 25 11 26 pub const std_options: std.Options = .{ 12 27 .log_scope_levels = &.{ ··· 15 30 }, 16 31 }; 17 32 33 + const Options = struct { 34 + help: bool = false, 35 + version: bool = false, 36 + @"choose-dir": bool = false, 37 + @"entry-path": []const u8 = ".", 38 + 39 + fn optKind(a: []const u8) enum { short, long, positional } { 40 + if (std.mem.startsWith(u8, a, "--")) return .long; 41 + if (std.mem.startsWith(u8, a, "-")) return .short; 42 + return .positional; 43 + } 44 + }; 45 + 18 46 pub fn main() !void { 19 47 var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 20 48 defer { ··· 25 53 } 26 54 const alloc = gpa.allocator(); 27 55 28 - config.parse(alloc) catch |err| switch (err) { 29 - error.ConfigNotFound => {}, 30 - error.MissingConfigHomeEnvironmentVariable => { 31 - std.log.err("Could not read config due to $HOME or $XDG_CONFIG_HOME not being set.", .{}); 32 - return; 33 - }, 34 - error.SyntaxError => { 35 - std.log.err("Could not read config due to a syntax error.", .{}); 36 - return; 37 - }, 38 - else => { 39 - std.log.err("Could not read config due to an unknown error.", .{}); 40 - return; 41 - }, 42 - }; 56 + var last_dir: ?[]const u8 = null; 57 + var entry_path_buf: [std.fs.max_path_bytes]u8 = undefined; 43 58 44 - var app = try App.init(alloc); 45 - defer app.deinit(); 59 + var opts = Options{}; 60 + var args = std.process.args(); 61 + _ = args.skip(); 62 + while (args.next()) |arg| { 63 + switch (Options.optKind(arg)) { 64 + .short => { 65 + const str = arg[1..]; 66 + for (str) |b| { 67 + switch (b) { 68 + 'v' => opts.version = true, 69 + 'h' => opts.help = true, 70 + else => { 71 + std.log.err("Invalid opt: '{c}'", .{b}); 72 + std.process.exit(1); 73 + }, 74 + } 75 + } 76 + }, 77 + .long => { 78 + var split = std.mem.splitScalar(u8, arg[2..], '='); 79 + const opt = split.first(); 80 + const val = split.rest(); 81 + if (std.mem.eql(u8, opt, "version")) { 82 + opts.version = true; 83 + } else if (std.mem.eql(u8, opt, "help")) { 84 + opts.help = true; 85 + } else if (std.mem.eql(u8, opt, "choose-dir")) { 86 + opts.@"choose-dir" = true; 87 + } else if (std.mem.eql(u8, opt, "entry-dir")) { 88 + const path = if (std.mem.eql(u8, val, "")) "." else val; 89 + var dir = try std.fs.cwd().openDir(".", .{ .iterate = true }); 90 + defer dir.close(); 91 + opts.@"entry-path" = resolvePath(&entry_path_buf, path, dir); 92 + } 93 + }, 94 + .positional => { 95 + std.log.err("Invalid opt: '{s}'. Jido does not take positional arguments.", .{arg}); 96 + std.process.exit(1); 97 + }, 98 + } 99 + } 46 100 47 - try app.run(); 101 + if (opts.help) { 102 + std.debug.print(help_menu, .{}); 103 + return; 104 + } 105 + 106 + if (opts.version) { 107 + std.debug.print("jido v{f}\n", .{options.version}); 108 + return; 109 + } 110 + 111 + { 112 + var app = App.init(alloc, opts.@"entry-path") catch { 113 + vaxis.recover(); 114 + std.process.exit(1); 115 + }; 116 + defer app.deinit(); 117 + 118 + config.parse(alloc, &app) catch |err| switch (err) { 119 + error.SyntaxError => { 120 + app.notification.write("Encountered a syntax error while parsing the config file.", .err) catch { 121 + std.log.err("Encountered a syntax error while parsing the config file.", .{}); 122 + }; 123 + }, 124 + error.InvalidCharacter => { 125 + app.notification.write("One or more overriden keybinds are invalid.", .err) catch { 126 + std.log.err("One or more overriden keybinds are invalid.", .{}); 127 + }; 128 + }, 129 + error.DuplicateKeybind => { 130 + // Error logged in function 131 + }, 132 + else => { 133 + const message = try std.fmt.allocPrint(alloc, "Encountend an unknown error while parsing the config file - {}", .{err}); 134 + defer alloc.free(message); 135 + 136 + app.notification.write(message, .err) catch { 137 + std.log.err("Encountend an unknown error while parsing the config file - {}", .{err}); 138 + }; 139 + }, 140 + }; 141 + 142 + app.file_logger = if (config.config_dir) |dir| FileLogger.init(dir) else logger: { 143 + std.log.err("Failed to initialise file logger - no config directory found", .{}); 144 + break :logger null; 145 + }; 146 + app.notification.loop = &app.loop; 147 + 148 + try app.run(); 149 + 150 + if (opts.@"choose-dir") { 151 + last_dir = alloc.dupe(u8, try app.directories.fullPath(".")) catch null; 152 + } 153 + } 154 + 155 + // Must be printed after app has deinit as part of that process clears 156 + // the screen. 157 + if (last_dir) |path| { 158 + var stdout_buffer: [std.fs.max_path_bytes]u8 = undefined; 159 + var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer); 160 + const stdout = &stdout_writer.interface; 161 + stdout.print("{s}\n", .{path}) catch {}; 162 + stdout.flush() catch {}; 163 + 164 + alloc.free(path); 165 + } 48 166 }
+28 -65
src/notification.zig
··· 1 1 const std = @import("std"); 2 + const vaxis = @import("vaxis"); 3 + const Event = @import("app.zig").Event; 4 + 5 + const FileLogger = @import("file_logger.zig"); 2 6 3 7 const Self = @This(); 4 8 5 - // Seconds. 9 + /// Seconds. 6 10 pub const notification_timeout = 3; 7 11 8 12 const Style = enum { 9 13 err, 10 14 info, 15 + warn, 11 16 }; 12 17 13 - /// Simplified construct 14 - const Error = enum { 15 - PermissionDenied, 16 - UnknownError, 17 - UnableToUndo, 18 - UnableToOpenFile, 19 - UnableToDelete, 20 - UnableToDeleteAcrossMountPoints, 21 - UnsupportedImageFormat, 22 - EditorNotSet, 23 - ItemAlreadyExists, 24 - UnableToRename, 25 - IncorrectPath, 26 - }; 18 + var buf: [1024]u8 = undefined; 27 19 28 - const Info = enum { 29 - CreatedFile, 30 - CreatedFolder, 31 - Deleted, 32 - Renamed, 33 - RestoredDelete, 34 - RestoredRename, 35 - EmptyUndo, 36 - ChangedDir, 37 - }; 38 - 39 - len: usize = 0, 40 - buf: [1024]u8 = undefined, 41 20 style: Style = Style.info, 42 - fbs: std.io.FixedBufferStream([]u8) = undefined, 21 + fbs: std.io.FixedBufferStream([]u8) = std.io.fixedBufferStream(&buf), 22 + /// How long until the notification disappears in seconds. 43 23 timer: i64 = 0, 44 - 45 - pub fn init(self: *Self) void { 46 - self.fbs = std.io.fixedBufferStream(&self.buf); 47 - } 24 + loop: ?*vaxis.Loop(Event) = null, 48 25 49 26 pub fn write(self: *Self, text: []const u8, style: Style) !void { 50 27 self.fbs.reset(); 51 - self.len = try self.fbs.write(text); 28 + _ = try self.fbs.write(text); 52 29 self.timer = std.time.timestamp(); 53 - 54 30 self.style = style; 55 - } 56 31 57 - pub fn write_err(self: *Self, err: Error) !void { 58 - try switch (err) { 59 - .PermissionDenied => self.write("Permission denied.", .err), 60 - .UnknownError => self.write("An unknown error occurred.", .err), 61 - .UnableToOpenFile => self.write("Unable to open file.", .err), 62 - .UnableToDelete => self.write("Unable to delete item.", .err), 63 - .UnableToDeleteAcrossMountPoints => self.write("Unable to move item to /tmp. Failed to delete.", .err), 64 - .UnableToUndo => self.write("Unable to undo previous action.", .err), 65 - .ItemAlreadyExists => self.write("Item already exists.", .err), 66 - .UnableToRename => self.write("Unable to rename item.", .err), 67 - .IncorrectPath => self.write("Unable to find path.", .err), 68 - .EditorNotSet => self.write("$EDITOR is not set.", .err), 69 - .UnsupportedImageFormat => self.write("Unsupported image format.", .err), 70 - }; 71 - } 72 - 73 - pub fn write_info(self: *Self, info: Info) !void { 74 - try switch (info) { 75 - .CreatedFile => self.write("Successfully created file.", .info), 76 - .CreatedFolder => self.write("Successfully created folder.", .info), 77 - .Deleted => self.write("Successfully deleted item.", .info), 78 - .Renamed => self.write("Successfully renamed item.", .info), 79 - .RestoredDelete => self.write("Successfully restored deleted item.", .info), 80 - .RestoredRename => self.write("Successfully restored renamed item.", .info), 81 - .EmptyUndo => self.write("Nothing to undo.", .info), 82 - .ChangedDir => self.write("Successfully changed directory.", .info), 83 - }; 32 + if (self.loop) |loop| { 33 + loop.postEvent(.notification); 34 + } 84 35 } 85 36 86 37 pub fn reset(self: *Self) void { 87 38 self.fbs.reset(); 88 - self.len = 0; 89 39 self.style = Style.info; 90 40 } 91 41 92 42 pub fn slice(self: *Self) []const u8 { 93 - return self.buf[0..self.len]; 43 + return self.fbs.getWritten(); 44 + } 45 + 46 + pub fn clearIfEnded(self: *Self) bool { 47 + if (std.time.timestamp() - self.timer > notification_timeout) { 48 + self.reset(); 49 + return true; 50 + } 51 + 52 + return false; 53 + } 54 + 55 + pub fn len(self: Self) usize { 56 + return self.fbs.pos; 94 57 }
+5
src/sort.zig
··· 1 + const std = @import("std"); 2 + 3 + pub fn string(_: void, lhs: []const u8, rhs: []const u8) bool { 4 + return std.mem.lessThan(u8, lhs, rhs); 5 + }
+84
src/test_file_operations.zig
··· 1 + const std = @import("std"); 2 + const testing = std.testing; 3 + const TestEnv = @import("test_helpers.zig").TestEnv; 4 + const Directories = @import("directories.zig"); 5 + const environment = @import("environment.zig"); 6 + 7 + test "FileOps: create new directory" { 8 + var env = try TestEnv.init(testing.allocator); 9 + defer env.deinit(); 10 + 11 + var dirs = try Directories.init(testing.allocator, env.tmp_path); 12 + defer dirs.deinit(); 13 + 14 + try dirs.dir.makeDir("testdir"); 15 + 16 + var test_dir = dirs.dir.openDir("testdir", .{}) catch |err| { 17 + std.debug.print("Failed to open created directory: {}\n", .{err}); 18 + return err; 19 + }; 20 + test_dir.close(); 21 + 22 + try dirs.populateEntries(""); 23 + var found = false; 24 + for (dirs.entries.all()) |entry| { 25 + if (std.mem.eql(u8, entry.name, "testdir")) { 26 + found = true; 27 + try testing.expectEqual(std.fs.Dir.Entry.Kind.directory, entry.kind); 28 + } 29 + } 30 + try testing.expect(found); 31 + } 32 + 33 + test "FileOps: create new file" { 34 + var env = try TestEnv.init(testing.allocator); 35 + defer env.deinit(); 36 + 37 + var dirs = try Directories.init(testing.allocator, env.tmp_path); 38 + defer dirs.deinit(); 39 + 40 + const file = try dirs.dir.createFile("testfile.txt", .{}); 41 + file.close(); 42 + 43 + try testing.expect(environment.fileExists(dirs.dir, "testfile.txt")); 44 + 45 + try dirs.populateEntries(""); 46 + var found = false; 47 + for (dirs.entries.all()) |entry| { 48 + if (std.mem.eql(u8, entry.name, "testfile.txt")) { 49 + found = true; 50 + try testing.expectEqual(std.fs.Dir.Entry.Kind.file, entry.kind); 51 + } 52 + } 53 + try testing.expect(found); 54 + } 55 + 56 + test "FileOps: rename file" { 57 + var env = try TestEnv.init(testing.allocator); 58 + defer env.deinit(); 59 + 60 + try env.createFiles(&.{"oldname.txt"}); 61 + 62 + var dirs = try Directories.init(testing.allocator, env.tmp_path); 63 + defer dirs.deinit(); 64 + 65 + try dirs.populateEntries(""); 66 + 67 + try testing.expect(environment.fileExists(dirs.dir, "oldname.txt")); 68 + try dirs.dir.rename("oldname.txt", "newname.txt"); 69 + try testing.expect(!environment.fileExists(dirs.dir, "oldname.txt")); 70 + try testing.expect(environment.fileExists(dirs.dir, "newname.txt")); 71 + 72 + dirs.clearEntries(); 73 + try dirs.populateEntries(""); 74 + 75 + var found_old = false; 76 + var found_new = false; 77 + for (dirs.entries.all()) |entry| { 78 + if (std.mem.eql(u8, entry.name, "oldname.txt")) found_old = true; 79 + if (std.mem.eql(u8, entry.name, "newname.txt")) found_new = true; 80 + } 81 + 82 + try testing.expect(!found_old); 83 + try testing.expect(found_new); 84 + }
+61
src/test_helpers.zig
··· 1 + const std = @import("std"); 2 + 3 + pub const TestEnv = struct { 4 + allocator: std.mem.Allocator, 5 + tmp_dir: std.testing.TmpDir, 6 + tmp_path: []const u8, 7 + 8 + pub fn init(allocator: std.mem.Allocator) !TestEnv { 9 + var tmp_dir = std.testing.tmpDir(.{}); 10 + const real_path = try tmp_dir.dir.realpathAlloc(allocator, "."); 11 + 12 + return TestEnv{ 13 + .allocator = allocator, 14 + .tmp_dir = tmp_dir, 15 + .tmp_path = real_path, 16 + }; 17 + } 18 + 19 + pub fn deinit(self: *TestEnv) void { 20 + self.allocator.free(self.tmp_path); 21 + self.tmp_dir.cleanup(); 22 + } 23 + 24 + pub fn createFiles(self: *TestEnv, names: []const []const u8) !void { 25 + for (names) |name| { 26 + const file = try self.tmp_dir.dir.createFile(name, .{}); 27 + file.close(); 28 + } 29 + } 30 + 31 + pub const DirNode = struct { 32 + name: []const u8, 33 + children: ?[]const DirNode, 34 + }; 35 + 36 + pub fn createDirStructure(self: *TestEnv, nodes: []const DirNode) !void { 37 + for (nodes) |node| { 38 + if (node.children) |children| { 39 + try self.tmp_dir.dir.makeDir(node.name); 40 + var subdir = try self.tmp_dir.dir.openDir(node.name, .{}); 41 + defer subdir.close(); 42 + 43 + for (children) |child| { 44 + if (child.children) |_| { 45 + try subdir.makeDir(child.name); 46 + } else { 47 + const file = try subdir.createFile(child.name, .{}); 48 + file.close(); 49 + } 50 + } 51 + } else { 52 + const file = try self.tmp_dir.dir.createFile(node.name, .{}); 53 + file.close(); 54 + } 55 + } 56 + } 57 + 58 + pub fn path(self: *TestEnv, relative: []const u8) ![]const u8 { 59 + return try std.fs.path.join(self.allocator, &.{ self.tmp_path, relative }); 60 + } 61 + };
+114
src/test_navigation.zig
··· 1 + const std = @import("std"); 2 + const testing = std.testing; 3 + const TestEnv = @import("test_helpers.zig").TestEnv; 4 + const Directories = @import("directories.zig"); 5 + const events = @import("events.zig"); 6 + const App = @import("app.zig"); 7 + 8 + test "Navigation: traverse left to parent directory" { 9 + var env = try TestEnv.init(testing.allocator); 10 + defer env.deinit(); 11 + 12 + try env.createDirStructure(&.{ 13 + .{ .name = "parent", .children = &.{ 14 + .{ .name = "child", .children = &.{} }, 15 + .{ .name = "sibling.txt", .children = null }, 16 + } }, 17 + }); 18 + 19 + const child_path = try env.path("parent/child"); 20 + defer testing.allocator.free(child_path); 21 + 22 + var dirs = try Directories.init(testing.allocator, child_path); 23 + defer dirs.deinit(); 24 + 25 + const before_path = try dirs.fullPath("."); 26 + try testing.expect(std.mem.endsWith(u8, before_path, "child")); 27 + 28 + const parent_dir = try dirs.dir.openDir("../", .{ .iterate = true }); 29 + dirs.dir.close(); 30 + dirs.dir = parent_dir; 31 + 32 + const after_path = try dirs.fullPath("."); 33 + try testing.expect(std.mem.endsWith(u8, after_path, "parent")); 34 + 35 + try dirs.populateEntries(""); 36 + var found_child = false; 37 + for (dirs.entries.all()) |entry| { 38 + if (std.mem.eql(u8, entry.name, "child")) { 39 + found_child = true; 40 + try testing.expectEqual(std.fs.Dir.Entry.Kind.directory, entry.kind); 41 + } 42 + } 43 + try testing.expect(found_child); 44 + } 45 + 46 + test "Navigation: traverse right into directory" { 47 + var env = try TestEnv.init(testing.allocator); 48 + defer env.deinit(); 49 + 50 + try env.createDirStructure(&.{ 51 + .{ .name = "subdir", .children = &.{ 52 + .{ .name = "inner.txt", .children = null }, 53 + } }, 54 + .{ .name = "file.txt", .children = null }, 55 + }); 56 + 57 + var dirs = try Directories.init(testing.allocator, env.tmp_path); 58 + defer dirs.deinit(); 59 + 60 + try dirs.populateEntries(""); 61 + 62 + for (dirs.entries.all(), 0..) |entry, i| { 63 + if (std.mem.eql(u8, entry.name, "subdir")) { 64 + dirs.entries.selected = i; 65 + break; 66 + } 67 + } 68 + 69 + const selected = try dirs.getSelected(); 70 + try testing.expect(selected != null); 71 + try testing.expectEqualStrings("subdir", selected.?.name); 72 + 73 + const subdir = try dirs.dir.openDir("subdir", .{ .iterate = true }); 74 + dirs.dir.close(); 75 + dirs.dir = subdir; 76 + 77 + const current_path = try dirs.fullPath("."); 78 + try testing.expect(std.mem.endsWith(u8, current_path, "subdir")); 79 + 80 + dirs.clearEntries(); 81 + try dirs.populateEntries(""); 82 + try testing.expectEqual(@as(usize, 1), dirs.entries.len()); 83 + 84 + const inner = try dirs.entries.get(0); 85 + try testing.expectEqualStrings("inner.txt", inner.name); 86 + } 87 + 88 + test "Navigation: move selection with next and previous" { 89 + var env = try TestEnv.init(testing.allocator); 90 + defer env.deinit(); 91 + 92 + try env.createFiles(&.{ "file1.txt", "file2.txt", "file3.txt", "file4.txt", "file5.txt" }); 93 + 94 + var dirs = try Directories.init(testing.allocator, env.tmp_path); 95 + defer dirs.deinit(); 96 + 97 + try dirs.populateEntries(""); 98 + try testing.expectEqual(@as(usize, 5), dirs.entries.len()); 99 + try testing.expectEqual(@as(usize, 0), dirs.entries.selected); 100 + 101 + dirs.entries.next(); 102 + dirs.entries.next(); 103 + dirs.entries.next(); 104 + try testing.expectEqual(@as(usize, 3), dirs.entries.selected); 105 + 106 + dirs.entries.previous(); 107 + try testing.expectEqual(@as(usize, 2), dirs.entries.selected); 108 + 109 + dirs.entries.selectLast(); 110 + try testing.expectEqual(@as(usize, 4), dirs.entries.selected); 111 + 112 + dirs.entries.selectFirst(); 113 + try testing.expectEqual(@as(usize, 0), dirs.entries.selected); 114 + }