├── .envrc ├── .github ├── dependabot.yml ├── no-response.yml └── workflows │ ├── ci.yml │ ├── flakehub-publish-tagged.yml │ └── release.yml ├── .gitignore ├── CONTRIBUTING.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── assets └── logo.svg ├── flake.lock ├── flake.nix ├── rustfmt.toml ├── yeet-buffer ├── Cargo.toml └── src │ ├── lib.rs │ ├── message.rs │ ├── model │ ├── ansi.rs │ ├── mod.rs │ ├── undo.rs │ └── viewport.rs │ ├── update │ ├── cursor.rs │ ├── find.rs │ ├── mod.rs │ ├── modification.rs │ ├── viewport.rs │ └── word.rs │ └── view │ ├── line.rs │ ├── mod.rs │ ├── prefix.rs │ └── style.rs ├── yeet-frontend ├── Cargo.toml └── src │ ├── action.rs │ ├── error.rs │ ├── event.rs │ ├── init │ ├── history.rs │ ├── junkyard.rs │ ├── mark.rs │ ├── mod.rs │ └── qfix.rs │ ├── layout.rs │ ├── lib.rs │ ├── model │ ├── history.rs │ ├── junkyard.rs │ ├── mark.rs │ ├── mod.rs │ ├── qfix.rs │ └── register.rs │ ├── open.rs │ ├── settings.rs │ ├── task │ ├── command.rs │ ├── image.rs │ ├── mod.rs │ └── syntax.rs │ ├── terminal.rs │ ├── update │ ├── command │ │ ├── file.rs │ │ ├── mod.rs │ │ ├── print.rs │ │ ├── qfix.rs │ │ └── task.rs │ ├── commandline.rs │ ├── cursor.rs │ ├── enumeration.rs │ ├── history.rs │ ├── junkyard.rs │ ├── mark.rs │ ├── mod.rs │ ├── mode.rs │ ├── modification.rs │ ├── navigation.rs │ ├── open.rs │ ├── path.rs │ ├── qfix.rs │ ├── register.rs │ ├── save.rs │ ├── search.rs │ ├── selection.rs │ ├── settings.rs │ ├── sign.rs │ ├── task.rs │ └── viewport.rs │ └── view │ ├── commandline.rs │ ├── mod.rs │ └── statusline.rs ├── yeet-keymap ├── Cargo.toml ├── src │ ├── buffer.rs │ ├── conversion.rs │ ├── key.rs │ ├── lib.rs │ ├── map.rs │ ├── message.rs │ └── tree.rs └── tests │ └── lib_tests.rs ├── yeet.json └── yeet ├── Cargo.toml └── src └── main.rs /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | - package-ecosystem: "cargo" 8 | directory: "/" 9 | schedule: 10 | interval: "weekly" 11 | -------------------------------------------------------------------------------- /.github/no-response.yml: -------------------------------------------------------------------------------- 1 | # Configuration for probot-no-response - https://github.com/probot/no-response 2 | daysUntilClose: 14 3 | responseRequiredLabel: question 4 | closeComment: > 5 | This issue has been automatically closed because there has been no response 6 | to the request for more information from the original author. With only the 7 | information that is currently in the issue, there isn't enough information 8 | to take further action. Please reach out if you have or find the answers we 9 | need so that we can investigate further. 10 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | push: 4 | branches: ["main"] 5 | pull_request: 6 | branches: ["main"] 7 | env: 8 | CARGO_TERM_COLOR: always 9 | jobs: 10 | validating: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: format 15 | run: cargo fmt --check --verbose 16 | - name: building 17 | run: cargo build --verbose 18 | - name: testing 19 | run: cargo test --verbose 20 | -------------------------------------------------------------------------------- /.github/workflows/flakehub-publish-tagged.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - "v?[0-9]+.[0-9]+.[0-9]+*" 5 | workflow_dispatch: 6 | inputs: 7 | tag: 8 | description: "The existing tag to publish to FlakeHub" 9 | type: "string" 10 | required: true 11 | jobs: 12 | flakehub-publish: 13 | runs-on: "ubuntu-latest" 14 | permissions: 15 | id-token: "write" 16 | contents: "read" 17 | steps: 18 | - uses: "actions/checkout@v4" 19 | with: 20 | ref: "${{ (inputs.tag != null) && format('refs/tags/{0}', inputs.tag) || '' }}" 21 | - uses: "DeterminateSystems/nix-installer-action@main" 22 | - uses: "DeterminateSystems/flakehub-push@main" 23 | with: 24 | visibility: "public" 25 | name: "aserowy/yeet" 26 | tag: "${{ inputs.tag }}" 27 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v[0-9]+.[0-9]+.[0-9]+" 7 | 8 | jobs: 9 | release: 10 | permissions: 11 | contents: write 12 | strategy: 13 | matrix: 14 | include: 15 | - os: ubuntu-latest 16 | target: x86_64-unknown-linux-gnu 17 | - os: ubuntu-latest 18 | target: aarch64-unknown-linux-gnu 19 | - os: macos-latest 20 | target: x86_64-apple-darwin 21 | - os: macos-latest 22 | target: aarch64-apple-darwin 23 | - os: windows-latest 24 | target: x86_64-pc-windows-msvc 25 | - os: windows-latest 26 | target: aarch64-pc-windows-msvc 27 | runs-on: ${{ matrix.os }} 28 | steps: 29 | - uses: actions/checkout@v4 30 | 31 | - name: setup rust 32 | run: rustup toolchain install stable --profile minimal 33 | 34 | - name: add targets 35 | run: rustup target add ${{ matrix.target }} 36 | 37 | - name: install gcc for linux target 38 | if: matrix.target == 'aarch64-unknown-linux-gnu' 39 | run: | 40 | sudo apt-get update 41 | sudo apt-get install -yq gcc-aarch64-linux-gnu 42 | 43 | - name: setup rust cache 44 | uses: Swatinem/rust-cache@v2 45 | 46 | - name: build target 47 | env: 48 | CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: /usr/bin/aarch64-linux-gnu-gcc 49 | run: cargo build --release --locked --target ${{ matrix.target }} 50 | 51 | - name: pack unix artifacts 52 | if: matrix.os == 'ubuntu-latest' || matrix.os == 'macos-latest' 53 | env: 54 | TARGET_NAME: yeet-${{ matrix.target }} 55 | run: | 56 | mkdir $TARGET_NAME 57 | cp target/${{ matrix.target }}/release/yeet $TARGET_NAME 58 | cp README.md LICENSE $TARGET_NAME 59 | zip -r $TARGET_NAME.zip $TARGET_NAME 60 | 61 | - name: pack win artifacts 62 | if: matrix.os == 'windows-latest' 63 | env: 64 | TARGET_NAME: yeet-${{ matrix.target }} 65 | run: | 66 | New-Item -ItemType Directory -Path ${env:TARGET_NAME} 67 | Copy-Item -Path "target\${{ matrix.target }}\release\yeet.exe" -Destination ${env:TARGET_NAME} 68 | Copy-Item -Path "README.md", "LICENSE" -Destination ${env:TARGET_NAME} 69 | Compress-Archive -Path ${env:TARGET_NAME} -DestinationPath "${env:TARGET_NAME}.zip" 70 | 71 | - name: release on gh 72 | uses: softprops/action-gh-release@v2 73 | if: startsWith(github.ref, 'refs/tags/') 74 | with: 75 | draft: true 76 | files: | 77 | yeet-${{ matrix.target }}.zip 78 | generate_release_notes: true 79 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/rust,direnv 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=rust,direnv 3 | 4 | ### direnv ### 5 | .direnv 6 | # .envrc 7 | 8 | ### Rust ### 9 | # Generated by Cargo 10 | # will have compiled files and executables 11 | debug/ 12 | target/ 13 | 14 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 15 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 16 | # Cargo.lock 17 | 18 | # These are backup files generated by rustfmt 19 | **/*.rs.bk 20 | 21 | # MSVC Windows builds of rustc generate these, which store debugging information 22 | *.pdb 23 | 24 | # End of https://www.toptal.com/developers/gitignore/api/rust,direnv 25 | 26 | 27 | # Added by cargo 28 | 29 | /result 30 | /target 31 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # contributing 2 | 3 | You are welcome :) 4 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | 4 | members = [ 5 | "yeet", "yeet-buffer", "yeet-frontend", "yeet-keymap", 6 | ] 7 | 8 | [workspace.package] 9 | authors = ["Alexander Serowy"] 10 | description = "yet another... tui file manager with a touch of neovim like buffers and modal editing" 11 | edition = "2021" 12 | repository = "https://github.com/aserowy/yeet" 13 | version = "0.1.0" 14 | 15 | [workspace.dependencies] 16 | ansi-to-tui = "7.0.0" 17 | arboard = { version = "3.5.0", default-features = false, features = ["windows-sys", "wayland-data-control"] } 18 | clap = "4.5.38" 19 | crossterm = { version = "0.28.1", features = ["event-stream"] } 20 | csv = "1.3.0" 21 | dirs = "5.0.1" 22 | flate2 = "1.1.1" 23 | futures = "0.3.31" 24 | image = "0.25.6" 25 | infer = "0.16.0" 26 | notify = { version = "7.0.0", default-features = false, features = ["macos_fsevent"] } 27 | pathdiff = "0.2.2" 28 | ratatui = "0.29.0" 29 | ratatui-image = { version = "3.0.0", features = ["crossterm", "serde"] } 30 | regex = "1.11.1" 31 | syntect = { version = "5.2.0", default-features = false, features = ["default-fancy"]} 32 | tar = "0.4.44" 33 | thiserror = "2.0.12" 34 | tokio = { version = "1.45.1", features = ["full"] } 35 | tokio-util = "0.7.15" 36 | tracing = "0.1.40" 37 | tracing-appender = "0.2.3" 38 | tracing-subscriber = "0.3.19" 39 | 40 | [workspace.lints.rust] 41 | unsafe_code = "forbid" 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Alexander Serowy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-parts": { 4 | "inputs": { 5 | "nixpkgs-lib": "nixpkgs-lib" 6 | }, 7 | "locked": { 8 | "lastModified": 1730504689, 9 | "narHash": "sha256-hgmguH29K2fvs9szpq2r3pz2/8cJd2LPS+b4tfNFCwE=", 10 | "owner": "hercules-ci", 11 | "repo": "flake-parts", 12 | "rev": "506278e768c2a08bec68eb62932193e341f55c90", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "hercules-ci", 17 | "repo": "flake-parts", 18 | "type": "github" 19 | } 20 | }, 21 | "flake-utils": { 22 | "locked": { 23 | "lastModified": 1659877975, 24 | "narHash": "sha256-zllb8aq3YO3h8B/U0/J1WBgAL8EX5yWf5pMj3G0NAmc=", 25 | "owner": "numtide", 26 | "repo": "flake-utils", 27 | "rev": "c0e246b9b83f637f4681389ecabcb2681b4f3af0", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "numtide", 32 | "repo": "flake-utils", 33 | "type": "github" 34 | } 35 | }, 36 | "nixgl": { 37 | "inputs": { 38 | "flake-utils": "flake-utils", 39 | "nixpkgs": [ 40 | "nixpkgs" 41 | ] 42 | }, 43 | "locked": { 44 | "lastModified": 1713543440, 45 | "narHash": "sha256-lnzZQYG0+EXl/6NkGpyIz+FEOc/DSEG57AP1VsdeNrM=", 46 | "owner": "nix-community", 47 | "repo": "nixGL", 48 | "rev": "310f8e49a149e4c9ea52f1adf70cdc768ec53f8a", 49 | "type": "github" 50 | }, 51 | "original": { 52 | "owner": "nix-community", 53 | "repo": "nixGL", 54 | "type": "github" 55 | } 56 | }, 57 | "nixpkgs": { 58 | "locked": { 59 | "lastModified": 1732014248, 60 | "narHash": "sha256-y/MEyuJ5oBWrWAic/14LaIr/u5E0wRVzyYsouYY3W6w=", 61 | "owner": "nixos", 62 | "repo": "nixpkgs", 63 | "rev": "23e89b7da85c3640bbc2173fe04f4bd114342367", 64 | "type": "github" 65 | }, 66 | "original": { 67 | "owner": "nixos", 68 | "ref": "nixos-unstable", 69 | "repo": "nixpkgs", 70 | "type": "github" 71 | } 72 | }, 73 | "nixpkgs-lib": { 74 | "locked": { 75 | "lastModified": 1730504152, 76 | "narHash": "sha256-lXvH/vOfb4aGYyvFmZK/HlsNsr/0CVWlwYvo2rxJk3s=", 77 | "type": "tarball", 78 | "url": "https://github.com/NixOS/nixpkgs/archive/cc2f28000298e1269cea6612cd06ec9979dd5d7f.tar.gz" 79 | }, 80 | "original": { 81 | "type": "tarball", 82 | "url": "https://github.com/NixOS/nixpkgs/archive/cc2f28000298e1269cea6612cd06ec9979dd5d7f.tar.gz" 83 | } 84 | }, 85 | "root": { 86 | "inputs": { 87 | "flake-parts": "flake-parts", 88 | "nixgl": "nixgl", 89 | "nixpkgs": "nixpkgs", 90 | "rust-overlay": "rust-overlay" 91 | } 92 | }, 93 | "rust-overlay": { 94 | "inputs": { 95 | "nixpkgs": [ 96 | "nixpkgs" 97 | ] 98 | }, 99 | "locked": { 100 | "lastModified": 1732242723, 101 | "narHash": "sha256-NWI8csIK0ujFlFuEXKnoc+7hWoCiEtINK9r48LUUMeU=", 102 | "owner": "oxalica", 103 | "repo": "rust-overlay", 104 | "rev": "a229311fcb45b88a95fdfa5cecd8349c809a272a", 105 | "type": "github" 106 | }, 107 | "original": { 108 | "owner": "oxalica", 109 | "repo": "rust-overlay", 110 | "type": "github" 111 | } 112 | } 113 | }, 114 | "root": "root", 115 | "version": 7 116 | } 117 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | # https://github.com/cpu/woodwidelog/blob/bb549af2b33c5c50ae6e7361da4af3b1993caa1d/content/articles/rust-flake/index.md?plain=1#L50 2 | { 3 | description = "yeet the great flake"; 4 | 5 | inputs = { 6 | flake-parts.url = "github:hercules-ci/flake-parts"; 7 | nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; 8 | nixgl = { 9 | url = "github:nix-community/nixGL"; 10 | inputs = { 11 | nixpkgs.follows = "nixpkgs"; 12 | }; 13 | }; 14 | rust-overlay = { 15 | url = "github:oxalica/rust-overlay"; 16 | inputs = { 17 | nixpkgs.follows = "nixpkgs"; 18 | }; 19 | }; 20 | }; 21 | 22 | outputs = { self, nixpkgs, nixgl, flake-parts, rust-overlay, ... }@inputs: 23 | flake-parts.lib.mkFlake { inherit inputs; } { 24 | imports = [ 25 | inputs.flake-parts.flakeModules.easyOverlay 26 | ]; 27 | 28 | systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" "x86_64-darwin" ]; 29 | 30 | perSystem = { config, self', inputs', pkgs, system, lib, ... }: 31 | let 32 | overlays = [ (import rust-overlay) nixgl.overlay ]; 33 | pkgs = import nixpkgs { 34 | inherit system overlays; 35 | }; 36 | 37 | toml_version = builtins.fromTOML (builtins.readFile ./Cargo.toml); 38 | toml_name = builtins.fromTOML (builtins.readFile ./yeet/Cargo.toml); 39 | 40 | package = (pkgs.makeRustPlatform { 41 | cargo = pkgs.rust-bin.stable.latest.minimal; 42 | rustc = pkgs.rust-bin.stable.latest.minimal; 43 | }).buildRustPackage { 44 | inherit (toml_name.package) name; 45 | inherit (toml_version.workspace.package) version; 46 | 47 | src = ./.; 48 | cargoLock.lockFile = ./Cargo.lock; 49 | 50 | buildInputs = lib.optionals pkgs.stdenv.isDarwin ( 51 | with pkgs.darwin.apple_sdk.frameworks; [ AppKit Foundation ] 52 | ); 53 | }; 54 | 55 | rust-stable = inputs'.rust-overlay.packages.rust.override { 56 | extensions = [ "rust-src" "rust-analyzer" "clippy" ]; 57 | }; 58 | 59 | shell = pkgs.mkShell { 60 | buildInputs = lib.optionals pkgs.stdenv.isDarwin ( 61 | with pkgs.darwin.apple_sdk.frameworks; [ AppKit Foundation ] 62 | ); 63 | nativeBuildInputs = [ 64 | rust-stable 65 | 66 | pkgs.asciinema 67 | pkgs.asciinema-agg 68 | pkgs.gh 69 | pkgs.nil 70 | pkgs.nixpkgs-fmt 71 | pkgs.nodejs_20 72 | pkgs.nodePackages.markdownlint-cli 73 | pkgs.nodePackages.prettier 74 | 75 | pkgs.chafa 76 | pkgs.fd 77 | pkgs.kitty 78 | pkgs.wezterm 79 | ] ++ lib.optionals (!pkgs.stdenv.isDarwin) [ 80 | pkgs.vscode-extensions.vadimcn.vscode-lldb 81 | ]; 82 | shellHook = '' 83 | export PATH=~/.cargo/bin:$PATH 84 | 85 | ${ if (!pkgs.stdenv.isDarwin) then 86 | "export PATH=${pkgs.vscode-extensions.vadimcn.vscode-lldb}/share/vscode/extensions/vadimcn.vscode-lldb/adapter:$PATH" 87 | else 88 | "" 89 | } 90 | ''; 91 | 92 | RUST_BACKTRACE = "full"; 93 | }; 94 | in 95 | { 96 | overlayAttrs = { 97 | inherit (config.packages) yeet; 98 | }; 99 | 100 | packages = { 101 | default = self'.packages.yeet; 102 | yeet = package; 103 | }; 104 | 105 | devShells.default = shell; 106 | }; 107 | }; 108 | } 109 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | # after options got available on stable 2 | # group_imports = "StdExternalCrate" 3 | # imports_granularity = "Crate" 4 | -------------------------------------------------------------------------------- /yeet-buffer/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "yeet-buffer" 3 | 4 | authors.workspace = true 5 | edition.workspace = true 6 | repository.workspace = true 7 | version.workspace = true 8 | 9 | [lints] 10 | workspace = true 11 | 12 | [dependencies] 13 | ansi-to-tui.workspace = true 14 | ratatui.workspace = true 15 | tracing.workspace = true 16 | -------------------------------------------------------------------------------- /yeet-buffer/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod message; 2 | pub mod model; 3 | pub mod update; 4 | pub mod view; 5 | -------------------------------------------------------------------------------- /yeet-buffer/src/message.rs: -------------------------------------------------------------------------------- 1 | use std::cmp::Ordering; 2 | 3 | use crate::model::{BufferLine, Mode}; 4 | 5 | #[derive(Clone, Eq, PartialEq)] 6 | pub enum BufferMessage { 7 | ChangeMode(Mode, Mode), 8 | Modification(usize, TextModification), 9 | MoveCursor(usize, CursorDirection), 10 | MoveViewPort(ViewPortDirection), 11 | RemoveLine(usize), 12 | ResetCursor, 13 | SaveBuffer, 14 | SetContent(Vec), 15 | SetCursorToLineContent(String), 16 | SortContent(fn(&BufferLine, &BufferLine) -> Ordering), 17 | UpdateViewPortByCursor, 18 | } 19 | 20 | impl std::fmt::Debug for BufferMessage { 21 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 22 | match self { 23 | BufferMessage::ChangeMode(from, to) => { 24 | f.debug_tuple("ChangeMode").field(from).field(to).finish() 25 | } 26 | BufferMessage::Modification(index, modification) => f 27 | .debug_tuple("Modification") 28 | .field(index) 29 | .field(modification) 30 | .finish(), 31 | BufferMessage::MoveCursor(index, direction) => f 32 | .debug_tuple("MoveCursor") 33 | .field(index) 34 | .field(direction) 35 | .finish(), 36 | BufferMessage::MoveViewPort(direction) => { 37 | f.debug_tuple("MoveViewPort").field(direction).finish() 38 | } 39 | BufferMessage::RemoveLine(index) => f.debug_tuple("RemoveLine").field(index).finish(), 40 | BufferMessage::ResetCursor => f.debug_tuple("ResetCursor").finish(), 41 | BufferMessage::SaveBuffer => f.debug_tuple("SaveBuffer").finish(), 42 | BufferMessage::SetContent(_) => f.debug_tuple("SetContent").finish(), 43 | BufferMessage::SetCursorToLineContent(content) => f 44 | .debug_tuple("SetCursorToLineContent") 45 | .field(content) 46 | .finish(), 47 | BufferMessage::SortContent(_) => f 48 | .debug_tuple("SortContent") 49 | .field(&"fn(&BufferLine, &BufferLine) -> Ordering") 50 | .finish(), 51 | BufferMessage::UpdateViewPortByCursor => { 52 | f.debug_tuple("UpdateViewPortByCursor").finish() 53 | } 54 | } 55 | } 56 | } 57 | 58 | #[derive(Clone, Debug, Eq, Hash, PartialEq)] 59 | pub enum TextModification { 60 | DeleteLine, 61 | DeleteMotion(usize, CursorDirection), 62 | Insert(String), 63 | InsertLineBreak, 64 | InsertNewLine(LineDirection), 65 | } 66 | 67 | #[derive(Clone, Debug, Eq, Hash, PartialEq)] 68 | pub enum LineDirection { 69 | Up, 70 | Down, 71 | } 72 | 73 | #[derive(Clone, Debug, Eq, Hash, PartialEq)] 74 | pub enum CursorDirection { 75 | Bottom, 76 | Down, 77 | FindBackward(char), 78 | FindForward(char), 79 | LastFindBackward, 80 | LastFindForward, 81 | Left, 82 | LineEnd, 83 | LineStart, 84 | Right, 85 | Search(Search), 86 | TillBackward(char), 87 | TillForward(char), 88 | Top, 89 | Up, 90 | WordEndBackward, 91 | WordEndForward, 92 | WordStartBackward, 93 | WordStartForward, 94 | WordUpperEndBackward, 95 | WordUpperEndForward, 96 | WordUpperStartBackward, 97 | WordUpperStartForward, 98 | } 99 | 100 | #[derive(Clone, Debug, Default, Eq, Hash, PartialEq)] 101 | pub enum Search { 102 | #[default] 103 | Next, 104 | Previous, 105 | } 106 | 107 | #[derive(Clone, Debug, Eq, PartialEq)] 108 | pub enum ViewPortDirection { 109 | BottomOnCursor, 110 | CenterOnCursor, 111 | HalfPageDown, 112 | HalfPageUp, 113 | TopOnCursor, 114 | } 115 | -------------------------------------------------------------------------------- /yeet-buffer/src/model/ansi.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | #[derive(Clone, Debug, Default, Eq, PartialEq)] 4 | pub struct Ansi { 5 | content: String, 6 | } 7 | 8 | impl Display for Ansi { 9 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 10 | self.content.fmt(f) 11 | } 12 | } 13 | 14 | impl Ansi { 15 | pub fn new(content: &str) -> Self { 16 | Self { 17 | content: content.to_string(), 18 | } 19 | } 20 | 21 | pub fn to_stripped_string(&self) -> String { 22 | let mut is_ansi = false; 23 | let mut result = String::new(); 24 | for c in self.content.chars() { 25 | if c == '\x1b' { 26 | is_ansi = true; 27 | } else if is_ansi && c == 'm' { 28 | is_ansi = false; 29 | } else if !is_ansi { 30 | result.push(c); 31 | } 32 | } 33 | result 34 | } 35 | 36 | pub fn count_chars(&self) -> usize { 37 | let mut count = 0; 38 | let mut is_ansi = false; 39 | for c in self.content.chars() { 40 | if c == '\x1b' { 41 | is_ansi = true; 42 | } else if is_ansi && c == 'm' { 43 | is_ansi = false; 44 | } else if !is_ansi { 45 | count += 1; 46 | } 47 | } 48 | count 49 | } 50 | 51 | pub fn skip_chars(&self, position: usize) -> Self { 52 | if position == 0 { 53 | return self.clone(); 54 | } 55 | let index = self.position_to_index(position); 56 | match self.content.get(index..) { 57 | Some(content) => Self::new(content), 58 | None => Ansi::new(""), 59 | } 60 | } 61 | 62 | pub fn take_chars(&self, position: usize) -> Self { 63 | if position == 0 { 64 | return Ansi::new(""); 65 | } 66 | let index = self.position_to_index(position); 67 | match self.content.get(..index) { 68 | Some(content) => Self::new(content), 69 | None => self.clone(), 70 | } 71 | } 72 | 73 | pub fn join(&mut self, other: &Self) -> Self { 74 | Ansi::new(&format!("{}{}", self.content, other.content)) 75 | } 76 | 77 | pub fn append(&mut self, s: &str) { 78 | self.content.push_str(s); 79 | } 80 | 81 | pub fn insert(&mut self, position: usize, s: &str) { 82 | let index = self.position_to_index(position); 83 | self.content.insert_str(index, s); 84 | } 85 | 86 | pub fn prepend(&mut self, s: &str) { 87 | self.content.insert_str(0, s); 88 | } 89 | 90 | pub fn remove(&mut self, position: usize, size: usize) { 91 | let index_start = self.position_to_index(position); 92 | let index_end = self.position_to_index(position + size); 93 | for _ in 0..(index_end - index_start) { 94 | self.content.remove(index_start); 95 | } 96 | } 97 | 98 | pub fn get_ansi_escape_sequences_till_char(&self, position: usize) -> String { 99 | let mut current_position = 0; 100 | let mut is_ansi = false; 101 | let mut result = String::new(); 102 | for c in self.content.chars() { 103 | if c == '\x1b' { 104 | is_ansi = true; 105 | result.push(c); 106 | } else if is_ansi && c == 'm' { 107 | is_ansi = false; 108 | result.push(c); 109 | } else if is_ansi { 110 | result.push(c); 111 | } else if !is_ansi { 112 | current_position += 1; 113 | if current_position > position { 114 | break; 115 | } 116 | } 117 | } 118 | result 119 | } 120 | 121 | pub fn is_empty(&self) -> bool { 122 | self.count_chars() == 0 123 | } 124 | 125 | fn position_to_index(&self, position: usize) -> usize { 126 | let mut current_position = 0; 127 | let mut is_ansi = false; 128 | for (i, c) in self.content.chars().enumerate() { 129 | if c == '\x1b' { 130 | is_ansi = true; 131 | } else if is_ansi && c == 'm' { 132 | is_ansi = false; 133 | } else if !is_ansi { 134 | if current_position == position { 135 | return i; 136 | } 137 | current_position += 1; 138 | } 139 | } 140 | return self.content.chars().count(); 141 | } 142 | } 143 | 144 | #[cfg(test)] 145 | mod tests { 146 | use super::*; 147 | 148 | #[test] 149 | fn test_new() { 150 | let ansi = Ansi::new("Hello, \x1b[31mworld\x1b[0m!"); 151 | assert_eq!(ansi.content, "Hello, \x1b[31mworld\x1b[0m!"); 152 | } 153 | 154 | #[test] 155 | fn test_to_stripped_string() { 156 | let ansi = Ansi::new("Hello, \x1b[31mworld\x1b[0m!"); 157 | assert_eq!(ansi.to_stripped_string(), "Hello, world!"); 158 | } 159 | 160 | #[test] 161 | fn test_count_chars() { 162 | let ansi = Ansi::new("Hello, \x1b[31mworld\x1b[0m!"); 163 | assert_eq!(ansi.count_chars(), 13); 164 | } 165 | 166 | #[test] 167 | fn test_skip_chars() { 168 | let ansi = Ansi::new("Hello, \x1b[31mworld\x1b[0m!"); 169 | let skipped = ansi.skip_chars(5); 170 | assert_eq!(skipped.content, ", \x1b[31mworld\x1b[0m!"); 171 | 172 | let ansi = Ansi::new("Hello, \x1b[31mworld\x1b[0m!"); 173 | let skipped = ansi.skip_chars(7); 174 | assert_eq!(skipped.content, "world\x1b[0m!"); 175 | 176 | let ansi = Ansi::new("Hello, \x1b[31mworld\x1b[0m!"); 177 | let skipped = ansi.skip_chars(0); 178 | assert_eq!(skipped.content, "Hello, \x1b[31mworld\x1b[0m!"); 179 | 180 | let ansi = Ansi::new("\x1b[31mHello, \x1b[31mworld\x1b[0m!"); 181 | let skipped = ansi.skip_chars(0); 182 | assert_eq!(skipped.content, "\x1b[31mHello, \x1b[31mworld\x1b[0m!"); 183 | 184 | let ansi = Ansi::new("Hello"); 185 | let skipped = ansi.skip_chars(5); 186 | assert_eq!(skipped.content, ""); 187 | } 188 | 189 | #[test] 190 | fn test_take_chars() { 191 | let ansi = Ansi::new("Hello, \x1b[31mworld\x1b[0m!"); 192 | let taken = ansi.take_chars(5); 193 | assert_eq!(taken.content, "Hello"); 194 | 195 | let ansi = Ansi::new("Hello, \x1b[31mworld\x1b[0m!"); 196 | let taken = ansi.take_chars(8); 197 | assert_eq!(taken.content, "Hello, \x1b[31mw"); 198 | 199 | let ansi = Ansi::new("\x1b[12mHello, \x1b[31mworld\x1b[0m!"); 200 | let taken = ansi.take_chars(0); 201 | assert_eq!(taken.content, ""); 202 | 203 | let ansi = Ansi::new("Hello"); 204 | let skipped = ansi.take_chars(5); 205 | assert_eq!(skipped.content, "Hello"); 206 | } 207 | 208 | #[test] 209 | fn test_join() { 210 | let mut ansi1 = Ansi::new("Hello, "); 211 | let ansi2 = Ansi::new("\x1b[31mworld\x1b[0m!"); 212 | let joined = ansi1.join(&ansi2); 213 | assert_eq!(joined.content, "Hello, \x1b[31mworld\x1b[0m!"); 214 | } 215 | 216 | #[test] 217 | fn test_append() { 218 | let mut ansi = Ansi::new("Hello"); 219 | ansi.append(", \x1b[31mworld\x1b[0m!"); 220 | assert_eq!(ansi.content, "Hello, \x1b[31mworld\x1b[0m!"); 221 | } 222 | 223 | #[test] 224 | fn test_insert() { 225 | let mut ansi = Ansi::new("Hello, world!"); 226 | ansi.insert(7, "asdf\x1b[31m"); 227 | assert_eq!(ansi.content, "Hello, asdf\x1b[31mworld!"); 228 | 229 | ansi.insert(0, "\x1b[31m"); 230 | assert_eq!(ansi.content, "\x1b[31mHello, asdf\x1b[31mworld!"); 231 | 232 | ansi.insert(0, "\x1b[1m"); 233 | assert_eq!(ansi.content, "\x1b[31m\x1b[1mHello, asdf\x1b[31mworld!"); 234 | 235 | let mut ansi = Ansi::new(""); 236 | ansi.insert(0, "1"); 237 | ansi.insert(1, "2"); 238 | ansi.insert(2, "3"); 239 | assert_eq!(ansi.content, "123"); 240 | } 241 | 242 | #[test] 243 | fn test_prepend() { 244 | let mut ansi = Ansi::new("world!"); 245 | ansi.prepend("Hello, \x1b[31m"); 246 | assert_eq!(ansi.content, "Hello, \x1b[31mworld!"); 247 | } 248 | 249 | #[test] 250 | fn test_remove() { 251 | let mut ansi = Ansi::new("Hello, \x1b[31mworld\x1b[0m!"); 252 | ansi.remove(7, 5); 253 | assert_eq!(ansi.content, "Hello, \x1b[31m!"); 254 | } 255 | 256 | #[test] 257 | fn test_get_ansi_escape_sequences_till_char() { 258 | let ansi = Ansi::new("Hello, \x1b[31mworld\x1b[0m!"); 259 | assert_eq!(ansi.get_ansi_escape_sequences_till_char(7), "\x1b[31m"); 260 | 261 | let ansi = Ansi::new("\x1b[31mworld\x1b[0m!"); 262 | assert_eq!(ansi.get_ansi_escape_sequences_till_char(0), "\x1b[31m"); 263 | } 264 | 265 | #[test] 266 | fn test_is_empty() { 267 | let ansi = Ansi::new(""); 268 | assert!(ansi.is_empty()); 269 | 270 | let ansi = Ansi::new("\x1b[31m\x1b[0m"); 271 | assert!(ansi.is_empty()); 272 | 273 | let ansi = Ansi::new("Hello"); 274 | assert!(!ansi.is_empty()); 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /yeet-buffer/src/model/mod.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use crate::message::CursorDirection; 4 | 5 | use ansi::Ansi; 6 | use undo::{BufferChanged, Undo}; 7 | 8 | pub mod ansi; 9 | pub mod undo; 10 | pub mod viewport; 11 | 12 | #[derive(Clone, Debug, Default, Eq, Hash, PartialEq)] 13 | pub enum Mode { 14 | Command(CommandMode), 15 | Insert, 16 | #[default] 17 | Navigation, 18 | Normal, 19 | } 20 | 21 | impl Mode { 22 | pub fn is_command(&self) -> bool { 23 | matches!(self, Mode::Command(_)) 24 | } 25 | } 26 | 27 | impl Display for Mode { 28 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 29 | let content = match self { 30 | Mode::Command(_) => "command".to_string(), 31 | Mode::Insert => "insert".to_string(), 32 | Mode::Navigation => "navigation".to_string(), 33 | Mode::Normal => "normal".to_string(), 34 | }; 35 | 36 | write!(f, "{}", content) 37 | } 38 | } 39 | 40 | #[derive(Clone, Debug, Eq, Hash, PartialEq)] 41 | pub enum CommandMode { 42 | Command, 43 | PrintMultiline, 44 | Search(SearchDirection), 45 | } 46 | 47 | #[derive(Clone, Debug, Default, Eq, Hash, PartialEq)] 48 | pub enum SearchDirection { 49 | #[default] 50 | Down, 51 | Up, 52 | } 53 | 54 | #[derive(Default)] 55 | pub struct Buffer { 56 | pub last_find: Option, 57 | pub lines: Vec, 58 | pub undo: Undo, 59 | } 60 | 61 | impl std::fmt::Debug for Buffer { 62 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 63 | f.debug_struct("Buffer") 64 | .field("last_find", &self.last_find) 65 | .field("lines", &self.lines) 66 | .finish() 67 | } 68 | } 69 | 70 | #[derive(Clone, Debug, Default)] 71 | pub struct Cursor { 72 | pub hide_cursor: bool, 73 | pub hide_cursor_line: bool, 74 | pub horizontal_index: CursorPosition, 75 | pub vertical_index: usize, 76 | } 77 | 78 | #[derive(Clone, Debug, PartialEq)] 79 | pub enum CursorPosition { 80 | Absolute { current: usize, expanded: usize }, 81 | End, 82 | None, 83 | } 84 | 85 | impl Default for CursorPosition { 86 | fn default() -> Self { 87 | CursorPosition::Absolute { 88 | current: 0, 89 | expanded: 0, 90 | } 91 | } 92 | } 93 | 94 | #[derive(Clone, Debug, Default, Eq, PartialEq)] 95 | pub struct BufferLine { 96 | pub prefix: Option, 97 | pub content: Ansi, 98 | pub search_char_position: Option>, 99 | pub signs: Vec, 100 | } 101 | 102 | impl BufferLine { 103 | pub fn from(content: &str) -> Self { 104 | Self { 105 | content: Ansi::new(content), 106 | ..Default::default() 107 | } 108 | } 109 | 110 | pub fn is_empty(&self) -> bool { 111 | self.content.is_empty() 112 | } 113 | 114 | pub fn len(&self) -> usize { 115 | self.content.count_chars() 116 | } 117 | } 118 | 119 | pub type SignIdentifier = &'static str; 120 | 121 | #[derive(Clone, Debug, Eq, PartialEq)] 122 | pub struct Sign { 123 | pub id: SignIdentifier, 124 | pub content: char, 125 | pub priority: usize, 126 | pub style: String, 127 | } 128 | 129 | #[derive(Clone, PartialEq)] 130 | pub enum BufferResult { 131 | Changes(Vec), 132 | CursorPositionChanged, 133 | FindScopeChanged(CursorDirection), 134 | } 135 | -------------------------------------------------------------------------------- /yeet-buffer/src/model/viewport.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | 3 | use super::{BufferLine, SignIdentifier}; 4 | 5 | #[derive(Debug, Default)] 6 | pub struct WindowSettings { 7 | pub sign_column_width: usize, 8 | } 9 | 10 | #[derive(Debug, Default)] 11 | pub struct ViewPort { 12 | pub height: usize, 13 | pub hidden_sign_ids: HashSet, 14 | pub horizontal_index: usize, 15 | pub line_number: LineNumber, 16 | pub line_number_width: usize, 17 | pub sign_column_width: usize, 18 | pub vertical_index: usize, 19 | pub width: usize, 20 | } 21 | 22 | // TODO: enable with settings 23 | // TODO: refactor into functions 24 | impl ViewPort { 25 | pub fn get_border_width(&self) -> usize { 26 | if self.get_prefix_width() > 0 { 27 | 1 28 | } else { 29 | 0 30 | } 31 | } 32 | 33 | pub fn get_content_width(&self, line: &BufferLine) -> usize { 34 | let offset = self.get_offset_width(line); 35 | if self.width < offset { 36 | 0 37 | } else { 38 | self.width - offset 39 | } 40 | } 41 | 42 | pub fn get_line_number_width(&self) -> usize { 43 | match self.line_number { 44 | LineNumber::Absolute => self.line_number_width, 45 | LineNumber::None => 0, 46 | LineNumber::Relative => self.line_number_width, 47 | } 48 | } 49 | 50 | pub fn get_offset_width(&self, line: &BufferLine) -> usize { 51 | let custom_prefix_width = if let Some(prefix) = &line.prefix { 52 | prefix.chars().count() 53 | } else { 54 | 0 55 | }; 56 | 57 | self.get_prefix_width() + self.get_border_width() + custom_prefix_width 58 | } 59 | 60 | fn get_prefix_width(&self) -> usize { 61 | self.sign_column_width + self.get_line_number_width() 62 | } 63 | 64 | pub fn set(&mut self, settings: &WindowSettings) { 65 | self.sign_column_width = settings.sign_column_width; 66 | } 67 | } 68 | 69 | #[derive(Clone, Debug, Default, PartialEq)] 70 | pub enum LineNumber { 71 | Absolute, 72 | #[default] 73 | None, 74 | Relative, 75 | } 76 | -------------------------------------------------------------------------------- /yeet-buffer/src/update/find.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | message::CursorDirection, 3 | model::{Buffer, BufferLine, Cursor, CursorPosition}, 4 | }; 5 | 6 | use super::cursor; 7 | 8 | pub fn char(cursor: &mut Cursor, direction: &CursorDirection, model: &Buffer) { 9 | match direction { 10 | CursorDirection::FindBackward(find) => { 11 | if let Some(found) = find_char_backward(find, &model.lines, cursor) { 12 | cursor.horizontal_index = CursorPosition::Absolute { 13 | current: found, 14 | expanded: found, 15 | }; 16 | } 17 | } 18 | CursorDirection::FindForward(find) => { 19 | if let Some(found) = find_char_forward(find, &model.lines, cursor) { 20 | cursor.horizontal_index = CursorPosition::Absolute { 21 | current: found, 22 | expanded: found, 23 | }; 24 | } 25 | } 26 | CursorDirection::TillBackward(find) => { 27 | if let Some(found) = find_char_backward(find, &model.lines, cursor) { 28 | let new = found + 1; 29 | cursor.horizontal_index = CursorPosition::Absolute { 30 | current: new, 31 | expanded: new, 32 | }; 33 | } 34 | } 35 | CursorDirection::TillForward(find) => { 36 | if let Some(found) = find_char_forward(find, &model.lines, cursor) { 37 | let new = found - 1; 38 | cursor.horizontal_index = CursorPosition::Absolute { 39 | current: new, 40 | expanded: new, 41 | }; 42 | } 43 | } 44 | _ => unreachable!(), 45 | }; 46 | } 47 | 48 | fn find_char_backward(find: &char, lines: &[BufferLine], cursor: &Cursor) -> Option { 49 | let current = match lines.get(cursor.vertical_index) { 50 | Some(line) => line, 51 | None => return None, 52 | }; 53 | 54 | let index = match cursor::get_horizontal_index(&cursor.horizontal_index, current) { 55 | Some(index) => index, 56 | None => return None, 57 | }; 58 | 59 | if index <= 1 { 60 | return None; 61 | } 62 | 63 | current 64 | .content 65 | .to_stripped_string() 66 | .chars() 67 | .take(index) 68 | .collect::>() 69 | .iter() 70 | .rposition(|c| c == find) 71 | } 72 | 73 | fn find_char_forward(find: &char, lines: &[BufferLine], cursor: &mut Cursor) -> Option { 74 | let current = match lines.get(cursor.vertical_index) { 75 | Some(line) => line, 76 | None => return None, 77 | }; 78 | 79 | let index = match cursor::get_horizontal_index(&cursor.horizontal_index, current) { 80 | Some(index) => index, 81 | None => return None, 82 | }; 83 | 84 | current 85 | .content 86 | .to_stripped_string() 87 | .chars() 88 | .skip(index + 1) 89 | .position(|c| &c == find) 90 | .map(|i| index + i + 1) 91 | } 92 | -------------------------------------------------------------------------------- /yeet-buffer/src/update/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | message::{BufferMessage, CursorDirection}, 3 | model::{viewport::ViewPort, Buffer, BufferResult, Cursor, CursorPosition, Mode}, 4 | update::cursor::{set_outbound_cursor_to_inbound_position, update_cursor_by_direction}, 5 | }; 6 | 7 | mod cursor; 8 | mod find; 9 | mod modification; 10 | mod viewport; 11 | mod word; 12 | 13 | pub fn update_buffer( 14 | viewport: &mut ViewPort, 15 | cursor: &mut Option, 16 | mode: &Mode, 17 | buffer: &mut Buffer, 18 | message: &BufferMessage, 19 | ) -> Vec { 20 | tracing::debug!("handling buffer message: {:?}", message); 21 | 22 | let result = match message { 23 | // TODO: repeat actions by count when switching from insert to normal 24 | // count is entered before going into insert. ChangeMode with count? Or Insert with count? 25 | BufferMessage::ChangeMode(from, to) => { 26 | if from == &Mode::Insert && to != &Mode::Insert { 27 | buffer.undo.close_transaction(); 28 | 29 | if let Some(cursor) = cursor { 30 | update_cursor_by_direction(cursor, mode, buffer, &1, &CursorDirection::Left); 31 | } 32 | } 33 | Vec::new() 34 | } 35 | BufferMessage::Modification(count, modification) => { 36 | if let Some(cursor) = cursor { 37 | let changes = modification::update(cursor, mode, buffer, count, modification); 38 | if let Some(changes) = changes { 39 | buffer.undo.add(mode, changes); 40 | } 41 | } 42 | Vec::new() 43 | } 44 | BufferMessage::MoveCursor(count, direction) => { 45 | if let Some(cursor) = cursor { 46 | update_cursor_by_direction(cursor, mode, buffer, count, direction) 47 | } else { 48 | Vec::new() 49 | } 50 | // TODO: history::add_history_entry(&mut model.history, selected.as_path()); 51 | } 52 | BufferMessage::MoveViewPort(direction) => { 53 | viewport::update_by_direction(viewport, cursor, buffer, direction); 54 | Vec::new() 55 | } 56 | BufferMessage::RemoveLine(index) => { 57 | buffer.lines.remove(*index); 58 | 59 | if let Some(cursor) = cursor { 60 | set_outbound_cursor_to_inbound_position(cursor, mode, buffer); 61 | } 62 | 63 | Vec::new() 64 | } 65 | BufferMessage::ResetCursor => { 66 | viewport.horizontal_index = 0; 67 | viewport.vertical_index = 0; 68 | 69 | if let Some(cursor) = cursor { 70 | cursor.vertical_index = 0; 71 | 72 | cursor.horizontal_index = match &cursor.horizontal_index { 73 | CursorPosition::Absolute { 74 | current: _, 75 | expanded: _, 76 | } => CursorPosition::Absolute { 77 | current: 0, 78 | expanded: 0, 79 | }, 80 | CursorPosition::End => CursorPosition::End, 81 | CursorPosition::None => CursorPosition::None, 82 | } 83 | } 84 | 85 | Vec::new() 86 | } 87 | BufferMessage::SaveBuffer => { 88 | let changes = buffer.undo.save(); 89 | vec![BufferResult::Changes(changes)] 90 | } 91 | BufferMessage::SetContent(content) => { 92 | // TODO: optional selection? 93 | buffer.lines = content.to_vec(); 94 | 95 | if let Some(cursor) = cursor { 96 | set_outbound_cursor_to_inbound_position(cursor, mode, buffer); 97 | } 98 | 99 | Vec::new() 100 | } 101 | BufferMessage::SetCursorToLineContent(content) => { 102 | let cursor = match cursor { 103 | Some(it) => it, 104 | None => return Vec::new(), 105 | }; 106 | 107 | let line = buffer 108 | .lines 109 | .iter() 110 | .enumerate() 111 | .find(|(_, line)| &line.content.to_stripped_string() == content); 112 | 113 | if let Some((index, _)) = line { 114 | cursor.vertical_index = index; 115 | cursor.hide_cursor_line = false; 116 | 117 | set_outbound_cursor_to_inbound_position(cursor, mode, buffer); 118 | viewport::update_by_cursor(viewport, cursor, buffer); 119 | 120 | vec![BufferResult::CursorPositionChanged] 121 | } else { 122 | Vec::new() 123 | } 124 | } 125 | BufferMessage::SortContent(sort) => { 126 | // TODO: cursor should stay on current selection 127 | buffer.lines.sort_unstable_by(sort); 128 | if let Some(cursor) = cursor { 129 | set_outbound_cursor_to_inbound_position(cursor, mode, buffer); 130 | } 131 | Vec::new() 132 | } 133 | BufferMessage::UpdateViewPortByCursor => Vec::new(), 134 | }; 135 | 136 | if let Some(cursor) = cursor { 137 | viewport::update_by_cursor(viewport, cursor, buffer); 138 | } 139 | 140 | result 141 | } 142 | 143 | pub fn focus_buffer(cursor: &mut Option) { 144 | if let Some(cursor) = cursor { 145 | cursor.hide_cursor = false; 146 | } 147 | } 148 | 149 | pub fn unfocus_buffer(cursor: &mut Option) { 150 | if let Some(cursor) = cursor { 151 | cursor.hide_cursor = true; 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /yeet-buffer/src/update/viewport.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | message::ViewPortDirection, 3 | model::{viewport::ViewPort, Buffer, Cursor, CursorPosition}, 4 | }; 5 | 6 | pub fn update_by_cursor(viewport: &mut ViewPort, cursor: &Cursor, buffer: &Buffer) { 7 | if buffer.lines.is_empty() { 8 | return; 9 | } 10 | 11 | if viewport.vertical_index > cursor.vertical_index { 12 | viewport.vertical_index = cursor.vertical_index; 13 | } else if viewport.vertical_index + (viewport.height - 1) < cursor.vertical_index { 14 | viewport.vertical_index = cursor.vertical_index - (viewport.height - 1); 15 | } 16 | 17 | let cursor_index = match cursor.horizontal_index { 18 | CursorPosition::Absolute { 19 | current, 20 | expanded: _, 21 | } => current, 22 | CursorPosition::End => { 23 | let line_lenght = buffer.lines[cursor.vertical_index].len(); 24 | if line_lenght == 0 { 25 | 0 26 | } else { 27 | line_lenght - 1 28 | } 29 | } 30 | CursorPosition::None => return, 31 | }; 32 | 33 | let line = &buffer.lines[cursor.vertical_index]; 34 | if viewport.horizontal_index > cursor_index { 35 | viewport.horizontal_index = cursor_index; 36 | } else if viewport.horizontal_index + viewport.get_content_width(line) < cursor_index { 37 | viewport.horizontal_index = cursor_index - viewport.get_content_width(line) 38 | } 39 | } 40 | 41 | pub fn update_by_direction( 42 | viewport: &mut ViewPort, 43 | cursor: &mut Option, 44 | buffer: &mut Buffer, 45 | direction: &ViewPortDirection, 46 | ) { 47 | if buffer.lines.is_empty() { 48 | return; 49 | } 50 | 51 | match direction { 52 | ViewPortDirection::BottomOnCursor => { 53 | if let Some(cursor) = &cursor { 54 | if cursor.vertical_index < viewport.height { 55 | viewport.vertical_index = 0; 56 | } else { 57 | let index = cursor.vertical_index - viewport.height + 1; 58 | viewport.vertical_index = index; 59 | } 60 | } 61 | } 62 | ViewPortDirection::CenterOnCursor => { 63 | if let Some(cursor) = &cursor { 64 | let index_offset = viewport.height / 2; 65 | if cursor.vertical_index < index_offset { 66 | viewport.vertical_index = 0; 67 | } else { 68 | viewport.vertical_index = cursor.vertical_index - index_offset; 69 | } 70 | } 71 | } 72 | ViewPortDirection::HalfPageDown => { 73 | let index_offset = viewport.height / 2; 74 | let viewport_end_index = viewport.vertical_index + (viewport.height - 1); 75 | let viewport_end_after_move_index = viewport_end_index + index_offset; 76 | 77 | if viewport_end_after_move_index < buffer.lines.len() { 78 | viewport.vertical_index += index_offset; 79 | } else if viewport.height > buffer.lines.len() { 80 | viewport.vertical_index = 0; 81 | } else { 82 | viewport.vertical_index = buffer.lines.len() - viewport.height; 83 | } 84 | 85 | if let Some(ref mut cursor) = cursor { 86 | if cursor.vertical_index + index_offset >= buffer.lines.len() { 87 | cursor.vertical_index = buffer.lines.len() - 1; 88 | } else { 89 | cursor.vertical_index += index_offset; 90 | } 91 | } 92 | } 93 | ViewPortDirection::HalfPageUp => { 94 | let index_offset = viewport.height / 2; 95 | if viewport.vertical_index < index_offset { 96 | viewport.vertical_index = 0; 97 | } else { 98 | viewport.vertical_index -= index_offset; 99 | } 100 | 101 | if let Some(ref mut cursor) = cursor { 102 | if cursor.vertical_index < index_offset { 103 | cursor.vertical_index = 0; 104 | } else { 105 | cursor.vertical_index -= index_offset; 106 | } 107 | } 108 | } 109 | ViewPortDirection::TopOnCursor => { 110 | if let Some(cursor) = &cursor { 111 | viewport.vertical_index = cursor.vertical_index; 112 | } 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /yeet-buffer/src/view/line.rs: -------------------------------------------------------------------------------- 1 | use crate::model::{ansi::Ansi, viewport::ViewPort, BufferLine, Cursor, CursorPosition, Mode}; 2 | 3 | pub fn add_line_styles( 4 | vp: &ViewPort, 5 | mode: &Mode, 6 | cursor: &Option, 7 | index: &usize, 8 | line: &mut BufferLine, 9 | ) -> Ansi { 10 | let content_width = vp.get_content_width(line); 11 | let ansi = line.content.skip_chars(vp.horizontal_index); 12 | let ansi = add_search_styles(line, &ansi); 13 | 14 | add_cursor_styles(vp, mode, cursor, index, content_width, &ansi) 15 | } 16 | 17 | fn add_search_styles(line: &BufferLine, ansi: &Ansi) -> Ansi { 18 | if let Some(search_char_position) = &line.search_char_position { 19 | let mut content = ansi.clone(); 20 | for (index, length) in search_char_position.iter() { 21 | let reset = format!( 22 | "\x1b[0m{}", 23 | content.get_ansi_escape_sequences_till_char(*index + 1) 24 | ); 25 | 26 | content.insert(*index, "\x1b[41m"); 27 | content.insert(index + length, &reset); 28 | } 29 | content 30 | } else { 31 | ansi.clone() 32 | } 33 | } 34 | 35 | fn add_cursor_styles( 36 | vp: &ViewPort, 37 | mode: &Mode, 38 | cursor: &Option, 39 | index: &usize, 40 | content_width: usize, 41 | ansi: &Ansi, 42 | ) -> Ansi { 43 | let mut content = ansi.clone(); 44 | if let Some(cursor) = cursor { 45 | if cursor.vertical_index - vp.vertical_index != *index { 46 | return content; 47 | } 48 | 49 | let char_count = content.count_chars(); 50 | let line_length = if char_count > content_width { 51 | content_width 52 | } else if char_count == 0 { 53 | 1 54 | } else { 55 | char_count 56 | }; 57 | 58 | let repeat_count = if content_width > line_length { 59 | content_width - line_length 60 | } else { 61 | 0 62 | }; 63 | if cursor.hide_cursor_line { 64 | content.append(" ".repeat(repeat_count).as_str()); 65 | } else { 66 | content.prepend("\x1b[100m"); 67 | content.append(" ".repeat(repeat_count).as_str()); 68 | content.append("\x1b[0m"); 69 | }; 70 | 71 | if cursor.hide_cursor { 72 | return content; 73 | } 74 | 75 | let cursor_index = match &cursor.horizontal_index { 76 | CursorPosition::End => line_length - 1, 77 | CursorPosition::None => return content, 78 | CursorPosition::Absolute { 79 | current, 80 | expanded: _, 81 | } => *current, 82 | }; 83 | 84 | // FIX: reset should just use the ansi code for reset inverse (27) 85 | // https://github.com/ratatui/ansi-to-tui/issues/50 86 | let reset = format!( 87 | "\x1b[0m{}", 88 | content.get_ansi_escape_sequences_till_char(cursor_index + 1) 89 | ); 90 | 91 | let (code, reset) = match mode { 92 | Mode::Command(_) | Mode::Normal => ("\x1b[7m", reset.as_str()), 93 | Mode::Insert => ("\x1b[4m", reset.as_str()), 94 | Mode::Navigation => ("", ""), 95 | }; 96 | 97 | content.insert(cursor_index, code); 98 | content.insert(cursor_index + 1, reset); 99 | } 100 | 101 | content 102 | } 103 | -------------------------------------------------------------------------------- /yeet-buffer/src/view/mod.rs: -------------------------------------------------------------------------------- 1 | use ansi_to_tui::IntoText; 2 | use ratatui::{ 3 | prelude::Rect, 4 | style::{Color, Style}, 5 | text::Line, 6 | widgets::{Block, Borders, Paragraph}, 7 | Frame, 8 | }; 9 | 10 | use crate::model::{ansi::Ansi, viewport::ViewPort, Buffer, BufferLine, Cursor, Mode}; 11 | 12 | mod line; 13 | mod prefix; 14 | mod style; 15 | 16 | // FIX: long lines break viewport content 17 | pub fn view( 18 | viewport: &ViewPort, 19 | cursor: &Option, 20 | mode: &Mode, 21 | buffer: &Buffer, 22 | show_border: &bool, 23 | frame: &mut Frame, 24 | rect: Rect, 25 | ) { 26 | let rendered = get_rendered_lines(viewport, buffer); 27 | let styled = get_styled_lines(viewport, mode, cursor, rendered); 28 | 29 | let rect = if *show_border { 30 | let block = Block::default() 31 | .borders(Borders::RIGHT) 32 | .border_style(Style::default().fg(Color::Black)); 33 | 34 | let inner = block.inner(rect); 35 | 36 | frame.render_widget(block, rect); 37 | 38 | inner 39 | } else { 40 | rect 41 | }; 42 | 43 | frame.render_widget(Paragraph::new(styled), rect); 44 | } 45 | 46 | fn get_rendered_lines(viewport: &ViewPort, buffer: &Buffer) -> Vec { 47 | buffer 48 | .lines 49 | .iter() 50 | .skip(viewport.vertical_index) 51 | .take(viewport.height) 52 | .map(|line| line.to_owned()) 53 | .collect() 54 | } 55 | 56 | fn get_styled_lines<'a>( 57 | vp: &ViewPort, 58 | mode: &Mode, 59 | cursor: &Option, 60 | lines: Vec, 61 | ) -> Vec> { 62 | let lines = if lines.is_empty() { 63 | vec![BufferLine::default()] 64 | } else { 65 | lines 66 | }; 67 | 68 | let mut result = Vec::new(); 69 | for (i, mut bl) in lines.into_iter().enumerate() { 70 | let corrected_index = i + vp.vertical_index; 71 | 72 | let content = Ansi::new("") 73 | .join(&prefix::get_signs(vp, &bl)) 74 | .join(&prefix::get_line_number(vp, corrected_index, cursor)) 75 | .join(&prefix::get_custom_prefix(&bl)) 76 | .join(&prefix::get_border(vp)) 77 | .join(&line::add_line_styles(vp, mode, cursor, &i, &mut bl)); 78 | 79 | if let Ok(text) = content.to_string().into_text() { 80 | result.push(text.lines); 81 | } 82 | } 83 | 84 | result.into_iter().flatten().collect() 85 | } 86 | -------------------------------------------------------------------------------- /yeet-buffer/src/view/prefix.rs: -------------------------------------------------------------------------------- 1 | use std::cmp::Reverse; 2 | 3 | use crate::model::{ 4 | ansi::Ansi, 5 | viewport::{LineNumber, ViewPort}, 6 | BufferLine, Cursor, 7 | }; 8 | 9 | pub fn get_border(vp: &ViewPort) -> Ansi { 10 | Ansi::new(&" ".repeat(vp.get_border_width())) 11 | } 12 | 13 | pub fn get_custom_prefix(line: &BufferLine) -> Ansi { 14 | if let Some(prefix) = &line.prefix { 15 | Ansi::new(prefix) 16 | } else { 17 | Ansi::new("") 18 | } 19 | } 20 | 21 | pub fn get_line_number(vp: &ViewPort, index: usize, cursor: &Option) -> Ansi { 22 | if vp.line_number == LineNumber::None { 23 | return Ansi::new(""); 24 | } 25 | 26 | let width = vp.get_line_number_width(); 27 | let number = { 28 | let number_string = (index + 1).to_string(); 29 | if let Some(index) = number_string.char_indices().nth_back(width - 1) { 30 | number_string[index.0..].to_string() 31 | } else { 32 | number_string 33 | } 34 | }; 35 | 36 | if let Some(cursor) = cursor { 37 | if cursor.vertical_index == index { 38 | return Ansi::new(&format!("\x1b[1m{: Ansi::new(&format!("{:>width$} ", number)), 44 | LineNumber::None => Ansi::new(""), 45 | LineNumber::Relative => { 46 | if let Some(cursor) = cursor { 47 | let relative = if cursor.vertical_index > index { 48 | cursor.vertical_index - index 49 | } else { 50 | index - cursor.vertical_index 51 | }; 52 | 53 | Ansi::new(&format!("\x1b[90m{:>width$}\x1b[0m", relative)) 54 | } else { 55 | Ansi::new(&format!("{:>width$}", number)) 56 | } 57 | } 58 | } 59 | } 60 | 61 | pub fn get_signs(vp: &ViewPort, bl: &BufferLine) -> Ansi { 62 | let max_sign_count = vp.sign_column_width; 63 | 64 | let mut filtered: Vec<_> = bl 65 | .signs 66 | .iter() 67 | .filter(|s| !vp.hidden_sign_ids.contains(&s.id)) 68 | .collect(); 69 | 70 | filtered.sort_unstable_by_key(|s| Reverse(s.priority)); 71 | 72 | let signs_string = filtered 73 | .iter() 74 | .take(max_sign_count) 75 | .fold("".to_string(), |acc, s| { 76 | format!("{}{}{}\x1b[0m", acc, s.style, s.content) 77 | }); 78 | 79 | let signs = Ansi::new(&signs_string); 80 | let char_count = signs.count_chars(); 81 | if char_count < max_sign_count { 82 | Ansi::new(&format!( 83 | "{}{}", 84 | signs, 85 | " ".repeat(max_sign_count - char_count) 86 | )) 87 | } else { 88 | signs 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /yeet-buffer/src/view/style.rs: -------------------------------------------------------------------------------- 1 | // use ratatui::{ 2 | // style::{Color, Modifier, Style}, 3 | // text::{Line, Span}, 4 | // }; 5 | // 6 | // use crate::model::viewport::ViewPort; 7 | // 8 | // pub const CURSOR_COMMAND_STYLE_PARTIAL: StylePartial = StylePartial::Modifier(Modifier::REVERSED); 9 | // pub const CURSOR_INSERT_STYLE_PARTIAL: StylePartial = StylePartial::Modifier(Modifier::UNDERLINED); 10 | // pub const CURSOR_NORMAL_STYLE_PARTIAL: StylePartial = StylePartial::Modifier(Modifier::REVERSED); 11 | // pub const CURSOR_NAV_STYLE_PARTIAL: StylePartial = StylePartial::Background(Color::DarkGray); 12 | // pub const CURSORLINE_NAV_STYLE_PARTIAL: StylePartial = StylePartial::Background(Color::DarkGray); 13 | // pub const CURSORLINE_NORMAL_STYLE_PARTIAL: StylePartial = StylePartial::Background(Color::DarkGray); 14 | // pub const LINE_NUMBER_ABS_STYLE_PARTIAL: StylePartial = StylePartial::Foreground(Color::White); 15 | // pub const LINE_NUMBER_REL_STYLE_PARTIAL: StylePartial = StylePartial::Foreground(Color::DarkGray); 16 | // 17 | // pub fn get_line<'a>( 18 | // vp: &ViewPort, 19 | // line: String, 20 | // ) -> Line<'a> { 21 | // let style_spans = merge_style_partial_spans(vp, style_partials); 22 | // let spans = get_spans(vp, line, style_spans); 23 | // 24 | // Line::from(spans) 25 | // } 26 | // 27 | // fn merge_style_partial_spans( 28 | // view_port: &ViewPort, 29 | // style_partials: Vec, 30 | // ) -> Vec { 31 | // let mut result = vec![(0, view_port.width, Style::default())]; 32 | // 33 | // for partial in &style_partials { 34 | // let mut styles = Vec::new(); 35 | // for (start, end, style) in &result { 36 | // if &partial.start > end || &partial.end < start { 37 | // styles.push((*start, *end, *style)); 38 | // continue; 39 | // } 40 | // 41 | // let split_start = if &partial.start > start { 42 | // &partial.start 43 | // } else { 44 | // start 45 | // }; 46 | // 47 | // let split_end = if &partial.end < end { 48 | // &partial.end 49 | // } else { 50 | // end 51 | // }; 52 | // 53 | // let mixed_style = match partial.style { 54 | // StylePartial::Foreground(clr) => style.fg(clr), 55 | // StylePartial::Modifier(mdfr) => style.add_modifier(mdfr), 56 | // StylePartial::Background(clr) => style.bg(clr), 57 | // }; 58 | // 59 | // if split_start == start && split_end == end { 60 | // styles.push((*split_start, *split_end, mixed_style)); 61 | // } else if split_start == start { 62 | // styles.push((*start, *split_end, mixed_style)); 63 | // styles.push((*split_end, *end, *style)); 64 | // } else if split_end == end { 65 | // styles.push((*start, *split_start, *style)); 66 | // styles.push((*split_start, *end, mixed_style)); 67 | // } else { 68 | // styles.push((*start, *split_start, *style)); 69 | // styles.push((*split_start, *split_end, mixed_style)); 70 | // styles.push((*split_end, *end, *style)); 71 | // } 72 | // } 73 | // 74 | // if !styles.is_empty() { 75 | // result = styles; 76 | // } 77 | // } 78 | // 79 | // result 80 | // } 81 | // 82 | // fn get_spans<'a>(view_port: &ViewPort, line: String, style_spans: Vec) -> Vec> { 83 | // let line = line.chars().skip(view_port.horizontal_index); 84 | // 85 | // let line_count = line.clone().count(); 86 | // let line_length = if line_count > view_port.width { 87 | // view_port.width 88 | // } else { 89 | // line_count 90 | // }; 91 | // 92 | // let mut spans = Vec::new(); 93 | // for (start, end, style) in style_spans { 94 | // if end > line_length { 95 | // let filler_count = end - line_length; 96 | // let mut filler = String::with_capacity(filler_count); 97 | // filler.push_str(&" ".repeat(filler_count)); 98 | // 99 | // if line_length > start { 100 | // let part = line 101 | // .clone() 102 | // .skip(start) 103 | // .take(line_length - start) 104 | // .collect::(); 105 | // 106 | // spans.push(Span::styled(part, style)); 107 | // } 108 | // 109 | // spans.push(Span::styled(filler, style)); 110 | // } else { 111 | // let part = line 112 | // .clone() 113 | // .skip(start) 114 | // .take(end - start) 115 | // .collect::(); 116 | // 117 | // spans.push(Span::styled(part, style)); 118 | // } 119 | // } 120 | // 121 | // spans 122 | // } 123 | -------------------------------------------------------------------------------- /yeet-frontend/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "yeet-frontend" 3 | 4 | authors.workspace = true 5 | edition.workspace = true 6 | repository.workspace = true 7 | version.workspace = true 8 | 9 | [lints] 10 | workspace = true 11 | 12 | [dependencies] 13 | yeet-buffer = { path = "../yeet-buffer" } 14 | yeet-keymap = { path = "../yeet-keymap" } 15 | 16 | arboard.workspace = true 17 | crossterm.workspace = true 18 | csv.workspace = true 19 | dirs.workspace = true 20 | flate2.workspace = true 21 | futures.workspace = true 22 | image.workspace = true 23 | infer.workspace = true 24 | notify.workspace = true 25 | pathdiff.workspace = true 26 | ratatui.workspace = true 27 | ratatui-image.workspace = true 28 | syntect.workspace = true 29 | tar.workspace = true 30 | thiserror.workspace = true 31 | tokio.workspace = true 32 | tokio-util.workspace = true 33 | tracing.workspace = true 34 | -------------------------------------------------------------------------------- /yeet-frontend/src/error.rs: -------------------------------------------------------------------------------- 1 | use thiserror::Error; 2 | 3 | use crate::event::Envelope; 4 | 5 | #[derive(Debug, Error)] 6 | pub enum AppError { 7 | #[error("Sending render action failed")] 8 | ActionSendFailed(#[from] tokio::sync::mpsc::error::SendError), 9 | #[error("Error aggregation")] 10 | Aggregate(Vec), 11 | #[error("Command execution failed")] 12 | ExecutionFailed(String), 13 | #[error("File operation failed")] 14 | FileOperationFailed(#[from] std::io::Error), 15 | #[error("Invalid mime type resolved")] 16 | InvalidMimeType, 17 | #[error("Path target is invalid")] 18 | InvalidTargetPath, 19 | #[error("Loading navigation history failed")] 20 | LoadHistoryFailed, 21 | #[error("Loading marks failed")] 22 | LoadMarkFailed, 23 | #[error("Loading quickfix failed")] 24 | LoadQuickFixFailed, 25 | #[error("Preview picker is not set")] 26 | PreviewPickerNotResolved, 27 | #[error("Generating preview protocol failed")] 28 | PreviewProtocolGenerationFailed, 29 | #[error("Loading image failed")] 30 | ImageOperationFailed(#[from] image::ImageError), 31 | #[error("Terminal not initialized")] 32 | TerminalNotInitialized, 33 | #[error("Watch operation on path failed")] 34 | WatchOperationFailed(#[from] notify::Error), 35 | } 36 | -------------------------------------------------------------------------------- /yeet-frontend/src/init/history.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::HashMap, 3 | fs::{self, File, OpenOptions}, 4 | path::{Path, PathBuf}, 5 | }; 6 | 7 | use crate::{ 8 | error::AppError, 9 | model::history::{History, HistoryNode, HistoryState}, 10 | update::history::add_history_component, 11 | }; 12 | 13 | pub fn load_history_from_file(history: &mut History) -> Result<(), AppError> { 14 | let history_path = get_history_path()?; 15 | if !Path::new(&history_path).exists() { 16 | return Ok(()); 17 | } 18 | 19 | // TODO: change to tokio fs 20 | let history_file = File::open(history_path)?; 21 | let mut history_csv_reader = csv::ReaderBuilder::new() 22 | .has_headers(false) 23 | .from_reader(history_file); 24 | 25 | for result in history_csv_reader.records() { 26 | let record = match result { 27 | Ok(record) => record, 28 | Err(_) => return Err(AppError::LoadHistoryFailed), 29 | }; 30 | 31 | let changed_at = match record.get(0) { 32 | Some(val) => { 33 | if let Ok(changed_at) = val.parse::() { 34 | changed_at 35 | } else { 36 | continue; 37 | } 38 | } 39 | None => continue, 40 | }; 41 | 42 | let path = match record.get(1) { 43 | Some(path) => path, 44 | None => continue, 45 | }; 46 | 47 | let mut iter = Path::new(path).components(); 48 | if let Some(component) = iter.next() { 49 | if let Some(component_name) = component.as_os_str().to_str() { 50 | add_history_component( 51 | &mut history.entries, 52 | changed_at, 53 | HistoryState::Loaded, 54 | component_name, 55 | iter, 56 | ); 57 | } 58 | } 59 | } 60 | 61 | Ok(()) 62 | } 63 | 64 | pub fn optimize_history_file() -> Result<(), AppError> { 65 | let mut history = History::default(); 66 | load_history_from_file(&mut history)?; 67 | save_filtered(&history, HistoryState::Loaded, true)?; 68 | 69 | Ok(()) 70 | } 71 | 72 | pub fn save_history_to_file(history: &History) -> Result<(), AppError> { 73 | save_filtered(history, HistoryState::Added, false) 74 | } 75 | 76 | fn save_filtered( 77 | history: &History, 78 | state_filter: HistoryState, 79 | overwrite: bool, 80 | ) -> Result<(), AppError> { 81 | let entries = get_paths(PathBuf::new(), &history.entries); 82 | 83 | let history_path = get_history_path()?; 84 | let history_dictionary = match Path::new(&history_path).parent() { 85 | Some(path) => path, 86 | None => return Err(AppError::LoadHistoryFailed), 87 | }; 88 | 89 | fs::create_dir_all(history_dictionary)?; 90 | 91 | let history_writer = OpenOptions::new() 92 | .write(true) 93 | .create(true) 94 | .truncate(overwrite) 95 | .append(!overwrite) 96 | .open(history_path)?; 97 | 98 | let mut writer = csv::Writer::from_writer(history_writer); 99 | for (changed_at, state, path) in entries { 100 | if state != state_filter { 101 | continue; 102 | } 103 | 104 | if !path.exists() { 105 | continue; 106 | } 107 | 108 | if let Some(path) = path.to_str() { 109 | let write_result = writer.write_record([changed_at.to_string().as_str(), path]); 110 | if let Err(error) = write_result { 111 | tracing::error!("writing history failed: {:?}", error); 112 | } 113 | } 114 | } 115 | 116 | writer.flush()?; 117 | 118 | Ok(()) 119 | } 120 | 121 | fn get_paths( 122 | current_path: PathBuf, 123 | nodes: &HashMap, 124 | ) -> Vec<(u64, HistoryState, PathBuf)> { 125 | let mut result = Vec::new(); 126 | for node in nodes.values() { 127 | let mut path = current_path.clone(); 128 | path.push(&node.component); 129 | 130 | if node.nodes.is_empty() { 131 | result.push((node.changed_at, node.state.clone(), path)); 132 | } else { 133 | result.append(&mut get_paths(path, &node.nodes)); 134 | } 135 | } 136 | 137 | result 138 | } 139 | 140 | fn get_history_path() -> Result { 141 | let cache_dir = match dirs::cache_dir() { 142 | Some(cache_dir) => match cache_dir.to_str() { 143 | Some(cache_dir_string) => cache_dir_string.to_string(), 144 | None => return Err(AppError::LoadHistoryFailed), 145 | }, 146 | None => return Err(AppError::LoadHistoryFailed), 147 | }; 148 | 149 | Ok(format!("{}{}", cache_dir, "/yeet/history")) 150 | } 151 | -------------------------------------------------------------------------------- /yeet-frontend/src/init/junkyard.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fs::File, 3 | path::{Path, PathBuf}, 4 | time, 5 | }; 6 | 7 | use flate2::{read::GzDecoder, write::GzEncoder, Compression}; 8 | use tar::Archive; 9 | use tokio::fs; 10 | 11 | use crate::{ 12 | error::AppError, 13 | event::Emitter, 14 | model::junkyard::{FileEntry, JunkYard}, 15 | task::Task, 16 | update::junkyard::add_or_update_junkyard_entry, 17 | }; 18 | 19 | pub fn get_junkyard_path() -> Result { 20 | let yard_dir = match dirs::cache_dir() { 21 | Some(cache_dir) => cache_dir.join("yeet/junkyard/"), 22 | None => return Err(AppError::LoadHistoryFailed), 23 | }; 24 | 25 | Ok(yard_dir) 26 | } 27 | 28 | pub async fn cache_and_compress(entry: FileEntry) -> Result<(), AppError> { 29 | let cache_path = get_junk_cache_path().await?; 30 | 31 | let added_at = match time::SystemTime::now().duration_since(time::UNIX_EPOCH) { 32 | Ok(time) => time.as_nanos(), 33 | Err(_) => 0, 34 | }; 35 | 36 | let target_path = cache_path.join(format!("{}/", added_at)); 37 | if !target_path.exists() { 38 | fs::create_dir_all(&target_path).await?; 39 | } 40 | 41 | if let Some(file_name) = entry.target.file_name() { 42 | let target_file = target_path.join(file_name); 43 | fs::rename(entry.target, target_file.clone()).await?; 44 | compress_with_archive_name(&target_file, &entry.id).await?; 45 | } 46 | 47 | fs::remove_dir_all(target_path).await?; 48 | 49 | Ok(()) 50 | } 51 | 52 | pub async fn compress(entry: FileEntry) -> Result<(), AppError> { 53 | compress_with_archive_name(&entry.target, &entry.id).await 54 | } 55 | 56 | pub async fn delete(entry: FileEntry) -> Result<(), AppError> { 57 | let path = get_junk_path().await?.join(&entry.id); 58 | fs::remove_file(path).await?; 59 | Ok(()) 60 | } 61 | 62 | pub async fn init_junkyard(junk: &mut JunkYard, emitter: &mut Emitter) -> Result<(), AppError> { 63 | junk.path = get_junk_path().await?; 64 | 65 | let mut read_dir = fs::read_dir(&junk.path).await?; 66 | while let Some(entry) = read_dir.next_entry().await? { 67 | if let Some(obsolete) = add_or_update_junkyard_entry(junk, &entry.path()) { 68 | for entry in obsolete.entries { 69 | emitter.run(Task::DeleteJunkYardEntry(entry)); 70 | } 71 | } 72 | } 73 | 74 | emitter.watch(&junk.path)?; 75 | 76 | Ok(()) 77 | } 78 | 79 | pub fn restore(entry: FileEntry, path: PathBuf) -> Result<(), AppError> { 80 | let archive_file = File::open(entry.cache)?; 81 | let archive_decoder = GzDecoder::new(archive_file); 82 | let mut archive = Archive::new(archive_decoder); 83 | archive.unpack(path)?; 84 | 85 | Ok(()) 86 | } 87 | 88 | async fn compress_with_archive_name(path: &Path, archive_name: &str) -> Result<(), AppError> { 89 | let compress_path = get_junk_compress_path().await?.join(archive_name); 90 | 91 | let file = File::create(&compress_path)?; 92 | let encoder = GzEncoder::new(file, Compression::default()); 93 | let mut archive = tar::Builder::new(encoder); 94 | 95 | if let Some(file_name) = path.file_name() { 96 | if path.is_dir() { 97 | let archive_directory = format!("{}/", file_name.to_string_lossy()); 98 | archive.append_dir_all(archive_directory, path)?; 99 | } else { 100 | archive.append_file( 101 | file_name.to_string_lossy().to_string(), 102 | &mut File::open(path)?, 103 | )?; 104 | } 105 | } 106 | archive.finish()?; 107 | 108 | let target_path = get_junk_path().await?.join(archive_name); 109 | match fs::rename(compress_path, target_path).await { 110 | Ok(it) => it, 111 | Err(err) => { 112 | tracing::error!("Failed to rename file: {:?}", err); 113 | } 114 | }; 115 | 116 | Ok(()) 117 | } 118 | 119 | async fn get_junk_cache_path() -> Result { 120 | let path = get_junk_path().await?.join(".cache/"); 121 | if !path.exists() { 122 | fs::create_dir_all(&path).await?; 123 | } 124 | Ok(path) 125 | } 126 | 127 | async fn get_junk_compress_path() -> Result { 128 | let path = get_junk_path().await?.join(".compress/"); 129 | if !path.exists() { 130 | fs::create_dir_all(&path).await?; 131 | } 132 | Ok(path) 133 | } 134 | 135 | async fn get_junk_path() -> Result { 136 | let junk_path = get_junkyard_path()?; 137 | if !junk_path.exists() { 138 | fs::create_dir_all(&junk_path).await?; 139 | } 140 | Ok(junk_path) 141 | } 142 | -------------------------------------------------------------------------------- /yeet-frontend/src/init/mark.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fs::{self, File, OpenOptions}, 3 | path::{Path, PathBuf}, 4 | }; 5 | 6 | use crate::{error::AppError, model::mark::Marks}; 7 | 8 | #[tracing::instrument] 9 | pub fn load_marks_from_file(mark: &mut Marks) -> Result<(), AppError> { 10 | let mark_path = get_mark_path()?; 11 | if !Path::new(&mark_path).exists() { 12 | tracing::debug!("marks file does not exist on path {}", mark_path); 13 | 14 | return Ok(()); 15 | } 16 | 17 | // TODO: change to tokio fs 18 | let mark_file = File::open(mark_path)?; 19 | let mut mark_csv_reader = csv::ReaderBuilder::new() 20 | .has_headers(false) 21 | .from_reader(mark_file); 22 | 23 | tracing::trace!("marks file opened for reading"); 24 | 25 | for result in mark_csv_reader.records() { 26 | let record = match result { 27 | Ok(record) => record, 28 | Err(_) => return Err(AppError::LoadMarkFailed), 29 | }; 30 | 31 | let char = match record.get(0) { 32 | Some(val) => { 33 | if let Ok(it) = val.parse::() { 34 | it 35 | } else { 36 | continue; 37 | } 38 | } 39 | None => continue, 40 | }; 41 | 42 | let path = match record.get(1) { 43 | Some(path) => PathBuf::from(path), 44 | None => continue, 45 | }; 46 | 47 | if path.exists() { 48 | mark.entries.insert(char, path); 49 | } 50 | } 51 | 52 | tracing::trace!("marks file read"); 53 | 54 | Ok(()) 55 | } 56 | 57 | #[tracing::instrument] 58 | pub fn save_marks_to_file(marks: &Marks) -> Result<(), AppError> { 59 | let mark_path = get_mark_path()?; 60 | let mark_dictionary = match Path::new(&mark_path).parent() { 61 | Some(path) => path, 62 | None => return Err(AppError::LoadMarkFailed), 63 | }; 64 | 65 | fs::create_dir_all(mark_dictionary)?; 66 | 67 | let mark_writer = OpenOptions::new() 68 | .write(true) 69 | .create(true) 70 | .truncate(true) 71 | .open(mark_path)?; 72 | 73 | tracing::trace!("marks file opened for writing"); 74 | 75 | let mut persisted = Marks::default(); 76 | load_marks_from_file(&mut persisted)?; 77 | persisted.entries.extend(marks.entries.clone()); 78 | 79 | tracing::trace!("persisted marks loaded and merged"); 80 | 81 | let mut writer = csv::Writer::from_writer(mark_writer); 82 | for (char, path) in persisted.entries { 83 | if !path.exists() { 84 | continue; 85 | } 86 | 87 | if let Some(path) = path.to_str() { 88 | let write_result = writer.write_record([char.to_string().as_str(), path]); 89 | if let Err(error) = write_result { 90 | tracing::error!("writing mark failed: {:?}", error); 91 | } 92 | } 93 | } 94 | 95 | writer.flush()?; 96 | 97 | tracing::trace!("marks file written"); 98 | 99 | Ok(()) 100 | } 101 | 102 | fn get_mark_path() -> Result { 103 | let cache_dir = match dirs::cache_dir() { 104 | Some(cache_dir) => match cache_dir.to_str() { 105 | Some(cache_dir_string) => cache_dir_string.to_string(), 106 | None => return Err(AppError::LoadMarkFailed), 107 | }, 108 | None => return Err(AppError::LoadMarkFailed), 109 | }; 110 | 111 | Ok(format!("{}{}", cache_dir, "/yeet/marks")) 112 | } 113 | -------------------------------------------------------------------------------- /yeet-frontend/src/init/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod history; 2 | pub mod junkyard; 3 | pub mod mark; 4 | pub mod qfix; 5 | -------------------------------------------------------------------------------- /yeet-frontend/src/init/qfix.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fs::{self, File, OpenOptions}, 3 | path::{Path, PathBuf}, 4 | }; 5 | 6 | use crate::{error::AppError, model::qfix::QuickFix}; 7 | 8 | #[tracing::instrument] 9 | pub fn load_qfix_from_files(qfix: &mut QuickFix) -> Result<(), AppError> { 10 | let qfix_cache_path = get_qfix_cache_path()?; 11 | if !Path::new(&qfix_cache_path).exists() { 12 | tracing::debug!("qfix file does not exist on path {}", qfix_cache_path); 13 | 14 | return Ok(()); 15 | } 16 | 17 | // TODO: change to tokio fs 18 | let qfix_cache_file = File::open(qfix_cache_path)?; 19 | let mut qfix_entry_csv_reader = csv::ReaderBuilder::new() 20 | .has_headers(false) 21 | .from_reader(qfix_cache_file); 22 | 23 | tracing::trace!("qfix file opened for reading"); 24 | 25 | for result in qfix_entry_csv_reader.records() { 26 | let record = match result { 27 | Ok(record) => record, 28 | Err(_) => return Err(AppError::LoadQuickFixFailed), 29 | }; 30 | 31 | let path = match record.get(0) { 32 | Some(path) => PathBuf::from(path), 33 | None => continue, 34 | }; 35 | 36 | qfix.entries.push(path); 37 | } 38 | 39 | tracing::trace!("qfix file read"); 40 | 41 | Ok(()) 42 | } 43 | 44 | #[tracing::instrument] 45 | pub fn save_qfix_to_files(qfix: &QuickFix) -> Result<(), AppError> { 46 | let qfix_entry_path = get_qfix_cache_path()?; 47 | let qfix_entry_dictionary = match Path::new(&qfix_entry_path).parent() { 48 | Some(path) => path, 49 | None => return Err(AppError::LoadQuickFixFailed), 50 | }; 51 | 52 | fs::create_dir_all(qfix_entry_dictionary)?; 53 | 54 | let qfix_entry_writer = OpenOptions::new() 55 | .write(true) 56 | .create(true) 57 | .truncate(true) 58 | .open(qfix_entry_path)?; 59 | 60 | tracing::trace!("qfix file opened for writing"); 61 | 62 | let mut writer = csv::Writer::from_writer(qfix_entry_writer); 63 | for path in qfix.entries.iter() { 64 | if !path.exists() { 65 | continue; 66 | } 67 | 68 | if let Some(path) = path.to_str() { 69 | let write_result = writer.write_record([path]); 70 | if let Err(error) = write_result { 71 | tracing::error!("writing qfix entry failed: {:?}", error); 72 | } 73 | } 74 | } 75 | 76 | writer.flush()?; 77 | 78 | tracing::trace!("qfix file written"); 79 | 80 | Ok(()) 81 | } 82 | 83 | fn get_qfix_cache_path() -> Result { 84 | let cache_dir = match dirs::cache_dir() { 85 | Some(cache_dir) => match cache_dir.to_str() { 86 | Some(cache_dir_string) => cache_dir_string.to_string(), 87 | None => return Err(AppError::LoadQuickFixFailed), 88 | }, 89 | None => return Err(AppError::LoadQuickFixFailed), 90 | }; 91 | 92 | Ok(format!("{}{}", cache_dir, "/yeet/qfix")) 93 | } 94 | -------------------------------------------------------------------------------- /yeet-frontend/src/layout.rs: -------------------------------------------------------------------------------- 1 | use ratatui::prelude::{Constraint, Direction, Layout, Rect}; 2 | 3 | #[derive(Clone)] 4 | pub struct AppLayout { 5 | // TODO: split layout to enable file buffer related layout 6 | pub parent: Rect, 7 | pub current: Rect, 8 | pub preview: Rect, 9 | pub statusline: Rect, 10 | pub commandline: Rect, 11 | } 12 | 13 | impl AppLayout { 14 | pub fn new(rect: Rect, commandline_height: u16) -> Self { 15 | let main = Layout::default() 16 | .direction(Direction::Vertical) 17 | .constraints([ 18 | Constraint::Percentage(100), 19 | Constraint::Length(1), 20 | Constraint::Length(commandline_height), 21 | ]) 22 | .split(rect); 23 | 24 | let files = Layout::default() 25 | .direction(Direction::Horizontal) 26 | .constraints(Constraint::from_ratios([(1, 5), (2, 5), (2, 5)])) 27 | .split(main[0]); 28 | 29 | Self { 30 | parent: files[0], 31 | current: files[1], 32 | preview: files[2], 33 | statusline: main[1], 34 | commandline: main[2], 35 | } 36 | } 37 | } 38 | 39 | impl Default for AppLayout { 40 | fn default() -> Self { 41 | AppLayout::new(Rect::default(), 0) 42 | } 43 | } 44 | 45 | #[derive(Clone)] 46 | pub struct CommandLineLayout { 47 | pub buffer: Rect, 48 | pub key_sequence: Rect, 49 | } 50 | 51 | impl CommandLineLayout { 52 | pub fn new(rect: Rect, key_sequence_length: u16) -> Self { 53 | let layout = Layout::default() 54 | .direction(Direction::Horizontal) 55 | .constraints([ 56 | Constraint::Percentage(100), 57 | Constraint::Length(key_sequence_length), 58 | ]) 59 | .split(rect); 60 | 61 | Self { 62 | buffer: layout[0], 63 | key_sequence: layout[1], 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /yeet-frontend/src/model/history.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | #[derive(Clone, Debug, Default, Eq, PartialEq)] 4 | pub struct History { 5 | pub entries: HashMap, 6 | } 7 | 8 | #[derive(Clone, Debug, Eq, PartialEq)] 9 | pub struct HistoryNode { 10 | pub changed_at: u64, 11 | pub component: String, 12 | pub nodes: HashMap, 13 | pub state: HistoryState, 14 | } 15 | 16 | #[derive(Clone, Debug, Default, Eq, PartialEq)] 17 | pub enum HistoryState { 18 | Added, 19 | #[default] 20 | Loaded, 21 | } 22 | -------------------------------------------------------------------------------- /yeet-frontend/src/model/junkyard.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | #[derive(Clone, Debug, Default, Eq, PartialEq)] 4 | pub struct JunkYard { 5 | pub current: FileEntryType, 6 | pub path: PathBuf, 7 | pub trashed: Vec, 8 | pub yanked: Option, 9 | } 10 | 11 | #[derive(Clone, Debug, Default, Eq, PartialEq)] 12 | pub enum FileEntryType { 13 | _Custom(String), 14 | #[default] 15 | Trash, 16 | Yank, 17 | } 18 | 19 | #[derive(Clone, Debug, Eq, PartialEq)] 20 | pub struct FileTransaction { 21 | pub id: String, 22 | pub entries: Vec, 23 | } 24 | 25 | #[derive(Clone, Debug, Eq, PartialEq)] 26 | pub struct FileEntry { 27 | pub id: String, 28 | pub cache: PathBuf, 29 | pub status: FileEntryStatus, 30 | pub target: PathBuf, 31 | } 32 | 33 | #[derive(Clone, Debug, Default, Eq, PartialEq)] 34 | pub enum FileEntryStatus { 35 | #[default] 36 | Processing, 37 | Ready, 38 | } 39 | -------------------------------------------------------------------------------- /yeet-frontend/src/model/mark.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, path::PathBuf}; 2 | 3 | use yeet_buffer::model::SignIdentifier; 4 | 5 | pub const MARK_SIGN_ID: SignIdentifier = "mark"; 6 | 7 | #[derive(Clone, Debug, Default, Eq, PartialEq)] 8 | pub struct Marks { 9 | pub entries: HashMap, 10 | } 11 | -------------------------------------------------------------------------------- /yeet-frontend/src/model/mod.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::HashMap, 3 | path::{Path, PathBuf}, 4 | }; 5 | 6 | use crate::{ 7 | layout::{AppLayout, CommandLineLayout}, 8 | settings::Settings, 9 | }; 10 | use ratatui::layout::Rect; 11 | use ratatui_image::protocol::Protocol; 12 | use tokio_util::sync::CancellationToken; 13 | use yeet_buffer::model::{ 14 | viewport::{LineNumber, ViewPort}, 15 | Buffer, Cursor, Mode, 16 | }; 17 | 18 | use self::{history::History, junkyard::JunkYard, mark::Marks, qfix::QuickFix, register::Register}; 19 | 20 | pub mod history; 21 | pub mod junkyard; 22 | pub mod mark; 23 | pub mod qfix; 24 | pub mod register; 25 | 26 | #[derive(Default)] 27 | pub struct Model { 28 | pub commandline: CommandLine, 29 | pub current_tasks: HashMap, 30 | pub files: FileWindow, 31 | pub history: History, 32 | pub junk: JunkYard, 33 | pub latest_task_id: u16, 34 | pub layout: AppLayout, 35 | pub marks: Marks, 36 | pub mode: Mode, 37 | pub mode_before: Option, 38 | pub qfix: QuickFix, 39 | pub register: Register, 40 | pub remaining_keysequence: Option, 41 | pub settings: Settings, 42 | pub watches: Vec, 43 | } 44 | 45 | impl std::fmt::Debug for Model { 46 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 47 | f.debug_struct("Model") 48 | .field("junk", &self.junk) 49 | .field("marks", &self.marks) 50 | .field("qfix", &self.qfix) 51 | .field("settings", &self.settings) 52 | .finish() 53 | } 54 | } 55 | 56 | #[derive(Debug)] 57 | pub struct CurrentTask { 58 | pub external_id: String, 59 | pub id: u16, 60 | pub token: CancellationToken, 61 | } 62 | 63 | pub struct FileWindow { 64 | pub current: PathBuffer, 65 | pub current_vp: ViewPort, 66 | pub current_cursor: Option, 67 | pub parent: BufferType, 68 | pub parent_vp: ViewPort, 69 | pub parent_cursor: Option, 70 | pub preview: BufferType, 71 | pub preview_vp: ViewPort, 72 | pub preview_cursor: Option, 73 | pub show_border: bool, 74 | } 75 | 76 | impl FileWindow { 77 | pub fn get_mut_directories( 78 | &mut self, 79 | ) -> Vec<(&Path, &mut ViewPort, &mut Option, &mut Buffer)> { 80 | let parent_content_ref = if let BufferType::Text(path, buffer) = &mut self.parent { 81 | Some(( 82 | path.as_path(), 83 | &mut self.parent_vp, 84 | &mut self.parent_cursor, 85 | buffer, 86 | )) 87 | } else { 88 | None 89 | }; 90 | 91 | let preview_content_ref = if let BufferType::Text(path, buffer) = &mut self.preview { 92 | Some(( 93 | path.as_path(), 94 | &mut self.preview_vp, 95 | &mut self.preview_cursor, 96 | buffer, 97 | )) 98 | } else { 99 | None 100 | }; 101 | 102 | vec![ 103 | Some(( 104 | self.current.path.as_path(), 105 | &mut self.current_vp, 106 | &mut self.current_cursor, 107 | &mut self.current.buffer, 108 | )), 109 | parent_content_ref, 110 | preview_content_ref, 111 | ] 112 | .into_iter() 113 | .flatten() 114 | .collect::>() 115 | } 116 | } 117 | 118 | impl Default for FileWindow { 119 | fn default() -> Self { 120 | Self { 121 | current: Default::default(), 122 | current_cursor: Some(Cursor::default()), 123 | current_vp: ViewPort { 124 | line_number: LineNumber::Relative, 125 | line_number_width: 3, 126 | ..Default::default() 127 | }, 128 | parent: Default::default(), 129 | parent_vp: Default::default(), 130 | parent_cursor: Default::default(), 131 | preview: Default::default(), 132 | preview_vp: Default::default(), 133 | preview_cursor: Default::default(), 134 | show_border: true, 135 | } 136 | } 137 | } 138 | 139 | #[derive(Debug)] 140 | pub enum WindowType { 141 | Current, 142 | Parent, 143 | Preview, 144 | } 145 | 146 | // NOTE: most of the time Text is used. Thus, boxing Buffer would only increase hassle to work 147 | // with this BufferType. 148 | #[allow(clippy::large_enum_variant)] 149 | #[derive(Default)] 150 | pub enum BufferType { 151 | Image(PathBuf, Protocol), 152 | #[default] 153 | None, 154 | Text(PathBuf, Buffer), 155 | } 156 | 157 | impl BufferType { 158 | pub fn resolve_path(&self) -> Option<&Path> { 159 | match self { 160 | BufferType::Text(path, _) => Some(path), 161 | BufferType::Image(path, _) => Some(path), 162 | BufferType::None => None, 163 | } 164 | } 165 | } 166 | 167 | pub struct CommandLine { 168 | pub buffer: Buffer, 169 | pub cursor: Option, 170 | pub key_sequence: String, 171 | pub layout: CommandLineLayout, 172 | pub viewport: ViewPort, 173 | } 174 | 175 | impl Default for CommandLine { 176 | fn default() -> Self { 177 | Self { 178 | cursor: Some(Cursor { 179 | hide_cursor: true, 180 | hide_cursor_line: true, 181 | vertical_index: 0, 182 | ..Default::default() 183 | }), 184 | buffer: Default::default(), 185 | key_sequence: "".to_owned(), 186 | layout: CommandLineLayout::new(Rect::default(), 0), 187 | viewport: Default::default(), 188 | } 189 | } 190 | } 191 | 192 | #[derive(Default)] 193 | pub struct PathBuffer { 194 | pub buffer: Buffer, 195 | pub path: PathBuf, 196 | pub state: DirectoryBufferState, 197 | } 198 | 199 | #[derive(Debug, Default, PartialEq)] 200 | pub enum DirectoryBufferState { 201 | Loading, 202 | PartiallyLoaded, 203 | Ready, 204 | #[default] 205 | Uninitialized, 206 | } 207 | -------------------------------------------------------------------------------- /yeet-frontend/src/model/qfix.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use yeet_buffer::model::SignIdentifier; 4 | 5 | pub const QFIX_SIGN_ID: SignIdentifier = "qfix"; 6 | 7 | #[derive(Clone, Debug, Default, Eq, PartialEq)] 8 | pub struct QuickFix { 9 | pub current_index: usize, 10 | pub cdo: CdoState, 11 | pub entries: Vec, 12 | } 13 | 14 | #[derive(Clone, Debug, Default, Eq, PartialEq)] 15 | pub enum CdoState { 16 | Cdo(Option, String), 17 | Cnext(String), 18 | #[default] 19 | None, 20 | } 21 | -------------------------------------------------------------------------------- /yeet-frontend/src/model/register.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::HashMap, 3 | hash::{Hash, Hasher}, 4 | }; 5 | 6 | use arboard::Clipboard; 7 | use yeet_buffer::model::SearchDirection; 8 | 9 | pub struct Register { 10 | pub clipboard: Option, 11 | pub command: Option, 12 | pub content: HashMap, 13 | pub dot: Option, 14 | pub last_macro: Option, 15 | pub searched: Option<(SearchDirection, String)>, 16 | pub scopes: HashMap, 17 | } 18 | 19 | impl Default for Register { 20 | fn default() -> Self { 21 | Self { 22 | clipboard: Clipboard::new().ok(), 23 | command: None, 24 | content: Default::default(), 25 | dot: None, 26 | last_macro: None, 27 | searched: None, 28 | scopes: Default::default(), 29 | } 30 | } 31 | } 32 | 33 | #[derive(Clone, Debug, Eq)] 34 | pub enum RegisterScope { 35 | Dot, 36 | Macro(char), 37 | } 38 | 39 | impl Hash for RegisterScope { 40 | fn hash(&self, state: &mut H) { 41 | match self { 42 | RegisterScope::Dot => state.write_u8(2), 43 | RegisterScope::Macro(_) => state.write_u8(4), 44 | } 45 | } 46 | } 47 | 48 | impl PartialEq for RegisterScope { 49 | fn eq(&self, other: &Self) -> bool { 50 | matches!( 51 | (self, other), 52 | (RegisterScope::Dot, RegisterScope::Dot) 53 | | (RegisterScope::Macro(_), RegisterScope::Macro(_)) 54 | ) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /yeet-frontend/src/open.rs: -------------------------------------------------------------------------------- 1 | #![allow(unused_imports)] 2 | 3 | use std::{ 4 | ffi::{OsStr, OsString}, 5 | io, 6 | path::Path, 7 | process::ExitStatus, 8 | }; 9 | 10 | use tokio::process::Command; 11 | 12 | #[cfg(all(unix, not(target_os = "macos")))] 13 | pub async fn path(path: &Path) -> Result { 14 | use std::{env, path::PathBuf}; 15 | 16 | use tokio::fs; 17 | 18 | async fn is_wsl() -> bool { 19 | if std::env::consts::OS != "linux" { 20 | return false; 21 | } 22 | if let Ok(osrelease) = fs::read_to_string("/proc/sys/kernel/osrelease").await { 23 | if osrelease.to_lowercase().contains("microsoft") { 24 | return !is_docker().await; 25 | } 26 | } 27 | if let Ok(version) = fs::read_to_string("/proc/version").await { 28 | if version.to_lowercase().contains("microsoft") { 29 | return !is_docker().await; 30 | } 31 | } 32 | 33 | false 34 | } 35 | 36 | async fn is_docker() -> bool { 37 | match fs::read_to_string("/proc/self/cgroup").await { 38 | Ok(file_contents) => { 39 | file_contents.contains("docker") || fs::metadata("/.dockerenv").await.is_ok() 40 | } 41 | Err(_error) => fs::metadata("/.dockerenv").await.is_ok(), 42 | } 43 | } 44 | 45 | fn wsl_path>(path: T) -> OsString { 46 | fn path_relative_to_current_dir>(path: T) -> Option { 47 | let path = Path::new(&path); 48 | if path.is_relative() { 49 | return None; 50 | } 51 | 52 | let base = env::current_dir().ok()?; 53 | pathdiff::diff_paths(path, base) 54 | } 55 | 56 | match path_relative_to_current_dir(&path) { 57 | None => OsString::from(&path), 58 | Some(relative) => OsString::from(relative), 59 | } 60 | } 61 | 62 | let mut cmd = if is_wsl().await { 63 | Command::new("wslview").arg(wsl_path(path)).spawn()? 64 | } else { 65 | Command::new("xdg-open").arg(path).spawn()? 66 | }; 67 | cmd.wait().await 68 | } 69 | 70 | #[cfg(target_os = "macos")] 71 | pub async fn path(path: &Path) -> Result { 72 | Command::new("/usr/bin/open") 73 | .arg(path) 74 | .spawn()? 75 | .wait() 76 | .await 77 | } 78 | 79 | #[cfg(target_os = "redox")] 80 | pub async fn path(path: &Path) -> Result { 81 | Command::new("/ui/bin/launcher") 82 | .arg(path) 83 | .spawn()? 84 | .wait() 85 | .await 86 | } 87 | 88 | #[cfg(windows)] 89 | pub async fn path(path: &Path) -> Result { 90 | const CREATE_NO_WINDOW: u32 = 0x08000000; 91 | 92 | fn wrap_in_quotes>(path: T) -> String { 93 | let mut result = OsString::from("\""); 94 | result.push(path); 95 | result.push("\""); 96 | 97 | result.to_string_lossy().to_string() 98 | } 99 | 100 | Command::new("cmd") 101 | .args(&["/c", "start", "\"\"", &wrap_in_quotes(path)]) 102 | .creation_flags(CREATE_NO_WINDOW) 103 | .spawn()? 104 | .wait() 105 | .await 106 | } 107 | 108 | #[cfg(not(any(unix, windows, target_os = "redox")))] 109 | compile_error!("open is not supported on this platform"); 110 | -------------------------------------------------------------------------------- /yeet-frontend/src/settings.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use yeet_buffer::model::viewport::WindowSettings; 4 | 5 | #[derive(Debug)] 6 | pub struct Settings { 7 | pub current: WindowSettings, 8 | pub parent: WindowSettings, 9 | pub preview: WindowSettings, 10 | pub selection_to_file_on_open: Option, 11 | pub selection_to_stdout_on_open: bool, 12 | pub show_quickfix_signs: bool, 13 | pub show_mark_signs: bool, 14 | pub startup_path: Option, 15 | } 16 | 17 | impl Default for Settings { 18 | fn default() -> Self { 19 | Self { 20 | current: WindowSettings { 21 | sign_column_width: 2, 22 | }, 23 | parent: WindowSettings::default(), 24 | preview: WindowSettings::default(), 25 | selection_to_file_on_open: None, 26 | selection_to_stdout_on_open: false, 27 | show_mark_signs: true, 28 | show_quickfix_signs: true, 29 | startup_path: None, 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /yeet-frontend/src/task/command.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | path::{Path, PathBuf}, 3 | process::Stdio, 4 | str, 5 | }; 6 | 7 | use tokio::process::Command; 8 | 9 | use crate::error::AppError; 10 | 11 | pub async fn fd(base_path: &Path, params: String) -> Result, AppError> { 12 | tracing::debug!("executing fd at {:?} with {:?} params", base_path, params); 13 | 14 | let params = params.split(" "); 15 | let result = Command::new("fd") 16 | .args(["--color", "never", "--absolute-path", "--base-directory"]) 17 | .arg(base_path) 18 | .args(params) 19 | .stdin(Stdio::null()) 20 | .stdout(Stdio::piped()) 21 | .stderr(Stdio::null()) 22 | .kill_on_drop(true) 23 | .output() 24 | .await; 25 | 26 | match result { 27 | Ok(output) => { 28 | if !output.status.success() { 29 | let message = format!("fd failed: {:?}", output); 30 | tracing::error!(message); 31 | Err(AppError::ExecutionFailed(message)) 32 | } else if output.stdout.is_empty() { 33 | let message = "fd failed: result is empty".to_string(); 34 | tracing::error!(message); 35 | Err(AppError::ExecutionFailed(message)) 36 | } else { 37 | let result = str::from_utf8(&output.stdout).map_or(vec![], |s| { 38 | s.lines() 39 | .map(|l| l.to_string()) 40 | .filter_map(|s| { 41 | let path = PathBuf::from(s); 42 | if path.exists() { 43 | Some(path) 44 | } else { 45 | None 46 | } 47 | }) 48 | .collect() 49 | }); 50 | Ok(result) 51 | } 52 | } 53 | Err(err) => { 54 | let message = format!("fd failed: {:?}", err); 55 | tracing::error!(message); 56 | Err(AppError::ExecutionFailed(message)) 57 | } 58 | } 59 | } 60 | 61 | pub async fn zoxide(params: String) -> Result { 62 | tracing::debug!("executing zoxide with {:?} params", params); 63 | 64 | let result = Command::new("zoxide") 65 | .arg("query") 66 | .arg(params) 67 | .stdin(Stdio::null()) 68 | .stdout(Stdio::piped()) 69 | .stderr(Stdio::null()) 70 | .kill_on_drop(true) 71 | .output() 72 | .await; 73 | 74 | match result { 75 | Ok(output) => { 76 | if !output.status.success() { 77 | let message = format!("zoxide failed: {:?}", output); 78 | tracing::error!(message); 79 | Err(AppError::ExecutionFailed(message)) 80 | } else if output.stdout.is_empty() { 81 | let message = "zoxide failed: result is empty".to_string(); 82 | tracing::error!(message); 83 | Err(AppError::ExecutionFailed(message)) 84 | } else { 85 | let result = str::from_utf8(&output.stdout).map_or(vec![], |s| { 86 | s.lines() 87 | .map(|l| l.to_string()) 88 | .filter_map(|s| { 89 | let path = PathBuf::from(s); 90 | if path.exists() { 91 | Some(path) 92 | } else { 93 | None 94 | } 95 | }) 96 | .collect() 97 | }); 98 | 99 | if let Some(target) = result.into_iter().next() { 100 | Ok(target) 101 | } else { 102 | Err(AppError::ExecutionFailed( 103 | "zoxide failed: no valid path found".to_string(), 104 | )) 105 | } 106 | } 107 | } 108 | Err(err) => { 109 | let message = format!("zoxide failed: {:?}", err); 110 | tracing::error!(message); 111 | Err(AppError::ExecutionFailed(message)) 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /yeet-frontend/src/task/image.rs: -------------------------------------------------------------------------------- 1 | use std::{path::Path, process::Stdio, str}; 2 | 3 | use image::ImageReader; 4 | use ratatui::layout::Rect; 5 | use ratatui_image::{picker::Picker, Resize}; 6 | use tokio::process::Command; 7 | 8 | use crate::{error::AppError, event::Preview}; 9 | 10 | #[tracing::instrument] 11 | pub async fn load<'a>(picker: &mut Option, path: &Path, rect: &Rect) -> Preview { 12 | let picker = match picker { 13 | Some(pckr) => pckr, 14 | None => return load_with_chafa(path, rect).await, 15 | }; 16 | 17 | match load_with_ratatui_image(picker, path, rect).await { 18 | Ok(preview) => preview, 19 | Err(err) => { 20 | tracing::error!("image preview failed: {:?}", err); 21 | load_with_chafa(path, rect).await 22 | } 23 | } 24 | } 25 | 26 | async fn load_with_ratatui_image( 27 | picker: &mut Picker, 28 | path: &Path, 29 | rect: &Rect, 30 | ) -> Result { 31 | tracing::debug!("load image preview for path with ratatui image: {:?}", path); 32 | 33 | let image = ImageReader::open(path)?.decode()?; 34 | 35 | match picker.new_protocol(image, *rect, Resize::Fit(None)) { 36 | Ok(protocol) => Ok(Preview::Image(path.to_path_buf(), protocol)), 37 | Err(err) => { 38 | tracing::error!("Generation of preview image protocol failed: {:?}", err); 39 | Err(AppError::PreviewProtocolGenerationFailed) 40 | } 41 | } 42 | } 43 | 44 | async fn load_with_chafa(path: &Path, rect: &Rect) -> Preview { 45 | tracing::debug!("load image preview for path with chafa: {:?}", path); 46 | 47 | let result = Command::new("chafa") 48 | .args([ 49 | "-f", 50 | "symbols", 51 | "--relative", 52 | "off", 53 | "--polite", 54 | "on", 55 | "--passthrough", 56 | "none", 57 | "--animate", 58 | "off", 59 | "--view-size", 60 | format!("{}x{}", rect.width, rect.height).as_str(), 61 | ]) 62 | .arg(path) 63 | .stdin(Stdio::null()) 64 | .stdout(Stdio::piped()) 65 | .stderr(Stdio::null()) 66 | .kill_on_drop(true) 67 | .output() 68 | .await; 69 | 70 | match result { 71 | Ok(output) => { 72 | if !output.status.success() { 73 | tracing::error!("chafa failed: {:?}", output); 74 | Preview::None(path.to_path_buf()) 75 | } else if output.stdout.is_empty() { 76 | tracing::warn!("chafa failed: image result is empty"); 77 | Preview::None(path.to_path_buf()) 78 | } else { 79 | let content = str::from_utf8(&output.stdout) 80 | .map_or(vec![], |s| s.lines().map(|l| l.to_string()).collect()); 81 | 82 | Preview::Content(path.to_path_buf(), content) 83 | } 84 | } 85 | Err(err) => { 86 | tracing::error!("chafa failed: {:?}", err); 87 | Preview::None(path.to_path_buf()) 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /yeet-frontend/src/task/syntax.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use syntect::{ 4 | easy::HighlightLines, 5 | highlighting::Theme, 6 | parsing::{SyntaxReference, SyntaxSet}, 7 | util::{as_24_bit_terminal_escaped, LinesWithEndings}, 8 | }; 9 | use tokio::fs; 10 | 11 | use crate::event::Preview; 12 | 13 | pub async fn highlight(syntaxes: &SyntaxSet, theme: &Theme, path: &Path) -> Preview { 14 | match fs::read_to_string(path).await { 15 | Ok(content) => { 16 | let syntax = resolve_syntax(syntaxes, &content, path).await; 17 | if let Some(syntax) = syntax { 18 | tracing::debug!("syntax: {:?}", syntax.name); 19 | 20 | let mut highlighter = HighlightLines::new(syntax, theme); 21 | let mut result = vec![]; 22 | for line in LinesWithEndings::from(&content) { 23 | let highlighted = match highlighter.highlight_line(line, syntaxes) { 24 | Ok(ranges) => &as_24_bit_terminal_escaped(&ranges[..], false), 25 | Err(err) => { 26 | tracing::error!("unable to highlight line: {:?}", err); 27 | line 28 | } 29 | }; 30 | result.push(highlighted.to_string()); 31 | } 32 | 33 | Preview::Content(path.to_path_buf(), result) 34 | } else { 35 | tracing::debug!("unable to resolve syntax for: {:?}", path); 36 | 37 | let content: Vec<_> = content.lines().map(|l| l.to_string()).collect(); 38 | 39 | Preview::Content(path.to_path_buf(), content) 40 | } 41 | } 42 | Err(err) => { 43 | tracing::error!("reading file failed: {:?} {:?}", path, err); 44 | Preview::None(path.to_path_buf()) 45 | } 46 | } 47 | } 48 | 49 | async fn resolve_syntax<'a>( 50 | syntaxes: &'a SyntaxSet, 51 | content: &str, 52 | path: &Path, 53 | ) -> Option<&'a SyntaxReference> { 54 | let name = path 55 | .file_name() 56 | .map(|n| n.to_string_lossy()) 57 | .unwrap_or_default(); 58 | let syntax = syntaxes.find_syntax_by_extension(&name); 59 | if syntax.is_some() { 60 | return syntax; 61 | } 62 | 63 | let ext = path 64 | .extension() 65 | .map(|e| e.to_string_lossy()) 66 | .unwrap_or_default(); 67 | let syntax = syntaxes.find_syntax_by_extension(&ext); 68 | if syntax.is_some() { 69 | return syntax; 70 | } 71 | 72 | syntaxes.find_syntax_by_first_line(content) 73 | } 74 | -------------------------------------------------------------------------------- /yeet-frontend/src/terminal.rs: -------------------------------------------------------------------------------- 1 | use std::io::{stderr, BufWriter, Stderr}; 2 | 3 | use crossterm::{ 4 | terminal::{self, EnterAlternateScreen}, 5 | ExecutableCommand, 6 | }; 7 | use ratatui::{backend::CrosstermBackend, layout::Rect, Frame, Terminal}; 8 | 9 | use crate::error::AppError; 10 | 11 | pub struct TerminalWrapper { 12 | inner: Option>>>, 13 | } 14 | 15 | impl TerminalWrapper { 16 | pub fn start() -> Result { 17 | stderr().execute(EnterAlternateScreen)?; 18 | terminal::enable_raw_mode()?; 19 | 20 | let mut terminal = Terminal::new(CrosstermBackend::new(BufWriter::new(stderr())))?; 21 | terminal.clear()?; 22 | 23 | let result = Self { 24 | inner: Some(terminal), 25 | }; 26 | 27 | Ok(result) 28 | } 29 | 30 | pub fn shutdown(&mut self) -> Result<(), AppError> { 31 | self.inner = None; 32 | self.stop() 33 | } 34 | 35 | pub fn size(&self) -> Result { 36 | if let Some(term) = &self.inner { 37 | let size = term.size()?; 38 | Ok(Rect::new(0, 0, size.width, size.height)) 39 | } else { 40 | Err(AppError::TerminalNotInitialized) 41 | } 42 | } 43 | 44 | pub fn draw(&mut self, layout: impl FnMut(&mut Frame<'_>)) -> Result<(), AppError> { 45 | if let Some(term) = &mut self.inner { 46 | if let Err(err) = term.draw(layout) { 47 | return Err(AppError::from(err)); 48 | } 49 | } 50 | 51 | Ok(()) 52 | } 53 | 54 | pub fn suspend(&mut self) { 55 | self.stop().expect("Failed to stop terminal"); 56 | self.inner = None; 57 | } 58 | 59 | pub fn resume(&mut self) -> Result<(), AppError> { 60 | if self.inner.is_none() { 61 | stderr().execute(EnterAlternateScreen)?; 62 | terminal::enable_raw_mode()?; 63 | 64 | let mut terminal = Terminal::new(CrosstermBackend::new(BufWriter::new(stderr())))?; 65 | terminal.clear()?; 66 | 67 | self.inner = Some(terminal); 68 | } 69 | 70 | Ok(()) 71 | } 72 | 73 | pub fn resize(&mut self, x: u16, y: u16) -> Result<(), AppError> { 74 | if let Some(term) = &mut self.inner { 75 | if let Err(err) = term.resize(Rect::new(0, 0, x, y)) { 76 | return Err(AppError::from(err)); 77 | } 78 | } 79 | 80 | Ok(()) 81 | } 82 | 83 | fn stop(&self) -> Result<(), AppError> { 84 | terminal::disable_raw_mode()?; 85 | stderr().execute(terminal::LeaveAlternateScreen)?; 86 | 87 | Ok(()) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /yeet-frontend/src/update/command/file.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | use yeet_keymap::message::KeymapMessage; 4 | 5 | use crate::{ 6 | action::{self, Action}, 7 | event::Message, 8 | model::{mark::Marks, Model}, 9 | task::Task, 10 | }; 11 | 12 | pub fn copy(model: &Model, target: &str) -> Vec { 13 | let mut actions = Vec::new(); 14 | if let Some(path) = &model.files.preview.resolve_path() { 15 | tracing::info!("copying path: {:?}", path); 16 | match get_target_file_path(&model.marks, target, path) { 17 | Ok(target) => actions.push(Action::Task(Task::CopyPath(path.to_path_buf(), target))), 18 | Err(err) => { 19 | actions.push(Action::EmitMessages(vec![Message::Error(err)])); 20 | } 21 | }; 22 | } 23 | actions 24 | } 25 | 26 | pub fn delete_selection(model: &Model) -> Vec { 27 | let mut actions = Vec::new(); 28 | if let Some(path) = &model.files.preview.resolve_path() { 29 | tracing::info!("deleting path: {:?}", path); 30 | actions.push(Action::Task(Task::DeletePath(path.to_path_buf()))); 31 | } else { 32 | tracing::warn!("deleting path failed: no path in preview set"); 33 | } 34 | 35 | actions 36 | } 37 | 38 | pub fn rename_selection(model: &Model, target: &str) -> Vec { 39 | let mut actions = Vec::new(); 40 | if let Some(path) = &model.files.preview.resolve_path() { 41 | tracing::info!("renaming path: {:?}", path); 42 | match get_target_file_path(&model.marks, target, path) { 43 | Ok(target) => { 44 | actions.push(Action::Task(Task::RenamePath(path.to_path_buf(), target))); 45 | } 46 | Err(err) => { 47 | actions.push(Action::EmitMessages(vec![Message::Error(err)])); 48 | } 49 | }; 50 | } 51 | 52 | actions 53 | } 54 | 55 | pub fn refresh(model: &Model) -> Vec { 56 | let navigation = if let Some(path) = &model.files.preview.resolve_path() { 57 | KeymapMessage::NavigateToPathAsPreview(path.to_path_buf()) 58 | } else { 59 | KeymapMessage::NavigateToPath(model.files.current.path.clone()) 60 | }; 61 | 62 | vec![action::emit_keymap(navigation)] 63 | } 64 | 65 | fn get_target_file_path(marks: &Marks, target: &str, path: &Path) -> Result { 66 | let file_name = match path.file_name() { 67 | Some(it) => it, 68 | None => return Err(format!("could not resolve file name from path {:?}", path)), 69 | }; 70 | 71 | let target = if target.starts_with('\'') { 72 | let mark = match target.chars().nth(1) { 73 | Some(it) => it, 74 | None => return Err("invalid mark format".to_string()), 75 | }; 76 | 77 | if let Some(path) = marks.entries.get(&mark) { 78 | path.to_path_buf() 79 | } else { 80 | return Err(format!("mark '{}' not found", mark)); 81 | } 82 | } else if path.is_relative() { 83 | let current = match path.parent() { 84 | Some(it) => it, 85 | None => return Err(format!("could not resolve parent from path {:?}", path)), 86 | }; 87 | 88 | let path = Path::new(path); 89 | current.join(path) 90 | } else { 91 | PathBuf::from(path) 92 | }; 93 | 94 | let target_file = target.join(file_name); 95 | if target.is_dir() && target.exists() && !target_file.exists() { 96 | Ok(target.join(file_name)) 97 | } else { 98 | Err("target path is not valid".to_string()) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /yeet-frontend/src/update/command/mod.rs: -------------------------------------------------------------------------------- 1 | use yeet_buffer::{message::BufferMessage, model::Mode}; 2 | use yeet_keymap::message::{KeymapMessage, QuitMode}; 3 | 4 | use crate::{ 5 | action::{self, Action}, 6 | event::Message, 7 | model::Model, 8 | task::Task, 9 | }; 10 | 11 | mod file; 12 | mod print; 13 | mod qfix; 14 | mod task; 15 | 16 | #[tracing::instrument(skip(model))] 17 | pub fn execute(cmd: &str, model: &mut Model) -> Vec { 18 | let cmd_with_args = match cmd.split_once(' ') { 19 | Some(it) => it, 20 | None => (cmd, ""), 21 | }; 22 | 23 | tracing::debug!("executing command: {:?}", cmd_with_args); 24 | 25 | let mode_before = model.mode.clone(); 26 | let mode = get_mode_after_command(&model.mode_before); 27 | 28 | // NOTE: all file commands like e.g. d! should use preview path as target to enable cdo 29 | match cmd_with_args { 30 | ("cdo", command) => add_change_mode(mode_before, mode, qfix::cdo(model, command)), 31 | ("cfirst", "") => add_change_mode(mode_before, mode, qfix::select_first(model)), 32 | ("cl", "") => print::qfix(&model.qfix), 33 | ("clearcl", "") => add_change_mode(mode_before, mode, qfix::reset(model)), 34 | ("clearcl", path) => add_change_mode(mode_before, mode, qfix::clear_in(model, path)), 35 | ("cn", "") => add_change_mode(mode_before, mode, qfix::next(model)), 36 | ("cN", "") => add_change_mode(mode_before, mode, qfix::previous(model)), 37 | ("cp", target) => add_change_mode(mode_before, mode, file::copy(model, target)), 38 | ("d!", "") => add_change_mode(mode_before, mode, file::delete_selection(model)), 39 | ("delm", args) if !args.is_empty() => { 40 | let mut marks = Vec::new(); 41 | for mark in args.chars().filter(|c| c != &' ') { 42 | marks.push(mark); 43 | } 44 | 45 | add_change_mode( 46 | mode_before, 47 | mode, 48 | vec![action::emit_keymap(KeymapMessage::DeleteMarks(marks))], 49 | ) 50 | } 51 | ("delt", args) if !args.is_empty() => { 52 | let actions = match args.parse::() { 53 | Ok(it) => task::delete(model, it), 54 | Err(err) => { 55 | tracing::warn!("Failed to parse id: {}", err); 56 | return Vec::new(); 57 | } 58 | }; 59 | 60 | add_change_mode(mode_before, mode, actions) 61 | } 62 | ("e!", "") => add_change_mode(mode_before, mode, file::refresh(model)), 63 | ("fd", params) => add_change_mode( 64 | mode_before, 65 | mode, 66 | vec![Action::Task(Task::ExecuteFd( 67 | model.files.current.path.clone(), 68 | params.to_owned(), 69 | ))], 70 | ), 71 | ("invertcl", "") => add_change_mode(mode_before, mode, qfix::invert_in_current(model)), 72 | ("junk", "") => print::junkyard(&model.junk), 73 | ("marks", "") => print::marks(&model.marks), 74 | ("mv", target) => add_change_mode(mode_before, mode, file::rename_selection(model, target)), 75 | ("noh", "") => add_change_mode( 76 | mode_before, 77 | mode, 78 | vec![Action::EmitMessages(vec![Message::Keymap( 79 | KeymapMessage::ClearSearchHighlight, 80 | )])], 81 | ), 82 | ("q", "") => vec![action::emit_keymap(KeymapMessage::Quit( 83 | QuitMode::FailOnRunningTasks, 84 | ))], 85 | ("q!", "") => vec![action::emit_keymap(KeymapMessage::Quit(QuitMode::Force))], 86 | ("reg", "") => print::register(&model.register), 87 | ("tl", "") => print::tasks(&model.current_tasks), 88 | ("w", "") => add_change_mode( 89 | mode_before, 90 | mode, 91 | vec![Action::EmitMessages(vec![Message::Keymap( 92 | KeymapMessage::Buffer(BufferMessage::SaveBuffer), 93 | )])], 94 | ), 95 | ("wq", "") => add_change_mode( 96 | mode_before, 97 | mode, 98 | vec![Action::EmitMessages(vec![ 99 | Message::Keymap(KeymapMessage::Buffer(BufferMessage::SaveBuffer)), 100 | Message::Keymap(KeymapMessage::Quit(QuitMode::FailOnRunningTasks)), 101 | ])], 102 | ), 103 | ("z", params) => add_change_mode( 104 | mode_before, 105 | mode, 106 | vec![Action::Task(Task::ExecuteZoxide(params.to_owned()))], 107 | ), 108 | (cmd, args) => { 109 | let mut actions = Vec::new(); 110 | if !args.is_empty() { 111 | let err = format!("command '{} {}' is not valid", cmd, args); 112 | actions.push(Action::EmitMessages(vec![Message::Error(err)])); 113 | } 114 | add_change_mode(mode_before, mode, actions) 115 | } 116 | } 117 | } 118 | 119 | fn add_change_mode(mode_before: Mode, mode: Mode, mut actions: Vec) -> Vec { 120 | let emit = actions.iter_mut().find_map(|action| { 121 | if let Action::EmitMessages(messages) = action { 122 | Some(messages) 123 | } else { 124 | None 125 | } 126 | }); 127 | 128 | let change_mode_message = Message::Keymap(KeymapMessage::Buffer(BufferMessage::ChangeMode( 129 | mode_before, 130 | mode, 131 | ))); 132 | 133 | match emit { 134 | Some(messages) => messages.insert(0, change_mode_message), 135 | None => actions.insert(0, Action::EmitMessages(vec![change_mode_message])), 136 | } 137 | 138 | actions 139 | } 140 | 141 | fn get_mode_after_command(mode_before: &Option) -> Mode { 142 | if let Some(mode) = mode_before { 143 | match mode { 144 | Mode::Command(_) => Mode::default(), 145 | Mode::Insert | Mode::Normal => Mode::Normal, 146 | Mode::Navigation => Mode::Navigation, 147 | } 148 | } else { 149 | Mode::default() 150 | } 151 | } 152 | 153 | mod test { 154 | #[test] 155 | fn get_mode_after_command() { 156 | use yeet_buffer::model::Mode; 157 | 158 | let mode_before = Some(Mode::Normal); 159 | let result = super::get_mode_after_command(&mode_before); 160 | assert_eq!(result, Mode::Normal); 161 | 162 | let mode_before = Some(Mode::Insert); 163 | let result = super::get_mode_after_command(&mode_before); 164 | assert_eq!(result, Mode::Normal); 165 | 166 | let mode_before = Some(Mode::Navigation); 167 | let result = super::get_mode_after_command(&mode_before); 168 | assert_eq!(result, Mode::Navigation); 169 | 170 | let mode_before = None; 171 | let result = super::get_mode_after_command(&mode_before); 172 | assert_eq!(result, Mode::Navigation); 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /yeet-frontend/src/update/command/print.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use yeet_keymap::message::{KeymapMessage, PrintContent}; 4 | 5 | use crate::{ 6 | action::{self, Action}, 7 | model::{ 8 | junkyard::{FileEntryStatus, FileTransaction, JunkYard}, 9 | mark::Marks, 10 | qfix::QuickFix, 11 | register::Register, 12 | CurrentTask, 13 | }, 14 | update::junkyard::get_junkyard_transaction, 15 | }; 16 | 17 | pub fn marks(marks: &Marks) -> Vec { 18 | let mut marks: Vec<_> = marks 19 | .entries 20 | .iter() 21 | .map(|(key, path)| (key, path.to_string_lossy().to_string())) 22 | .map(|(key, path)| format!("{:<4} {}", key, path)) 23 | .collect(); 24 | 25 | marks.sort(); 26 | 27 | let mut contents = vec![":marks".to_string(), "Char Content".to_string()]; 28 | contents.extend(marks); 29 | 30 | let content = contents 31 | .iter() 32 | .map(|cntnt| PrintContent::Default(cntnt.to_string())) 33 | .collect(); 34 | 35 | vec![action::emit_keymap(KeymapMessage::Print(content))] 36 | } 37 | 38 | pub fn tasks(tasks: &HashMap) -> Vec { 39 | let mut contents = vec![":tl".to_string(), "Id Task".to_string()]; 40 | let mut tasks: Vec<_> = tasks 41 | .values() 42 | .map(|task| format!("{:<4} {}", task.id, task.external_id)) 43 | .collect(); 44 | 45 | tasks.sort(); 46 | contents.extend(tasks); 47 | 48 | let content = contents 49 | .iter() 50 | .map(|cntnt| PrintContent::Default(cntnt.to_string())) 51 | .collect(); 52 | 53 | vec![action::emit_keymap(KeymapMessage::Print(content))] 54 | } 55 | 56 | pub fn qfix(qfix: &QuickFix) -> Vec { 57 | let max_width = (qfix.entries.len() + 1).to_string().len(); 58 | 59 | let entries: Vec<_> = qfix 60 | .entries 61 | .iter() 62 | .enumerate() 63 | .map(|(i, path)| (i + 1, path.to_string_lossy().to_string())) 64 | .map(|(i, path)| format!("{:>max_width$} {}", i, path)) 65 | .collect(); 66 | 67 | let mut contents = vec![":cl".to_string()]; 68 | if entries.is_empty() { 69 | contents.push("no entries".to_string()); 70 | } else { 71 | contents.extend(entries); 72 | } 73 | 74 | let content = contents 75 | .iter() 76 | .enumerate() 77 | .map(|(i, cntnt)| { 78 | if i == qfix.current_index + 1 { 79 | PrintContent::Information(cntnt.to_string()) 80 | } else { 81 | PrintContent::Default(cntnt.to_string()) 82 | } 83 | }) 84 | .collect(); 85 | 86 | vec![action::emit_keymap(KeymapMessage::Print(content))] 87 | } 88 | 89 | pub fn junkyard(junkyard: &JunkYard) -> Vec { 90 | let mut contents = vec![":junk".to_string(), "Name Content".to_string()]; 91 | if let Some(current) = get_junkyard_transaction(junkyard, &'"') { 92 | contents.push(print_junkyard_entry("\"\"", current)); 93 | } 94 | if let Some(yanked) = &junkyard.yanked { 95 | contents.push(print_junkyard_entry("\"0", yanked)); 96 | } 97 | for (index, entry) in junkyard.trashed.iter().enumerate() { 98 | let junk_name = format!("\"{}", index + 1); 99 | contents.push(print_junkyard_entry(&junk_name, entry)); 100 | } 101 | 102 | let content = contents 103 | .iter() 104 | .map(|cntnt| PrintContent::Default(cntnt.to_string())) 105 | .collect(); 106 | 107 | vec![action::emit_keymap(KeymapMessage::Print(content))] 108 | } 109 | 110 | fn print_junkyard_entry(junk: &str, transaction: &FileTransaction) -> String { 111 | let is_ready = transaction 112 | .entries 113 | .iter() 114 | .all(|entry| entry.status == FileEntryStatus::Ready); 115 | 116 | let content = if is_ready { 117 | transaction 118 | .entries 119 | .iter() 120 | .map(|entry| entry.target.to_string_lossy().to_string()) 121 | .collect::>() 122 | .join(", ") 123 | } else { 124 | "Processing".to_string() 125 | }; 126 | 127 | format!("{:<4} {}", junk, content) 128 | } 129 | 130 | pub fn register(register: &Register) -> Vec { 131 | let mut contents = vec![":reg".to_string(), "Name Content".to_string()]; 132 | 133 | for (key, content) in register.content.iter() { 134 | contents.push(print_content(key, content)); 135 | } 136 | 137 | if let Some(last_macro) = ®ister.last_macro { 138 | contents.push(print_content(&'@', last_macro)); 139 | } 140 | if let Some(dot) = ®ister.dot { 141 | contents.push(print_content(&'.', dot)); 142 | } 143 | if let Some(command) = ®ister.command { 144 | contents.push(print_content(&':', command)); 145 | } 146 | if let Some(searched) = ®ister.searched { 147 | contents.push(print_content(&'/', &searched.1)); 148 | } 149 | 150 | let content = contents 151 | .iter() 152 | .map(|cntnt| PrintContent::Default(cntnt.to_string())) 153 | .collect(); 154 | 155 | vec![action::emit_keymap(KeymapMessage::Print(content))] 156 | } 157 | 158 | fn print_content(prefix: &char, content: &str) -> String { 159 | format!("\"{:<3} {}", prefix, content) 160 | } 161 | -------------------------------------------------------------------------------- /yeet-frontend/src/update/command/qfix.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use yeet_keymap::message::{KeymapMessage, PrintContent}; 4 | 5 | use crate::{ 6 | action::{self, Action}, 7 | model::{ 8 | qfix::{CdoState, QFIX_SIGN_ID}, 9 | Model, 10 | }, 11 | update::sign, 12 | }; 13 | 14 | pub fn reset(model: &mut Model) -> Vec { 15 | model.qfix.entries.clear(); 16 | model.qfix.current_index = 0; 17 | sign::unset_sign_on_all_buffers(model, QFIX_SIGN_ID); 18 | 19 | Vec::new() 20 | } 21 | 22 | pub fn clear_in(model: &mut Model, path: &str) -> Vec { 23 | let path = Path::new(path); 24 | let current_path = model.files.current.path.clone().join(path); 25 | 26 | tracing::debug!("clearing current cl for path: {:?}", current_path); 27 | 28 | for bl in model.files.current.buffer.lines.iter_mut() { 29 | if bl.content.is_empty() { 30 | continue; 31 | } 32 | 33 | let path = current_path.join(bl.content.to_stripped_string()); 34 | if model.qfix.entries.contains(&path) { 35 | model.qfix.entries.retain(|p| p != &path); 36 | sign::unset(bl, QFIX_SIGN_ID); 37 | } 38 | } 39 | 40 | Vec::new() 41 | } 42 | 43 | pub fn cdo(model: &mut Model, command: &str) -> Vec { 44 | tracing::debug!("cdo command set: {:?}", command); 45 | 46 | model.qfix.cdo = CdoState::Cdo(None, command.to_owned()); 47 | 48 | vec![action::emit_keymap(KeymapMessage::ExecuteCommandString( 49 | "cfirst".to_string(), 50 | ))] 51 | } 52 | 53 | pub fn select_first(model: &mut Model) -> Vec { 54 | model.qfix.current_index = 0; 55 | 56 | match model.qfix.entries.first() { 57 | Some(it) => { 58 | if it.exists() { 59 | vec![action::emit_keymap(KeymapMessage::NavigateToPathAsPreview( 60 | it.clone(), 61 | ))] 62 | } else { 63 | next(model) 64 | } 65 | } 66 | None => vec![action::emit_keymap(KeymapMessage::Print(vec![ 67 | PrintContent::Error("no more items".to_owned()), 68 | ]))], 69 | } 70 | } 71 | 72 | pub fn next(model: &mut Model) -> Vec { 73 | let mut entry = model.qfix.entries.iter().enumerate().filter_map(|(i, p)| { 74 | if i > model.qfix.current_index && p.exists() { 75 | Some((i, p)) 76 | } else { 77 | None 78 | } 79 | }); 80 | 81 | match entry.next() { 82 | Some((i, p)) => { 83 | model.qfix.current_index = i; 84 | vec![action::emit_keymap(KeymapMessage::NavigateToPathAsPreview( 85 | p.clone(), 86 | ))] 87 | } 88 | None => { 89 | vec![action::emit_keymap(KeymapMessage::Print(vec![ 90 | PrintContent::Error("no more items".to_owned()), 91 | ]))] 92 | } 93 | } 94 | } 95 | 96 | pub fn previous(model: &mut Model) -> Vec { 97 | let mut entry = model 98 | .qfix 99 | .entries 100 | .iter() 101 | .enumerate() 102 | .rev() 103 | .filter_map(|(i, p)| { 104 | if i < model.qfix.current_index && p.exists() { 105 | Some((i, p)) 106 | } else { 107 | None 108 | } 109 | }); 110 | 111 | match entry.next() { 112 | Some((i, p)) => { 113 | model.qfix.current_index = i; 114 | vec![action::emit_keymap(KeymapMessage::NavigateToPathAsPreview( 115 | p.clone(), 116 | ))] 117 | } 118 | None => { 119 | vec![action::emit_keymap(KeymapMessage::Print(vec![ 120 | PrintContent::Error("no more items".to_owned()), 121 | ]))] 122 | } 123 | } 124 | } 125 | 126 | pub fn invert_in_current(model: &mut Model) -> Vec { 127 | let current_path = model.files.current.path.clone(); 128 | for bl in model.files.current.buffer.lines.iter_mut() { 129 | if bl.content.is_empty() { 130 | continue; 131 | } 132 | 133 | let path = current_path.join(bl.content.to_stripped_string()); 134 | if model.qfix.entries.contains(&path) { 135 | model.qfix.entries.retain(|p| p != &path); 136 | sign::unset(bl, QFIX_SIGN_ID); 137 | } else { 138 | model.qfix.entries.push(path.clone()); 139 | sign::set(bl, QFIX_SIGN_ID); 140 | } 141 | } 142 | 143 | Vec::new() 144 | } 145 | -------------------------------------------------------------------------------- /yeet-frontend/src/update/command/task.rs: -------------------------------------------------------------------------------- 1 | use crate::{action::Action, model::Model}; 2 | 3 | pub fn delete(model: &mut Model, id: u16) -> Vec { 4 | if let Some((_, task)) = model.current_tasks.iter().find(|(_, task)| task.id == id) { 5 | task.token.cancel(); 6 | } 7 | Vec::new() 8 | } 9 | -------------------------------------------------------------------------------- /yeet-frontend/src/update/cursor.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use yeet_buffer::{ 4 | message::{BufferMessage, CursorDirection, Search}, 5 | model::{viewport::ViewPort, Buffer, BufferResult, Cursor, Mode, SearchDirection}, 6 | update::update_buffer, 7 | }; 8 | 9 | use crate::{ 10 | action::Action, 11 | model::{history::History, BufferType, Model, WindowType}, 12 | }; 13 | 14 | use super::{ 15 | history::get_selection_from_history, 16 | register::{get_direction_from_search_register, get_register}, 17 | search::search_in_buffers, 18 | selection, update_current, 19 | }; 20 | 21 | pub fn set_cursor_index_to_selection( 22 | viewport: &mut ViewPort, 23 | cursor: &mut Option, 24 | mode: &Mode, 25 | model: &mut Buffer, 26 | selection: &str, 27 | ) -> bool { 28 | let result = update_buffer( 29 | viewport, 30 | cursor, 31 | mode, 32 | model, 33 | &BufferMessage::SetCursorToLineContent(selection.to_string()), 34 | ); 35 | 36 | result.contains(&BufferResult::CursorPositionChanged) 37 | } 38 | 39 | pub fn set_cursor_index_with_history( 40 | viewport: &mut ViewPort, 41 | cursor: &mut Option, 42 | mode: &Mode, 43 | history: &History, 44 | buffer: &mut Buffer, 45 | path: &Path, 46 | ) -> bool { 47 | if let Some(history) = get_selection_from_history(history, path) { 48 | set_cursor_index_to_selection(viewport, cursor, mode, buffer, history) 49 | } else { 50 | false 51 | } 52 | } 53 | 54 | pub fn move_cursor(model: &mut Model, rpt: &usize, mtn: &CursorDirection) -> Vec { 55 | let premotion_preview_path = match &model.files.preview { 56 | BufferType::Image(path, _) | BufferType::Text(path, _) => Some(path.clone()), 57 | BufferType::None => None, 58 | }; 59 | 60 | let msg = BufferMessage::MoveCursor(*rpt, mtn.clone()); 61 | if let CursorDirection::Search(dr) = mtn { 62 | let term = get_register(&model.register, &'/'); 63 | search_in_buffers(model, term); 64 | 65 | let current_dr = match get_direction_from_search_register(&model.register) { 66 | Some(it) => it, 67 | None => return Vec::new(), 68 | }; 69 | 70 | let dr = match (dr, current_dr) { 71 | (Search::Next, SearchDirection::Down) => Search::Next, 72 | (Search::Next, SearchDirection::Up) => Search::Previous, 73 | (Search::Previous, SearchDirection::Down) => Search::Previous, 74 | (Search::Previous, SearchDirection::Up) => Search::Next, 75 | }; 76 | 77 | let msg = BufferMessage::MoveCursor(*rpt, CursorDirection::Search(dr.clone())); 78 | update_current(model, &msg); 79 | } else { 80 | update_current(model, &msg); 81 | }; 82 | 83 | let mut actions = Vec::new(); 84 | let current_preview_path = selection::get_current_selected_path(model); 85 | if premotion_preview_path == current_preview_path { 86 | return actions; 87 | } 88 | 89 | if let Some(path) = current_preview_path { 90 | let selection = get_selection_from_history(&model.history, &path).map(|s| s.to_owned()); 91 | actions.push(Action::Load(WindowType::Preview, path, selection)); 92 | } 93 | 94 | actions 95 | } 96 | -------------------------------------------------------------------------------- /yeet-frontend/src/update/enumeration.rs: -------------------------------------------------------------------------------- 1 | use std::{mem, path::PathBuf}; 2 | 3 | use yeet_buffer::{ 4 | message::{BufferMessage, ViewPortDirection}, 5 | model::{ansi::Ansi, BufferLine, Cursor, CursorPosition, Mode}, 6 | update::update_buffer, 7 | }; 8 | 9 | use crate::{ 10 | action::Action, 11 | event::ContentKind, 12 | model::{DirectoryBufferState, Model, WindowType}, 13 | update::{ 14 | cursor::{set_cursor_index_to_selection, set_cursor_index_with_history}, 15 | history::get_selection_from_history, 16 | selection, 17 | sign::{set_sign_if_marked, set_sign_if_qfix}, 18 | }, 19 | }; 20 | 21 | #[tracing::instrument(skip(model, contents))] 22 | pub fn update_on_enumeration_change( 23 | model: &mut Model, 24 | path: &PathBuf, 25 | contents: &[(ContentKind, String)], 26 | selection: &Option, 27 | ) -> Vec { 28 | // TODO: handle unsaved changes 29 | let directories = model.files.get_mut_directories(); 30 | if let Some((path, viewport, cursor, buffer)) = 31 | directories.into_iter().find(|(p, _, _, _)| p == path) 32 | { 33 | tracing::trace!("enumeration changed for buffer: {:?}", path); 34 | 35 | let is_first_changed_event = buffer.lines.is_empty(); 36 | let content = contents 37 | .iter() 38 | .map(|(knd, cntnt)| { 39 | let mut line = from_enumeration(cntnt, knd); 40 | set_sign_if_marked(&model.marks, &mut line, &path.join(cntnt)); 41 | set_sign_if_qfix(&model.qfix, &mut line, &path.join(cntnt)); 42 | 43 | line 44 | }) 45 | .collect(); 46 | 47 | update_buffer( 48 | viewport, 49 | cursor, 50 | &model.mode, 51 | buffer, 52 | &BufferMessage::SetContent(content), 53 | ); 54 | 55 | if is_first_changed_event { 56 | if let Some(selection) = selection { 57 | if set_cursor_index_to_selection(viewport, cursor, &model.mode, buffer, selection) { 58 | tracing::trace!("setting cursor index from selection: {:?}", selection); 59 | } 60 | } 61 | } 62 | } 63 | 64 | if path == &model.files.current.path { 65 | model.files.current.state = DirectoryBufferState::PartiallyLoaded; 66 | } 67 | 68 | tracing::trace!( 69 | "changed enumeration for path {:?} with current directory states: current is {:?}", 70 | path, 71 | model.files.current.state, 72 | ); 73 | 74 | Vec::new() 75 | } 76 | 77 | #[tracing::instrument(skip(model))] 78 | pub fn update_on_enumeration_finished( 79 | model: &mut Model, 80 | path: &PathBuf, 81 | contents: &[(ContentKind, String)], 82 | selection: &Option, 83 | ) -> Vec { 84 | update_on_enumeration_change(model, path, contents, selection); 85 | 86 | if model.mode != Mode::Navigation { 87 | return Vec::new(); 88 | } 89 | 90 | let directories = model.files.get_mut_directories(); 91 | if let Some((_, viewport, cursor, buffer)) = 92 | directories.into_iter().find(|(p, _, _, _)| p == path) 93 | { 94 | update_buffer( 95 | viewport, 96 | cursor, 97 | &model.mode, 98 | buffer, 99 | &BufferMessage::SortContent(super::SORT), 100 | ); 101 | 102 | if let Some(selection) = selection { 103 | let mut cursor_after_finished = match cursor { 104 | Some(it) => Some(it.clone()), 105 | None => Some(Cursor { 106 | horizontal_index: CursorPosition::None, 107 | vertical_index: 0, 108 | ..Default::default() 109 | }), 110 | }; 111 | 112 | if !set_cursor_index_to_selection( 113 | viewport, 114 | &mut cursor_after_finished, 115 | &model.mode, 116 | buffer, 117 | selection, 118 | ) { 119 | set_cursor_index_with_history( 120 | viewport, 121 | &mut cursor_after_finished, 122 | &model.mode, 123 | &model.history, 124 | buffer, 125 | path, 126 | ); 127 | } 128 | 129 | let _ = mem::replace(cursor, cursor_after_finished); 130 | } 131 | 132 | update_buffer( 133 | viewport, 134 | cursor, 135 | &model.mode, 136 | buffer, 137 | &BufferMessage::MoveViewPort(ViewPortDirection::CenterOnCursor), 138 | ); 139 | } 140 | 141 | if path == &model.files.current.path { 142 | model.files.current.state = DirectoryBufferState::Ready; 143 | } 144 | 145 | tracing::trace!( 146 | "finished enumeration for path {:?} with current directory states: current is {:?}", 147 | path, 148 | model.files.current.state, 149 | ); 150 | 151 | let mut actions = Vec::new(); 152 | if model.files.current.state == DirectoryBufferState::Loading { 153 | return actions; 154 | } 155 | 156 | let selected_path = match selection::get_current_selected_path(model) { 157 | Some(path) => path, 158 | None => return actions, 159 | }; 160 | 161 | if Some(selected_path.as_path()) == model.files.preview.resolve_path() { 162 | return actions; 163 | } 164 | 165 | let selection = get_selection_from_history(&model.history, path).map(|s| s.to_owned()); 166 | actions.push(Action::Load(WindowType::Preview, selected_path, selection)); 167 | 168 | actions 169 | } 170 | 171 | // TODO: move to ansi before 172 | pub fn from_enumeration(content: &String, kind: &ContentKind) -> BufferLine { 173 | let content = match kind { 174 | ContentKind::Directory => format!("\x1b[94m{}\x1b[39m", content), 175 | _ => content.to_string(), 176 | }; 177 | 178 | BufferLine { 179 | content: Ansi::new(&content), 180 | ..Default::default() 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /yeet-frontend/src/update/history.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::HashMap, 3 | path::{Components, Path}, 4 | time, 5 | }; 6 | 7 | use crate::model::history::{History, HistoryNode, HistoryState}; 8 | 9 | pub fn add_history_entry(history: &mut History, path: &Path) { 10 | let added_at = match time::SystemTime::now().duration_since(time::UNIX_EPOCH) { 11 | Ok(time) => time.as_secs(), 12 | Err(_) => 0, 13 | }; 14 | 15 | let mut iter = path.components(); 16 | if let Some(component) = iter.next() { 17 | if let Some(component_name) = component.as_os_str().to_str() { 18 | add_history_component( 19 | &mut history.entries, 20 | added_at, 21 | HistoryState::Added, 22 | component_name, 23 | iter, 24 | ); 25 | } 26 | } 27 | } 28 | 29 | pub fn add_history_component( 30 | nodes: &mut HashMap, 31 | changed_at: u64, 32 | state: HistoryState, 33 | component_name: &str, 34 | mut component_iter: Components<'_>, 35 | ) { 36 | if !nodes.contains_key(component_name) { 37 | nodes.insert( 38 | component_name.to_string(), 39 | HistoryNode { 40 | changed_at, 41 | component: component_name.to_string(), 42 | nodes: HashMap::new(), 43 | state: state.clone(), 44 | }, 45 | ); 46 | } 47 | 48 | if let Some(current_node) = nodes.get_mut(component_name) { 49 | if current_node.changed_at < changed_at { 50 | current_node.changed_at = changed_at; 51 | current_node.state = state.clone(); 52 | } 53 | 54 | if let Some(next_component) = component_iter.next() { 55 | if let Some(next_component_name) = next_component.as_os_str().to_str() { 56 | add_history_component( 57 | &mut current_node.nodes, 58 | changed_at, 59 | state, 60 | next_component_name, 61 | component_iter, 62 | ); 63 | } 64 | } 65 | } 66 | } 67 | 68 | pub fn get_selection_from_history<'a>(history: &'a History, path: &Path) -> Option<&'a str> { 69 | let mut current_nodes = &history.entries; 70 | for component in path.components() { 71 | if let Some(current_name) = component.as_os_str().to_str() { 72 | if let Some(current_node) = current_nodes.get(current_name) { 73 | current_nodes = ¤t_node.nodes; 74 | } else { 75 | return None; 76 | } 77 | } 78 | } 79 | 80 | current_nodes 81 | .values() 82 | .max_by_key(|node| node.changed_at) 83 | .map(|node| node.component.as_str()) 84 | } 85 | -------------------------------------------------------------------------------- /yeet-frontend/src/update/mark.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | action::Action, 3 | model::{mark::MARK_SIGN_ID, Model}, 4 | task::Task, 5 | }; 6 | 7 | use super::{ 8 | selection::{get_current_selected_bufferline, get_current_selected_path}, 9 | sign::{set, unset_sign_for_path}, 10 | }; 11 | 12 | pub fn add_mark(model: &mut Model, char: char) -> Vec { 13 | let selected = get_current_selected_path(model); 14 | if let Some(selected) = selected { 15 | let removed = model.marks.entries.insert(char, selected); 16 | if let Some(removed) = removed { 17 | unset_sign_for_path(model, &removed, MARK_SIGN_ID); 18 | } 19 | 20 | if let Some(bl) = get_current_selected_bufferline(model) { 21 | set(bl, MARK_SIGN_ID); 22 | } 23 | } 24 | Vec::new() 25 | } 26 | 27 | pub fn delete_mark(model: &mut Model, delete: &Vec) -> Vec { 28 | let mut persisted = Vec::new(); 29 | for mark in delete { 30 | let deleted = model.marks.entries.remove_entry(mark); 31 | if let Some((mark, path)) = deleted { 32 | unset_sign_for_path(model, path.as_path(), MARK_SIGN_ID); 33 | persisted.push(mark); 34 | } 35 | } 36 | 37 | if persisted.is_empty() { 38 | Vec::new() 39 | } else { 40 | vec![Action::Task(Task::DeleteMarks(persisted))] 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /yeet-frontend/src/update/mode.rs: -------------------------------------------------------------------------------- 1 | use yeet_buffer::{ 2 | message::BufferMessage, 3 | model::{BufferLine, CommandMode, Mode, SearchDirection}, 4 | update::{focus_buffer, unfocus_buffer, update_buffer}, 5 | }; 6 | use yeet_keymap::message::PrintContent; 7 | 8 | use crate::{ 9 | action::Action, 10 | model::{register::RegisterScope, Model}, 11 | update::update_current, 12 | }; 13 | 14 | use super::{ 15 | commandline::print_in_commandline, register::get_macro_register, save::persist_path_changes, 16 | viewport::set_viewport_dimensions, 17 | }; 18 | 19 | pub fn change_mode(model: &mut Model, from: &Mode, to: &Mode) -> Vec { 20 | match (from, to) { 21 | (Mode::Command(_), Mode::Command(_)) 22 | | (Mode::Insert, Mode::Insert) 23 | | (Mode::Navigation, Mode::Navigation) 24 | | (Mode::Normal, Mode::Normal) => return Vec::new(), 25 | _ => {} 26 | } 27 | 28 | model.mode = to.clone(); 29 | model.mode_before = Some(from.clone()); 30 | 31 | let mut actions = vec![Action::ModeChanged]; 32 | actions.extend(match from { 33 | Mode::Command(_) => { 34 | unfocus_buffer(&mut model.commandline.cursor); 35 | update_commandline_on_mode_change(model) 36 | } 37 | Mode::Insert | Mode::Navigation | Mode::Normal => { 38 | unfocus_buffer(&mut model.files.current_cursor); 39 | vec![] 40 | } 41 | }); 42 | 43 | set_commandline_content_to_mode(model); 44 | 45 | let msg = BufferMessage::ChangeMode(from.clone(), to.clone()); 46 | actions.extend(match to { 47 | Mode::Command(_) => { 48 | focus_buffer(&mut model.commandline.cursor); 49 | update_commandline_on_mode_change(model) 50 | } 51 | Mode::Insert => { 52 | focus_buffer(&mut model.files.current_cursor); 53 | update_current(model, &msg); 54 | vec![] 55 | } 56 | Mode::Navigation => { 57 | // TODO: handle file operations: show pending with gray, refresh on operation success 58 | // TODO: sort and refresh current on PathEnumerationFinished while not in Navigation mode 59 | focus_buffer(&mut model.files.current_cursor); 60 | update_current(model, &msg); 61 | persist_path_changes(model) 62 | } 63 | Mode::Normal => { 64 | focus_buffer(&mut model.files.current_cursor); 65 | update_current(model, &msg); 66 | vec![] 67 | } 68 | }); 69 | 70 | actions 71 | } 72 | 73 | fn update_commandline_on_mode_change(model: &mut Model) -> Vec { 74 | let commandline = &mut model.commandline; 75 | let buffer = &mut commandline.buffer; 76 | let viewport = &mut commandline.viewport; 77 | 78 | set_viewport_dimensions(viewport, &commandline.layout.buffer); 79 | 80 | let command_mode = match &model.mode { 81 | Mode::Command(it) => it, 82 | Mode::Insert | Mode::Navigation | Mode::Normal => { 83 | let from_command = model 84 | .mode_before 85 | .as_ref() 86 | .is_some_and(|mode| mode.is_command()); 87 | 88 | if from_command { 89 | update_buffer( 90 | viewport, 91 | &mut commandline.cursor, 92 | &model.mode, 93 | buffer, 94 | &BufferMessage::SetContent(vec![]), 95 | ); 96 | } 97 | return Vec::new(); 98 | } 99 | }; 100 | 101 | match command_mode { 102 | CommandMode::Command | CommandMode::Search(_) => { 103 | update_buffer( 104 | viewport, 105 | &mut commandline.cursor, 106 | &model.mode, 107 | buffer, 108 | &BufferMessage::ResetCursor, 109 | ); 110 | 111 | let prefix = match &command_mode { 112 | CommandMode::Command => Some(":".to_string()), 113 | CommandMode::Search(SearchDirection::Up) => Some("?".to_string()), 114 | CommandMode::Search(SearchDirection::Down) => Some("/".to_string()), 115 | CommandMode::PrintMultiline => unreachable!(), 116 | }; 117 | 118 | let bufferline = BufferLine { 119 | prefix, 120 | ..Default::default() 121 | }; 122 | 123 | update_buffer( 124 | viewport, 125 | &mut commandline.cursor, 126 | &model.mode, 127 | buffer, 128 | &BufferMessage::SetContent(vec![bufferline]), 129 | ); 130 | } 131 | CommandMode::PrintMultiline => {} 132 | }; 133 | 134 | Vec::new() 135 | } 136 | 137 | fn set_commandline_content_to_mode(model: &mut Model) { 138 | if let Some(RegisterScope::Macro(identifier)) = &get_macro_register(&model.register) { 139 | set_recording_in_commandline(model, *identifier); 140 | } else { 141 | set_mode_in_commandline(model); 142 | }; 143 | } 144 | 145 | pub fn set_recording_in_commandline(model: &mut Model, identifier: char) -> Vec { 146 | let content = format!("recording @{}", identifier); 147 | print_in_commandline(model, &[PrintContent::Default(content)]); 148 | Vec::new() 149 | } 150 | 151 | pub fn set_mode_in_commandline(model: &mut Model) -> Vec { 152 | let content = format!("--{}--", model.mode.to_string().to_uppercase()); 153 | print_in_commandline(model, &[PrintContent::Default(content)]); 154 | Vec::new() 155 | } 156 | -------------------------------------------------------------------------------- /yeet-frontend/src/update/modification.rs: -------------------------------------------------------------------------------- 1 | use yeet_buffer::message::{BufferMessage, TextModification}; 2 | 3 | use crate::{ 4 | action::Action, 5 | model::{BufferType, Model}, 6 | }; 7 | 8 | pub fn modify_buffer( 9 | model: &mut Model, 10 | repeat: &usize, 11 | modification: &TextModification, 12 | ) -> Vec { 13 | let msg = BufferMessage::Modification(*repeat, modification.clone()); 14 | super::update_current(model, &msg); 15 | 16 | model.files.preview = BufferType::None; 17 | 18 | Vec::new() 19 | } 20 | -------------------------------------------------------------------------------- /yeet-frontend/src/update/navigation.rs: -------------------------------------------------------------------------------- 1 | use std::{mem, path::Path}; 2 | 3 | use yeet_buffer::model::{viewport::ViewPort, Buffer, Cursor, CursorPosition}; 4 | 5 | use crate::{ 6 | action::Action, 7 | model::{BufferType, Model, WindowType}, 8 | }; 9 | 10 | use super::{history, selection}; 11 | 12 | #[tracing::instrument(skip(model))] 13 | pub fn navigate_to_mark(char: &char, model: &mut Model) -> Vec { 14 | let path = match model.marks.entries.get(char) { 15 | Some(it) => it.clone(), 16 | None => return Vec::new(), 17 | }; 18 | 19 | let selection = path 20 | .file_name() 21 | .map(|oss| oss.to_string_lossy().to_string()); 22 | 23 | let path = match path.parent() { 24 | Some(parent) => parent, 25 | None => &path, 26 | }; 27 | 28 | navigate_to_path_with_selection(model, path, &selection) 29 | } 30 | 31 | #[tracing::instrument(skip(model))] 32 | pub fn navigate_to_path(model: &mut Model, path: &Path) -> Vec { 33 | let (path, selection) = if path.is_file() { 34 | tracing::info!("path is a file, not a directory: {:?}", path); 35 | 36 | let selection = path 37 | .file_name() 38 | .map(|oss| oss.to_string_lossy().to_string()); 39 | 40 | match path.parent() { 41 | Some(parent) => (parent, selection), 42 | None => { 43 | tracing::warn!( 44 | "parent from path with file name could not get resolved: {:?}", 45 | path 46 | ); 47 | return Vec::new(); 48 | } 49 | } 50 | } else { 51 | (path, None) 52 | }; 53 | 54 | navigate_to_path_with_selection(model, path, &selection) 55 | } 56 | 57 | pub fn navigate_to_path_as_preview(model: &mut Model, path: &Path) -> Vec { 58 | let selection = path 59 | .file_name() 60 | .map(|oss| oss.to_string_lossy().to_string()); 61 | 62 | let path = match path.parent() { 63 | Some(parent) => parent, 64 | None => path, 65 | }; 66 | 67 | navigate_to_path_with_selection(model, path, &selection) 68 | } 69 | 70 | #[tracing::instrument(skip(model))] 71 | pub fn navigate_to_path_with_selection( 72 | model: &mut Model, 73 | path: &Path, 74 | selection: &Option, 75 | ) -> Vec { 76 | if path.is_file() { 77 | tracing::warn!("path is a file, not a directory: {:?}", path); 78 | return Vec::new(); 79 | } 80 | 81 | if !path.exists() { 82 | tracing::warn!("path does not exist: {:?}", path); 83 | return Vec::new(); 84 | } 85 | 86 | let selection = match selection { 87 | Some(it) => Some(it.to_owned()), 88 | None => { 89 | tracing::trace!("getting selection from history for path: {:?}", path); 90 | history::get_selection_from_history(&model.history, path) 91 | .map(|history| history.to_owned()) 92 | } 93 | }; 94 | 95 | tracing::trace!("resolved selection: {:?}", selection); 96 | 97 | model.files.preview = BufferType::None; 98 | 99 | let mut actions = Vec::new(); 100 | actions.push(Action::Load( 101 | WindowType::Current, 102 | path.to_path_buf(), 103 | selection.clone(), 104 | )); 105 | 106 | let parent = path.parent(); 107 | if let Some(parent) = parent { 108 | if parent != path { 109 | actions.push(Action::Load( 110 | WindowType::Parent, 111 | parent.to_path_buf(), 112 | path.file_name().map(|it| it.to_string_lossy().to_string()), 113 | )); 114 | } 115 | } 116 | 117 | actions 118 | } 119 | 120 | #[tracing::instrument(skip(model))] 121 | pub fn navigate_to_parent(model: &mut Model) -> Vec { 122 | if let Some(path) = model.files.current.path.clone().parent() { 123 | if model.files.current.path == path { 124 | return Vec::new(); 125 | } 126 | 127 | let mut actions = Vec::new(); 128 | 129 | if let Some(parent) = path.parent() { 130 | tracing::trace!("loading parent: {:?}", parent); 131 | 132 | actions.push(Action::Load( 133 | WindowType::Parent, 134 | parent.to_path_buf(), 135 | path.file_name().map(|it| it.to_string_lossy().to_string()), 136 | )); 137 | } 138 | 139 | let parent_buffer = match mem::replace(&mut model.files.parent, BufferType::None) { 140 | BufferType::Text(_, buffer) => buffer, 141 | BufferType::Image(_, _) | BufferType::None => Buffer::default(), 142 | }; 143 | 144 | let current_path = mem::replace(&mut model.files.current.path, path.to_path_buf()); 145 | let current_buffer = mem::replace(&mut model.files.current.buffer, parent_buffer); 146 | 147 | model.files.preview = BufferType::Text(current_path, current_buffer); 148 | model.files.preview_cursor = Some(Default::default()); 149 | 150 | mem_swap_viewport(&mut model.files.current_vp, &mut model.files.parent_vp); 151 | mem_swap_viewport(&mut model.files.parent_vp, &mut model.files.preview_vp); 152 | 153 | mem_swap_cursor( 154 | &mut model.files.current_cursor, 155 | &mut model.files.parent_cursor, 156 | ); 157 | mem_swap_cursor( 158 | &mut model.files.parent_cursor, 159 | &mut model.files.preview_cursor, 160 | ); 161 | 162 | actions 163 | } else { 164 | Vec::new() 165 | } 166 | } 167 | 168 | #[tracing::instrument(skip(model))] 169 | pub fn navigate_to_selected(model: &mut Model) -> Vec { 170 | if let Some(selected) = selection::get_current_selected_path(model) { 171 | if model.files.current.path == selected || !selected.is_dir() { 172 | return Vec::new(); 173 | } 174 | 175 | history::add_history_entry(&mut model.history, selected.as_path()); 176 | 177 | let mut actions = Vec::new(); 178 | let preview_buffer = match mem::replace(&mut model.files.preview, BufferType::None) { 179 | BufferType::Text(_, buffer) => buffer, 180 | BufferType::Image(_, _) | BufferType::None => { 181 | let history = 182 | history::get_selection_from_history(&model.history, selected.as_path()) 183 | .map(|s| s.to_string()); 184 | 185 | actions.push(Action::Load( 186 | WindowType::Current, 187 | selected.to_path_buf(), 188 | history, 189 | )); 190 | 191 | Buffer::default() 192 | } 193 | }; 194 | 195 | model.files.parent = BufferType::Text( 196 | mem::replace(&mut model.files.current.path, selected.to_path_buf()), 197 | mem::replace(&mut model.files.current.buffer, preview_buffer), 198 | ); 199 | 200 | mem_swap_cursor( 201 | &mut model.files.current_cursor, 202 | &mut model.files.parent_cursor, 203 | ); 204 | mem_swap_cursor( 205 | &mut model.files.current_cursor, 206 | &mut model.files.preview_cursor, 207 | ); 208 | 209 | if let Some(cursor) = &mut model.files.current_cursor { 210 | cursor.horizontal_index = CursorPosition::Absolute { 211 | current: 0, 212 | expanded: 0, 213 | }; 214 | } else { 215 | model.files.current_cursor = Some(Default::default()); 216 | } 217 | 218 | if let Some(selected) = selection::get_current_selected_path(model) { 219 | tracing::trace!("loading selection: {:?}", selected); 220 | 221 | let history = history::get_selection_from_history(&model.history, selected.as_path()) 222 | .map(|s| s.to_string()); 223 | 224 | actions.push(Action::Load( 225 | WindowType::Preview, 226 | selected.to_path_buf(), 227 | history, 228 | )); 229 | } 230 | 231 | mem_swap_viewport(&mut model.files.current_vp, &mut model.files.parent_vp); 232 | mem_swap_viewport(&mut model.files.current_vp, &mut model.files.preview_vp); 233 | 234 | actions 235 | } else { 236 | Vec::new() 237 | } 238 | } 239 | 240 | fn mem_swap_viewport(dest_viewport: &mut ViewPort, src_viewport: &mut ViewPort) { 241 | mem::swap( 242 | &mut dest_viewport.horizontal_index, 243 | &mut src_viewport.horizontal_index, 244 | ); 245 | mem::swap( 246 | &mut dest_viewport.vertical_index, 247 | &mut src_viewport.vertical_index, 248 | ); 249 | } 250 | 251 | fn mem_swap_cursor(dest_cursor: &mut Option, src_cursor: &mut Option) { 252 | if let (Some(dest_cursor), Some(src_cursor)) = (dest_cursor, src_cursor) { 253 | mem::swap( 254 | &mut dest_cursor.vertical_index, 255 | &mut src_cursor.vertical_index, 256 | ); 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /yeet-frontend/src/update/open.rs: -------------------------------------------------------------------------------- 1 | use yeet_buffer::model::Mode; 2 | use yeet_keymap::message::QuitMode; 3 | 4 | use crate::{action::Action, model::Model}; 5 | 6 | use super::selection::get_current_selected_path; 7 | 8 | pub fn open_selected(model: &Model) -> Vec { 9 | if model.mode != Mode::Navigation { 10 | return Vec::new(); 11 | } 12 | 13 | if let Some(selected) = get_current_selected_path(model) { 14 | if model.settings.selection_to_file_on_open.is_some() 15 | || model.settings.selection_to_stdout_on_open 16 | { 17 | vec![Action::Quit( 18 | QuitMode::FailOnRunningTasks, 19 | Some(selected.to_string_lossy().to_string()), 20 | )] 21 | } else { 22 | vec![Action::Open(selected)] 23 | } 24 | } else { 25 | Vec::new() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /yeet-frontend/src/update/path.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::HashMap, 3 | path::{Path, PathBuf}, 4 | }; 5 | 6 | use yeet_buffer::{ 7 | message::BufferMessage, 8 | model::{ansi::Ansi, Buffer, BufferLine, Cursor, Mode}, 9 | update::update_buffer, 10 | }; 11 | 12 | use crate::{ 13 | action::Action, 14 | model::{BufferType, Model, WindowType}, 15 | }; 16 | 17 | use super::{ 18 | history::get_selection_from_history, 19 | junkyard::remove_from_junkyard, 20 | selection, 21 | sign::{set_sign_if_marked, set_sign_if_qfix}, 22 | }; 23 | 24 | #[tracing::instrument(skip(model))] 25 | pub fn add_paths(model: &mut Model, paths: &[PathBuf]) -> Vec { 26 | let mut buffer_contents = vec![( 27 | model.files.current.path.as_path(), 28 | &mut model.files.current_vp, 29 | &mut model.files.current_cursor, 30 | &mut model.files.current.buffer, 31 | model.mode == Mode::Navigation, 32 | )]; 33 | 34 | if let BufferType::Text(path, buffer) = &mut model.files.parent { 35 | buffer_contents.push(( 36 | path.as_path(), 37 | &mut model.files.parent_vp, 38 | &mut model.files.parent_cursor, 39 | buffer, 40 | path.is_dir(), 41 | )); 42 | } 43 | 44 | if let BufferType::Text(path, buffer) = &mut model.files.preview { 45 | buffer_contents.push(( 46 | path.as_path(), 47 | &mut model.files.preview_vp, 48 | &mut model.files.preview_cursor, 49 | buffer, 50 | path.is_dir(), 51 | )); 52 | } 53 | 54 | for (path, viewport, cursor, buffer, sort) in buffer_contents { 55 | let paths_for_buffer: Vec<_> = paths.iter().filter(|p| p.parent() == Some(path)).collect(); 56 | if paths_for_buffer.is_empty() { 57 | continue; 58 | } 59 | 60 | let mut selection = match cursor { 61 | Some(it) => get_selected_content_from_buffer(it, buffer), 62 | None => None, 63 | }; 64 | 65 | let indexes = buffer 66 | .lines 67 | .iter() 68 | .enumerate() 69 | .map(|(i, bl)| { 70 | let content = bl.content.to_stripped_string(); 71 | let key = if content.contains('/') { 72 | content.split('/').collect::>()[0].to_string() 73 | } else { 74 | content.clone() 75 | }; 76 | 77 | (key, i) 78 | }) 79 | .collect::>(); 80 | 81 | for path in paths_for_buffer { 82 | if let Some(basename) = path.file_name().and_then(|oss| oss.to_str()) { 83 | let mut line = from(path); 84 | set_sign_if_marked(&model.marks, &mut line, path); 85 | set_sign_if_qfix(&model.qfix, &mut line, path); 86 | 87 | if let Some(index) = indexes.get(basename) { 88 | buffer.lines[*index] = line; 89 | } else { 90 | buffer.lines.push(line); 91 | } 92 | 93 | selection = selection.map(|sl| { 94 | if sl.starts_with(&[basename, "/"].concat()) { 95 | basename.to_owned() 96 | } else { 97 | sl 98 | } 99 | }); 100 | } 101 | } 102 | 103 | if sort { 104 | update_buffer( 105 | viewport, 106 | cursor, 107 | &model.mode, 108 | buffer, 109 | &BufferMessage::SortContent(super::SORT), 110 | ); 111 | } 112 | 113 | if let Some(selection) = selection { 114 | update_buffer( 115 | viewport, 116 | cursor, 117 | &model.mode, 118 | buffer, 119 | &BufferMessage::SetCursorToLineContent(selection), 120 | ); 121 | } 122 | } 123 | 124 | let mut actions = Vec::new(); 125 | if let Some(path) = selection::get_current_selected_path(model) { 126 | let selection = get_selection_from_history(&model.history, &path).map(|s| s.to_owned()); 127 | actions.push(Action::Load(WindowType::Preview, path, selection)); 128 | } 129 | 130 | actions 131 | } 132 | 133 | fn get_selected_content_from_buffer(cursor: &Cursor, model: &Buffer) -> Option { 134 | model 135 | .lines 136 | .get(cursor.vertical_index) 137 | .map(|line| line.content.to_stripped_string()) 138 | } 139 | 140 | fn from(path: &Path) -> BufferLine { 141 | let content = match path.file_name() { 142 | Some(content) => content.to_str().unwrap_or("").to_string(), 143 | None => "".to_string(), 144 | }; 145 | 146 | let content = if path.is_dir() { 147 | format!("\x1b[94m{}\x1b[39m", content) 148 | } else { 149 | content 150 | }; 151 | 152 | BufferLine { 153 | content: Ansi::new(&content), 154 | ..Default::default() 155 | } 156 | } 157 | 158 | #[tracing::instrument(skip(model))] 159 | pub fn remove_path(model: &mut Model, path: &Path) -> Vec { 160 | if path.starts_with(&model.junk.path) { 161 | remove_from_junkyard(&mut model.junk, path); 162 | } 163 | 164 | let current_selection = match &model.files.current_cursor { 165 | Some(it) => get_selected_content_from_buffer(it, &model.files.current.buffer), 166 | None => None, 167 | }; 168 | 169 | let mut buffer_contents = vec![( 170 | model.files.current.path.as_path(), 171 | &mut model.files.current_vp, 172 | &mut model.files.current_cursor, 173 | &mut model.files.current.buffer, 174 | )]; 175 | 176 | if let BufferType::Text(path, buffer) = &mut model.files.parent { 177 | buffer_contents.push(( 178 | path.as_path(), 179 | &mut model.files.parent_vp, 180 | &mut model.files.parent_cursor, 181 | buffer, 182 | )); 183 | } 184 | 185 | if let BufferType::Text(path, buffer) = &mut model.files.preview { 186 | buffer_contents.push(( 187 | path.as_path(), 188 | &mut model.files.preview_vp, 189 | &mut model.files.preview_cursor, 190 | buffer, 191 | )); 192 | } 193 | 194 | if let Some(parent) = path.parent() { 195 | if let Some((_, viewport, cursor, buffer)) = buffer_contents 196 | .into_iter() 197 | .find(|(p, _, _, _)| p == &parent) 198 | { 199 | if let Some(basename) = path.file_name().and_then(|oss| oss.to_str()) { 200 | let index = buffer 201 | .lines 202 | .iter() 203 | .enumerate() 204 | .find(|(_, bl)| bl.content.to_stripped_string() == basename) 205 | .map(|(i, _)| i); 206 | 207 | if let Some(index) = index { 208 | update_buffer( 209 | viewport, 210 | cursor, 211 | &model.mode, 212 | buffer, 213 | &BufferMessage::RemoveLine(index), 214 | ); 215 | } 216 | } 217 | } 218 | } 219 | 220 | if let Some(selection) = current_selection { 221 | update_buffer( 222 | &mut model.files.current_vp, 223 | &mut model.files.current_cursor, 224 | &model.mode, 225 | &mut model.files.current.buffer, 226 | &BufferMessage::SetCursorToLineContent(selection), 227 | ); 228 | }; 229 | 230 | let mut actions = Vec::new(); 231 | if let Some(path) = selection::get_current_selected_path(model) { 232 | let selection = get_selection_from_history(&model.history, &path).map(|s| s.to_owned()); 233 | actions.push(Action::Load(WindowType::Preview, path, selection)); 234 | } 235 | 236 | actions 237 | } 238 | -------------------------------------------------------------------------------- /yeet-frontend/src/update/qfix.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use crate::{ 4 | action::Action, 5 | model::{qfix::QFIX_SIGN_ID, Model}, 6 | }; 7 | 8 | use super::{ 9 | selection::{get_current_selected_bufferline, get_current_selected_path}, 10 | sign, 11 | }; 12 | 13 | pub fn toggle_selected_to_qfix(model: &mut Model) -> Vec { 14 | let selected = get_current_selected_path(model); 15 | if let Some(selected) = selected { 16 | if model.qfix.entries.contains(&selected) { 17 | model.qfix.entries.retain(|p| p != &selected); 18 | if let Some(bl) = get_current_selected_bufferline(model) { 19 | sign::unset(bl, QFIX_SIGN_ID); 20 | } 21 | } else { 22 | model.qfix.entries.push(selected); 23 | if let Some(bl) = get_current_selected_bufferline(model) { 24 | sign::set(bl, QFIX_SIGN_ID); 25 | } 26 | } 27 | } 28 | Vec::new() 29 | } 30 | 31 | pub fn add(model: &mut Model, paths: Vec) -> Vec { 32 | for path in paths { 33 | if !model.qfix.entries.contains(&path) { 34 | sign::set_sign_for_path(model, path.as_path(), QFIX_SIGN_ID); 35 | model.qfix.entries.push(path); 36 | }; 37 | } 38 | Vec::new() 39 | } 40 | -------------------------------------------------------------------------------- /yeet-frontend/src/update/register.rs: -------------------------------------------------------------------------------- 1 | use yeet_buffer::{ 2 | message::BufferMessage, 3 | model::{Mode, SearchDirection}, 4 | }; 5 | use yeet_keymap::message::{KeySequence, KeymapMessage}; 6 | 7 | use crate::{ 8 | action::{self, Action}, 9 | model::register::{Register, RegisterScope}, 10 | }; 11 | 12 | pub fn get_register(register: &Register, register_id: &char) -> Option { 13 | match register_id { 14 | '@' => register.last_macro.clone(), 15 | '.' => register.dot.clone(), 16 | ':' => register.command.clone(), 17 | '/' => register.searched.as_ref().map(|sd| sd.1.clone()), 18 | char => register.content.get(char).cloned(), 19 | } 20 | } 21 | 22 | pub fn replay_register(register: &mut Register, char: &char) -> Vec { 23 | if let Some(content) = get_register(register, char) { 24 | vec![action::emit_keymap(KeymapMessage::ExecuteKeySequence( 25 | content.to_string(), 26 | ))] 27 | } else { 28 | Vec::new() 29 | } 30 | } 31 | 32 | pub fn replay_macro_register(register: &mut Register, char: &char) -> Vec { 33 | if let Some(content) = get_register(register, char) { 34 | register.last_macro = Some(content.to_string()); 35 | vec![action::emit_keymap(KeymapMessage::ExecuteKeySequence( 36 | content.to_string(), 37 | ))] 38 | } else { 39 | Vec::new() 40 | } 41 | } 42 | 43 | pub fn get_direction_from_search_register(register: &Register) -> Option<&SearchDirection> { 44 | register.searched.as_ref().map(|sd| &sd.0) 45 | } 46 | 47 | pub fn get_macro_register(register: &Register) -> Option<&RegisterScope> { 48 | register 49 | .scopes 50 | .keys() 51 | .find(|scope| matches!(scope, RegisterScope::Macro(_))) 52 | } 53 | 54 | #[tracing::instrument(skip(mode, register))] 55 | pub fn start_register_scope(mode: &Mode, register: &mut Register, messages: &[KeymapMessage]) { 56 | if let Some(scope) = resolve_register_scope(mode, messages) { 57 | tracing::trace!("starting scope: {:?}", scope); 58 | 59 | register 60 | .scopes 61 | .entry(scope) 62 | .or_insert_with(|| "".to_owned()); 63 | } 64 | } 65 | 66 | fn resolve_register_scope(mode: &Mode, messages: &[KeymapMessage]) -> Option { 67 | if is_dot_scope(mode, messages) { 68 | Some(RegisterScope::Dot) 69 | } else { 70 | resolve_macro_register(messages).map(RegisterScope::Macro) 71 | } 72 | } 73 | 74 | fn is_dot_scope(mode: &Mode, messages: &[KeymapMessage]) -> bool { 75 | if mode.is_command() { 76 | return false; 77 | } 78 | 79 | messages.iter().any(|message| { 80 | matches!( 81 | message, 82 | KeymapMessage::Buffer(BufferMessage::ChangeMode(_, Mode::Insert)) 83 | | KeymapMessage::Buffer(BufferMessage::Modification(..)) 84 | ) 85 | }) 86 | } 87 | 88 | fn resolve_macro_register(messages: &[KeymapMessage]) -> Option { 89 | let message = messages 90 | .iter() 91 | .find(|m| matches!(m, KeymapMessage::StartMacro(_))); 92 | 93 | if let Some(KeymapMessage::StartMacro(identifier)) = message { 94 | Some(*identifier) 95 | } else { 96 | None 97 | } 98 | } 99 | 100 | #[tracing::instrument(skip(mode, register))] 101 | pub fn finish_register_scope( 102 | mode: &Mode, 103 | register: &mut Register, 104 | key_sequence: &KeySequence, 105 | keymap_messages: &Vec, 106 | ) { 107 | let sequence = match key_sequence { 108 | KeySequence::Completed(sequence) => sequence.as_str(), 109 | KeySequence::Changed(_) | KeySequence::None => return, 110 | }; 111 | 112 | let mut to_close = Vec::new(); 113 | for (scope, content) in register.scopes.iter_mut() { 114 | match scope { 115 | RegisterScope::Dot => { 116 | if mode != &Mode::Insert { 117 | to_close.push(scope.clone()); 118 | } 119 | 120 | content.push_str(sequence); 121 | } 122 | RegisterScope::Macro(_) => { 123 | let is_macro_start = resolve_macro_register(keymap_messages).is_some(); 124 | let is_macro_stop = keymap_messages 125 | .iter() 126 | .any(|m| m == &KeymapMessage::StopMacro); 127 | 128 | if is_macro_start { 129 | continue; 130 | } else if is_macro_stop { 131 | to_close.push(scope.clone()); 132 | } else { 133 | content.push_str(sequence); 134 | } 135 | } 136 | } 137 | } 138 | 139 | for scope in to_close { 140 | tracing::trace!("closing scope: {:?}", scope); 141 | 142 | if let Some(content) = register.scopes.remove(&scope) { 143 | match scope { 144 | RegisterScope::Dot => register.dot.replace(content), 145 | RegisterScope::Macro(identifier) => register.content.insert(identifier, content), 146 | }; 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /yeet-frontend/src/update/save.rs: -------------------------------------------------------------------------------- 1 | use yeet_buffer::{ 2 | message::BufferMessage, 3 | model::{ 4 | undo::{consolidate_modifications, BufferChanged}, 5 | BufferResult, 6 | }, 7 | update::update_buffer, 8 | }; 9 | 10 | use crate::{action::Action, model::Model, task::Task}; 11 | 12 | use super::{junkyard::trash_to_junkyard, selection::get_current_selected_bufferline}; 13 | 14 | #[tracing::instrument(skip(model))] 15 | pub fn persist_path_changes(model: &mut Model) -> Vec { 16 | let selection = get_current_selected_bufferline(model).map(|line| line.content.clone()); 17 | 18 | let mut content: Vec<_> = model.files.current.buffer.lines.drain(..).collect(); 19 | content.retain(|line| !line.content.is_empty()); 20 | 21 | update_buffer( 22 | &mut model.files.current_vp, 23 | &mut model.files.current_cursor, 24 | &model.mode, 25 | &mut model.files.current.buffer, 26 | &BufferMessage::SetContent(content), 27 | ); 28 | 29 | if let Some(selection) = selection { 30 | update_buffer( 31 | &mut model.files.current_vp, 32 | &mut model.files.current_cursor, 33 | &model.mode, 34 | &mut model.files.current.buffer, 35 | &BufferMessage::SetCursorToLineContent(selection.to_stripped_string()), 36 | ); 37 | } 38 | 39 | let result = update_buffer( 40 | &mut model.files.current_vp, 41 | &mut model.files.current_cursor, 42 | &model.mode, 43 | &mut model.files.current.buffer, 44 | &BufferMessage::SaveBuffer, 45 | ); 46 | 47 | let mut actions = Vec::new(); 48 | for br in result { 49 | if let BufferResult::Changes(modifications) = br { 50 | let path = &model.files.current.path; 51 | let mut trashes = Vec::new(); 52 | for modification in consolidate_modifications(&modifications) { 53 | match modification { 54 | BufferChanged::LineAdded(_, name) => { 55 | if !name.is_empty() { 56 | actions.push(Action::Task(Task::AddPath( 57 | path.join(name.to_stripped_string()), 58 | ))) 59 | } 60 | } 61 | BufferChanged::LineRemoved(_, name) => { 62 | trashes.push(path.join(name.to_stripped_string())); 63 | } 64 | BufferChanged::Content(_, old_name, new_name) => { 65 | let task = if new_name.is_empty() { 66 | Task::DeletePath(path.join(old_name.to_stripped_string())) 67 | } else { 68 | Task::RenamePath( 69 | path.join(old_name.to_stripped_string()), 70 | path.join(new_name.to_stripped_string()), 71 | ) 72 | }; 73 | actions.push(Action::Task(task)); 74 | } 75 | } 76 | } 77 | 78 | if !trashes.is_empty() { 79 | let (transaction, obsolete) = trash_to_junkyard(&mut model.junk, trashes); 80 | for entry in transaction.entries { 81 | actions.push(Action::Task(Task::TrashPath(entry))); 82 | } 83 | 84 | if let Some(obsolete) = obsolete { 85 | for entry in obsolete.entries { 86 | actions.push(Action::Task(Task::DeleteJunkYardEntry(entry))); 87 | } 88 | } 89 | } 90 | } 91 | } 92 | actions 93 | } 94 | -------------------------------------------------------------------------------- /yeet-frontend/src/update/search.rs: -------------------------------------------------------------------------------- 1 | use yeet_buffer::model::Buffer; 2 | 3 | use crate::{ 4 | action::Action, 5 | model::{BufferType, Model}, 6 | }; 7 | 8 | pub fn search_in_buffers(model: &mut Model, search: Option) { 9 | let search = match search { 10 | Some(it) => it, 11 | None => { 12 | clear_search(model); 13 | return; 14 | } 15 | }; 16 | 17 | set_search_char_positions(&mut model.files.current.buffer, search.as_str()); 18 | 19 | if let BufferType::Text(path, buffer) = &mut model.files.parent { 20 | if path.is_dir() { 21 | set_search_char_positions(buffer, search.as_str()); 22 | } 23 | }; 24 | 25 | if let BufferType::Text(path, buffer) = &mut model.files.preview { 26 | if path.is_dir() { 27 | set_search_char_positions(buffer, search.as_str()); 28 | } 29 | }; 30 | } 31 | 32 | pub fn clear_search(model: &mut Model) -> Vec { 33 | for line in &mut model.files.current.buffer.lines { 34 | line.search_char_position = None; 35 | } 36 | if let BufferType::Text(_, buffer) = &mut model.files.parent { 37 | for line in &mut buffer.lines { 38 | line.search_char_position = None; 39 | } 40 | } 41 | if let BufferType::Text(_, buffer) = &mut model.files.preview { 42 | for line in &mut buffer.lines { 43 | line.search_char_position = None; 44 | } 45 | } 46 | Vec::new() 47 | } 48 | 49 | fn set_search_char_positions(buffer: &mut Buffer, search: &str) { 50 | let smart_case = search.chars().all(|c| c.is_ascii_lowercase()); 51 | let search_length = search.chars().count(); 52 | 53 | for line in &mut buffer.lines { 54 | line.search_char_position = None; 55 | 56 | let mut content = line.content.to_stripped_string(); 57 | let lower = content.to_lowercase(); 58 | if smart_case { 59 | content = lower; 60 | }; 61 | 62 | let start = match content.find(search) { 63 | Some(it) => content[..it].chars().count(), 64 | None => continue, 65 | }; 66 | 67 | line.search_char_position = Some(vec![(start, search_length)]); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /yeet-frontend/src/update/selection.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use yeet_buffer::model::BufferLine; 4 | 5 | use crate::{action::Action, event::Message, model::Model}; 6 | 7 | pub fn get_current_selected_path(model: &Model) -> Option { 8 | let buffer = &model.files.current.buffer; 9 | if buffer.lines.is_empty() { 10 | return None; 11 | } 12 | 13 | let cursor = &model.files.current_cursor.as_ref()?; 14 | let current = &buffer.lines.get(cursor.vertical_index)?; 15 | if current.content.is_empty() { 16 | return None; 17 | } 18 | 19 | let target = model 20 | .files 21 | .current 22 | .path 23 | .join(current.content.to_stripped_string()); 24 | 25 | if target.exists() { 26 | Some(target) 27 | } else { 28 | None 29 | } 30 | } 31 | 32 | pub fn get_current_selected_bufferline(model: &mut Model) -> Option<&mut BufferLine> { 33 | let buffer = &mut model.files.current.buffer; 34 | if buffer.lines.is_empty() { 35 | return None; 36 | } 37 | 38 | let cursor = &model.files.current_cursor.as_ref()?; 39 | buffer.lines.get_mut(cursor.vertical_index) 40 | } 41 | 42 | pub fn copy_current_selected_path_to_clipboard(model: &mut Model) -> Vec { 43 | if let Some(path) = get_current_selected_path(model) { 44 | if let Some(clipboard) = model.register.clipboard.as_mut() { 45 | match clipboard.set_text(path.to_string_lossy()) { 46 | Ok(_) => Vec::new(), 47 | Err(err) => vec![Action::EmitMessages(vec![Message::Error(err.to_string())])], 48 | } 49 | } else { 50 | vec![Action::EmitMessages(vec![Message::Error( 51 | "Clipboard not available".to_string(), 52 | )])] 53 | } 54 | } else { 55 | Vec::new() 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /yeet-frontend/src/update/settings.rs: -------------------------------------------------------------------------------- 1 | use yeet_buffer::model::{viewport::ViewPort, SignIdentifier}; 2 | 3 | use crate::model::{mark::MARK_SIGN_ID, qfix::QFIX_SIGN_ID, Model}; 4 | 5 | pub fn update_with_settings(model: &mut Model) { 6 | model.files.current_vp.set(&model.settings.current); 7 | model.files.parent_vp.set(&model.settings.current); 8 | model.files.preview_vp.set(&model.settings.current); 9 | 10 | if model.settings.show_mark_signs { 11 | remove_hidden_sign_on_all_buffer(model, &MARK_SIGN_ID); 12 | } else { 13 | add_hidden_sign_on_all_buffer(model, MARK_SIGN_ID); 14 | } 15 | 16 | if model.settings.show_quickfix_signs { 17 | remove_hidden_sign_on_all_buffer(model, &QFIX_SIGN_ID); 18 | } else { 19 | add_hidden_sign_on_all_buffer(model, QFIX_SIGN_ID); 20 | } 21 | } 22 | 23 | fn add_hidden_sign_on_all_buffer(model: &mut Model, id: SignIdentifier) { 24 | add_hidden_sign(&mut model.files.current_vp, id); 25 | add_hidden_sign(&mut model.files.parent_vp, id); 26 | add_hidden_sign(&mut model.files.preview_vp, id); 27 | } 28 | 29 | fn add_hidden_sign(viewport: &mut ViewPort, id: SignIdentifier) { 30 | viewport.hidden_sign_ids.insert(id); 31 | } 32 | 33 | fn remove_hidden_sign_on_all_buffer(model: &mut Model, id: &SignIdentifier) { 34 | remove_hidden_sign(&mut model.files.current_vp, id); 35 | remove_hidden_sign(&mut model.files.parent_vp, id); 36 | remove_hidden_sign(&mut model.files.preview_vp, id); 37 | } 38 | 39 | fn remove_hidden_sign(viewport: &mut ViewPort, id: &SignIdentifier) { 40 | viewport.hidden_sign_ids.remove(id); 41 | } 42 | -------------------------------------------------------------------------------- /yeet-frontend/src/update/sign.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | use yeet_buffer::model::{BufferLine, Sign, SignIdentifier}; 3 | 4 | use crate::model::{ 5 | mark::{Marks, MARK_SIGN_ID}, 6 | qfix::{QuickFix, QFIX_SIGN_ID}, 7 | Model, 8 | }; 9 | 10 | pub fn set_sign_if_qfix(qfix: &QuickFix, bl: &mut BufferLine, path: &Path) { 11 | let is_marked = qfix.entries.iter().any(|p| p == path); 12 | if !is_marked { 13 | return; 14 | } 15 | 16 | set(bl, QFIX_SIGN_ID); 17 | } 18 | 19 | pub fn set_sign_if_marked(marks: &Marks, bl: &mut BufferLine, path: &Path) { 20 | let is_marked = marks.entries.values().any(|p| p == path); 21 | if !is_marked { 22 | return; 23 | } 24 | 25 | set(bl, MARK_SIGN_ID); 26 | } 27 | 28 | pub fn set(bl: &mut BufferLine, sign_id: SignIdentifier) { 29 | let is_signed = bl.signs.iter().any(|s| s.id == sign_id); 30 | if is_signed { 31 | return; 32 | } 33 | 34 | if let Some(sign) = generate_sign(sign_id) { 35 | bl.signs.push(sign); 36 | } 37 | } 38 | 39 | pub fn set_sign_for_path(model: &mut Model, path: &Path, sign_id: SignIdentifier) { 40 | let parent = match path.parent() { 41 | Some(it) => it, 42 | None => return, 43 | }; 44 | 45 | let target = model 46 | .files 47 | .get_mut_directories() 48 | .into_iter() 49 | .find_map(|(p, _, _, b)| if p == parent { Some(b) } else { None }); 50 | 51 | let buffer = match target { 52 | Some(buffer) => buffer, 53 | None => return, 54 | }; 55 | 56 | let file_name = match path.file_name() { 57 | Some(it) => match it.to_str() { 58 | Some(it) => it, 59 | None => return, 60 | }, 61 | None => return, 62 | }; 63 | 64 | if let Some(line) = buffer 65 | .lines 66 | .iter_mut() 67 | .find(|bl| bl.content.to_stripped_string() == file_name) 68 | { 69 | set(line, sign_id); 70 | } 71 | } 72 | 73 | fn generate_sign(sign_id: SignIdentifier) -> Option { 74 | match sign_id { 75 | QFIX_SIGN_ID => Some(Sign { 76 | id: QFIX_SIGN_ID, 77 | content: 'c', 78 | style: "\x1b[1;95m".to_string(), 79 | priority: 0, 80 | }), 81 | MARK_SIGN_ID => Some(Sign { 82 | id: MARK_SIGN_ID, 83 | content: 'm', 84 | style: "\x1b[1;96m".to_string(), 85 | priority: 0, 86 | }), 87 | _ => None, 88 | } 89 | } 90 | 91 | pub fn unset_sign_on_all_buffers(model: &mut Model, sign_id: SignIdentifier) { 92 | model 93 | .files 94 | .get_mut_directories() 95 | .into_iter() 96 | .flat_map(|(_, _, _, b)| &mut b.lines) 97 | .for_each(|l| unset(l, sign_id)); 98 | } 99 | 100 | pub fn unset_sign_for_path(model: &mut Model, path: &Path, sign_id: SignIdentifier) { 101 | let parent = match path.parent() { 102 | Some(it) => it, 103 | None => return, 104 | }; 105 | 106 | let target = model 107 | .files 108 | .get_mut_directories() 109 | .into_iter() 110 | .find_map(|(p, _, _, b)| if p == parent { Some(b) } else { None }); 111 | 112 | let buffer = match target { 113 | Some(buffer) => buffer, 114 | None => return, 115 | }; 116 | 117 | let file_name = match path.file_name() { 118 | Some(it) => match it.to_str() { 119 | Some(it) => it, 120 | None => return, 121 | }, 122 | None => return, 123 | }; 124 | 125 | if let Some(line) = buffer 126 | .lines 127 | .iter_mut() 128 | .find(|bl| bl.content.to_stripped_string() == file_name) 129 | { 130 | unset(line, sign_id); 131 | } 132 | } 133 | 134 | pub fn unset(bl: &mut BufferLine, sign_id: SignIdentifier) { 135 | let position = bl.signs.iter().position(|s| s.id == sign_id); 136 | if let Some(position) = position { 137 | bl.signs.remove(position); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /yeet-frontend/src/update/task.rs: -------------------------------------------------------------------------------- 1 | use std::cmp::Ordering; 2 | 3 | use tokio_util::sync::CancellationToken; 4 | 5 | use crate::{ 6 | action::Action, 7 | model::{CurrentTask, Model}, 8 | }; 9 | 10 | pub fn add(model: &mut Model, identifier: String, cancellation: CancellationToken) -> Vec { 11 | let id = next_id(model); 12 | 13 | if let Some(replaced_task) = model.current_tasks.insert( 14 | identifier.clone(), 15 | CurrentTask { 16 | token: cancellation, 17 | id, 18 | external_id: identifier, 19 | }, 20 | ) { 21 | replaced_task.token.cancel(); 22 | } 23 | Vec::new() 24 | } 25 | 26 | fn next_id(model: &mut Model) -> u16 { 27 | let mut next_id = if model.latest_task_id >= 9999 { 28 | 1 29 | } else { 30 | model.latest_task_id + 1 31 | }; 32 | 33 | let mut running_ids: Vec = model.current_tasks.values().map(|task| task.id).collect(); 34 | running_ids.sort(); 35 | 36 | for id in running_ids { 37 | match next_id.cmp(&id) { 38 | Ordering::Equal => next_id += 1, 39 | Ordering::Greater => break, 40 | Ordering::Less => {} 41 | } 42 | } 43 | 44 | model.latest_task_id = next_id; 45 | 46 | next_id 47 | } 48 | 49 | pub fn remove(model: &mut Model, identifier: String) -> Vec { 50 | if let Some(task) = model.current_tasks.remove(&identifier) { 51 | task.token.cancel(); 52 | } 53 | Vec::new() 54 | } 55 | -------------------------------------------------------------------------------- /yeet-frontend/src/update/viewport.rs: -------------------------------------------------------------------------------- 1 | use ratatui::layout::Rect; 2 | use yeet_buffer::{ 3 | message::{BufferMessage, ViewPortDirection}, 4 | model::viewport::ViewPort, 5 | }; 6 | 7 | use crate::{ 8 | action::Action, 9 | model::{Model, WindowType}, 10 | }; 11 | 12 | use super::{history, selection}; 13 | 14 | pub fn move_viewport(model: &mut Model, direction: &ViewPortDirection) -> Vec { 15 | let msg = BufferMessage::MoveViewPort(direction.clone()); 16 | super::update_current(model, &msg); 17 | 18 | let mut actions = Vec::new(); 19 | if let Some(path) = selection::get_current_selected_path(model) { 20 | let selection = 21 | history::get_selection_from_history(&model.history, &path).map(|s| s.to_owned()); 22 | actions.push(Action::Load(WindowType::Preview, path, selection)); 23 | } 24 | 25 | actions 26 | } 27 | 28 | pub fn set_viewport_dimensions(vp: &mut ViewPort, rect: &Rect) { 29 | vp.height = usize::from(rect.height); 30 | vp.width = usize::from(rect.width); 31 | } 32 | -------------------------------------------------------------------------------- /yeet-frontend/src/view/commandline.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{widgets::Paragraph, Frame}; 2 | use yeet_buffer::view; 3 | 4 | use crate::model::Model; 5 | 6 | pub fn view(model: &Model, frame: &mut Frame) { 7 | let commandline = &model.commandline; 8 | 9 | view::view( 10 | &commandline.viewport, 11 | &commandline.cursor, 12 | &model.mode, 13 | &commandline.buffer, 14 | &false, 15 | frame, 16 | commandline.layout.buffer, 17 | ); 18 | 19 | frame.render_widget( 20 | Paragraph::new(model.commandline.key_sequence.clone()), 21 | commandline.layout.key_sequence, 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /yeet-frontend/src/view/mod.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{layout::Rect, Frame}; 2 | use ratatui_image::Image; 3 | use yeet_buffer::{ 4 | model::{viewport::ViewPort, Cursor, Mode}, 5 | view, 6 | }; 7 | 8 | use crate::{ 9 | error::AppError, 10 | model::{BufferType, Model}, 11 | terminal::TerminalWrapper, 12 | }; 13 | 14 | mod commandline; 15 | mod statusline; 16 | 17 | pub fn render_model(terminal: &mut TerminalWrapper, model: &Model) -> Result<(), AppError> { 18 | terminal.draw(|frame| { 19 | let layout = model.layout.clone(); 20 | 21 | commandline::view(model, frame); 22 | 23 | view::view( 24 | &model.files.current_vp, 25 | &model.files.current_cursor, 26 | &model.mode, 27 | &model.files.current.buffer, 28 | &model.files.show_border, 29 | frame, 30 | layout.current, 31 | ); 32 | 33 | render_buffer( 34 | &model.files.parent_vp, 35 | &model.files.parent_cursor, 36 | &model.mode, 37 | frame, 38 | layout.parent, 39 | &model.files.parent, 40 | &model.files.show_border, 41 | ); 42 | render_buffer( 43 | &model.files.preview_vp, 44 | &model.files.preview_cursor, 45 | &model.mode, 46 | frame, 47 | layout.preview, 48 | &model.files.preview, 49 | &false, 50 | ); 51 | 52 | statusline::view(model, frame, layout.statusline); 53 | }) 54 | } 55 | 56 | fn render_buffer( 57 | viewport: &ViewPort, 58 | cursor: &Option, 59 | mode: &Mode, 60 | frame: &mut Frame, 61 | layout: Rect, 62 | buffer_type: &BufferType, 63 | show_border: &bool, 64 | ) { 65 | match buffer_type { 66 | BufferType::Text(_, buffer) => { 67 | view::view(viewport, cursor, mode, buffer, show_border, frame, layout); 68 | } 69 | BufferType::Image(_, protocol) => { 70 | frame.render_widget(Image::new(protocol), layout); 71 | } 72 | BufferType::None => { 73 | view::view( 74 | viewport, 75 | &None, 76 | mode, 77 | &Default::default(), 78 | show_border, 79 | frame, 80 | layout, 81 | ); 82 | } 83 | }; 84 | } 85 | -------------------------------------------------------------------------------- /yeet-frontend/src/view/statusline.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | prelude::{Constraint, Direction, Layout, Rect}, 3 | style::{Color, Style}, 4 | text::{Line, Span}, 5 | widgets::{Block, Paragraph}, 6 | Frame, 7 | }; 8 | use yeet_buffer::model::undo::{self, BufferChanged}; 9 | 10 | use crate::model::Model; 11 | 12 | pub fn view(model: &Model, frame: &mut Frame, rect: Rect) { 13 | let changes = get_changes_content(model); 14 | let position = get_position_content(model); 15 | 16 | let content = model.files.current.path.to_str().unwrap_or(""); 17 | let style = Style::default().fg(Color::Gray); 18 | let span = Span::styled(content, style); 19 | let path = Line::from(span); 20 | 21 | let layout = Layout::default() 22 | .direction(Direction::Horizontal) 23 | .constraints([ 24 | Constraint::Length(path.width() as u16), 25 | Constraint::Length(3), 26 | Constraint::Min(changes.width() as u16), 27 | Constraint::Length(position.width() as u16), 28 | ]) 29 | .split(rect); 30 | 31 | frame.render_widget( 32 | Block::default().style(Style::default().bg(Color::Black)), 33 | rect, 34 | ); 35 | 36 | frame.render_widget(Paragraph::new(path), layout[0]); 37 | frame.render_widget(Paragraph::new(changes), layout[2]); 38 | frame.render_widget(Paragraph::new(position), layout[3]); 39 | } 40 | 41 | fn get_position_content(model: &Model) -> Line { 42 | let count = model.files.current.buffer.lines.len(); 43 | let current_position = model 44 | .files 45 | .current_cursor 46 | .as_ref() 47 | .map(|crsr| crsr.vertical_index + 1); 48 | 49 | let mut content = Vec::new(); 50 | if let Some(mut position) = current_position { 51 | if count == 0 { 52 | position = 0; 53 | } 54 | 55 | content.push(Span::styled( 56 | format!("{}/", position), 57 | Style::default().fg(Color::Gray), 58 | )); 59 | } 60 | 61 | content.push(Span::styled( 62 | format!("{}", count), 63 | Style::default().fg(Color::Gray), 64 | )); 65 | 66 | Line::from(content) 67 | } 68 | 69 | fn get_changes_content(model: &Model) -> Line { 70 | let modifications = model.files.current.buffer.undo.get_uncommited_changes(); 71 | let changes = undo::consolidate_modifications(&modifications); 72 | 73 | let (mut added, mut changed, mut removed) = (0, 0, 0); 74 | for change in changes { 75 | match change { 76 | BufferChanged::Content(_, _, _) => changed += 1, 77 | BufferChanged::LineAdded(_, _) => added += 1, 78 | BufferChanged::LineRemoved(_, _) => removed += 1, 79 | } 80 | } 81 | 82 | let mut content = Vec::new(); 83 | if added > 0 { 84 | content.push(Span::styled( 85 | format!("+{} ", added), 86 | Style::default().fg(Color::Green), 87 | )); 88 | } 89 | 90 | if changed > 0 { 91 | content.push(Span::styled( 92 | format!("~{} ", changed), 93 | Style::default().fg(Color::Yellow), 94 | )); 95 | } 96 | 97 | if removed > 0 { 98 | content.push(Span::styled( 99 | format!("-{} ", removed), 100 | Style::default().fg(Color::Red), 101 | )); 102 | } 103 | 104 | Line::from(content) 105 | } 106 | -------------------------------------------------------------------------------- /yeet-keymap/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "yeet-keymap" 3 | 4 | authors.workspace = true 5 | edition.workspace = true 6 | repository.workspace = true 7 | version.workspace = true 8 | 9 | [lints] 10 | workspace = true 11 | 12 | [dependencies] 13 | yeet-buffer = { path = "../yeet-buffer" } 14 | 15 | crossterm.workspace = true 16 | dirs.workspace = true 17 | regex.workspace = true 18 | thiserror.workspace = true 19 | tracing.workspace = true 20 | -------------------------------------------------------------------------------- /yeet-keymap/src/buffer.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use crate::key::{Key, KeyCode}; 4 | 5 | #[derive(Default)] 6 | pub struct KeyBuffer { 7 | buffer: Vec, 8 | } 9 | 10 | impl KeyBuffer { 11 | pub fn add_key(&mut self, key: Key) { 12 | if key.code == KeyCode::Esc && !self.buffer.is_empty() { 13 | self.buffer.clear(); 14 | return; 15 | } 16 | 17 | self.buffer.push(key); 18 | } 19 | 20 | pub fn get_keys(&self) -> Vec { 21 | self.buffer.to_vec() 22 | } 23 | 24 | pub fn clear(&mut self) { 25 | self.buffer.clear(); 26 | } 27 | 28 | pub fn to_keycode_string(&self) -> String { 29 | let mut result = String::new(); 30 | for key in &self.buffer { 31 | result.push_str(&key.to_keycode_string()); 32 | } 33 | 34 | result 35 | } 36 | } 37 | 38 | impl Display for KeyBuffer { 39 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 40 | let mut result = String::new(); 41 | for key in &self.buffer { 42 | result.push_str(&key.to_string()); 43 | } 44 | 45 | write!(f, "{}", result) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /yeet-keymap/src/conversion.rs: -------------------------------------------------------------------------------- 1 | use std::collections::VecDeque; 2 | 3 | use crossterm::event::{self, KeyEvent, KeyEventKind}; 4 | 5 | use crate::key::{Key, KeyCode, KeyModifier}; 6 | 7 | pub fn from_keycode_string(keycodes: &str) -> VecDeque { 8 | let mut keys = VecDeque::new(); 9 | 10 | let regex = regex::Regex::new(r"<[^>]*>|.").expect("Failed to compile regex"); 11 | for capture in regex.find_iter(keycodes).map(|m| m.as_str()) { 12 | if let Some(key) = Key::from_keycode_string(capture) { 13 | keys.push_back(key); 14 | } 15 | } 16 | 17 | keys 18 | } 19 | 20 | pub fn to_key(event: &KeyEvent) -> Option { 21 | let modifier = event 22 | .modifiers 23 | .iter_names() 24 | .flat_map(|(s, _)| to_modifier(s)) 25 | .collect(); 26 | 27 | match event.code { 28 | event::KeyCode::Backspace => resolve(event.kind, KeyCode::Backspace, modifier), 29 | event::KeyCode::Enter => resolve(event.kind, KeyCode::Enter, modifier), 30 | event::KeyCode::Left => resolve(event.kind, KeyCode::Left, modifier), 31 | event::KeyCode::Right => resolve(event.kind, KeyCode::Right, modifier), 32 | event::KeyCode::Up => resolve(event.kind, KeyCode::Up, modifier), 33 | event::KeyCode::Down => resolve(event.kind, KeyCode::Down, modifier), 34 | // event::KeyCode::Home => resolve(event.kind, KeyCode::), 35 | // event::KeyCode::End => resolve(event.kind, KeyCode::), 36 | // event::KeyCode::PageUp => resolve(event.kind, KeyCode::), 37 | // event::KeyCode::PageDown => resolve(event.kind, KeyCode::), 38 | event::KeyCode::Tab => resolve(event.kind, KeyCode::Tab, modifier), 39 | // event::KeyCode::BackTab => resolve(event.kind, KeyCode::), 40 | event::KeyCode::Delete => resolve(event.kind, KeyCode::Delete, modifier), 41 | // event::KeyCode::Insert => resolve(event.kind, KeyCode::), 42 | // event::KeyCode::F(_) => resolve(event.kind, KeyCode::), 43 | event::KeyCode::Char(c) => resolve(event.kind, KeyCode::from_char(c), modifier), 44 | // event::KeyCode::Null => resolve(event.kind, KeyCode::), 45 | event::KeyCode::Esc => resolve(event.kind, KeyCode::Esc, modifier), 46 | // event::KeyCode::CapsLock => todo!(), 47 | // event::KeyCode::ScrollLock => None, 48 | // event::KeyCode::NumLock => resolve(event.kind, KeyCode::), 49 | // event::KeyCode::PrintScreen => resolve(event.kind, KeyCode::), 50 | // event::KeyCode::Pause => resolve(event.kind, KeyCode::), 51 | // event::KeyCode::Menu => resolve(event.kind, KeyCode::), 52 | // event::KeyCode::KeypadBegin => None, 53 | // event::KeyCode::Media(_) => resolve(event.kind, KeyCode::), 54 | _ => None, 55 | } 56 | } 57 | 58 | fn resolve(kind: KeyEventKind, code: KeyCode, modifier: Vec) -> Option { 59 | if kind != KeyEventKind::Press { 60 | return None; 61 | } 62 | 63 | Some(Key::new(code, modifier)) 64 | } 65 | 66 | fn to_modifier(modifier: &str) -> Option { 67 | match modifier { 68 | "ALT" => Some(KeyModifier::Alt), 69 | "CONTROL" => Some(KeyModifier::Ctrl), 70 | "HYPER" => Some(KeyModifier::Command), 71 | "META" => Some(KeyModifier::Alt), 72 | "SHIFT" => Some(KeyModifier::Shift), 73 | "SUPER" => Some(KeyModifier::Command), 74 | _ => None, 75 | } 76 | } 77 | 78 | #[cfg(test)] 79 | mod tests { 80 | use super::*; 81 | use std::collections::VecDeque; 82 | 83 | #[test] 84 | fn from_keycode_string_empty() { 85 | let keycodes = ""; 86 | let expected: VecDeque = VecDeque::new(); 87 | assert_eq!(from_keycode_string(keycodes), expected); 88 | } 89 | 90 | #[test] 91 | fn from_keycode_string_single() { 92 | let keycodes = ""; 93 | let mut expected: VecDeque = VecDeque::new(); 94 | expected.push_back(Key { 95 | code: KeyCode::Esc, 96 | modifiers: Vec::new(), 97 | }); 98 | assert_eq!(from_keycode_string(keycodes), expected); 99 | } 100 | 101 | #[test] 102 | fn from_keycode_string_with_dash() { 103 | let keycodes = "f-"; 104 | let mut expected: VecDeque = VecDeque::new(); 105 | expected.push_back(Key { 106 | code: KeyCode::from_char('f'), 107 | modifiers: Vec::new(), 108 | }); 109 | expected.push_back(Key { 110 | code: KeyCode::from_char('-'), 111 | modifiers: Vec::new(), 112 | }); 113 | assert_eq!(from_keycode_string(keycodes), expected); 114 | } 115 | 116 | #[test] 117 | fn from_keycode_string_multiple() { 118 | let keycodes = "aA"; 119 | let mut expected: VecDeque = VecDeque::new(); 120 | expected.push_back(Key { 121 | code: KeyCode::Esc, 122 | modifiers: Vec::new(), 123 | }); 124 | expected.push_back(Key { 125 | code: KeyCode::from_char('a'), 126 | modifiers: Vec::new(), 127 | }); 128 | expected.push_back(Key { 129 | code: KeyCode::from_char('w'), 130 | modifiers: vec![KeyModifier::Ctrl], 131 | }); 132 | expected.push_back(Key { 133 | code: KeyCode::from_char('a'), 134 | modifiers: vec![KeyModifier::Shift], 135 | }); 136 | expected.push_back(Key { 137 | code: KeyCode::from_char('e'), 138 | modifiers: vec![KeyModifier::Ctrl], 139 | }); 140 | assert_eq!(from_keycode_string(keycodes), expected); 141 | } 142 | 143 | #[test] 144 | fn from_keycode_string_invalid() { 145 | let keycodes = ""; 146 | assert!(from_keycode_string(keycodes).is_empty()); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /yeet-keymap/src/message.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use regex::Regex; 4 | use yeet_buffer::{ 5 | message::{BufferMessage, CursorDirection, TextModification}, 6 | model::Mode, 7 | }; 8 | 9 | #[derive(Clone, Debug, PartialEq)] 10 | pub struct Binding { 11 | pub expects: Option, 12 | pub force: Option, 13 | pub kind: BindingKind, 14 | pub repeat: Option, 15 | pub repeatable: bool, 16 | pub toggle: Option<(String, BindingKind)>, 17 | } 18 | 19 | impl Default for Binding { 20 | fn default() -> Self { 21 | Self { 22 | expects: None, 23 | force: None, 24 | kind: BindingKind::default(), 25 | repeat: None, 26 | repeatable: true, 27 | toggle: None, 28 | } 29 | } 30 | } 31 | 32 | impl Binding { 33 | pub fn from_motion(motion: CursorDirection) -> Self { 34 | Self { 35 | kind: BindingKind::Motion(motion), 36 | ..Default::default() 37 | } 38 | } 39 | } 40 | 41 | #[derive(Clone, Debug)] 42 | pub enum NextBindingKind { 43 | Motion, 44 | Raw(Option), 45 | } 46 | 47 | impl PartialEq for NextBindingKind { 48 | fn eq(&self, other: &Self) -> bool { 49 | match (self, other) { 50 | (Self::Motion, Self::Motion) => true, 51 | (Self::Raw(self_reg), Self::Raw(reg)) => match (self_reg, reg) { 52 | (Some(self_reg), Some(reg)) => self_reg.as_str() == reg.as_str(), 53 | (None, None) => true, 54 | _ => false, 55 | }, 56 | _ => false, 57 | } 58 | } 59 | } 60 | 61 | #[derive(Clone, Debug, Default, PartialEq)] 62 | pub enum BindingKind { 63 | Message(KeymapMessage), 64 | Motion(CursorDirection), 65 | #[default] 66 | None, 67 | Raw(char), 68 | Repeat, 69 | RepeatOrMotion(CursorDirection), 70 | Modification(TextModification), 71 | } 72 | 73 | #[derive(Clone, Debug, Eq, PartialEq)] 74 | pub enum KeySequence { 75 | Completed(String), 76 | Changed(String), 77 | None, 78 | } 79 | impl KeySequence { 80 | pub fn len_or_default(&self, default: usize) -> u16 { 81 | match self { 82 | KeySequence::Completed(_) => 0, 83 | KeySequence::Changed(sequence) => sequence.chars().count() as u16, 84 | KeySequence::None => default as u16, 85 | } 86 | } 87 | } 88 | 89 | #[derive(Clone, Debug, Eq, PartialEq)] 90 | pub enum KeymapMessage { 91 | Buffer(BufferMessage), 92 | ClearSearchHighlight, 93 | DeleteMarks(Vec), 94 | ExecuteCommand, 95 | ExecuteCommandString(String), 96 | ExecuteKeySequence(String), 97 | ExecuteRegister(char), 98 | LeaveCommandMode, 99 | NavigateToMark(char), 100 | NavigateToParent, 101 | NavigateToPath(PathBuf), 102 | NavigateToPathAsPreview(PathBuf), 103 | NavigateToSelected, 104 | OpenSelected, 105 | PasteFromJunkYard(char), 106 | Print(Vec), 107 | ReplayMacro(char), 108 | SetMark(char), 109 | StartMacro(char), 110 | StopMacro, 111 | ToggleQuickFix, 112 | Quit(QuitMode), 113 | YankPathToClipboard, 114 | // TODO: yank to junk with motion 115 | YankToJunkYard(usize), 116 | } 117 | 118 | #[derive(Clone, Debug, Eq, PartialEq)] 119 | pub enum QuitMode { 120 | FailOnRunningTasks, 121 | Force, 122 | } 123 | 124 | #[derive(Clone, Debug, Eq, PartialEq)] 125 | pub enum PrintContent { 126 | Error(String), 127 | Default(String), 128 | Information(String), 129 | } 130 | -------------------------------------------------------------------------------- /yeet-keymap/src/tree.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, iter::Enumerate, slice::Iter}; 2 | 3 | use yeet_buffer::model::Mode; 4 | 5 | use crate::{key::Key, message::Binding, KeyMapError}; 6 | 7 | #[derive(Default)] 8 | pub struct KeyTree { 9 | modes: HashMap, 10 | } 11 | 12 | #[derive(Clone)] 13 | pub enum Node { 14 | Binding(Binding), 15 | ExpectsOr(Binding, HashMap), 16 | Key(HashMap), 17 | } 18 | 19 | impl KeyTree { 20 | pub fn add_mapping( 21 | &mut self, 22 | mode: &Mode, 23 | keys: Vec, 24 | binding: Binding, 25 | ) -> Result<(), KeyMapError> { 26 | if !self.modes.contains_key(mode) { 27 | self.modes.insert(mode.clone(), Node::Key(HashMap::new())); 28 | } 29 | 30 | let max_index = keys.len() - 1; 31 | let mut iter = keys.iter().enumerate(); 32 | match self.modes.get_mut(mode) { 33 | Some(node) => { 34 | add_mapping_node(&max_index, &mut iter, node, binding); 35 | Ok(()) 36 | } 37 | None => Err(KeyMapError::ModeUnresolvable(mode.to_string())), 38 | } 39 | } 40 | 41 | pub fn get_binding( 42 | &self, 43 | mode: &Mode, 44 | keys: &[Key], 45 | ) -> Result<(Binding, Vec), KeyMapError> { 46 | if let Some(node) = self.modes.get(mode) { 47 | let mut iter = keys.iter(); 48 | let node = get_bindings_from_node(node, &mut iter)?; 49 | match node { 50 | Node::Binding(binding) | Node::ExpectsOr(binding, _) => { 51 | Ok((binding, iter.cloned().collect())) 52 | } 53 | Node::Key(_) => Err(KeyMapError::KeySequenceIncomplete), 54 | } 55 | } else { 56 | Err(KeyMapError::ModeUnresolvable(mode.to_string())) 57 | } 58 | } 59 | } 60 | 61 | fn add_mapping_node( 62 | max_index: &usize, 63 | iter: &mut Enumerate>, 64 | node: &mut Node, 65 | binding: Binding, 66 | ) { 67 | if let Some((index, key)) = iter.next() { 68 | if &index == max_index { 69 | match node { 70 | Node::Binding(_) => unreachable!(), 71 | Node::ExpectsOr(_, map) | Node::Key(map) => { 72 | if binding.expects.is_some() { 73 | if let Some(node) = map.remove(key) { 74 | match node { 75 | Node::Binding(_) | Node::ExpectsOr(_, _) => unreachable!(), 76 | Node::Key(m) => { 77 | map.insert(key.clone(), Node::ExpectsOr(binding, m.clone())); 78 | } 79 | } 80 | } else { 81 | map.insert(key.clone(), Node::ExpectsOr(binding, HashMap::new())); 82 | } 83 | } else if map.insert(key.clone(), Node::Binding(binding)).is_some() { 84 | panic!("This should not happen"); 85 | } 86 | } 87 | } 88 | } else { 89 | match node { 90 | Node::Binding(_) => unreachable!(), 91 | Node::ExpectsOr(_, map) | Node::Key(map) => { 92 | if !map.contains_key(key) { 93 | map.insert(key.clone(), Node::Key(HashMap::new())); 94 | } 95 | let node = map.get_mut(key).expect("Must exist"); 96 | add_mapping_node(max_index, iter, node, binding); 97 | } 98 | } 99 | } 100 | } 101 | } 102 | 103 | fn get_bindings_from_node(node: &Node, iter: &mut Iter<'_, Key>) -> Result { 104 | match node { 105 | Node::Binding(_) => Ok(node.clone()), 106 | Node::ExpectsOr(_, map) | Node::Key(map) => { 107 | let mut peak_iter = iter.clone(); 108 | let key = match peak_iter.next() { 109 | Some(it) => it, 110 | None => { 111 | return Ok(node.clone()); 112 | } 113 | }; 114 | 115 | if let Some(node) = map.get(key) { 116 | let _ = iter.next(); 117 | get_bindings_from_node(node, iter) 118 | } else { 119 | match node { 120 | Node::ExpectsOr(_, _) => Ok(node.clone()), 121 | Node::Key(_) => Err(KeyMapError::NoValidBindingFound), 122 | Node::Binding(_) => unreachable!(), 123 | } 124 | } 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /yeet.json: -------------------------------------------------------------------------------- 1 | { 2 | "url": "https://github.com/aserowy/yeet/releases/download/v2024.5.23/yeet-x86_64-pc-windows-msvc.zip", 3 | "version": "v2025.5.23", 4 | "extract_dir": "yeet-x86_64-pc-windows-msvc", 5 | "bin": "yeet.exe", 6 | "license": "MIT", 7 | "checkver": "github" 8 | } 9 | -------------------------------------------------------------------------------- /yeet/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "yeet" 3 | 4 | authors.workspace = true 5 | edition.workspace = true 6 | repository.workspace = true 7 | version.workspace = true 8 | 9 | [lints] 10 | workspace = true 11 | 12 | [dependencies] 13 | yeet-frontend = { path = "../yeet-frontend" } 14 | 15 | clap.workspace = true 16 | dirs.workspace = true 17 | thiserror.workspace = true 18 | tokio.workspace = true 19 | tracing.workspace = true 20 | tracing-appender.workspace = true 21 | tracing-subscriber.workspace = true 22 | -------------------------------------------------------------------------------- /yeet/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use clap::{value_parser, Arg, ArgAction, ArgMatches, Command}; 4 | use thiserror::Error; 5 | use tracing::Level; 6 | use yeet_frontend::settings::Settings; 7 | 8 | #[derive(Debug, Error)] 9 | pub enum Error { 10 | #[error("Initialization error")] 11 | Initialization, 12 | } 13 | 14 | #[tokio::main] 15 | async fn main() { 16 | let cli = cli().get_matches(); 17 | 18 | if let Ok(logpath) = get_logging_path() { 19 | let loglevel = get_log_level(&cli); 20 | let logfile = tracing_appender::rolling::daily(logpath, "log"); 21 | tracing_subscriber::fmt() 22 | .pretty() 23 | .with_max_level(loglevel) 24 | .with_file(false) 25 | .with_writer(logfile) 26 | .init(); 27 | } 28 | 29 | tracing::info!("starting application"); 30 | 31 | let default_panic = std::panic::take_hook(); 32 | std::panic::set_hook(Box::new(move |info| { 33 | tracing::error!("yeet paniced: {:?}", info); 34 | default_panic(info); 35 | std::process::exit(1); 36 | })); 37 | 38 | match yeet_frontend::run(get_settings(&cli)).await { 39 | Ok(()) => { 40 | tracing::info!("closing application"); 41 | } 42 | Err(err) => { 43 | tracing::error!("closing application with error: {:?}", err); 44 | } 45 | } 46 | } 47 | 48 | fn cli() -> Command { 49 | Command::new("yeet") 50 | .about("yeet - yet another... read the name on gh...") 51 | .args([ 52 | // NOTE: arguments 53 | Arg::new("path") 54 | .action(ArgAction::Set) 55 | .value_parser(value_parser!(PathBuf)) 56 | .help("path to open in yeet on startup"), 57 | // NOTE: options 58 | Arg::new("selection-to-file-on-open") 59 | .long("selection-to-file-on-open") 60 | .action(ArgAction::Set) 61 | .value_parser(value_parser!(PathBuf)) 62 | .help("on open write selected paths to the given file path instead and close the application"), 63 | Arg::new("selection-to-stdout-on-open") 64 | .long("selection-to-stdout-on-open") 65 | .action(ArgAction::SetTrue) 66 | .default_value("false") 67 | .help("on open print selected paths to stdout instead and close the application"), 68 | Arg::new("verbosity") 69 | .short('v') 70 | .long("verbosity") 71 | .default_value("warn") 72 | .value_parser(["error", "warn", "info", "debug", "trace"]) 73 | .help("set verbosity level for file logging"), 74 | ]) 75 | } 76 | 77 | fn get_log_level(args: &ArgMatches) -> Level { 78 | match args 79 | .get_one::("verbosity") 80 | .expect("default for verbosity set") 81 | .as_str() 82 | { 83 | "error" => Level::ERROR, 84 | "warn" => Level::WARN, 85 | "info" => Level::INFO, 86 | "debug" => Level::DEBUG, 87 | "trace" => Level::TRACE, 88 | _ => Level::WARN, 89 | } 90 | } 91 | 92 | fn get_logging_path() -> Result { 93 | let cache_dir = match dirs::cache_dir() { 94 | Some(cache_dir) => match cache_dir.to_str() { 95 | Some(cache_dir_string) => cache_dir_string.to_string(), 96 | None => return Err(Error::Initialization), 97 | }, 98 | None => return Err(Error::Initialization), 99 | }; 100 | 101 | Ok(format!("{}{}", cache_dir, "/yeet/logs")) 102 | } 103 | 104 | fn get_settings(args: &ArgMatches) -> Settings { 105 | Settings { 106 | selection_to_file_on_open: args.get_one("selection-to-file-on-open").cloned(), 107 | selection_to_stdout_on_open: args.get_flag("selection-to-stdout-on-open"), 108 | startup_path: args.get_one("path").cloned(), 109 | ..Default::default() 110 | } 111 | } 112 | --------------------------------------------------------------------------------