├── .config └── config.json5 ├── .envrc ├── .github ├── release.yml └── workflows │ ├── bump-version.yml │ ├── ci.yml │ └── publish.yml ├── .gitignore ├── .rustfmt.toml ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── build.rs ├── rust-toolchain.toml └── src ├── action.rs ├── app.rs ├── astgrep.rs ├── cli.rs ├── components.rs ├── components ├── confirm_empty_replace_dialog.rs ├── confirm_git_dir_dialog.rs ├── help_dialog.rs ├── notifications.rs ├── preview.rs ├── replace.rs ├── search.rs ├── search_result.rs ├── small_help.rs └── status.rs ├── config.rs ├── layout.rs ├── macros.rs ├── main.rs ├── mode.rs ├── redux.rs ├── redux ├── action.rs ├── reducer.rs ├── state.rs ├── thunk.rs ├── thunk │ ├── process_line_replace.rs │ ├── process_replace.rs │ ├── process_search.rs │ ├── process_single_file_replace.rs │ ├── remove_file_from_list.rs │ └── remove_line_from_file.rs └── utils.rs ├── ripgrep.rs ├── tabs.rs ├── tui.rs ├── ui.rs ├── ui ├── confirm_dialog_widget.rs ├── divider.rs ├── help_display_dialog.rs ├── notification_box.rs └── small_help_widget.rs └── utils.rs /.config/config.json5: -------------------------------------------------------------------------------- 1 | { 2 | "keybindings": { 3 | "": "Quit", 4 | "": "Quit", 5 | "": "Suspend", 6 | "": "Refresh", 7 | "": "LoopOverTabs", 8 | "": "BackLoopOverTabs", 9 | "": "ProcessReplace", 10 | "": "ShowHelp", 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | export SERPL_CONFIG=`pwd`/.config 2 | export SERPL_DATA=`pwd`/.data 3 | export SERPL_LOG_LEVEL=debug 4 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - ignore-for-release 5 | authors: 6 | - octocat 7 | categories: 8 | - title: Breaking Changes 🛠 9 | labels: 10 | - Semver-Major 11 | - breaking-change 12 | - title: Exciting New Features 🎉 13 | labels: 14 | - Semver-Minor 15 | - enhancement 16 | - title: Other Changes 17 | labels: 18 | - "*" 19 | -------------------------------------------------------------------------------- /.github/workflows/bump-version.yml: -------------------------------------------------------------------------------- 1 | name: Bump Version 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | test: 10 | name: Test Suite 11 | runs-on: ubuntu-latest 12 | if: "!contains(github.event.head_commit.message, 'Bump version to ')" 13 | steps: 14 | - name: Checkout repository 15 | uses: actions/checkout@v4 16 | - name: Install Rust toolchain 17 | uses: dtolnay/rust-toolchain@nightly 18 | - uses: Swatinem/rust-cache@v2 19 | - name: Run tests 20 | run: cargo test --all-features --workspace 21 | 22 | # clippy: 23 | # name: Clippy 24 | # runs-on: ubuntu-latest 25 | # if: "!contains(github.event.head_commit.message, 'Bump version to ')" 26 | # steps: 27 | # - name: Checkout repository 28 | # uses: actions/checkout@v4 29 | # - name: Install Rust toolchain 30 | # uses: dtolnay/rust-toolchain@nightly 31 | # with: 32 | # components: clippy 33 | # - uses: Swatinem/rust-cache@v2 34 | # - name: Clippy check 35 | # run: cargo clippy --all-targets --all-features --workspace -- -D warnings 36 | 37 | docs: 38 | name: Docs 39 | runs-on: ubuntu-latest 40 | if: "!contains(github.event.head_commit.message, 'Bump version to ')" 41 | steps: 42 | - name: Checkout repository 43 | uses: actions/checkout@v4 44 | - name: Install Rust toolchain 45 | uses: dtolnay/rust-toolchain@nightly 46 | - uses: Swatinem/rust-cache@v2 47 | - name: Check documentation 48 | env: 49 | RUSTDOCFLAGS: -D warnings 50 | run: cargo doc --no-deps --document-private-items --all-features --workspace --examples 51 | 52 | bump-version: 53 | name: Bump Version 54 | runs-on: ubuntu-latest 55 | needs: [test, docs] 56 | if: "!contains(github.event.head_commit.message, 'Bump version to ')" 57 | steps: 58 | - name: Checkout repository 59 | uses: actions/checkout@v4 60 | with: 61 | token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 62 | 63 | - name: Setup Git 64 | run: | 65 | git config --global user.name "github-actions" 66 | git config --global user.email "github-actions@github.com" 67 | 68 | - name: Bump Version 69 | id: bump_version 70 | run: | 71 | CURRENT_VERSION=$(grep -E '^version = "[0-9]+\.[0-9]+\.[0-9]+"' Cargo.toml | sed -E 's/version = "(.*)"/\1/') 72 | NEW_VERSION=$(echo $CURRENT_VERSION | awk -F. '{print $1"."$2"."$3+1}') 73 | sed -i "s/version = \"$CURRENT_VERSION\"/version = \"$NEW_VERSION\"/" Cargo.toml 74 | echo "Bumped version from $CURRENT_VERSION to $NEW_VERSION" 75 | echo "::set-output name=new_version::$NEW_VERSION" 76 | 77 | - name: Update Cargo.lock 78 | run: cargo update 79 | 80 | # - name: Format Code 81 | # run: cargo fmt --all 82 | 83 | - name: Commit and Tag 84 | run: | 85 | NEW_VERSION=${{ steps.bump_version.outputs.new_version }} 86 | git add . 87 | git commit -m "Bump version to $NEW_VERSION" 88 | git tag -a "$NEW_VERSION" -m "Release version $NEW_VERSION" 89 | git push origin main --tags 90 | env: 91 | GITHUB_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 92 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | 8 | test: 9 | name: Test Suite 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout repository 13 | uses: actions/checkout@v4 14 | - name: Install Rust toolchain 15 | uses: dtolnay/rust-toolchain@nightly 16 | - uses: Swatinem/rust-cache@v2 17 | - name: Run tests 18 | run: cargo test --all-features --workspace 19 | 20 | rustfmt: 21 | name: Rustfmt 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Checkout repository 25 | uses: actions/checkout@v4 26 | - name: Install Rust toolchain 27 | uses: dtolnay/rust-toolchain@nightly 28 | with: 29 | components: rustfmt 30 | - uses: Swatinem/rust-cache@v2 31 | - name: Check formatting 32 | run: cargo fmt --all --check 33 | 34 | # clippy: 35 | # name: Clippy 36 | # runs-on: ubuntu-latest 37 | # steps: 38 | # - name: Checkout repository 39 | # uses: actions/checkout@v4 40 | # - name: Install Rust toolchain 41 | # uses: dtolnay/rust-toolchain@nightly 42 | # with: 43 | # components: clippy 44 | # - uses: Swatinem/rust-cache@v2 45 | # - name: Clippy check 46 | # run: cargo clippy --all-targets --all-features --workspace -- -D warnings 47 | # 48 | docs: 49 | name: Docs 50 | runs-on: ubuntu-latest 51 | steps: 52 | - name: Checkout repository 53 | uses: actions/checkout@v4 54 | - name: Install Rust toolchain 55 | uses: dtolnay/rust-toolchain@nightly 56 | - uses: Swatinem/rust-cache@v2 57 | - name: Check documentation 58 | env: 59 | RUSTDOCFLAGS: -D warnings 60 | run: cargo doc --no-deps --document-private-items --all-features --workspace --examples 61 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - '[v]?[0-9]+.[0-9]+.[0-9]+' 7 | 8 | jobs: 9 | publish: 10 | 11 | name: Publishing for ${{ matrix.os }} 12 | runs-on: ${{ matrix.os }} 13 | 14 | strategy: 15 | matrix: 16 | include: 17 | - os: macos-latest 18 | os-name: macos 19 | target: x86_64-apple-darwin 20 | architecture: x86_64 21 | binary-postfix: "" 22 | binary-name: serpl 23 | use-cross: false 24 | - os: macos-latest 25 | os-name: macos 26 | target: aarch64-apple-darwin 27 | architecture: arm64 28 | binary-postfix: "" 29 | use-cross: false 30 | binary-name: serpl 31 | - os: ubuntu-latest 32 | os-name: linux 33 | target: x86_64-unknown-linux-gnu 34 | architecture: x86_64 35 | binary-postfix: "" 36 | use-cross: false 37 | binary-name: serpl 38 | - os: windows-latest 39 | os-name: windows 40 | target: x86_64-pc-windows-msvc 41 | architecture: x86_64 42 | binary-postfix: ".exe" 43 | use-cross: false 44 | binary-name: serpl 45 | - os: ubuntu-latest 46 | os-name: linux 47 | target: aarch64-unknown-linux-gnu 48 | architecture: arm64 49 | binary-postfix: "" 50 | use-cross: true 51 | binary-name: serpl 52 | - os: ubuntu-latest 53 | os-name: linux 54 | target: i686-unknown-linux-gnu 55 | architecture: i686 56 | binary-postfix: "" 57 | use-cross: true 58 | binary-name: serpl 59 | 60 | steps: 61 | - name: Checkout repository 62 | uses: actions/checkout@v4 63 | - name: Install Rust toolchain 64 | uses: actions-rs/toolchain@v1 65 | with: 66 | toolchain: stable 67 | 68 | target: ${{ matrix.target }} 69 | 70 | profile: minimal 71 | override: true 72 | - uses: Swatinem/rust-cache@v2 73 | - name: Cargo build 74 | uses: actions-rs/cargo@v1 75 | with: 76 | command: build 77 | 78 | use-cross: ${{ matrix.use-cross }} 79 | 80 | toolchain: stable 81 | 82 | args: --release --target ${{ matrix.target }} 83 | 84 | 85 | - name: install strip command 86 | shell: bash 87 | run: | 88 | 89 | if [[ ${{ matrix.target }} == aarch64-unknown-linux-gnu ]]; then 90 | 91 | sudo apt update 92 | sudo apt-get install -y binutils-aarch64-linux-gnu 93 | fi 94 | - name: Packaging final binary 95 | shell: bash 96 | run: | 97 | 98 | cd target/${{ matrix.target }}/release 99 | 100 | 101 | ####### reduce binary size by removing debug symbols ####### 102 | 103 | BINARY_NAME=${{ matrix.binary-name }}${{ matrix.binary-postfix }} 104 | if [[ ${{ matrix.target }} == aarch64-unknown-linux-gnu ]]; then 105 | 106 | GCC_PREFIX="aarch64-linux-gnu-" 107 | else 108 | GCC_PREFIX="" 109 | fi 110 | "$GCC_PREFIX"strip $BINARY_NAME 111 | 112 | ########## create tar.gz ########## 113 | 114 | RELEASE_NAME=${{ matrix.binary-name }}-${GITHUB_REF/refs\/tags\//}-${{ matrix.os-name }}-${{ matrix.architecture }} 115 | 116 | tar czvf $RELEASE_NAME.tar.gz $BINARY_NAME 117 | 118 | ########## create sha256 ########## 119 | 120 | if [[ ${{ runner.os }} == 'Windows' ]]; then 121 | 122 | certutil -hashfile $RELEASE_NAME.tar.gz sha256 | grep -E [A-Fa-f0-9]{64} > $RELEASE_NAME.sha256 123 | else 124 | shasum -a 256 $RELEASE_NAME.tar.gz > $RELEASE_NAME.sha256 125 | fi 126 | - name: Releasing assets 127 | uses: softprops/action-gh-release@v1 128 | with: 129 | files: | 130 | 131 | target/${{ matrix.target }}/release/${{ matrix.binary-name }}-*.tar.gz 132 | target/${{ matrix.target }}/release/${{ matrix.binary-name }}-*.sha256 133 | 134 | env: 135 | 136 | GITHUB_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 137 | 138 | 139 | publish-cargo: 140 | name: Publishing to Cargo 141 | runs-on: ubuntu-latest 142 | steps: 143 | - name: Checkout repository 144 | uses: actions/checkout@v4 145 | - name: Install Rust toolchain 146 | uses: dtolnay/rust-toolchain@stable 147 | - uses: Swatinem/rust-cache@v2 148 | - run: cargo publish 149 | env: 150 | CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} 151 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .data/*.log 3 | serpl.log 4 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 120 2 | use_small_heuristics = "Max" 3 | empty_item_single_line = false 4 | force_multiline_blocks = true 5 | format_code_in_doc_comments = true 6 | match_block_trailing_comma = true 7 | imports_granularity = "Crate" 8 | normalize_comments = true 9 | normalize_doc_attributes = true 10 | overflow_delimited_expr = true 11 | reorder_impl_items = true 12 | reorder_imports = true 13 | group_imports = "StdExternalCrate" 14 | tab_spaces = 2 15 | use_field_init_shorthand = true 16 | use_try_shorthand = true 17 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "serpl" 3 | version = "0.3.4" 4 | edition = "2021" 5 | description = "A simple terminal UI for search and replace, ala VS Code" 6 | repository = "https://github.com/yassinebridi/serpl" 7 | authors = ["Yassine Bridi "] 8 | build = "build.rs" 9 | license = "MIT" 10 | 11 | [features] 12 | ast_grep = [] 13 | 14 | [dependencies] 15 | better-panic = "0.3.0" 16 | clap = { version = "4.5.7", features = [ 17 | "derive", 18 | "cargo", 19 | "wrap_help", 20 | "unicode", 21 | "string", 22 | "unstable-styles", 23 | ] } 24 | color-eyre = "0.6.3" 25 | config = "0.14.0" 26 | crossterm = { version = "0.28.1", features = ["serde", "event-stream"] } 27 | derive_deref = "1.1.1" 28 | etcetera = "0.8.0" 29 | futures = "0.3.30" 30 | human-panic = "1.2.3" 31 | json5 = "0.4.1" 32 | lazy_static = "1.4.0" 33 | libc = "0.2.155" 34 | log = "0.4.21" 35 | pretty_assertions = "1.4.0" 36 | ratatui = { version = "0.29.0", features = ["serde", "macros"] } 37 | serde = { version = "1.0.203", features = ["derive"] } 38 | serde_json = "1.0.117" 39 | signal-hook = "0.3.17" 40 | strip-ansi-escapes = "0.2.0" 41 | strum = { version = "0.26.2", features = ["derive"] } 42 | tokio = { version = "1.38.0", features = ["full"] } 43 | tokio-util = "0.7.11" 44 | tracing = "0.1.40" 45 | tracing-error = "0.2.0" 46 | tracing-subscriber = { version = "0.3.18", features = ["env-filter", "serde"] } 47 | tui-input = "^0.11.1" 48 | redux-rs = { version = "=0.3.3", features = ["middleware_thunk"] } 49 | simple-logging = "2.0.2" 50 | regex = "1.10.5" 51 | async-trait = "0.1.80" 52 | anyhow = "1.0.86" 53 | 54 | [target.'cfg(target_os = "macos")'.dependencies] 55 | crossterm = { version = "0.28.1", features = [ 56 | "serde", 57 | "event-stream", 58 | "use-dev-tty", 59 | "libc", 60 | ] } 61 | 62 | [build-dependencies] 63 | vergen = { version = "8.3.1", features = ["build", "git", "gitoxide", "cargo"] } 64 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Yassine Bridi 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Serpl 2 | 3 | `serpl` is a terminal user interface (TUI) application that allows users to search and replace keywords in an entire folder, similar to the functionality available in VS Code. 4 | 5 | https://github.com/yassinebridi/serpl/assets/18403595/348506704-73336074-bfaf-4a9a-849c-bd4aa4e24afc 6 | 7 | ## Table of Contents 8 | 9 | 1. [Features](#features) 10 | 2. [Installation](#installation-and-update) 11 | - [Prerequisites](#prerequisites) 12 | - [Steps](#steps) 13 | - [Binaries](#binaries) 14 | - [OS Specific Installation](#os-specific-installation) 15 | 3. [Usage](#usage) 16 | - [Basic Commands](#basic-commands) 17 | - [Key Bindings](#key-bindings) 18 | - [Configuration](#configuration) 19 | 4. [Panes](#panes) 20 | - [Search Input](#search-input) 21 | - [Replace Input](#replace-input) 22 | - [Search Results Pane](#search-results-pane) 23 | - [Preview Pane](#preview-pane) 24 | 5. [Quick Hints](#quick-hints) 25 | 6. [Neovim Integration using toggleterm](#neovim-integration-using-toggleterm) 26 | 7. [Helix Integration](#helix-integration) 27 | 8. [License](#license) 28 | 9. [Contributing](#contributing) 29 | 10. [Acknowledgements](#acknowledgements) 30 | 11. [Similar Projects](#similar-projects) 31 | 32 | ## Features 33 | 34 | - Search for keywords across an entire project folder, with options for case sensitivity, AST Grep and more. 35 | - Replace keywords with options for preserving case, AST Grep and more. 36 | - Interactive preview of search results. 37 | - Keyboard navigation for efficient workflow. 38 | - Configurable key bindings and search modes. 39 | 40 | ## Installation and Update 41 | 42 | ### Prerequisites 43 | 44 | - [ripgrep](https://github.com/BurntSushi/ripgrep?tab=readme-ov-file#installation) installed on your system. 45 | - (Optional) [ast-grep](https://ast-grep.github.io) installed on your system, if you want to use the AST Grep functionality. 46 | 47 | ### Steps 48 | 49 | 1. Install the application using Cargo: 50 | ```bash 51 | cargo install serpl 52 | ``` 53 | - If you want to install the application with the AST Grep functionality, you can use the following command: 54 | ```bash 55 | cargo install serpl --features ast_grep 56 | ``` 57 | 2. Run the application: 58 | ```bash 59 | serpl 60 | ``` 61 | 62 | ### Binaries 63 | Check the [releases](https://github.com/yassinebridi/serpl/releases) page for the latest binaries. 64 | 65 | ### OS Specific Installation 66 | 67 | #### Brew 68 | 69 | `serpl` can be installed using [Homebrew](https://brew.sh/): 70 | 71 | ```bash 72 | brew install serpl 73 | ``` 74 | 75 | #### Arch Linux 76 | 77 | `serpl` can be installed from the [official repositories](https://archlinux.org/packages/extra/x86_64/serpl/) using [`pacman`](https://wiki.archlinux.org/title/Pacman): 78 | 79 | ```bash 80 | pacman -S serpl 81 | ``` 82 | 83 | #### Nix/NixOS 84 | 85 | `serpl` is included in [nixpkgs](https://github.com/nixos/nixpkgs) since 24.11, and can be installed via Nix in different ways: 86 | 87 | **On standalone Nix setups**: 88 | 89 | ```bash 90 | nix profile install nixpkgs#serpl 91 | ``` 92 | 93 | **On NixOS** (via `configuration.nix` or similar): 94 | 95 | ```nix 96 | {pkgs, ...}: { 97 | environment.systemPackages = [pkgs.serpl]; 98 | } 99 | ``` 100 | 101 | **On Home-Manager**: 102 | 103 | ```nix 104 | {pkgs, ...}: { 105 | home.packages = [pkgs.serpl]; 106 | } 107 | ``` 108 | 109 | ## Usage 110 | 111 | ### Basic Commands 112 | 113 | - Start the application in the current directory: 114 | ```bash 115 | serpl 116 | ``` 117 | - Start the application and provide the project root path: 118 | ```bash 119 | serpl --project-root /path/to/project 120 | ``` 121 | 122 | ### Key Bindings 123 | 124 | Default key bindings can be customized through the `config.json` file. 125 | 126 | #### Default Key Bindings 127 | 128 | | Key Combination | Action | 129 | | ---------------------------- | ----------------------------------------- | 130 | | `Ctrl + c` | Quit | 131 | | `Ctrl + d` | Quit | 132 | | `Ctrl + b` | Help | 133 | | `Tab` | Switch between tabs | 134 | | `Backtab` | Switch to previous tabs | 135 | | `Ctrl + o` | Process replace for all files | 136 | | `r` | Process replace for selected file or line | 137 | | `Ctrl + n` | Toggle search and replace modes | 138 | | `Enter` | Execute search (for large folders) | 139 | | `g` / `Left` / `h` | Go to top of the list | 140 | | `G` / `Right` / `l` | Go to bottom of the list | 141 | | `j` / `Down` | Move to the next item | 142 | | `k` / `Up` | Move to the previous item | 143 | | `/` | Search results list | 144 | | `d` | Delete selected file or line | 145 | | `Esc` | Exit the current pane or dialog | 146 | | `Enter` (in dialogs) / `y` | Confirm action | 147 | | `Esc` (in dialogs) / `n` | Cancel action | 148 | | `h`, `l`, `Tab` (in dialogs) | Navigate dialog options | 149 | 150 | ### Configuration 151 | 152 | `serpl` uses a configuration file to manage key bindings and other settings. By default, the path to the configuration file can be found by running `serpl --version`. You can use various file formats for the configuration, such as JSON, JSON5, YAML, TOML, or INI. 153 | 154 | #### Example Configurations 155 | 156 |
157 | JSON 158 | 159 | ```json 160 | { 161 | "keybindings": { 162 | "": "Quit", 163 | "": "Quit", 164 | "": "LoopOverTabs", 165 | "": "BackLoopOverTabs", 166 | "": "ProcessReplace", 167 | "": "ShowHelp" 168 | } 169 | } 170 | ``` 171 |
172 |
173 | JSON5 174 | 175 | ```json5 176 | { 177 | keybindings: { 178 | "": "Quit", 179 | "": "Quit", 180 | "": "LoopOverTabs", 181 | "": "BackLoopOverTabs", 182 | "": "ProcessReplace", 183 | "": "ShowHelp", 184 | }, 185 | } 186 | ``` 187 |
188 |
189 | YAML 190 | 191 | ```yaml 192 | keybindings: 193 | "": "Quit" 194 | "": "Quit" 195 | "": "LoopOverTabs" 196 | "": "BackLoopOverTabs" 197 | "": "ProcessReplace" 198 | "": "ShowHelp" 199 | ``` 200 |
201 |
202 | TOML 203 | 204 | ```toml 205 | [keybindings] 206 | "" = "Quit" 207 | "" = "Quit" 208 | "" = "LoopOverTabs" 209 | "" = "BackLoopOverTabs" 210 | "" = "ProcessReplace" 211 | "" = "ShowHelp" 212 | ``` 213 |
214 |
215 | INI 216 | 217 | ```ini 218 | [keybindings] 219 | = Quit 220 | = Quit 221 | = LoopOverTabs 222 | = BackLoopOverTabs 223 | = ProcessReplace 224 | = ShowHelp 225 | ``` 226 |
227 | 228 | You can customize the key bindings by modifying the configuration file in the format of your choice. 229 | 230 | ## Panes 231 | 232 | ### Search Input 233 | 234 | - Input field for entering search keywords. 235 | - Toggle search modes (Simple, Match Case, Match Whole Word, Match Case Whole Word, Regex, AST Grep). 236 | - Simple: Search all occurrences of the keyword. 237 | - Match Case: Search occurrences with the same case as the keyword. 238 | - Match Whole Word: Search occurrences that match the keyword exactly. 239 | - Match Case Whole Word: Search occurrences that match the keyword exactly with the same case. 240 | - Regex: Search occurrences using a regular expression. 241 | - AST Grep: Search occurrences using AST Grep. 242 | 243 | > [!TIP] 244 | > If current directory is considerebly large, you have to click `Enter` to start the search. 245 | 246 | ### Replace Input 247 | 248 | - Input field for entering replacement text. 249 | - Toggle replace modes (Simple, Preserve Case, AST Grep). 250 | - Simple: Replace all occurrences of the keyword. 251 | - Preserve Case: Replace occurrences while preserving the case of the keyword. 252 | - AST Grep: Replace occurrences using AST Grep. 253 | 254 | ### Search Results Pane 255 | 256 | - List of files with search results. 257 | - Navigation to select and view files. 258 | - Option to delete files from the search results. 259 | - Search results count and current file count. 260 | - Ability to search the list using the `/` key. 261 | 262 | ### Preview Pane 263 | 264 | - Display of the selected file with highlighted search results, and context. 265 | - Navigation to view different matches within the file. 266 | - Option to delete individual lines containing matches. 267 | 268 | ## Quick Hints 269 | - Use the `Ctrl + b` key combination to display the help dialog. 270 | - Use the `Ctrl + o` key combination to process the replace for all files. 271 | - Use the `r` key to process the replace for the selected file or line. 272 | - Use the `Ctrl + n` key combination to toggle between search and replace modes. 273 | - Use the `g`, `G`, `j`, and `k` keys to navigate through the search results. 274 | - Use the `d` key to delete the selected file or line. 275 | 276 | ## Neovim Integration using toggleterm 277 | 278 | Check out the [toggleterm.nvim](https://github.com/akinsho/toggleterm.nvim) plugin for Neovim, which provides a terminal that can be toggled with a key binding. 279 | Or you can use the following configuration, if you are using [AstroNvim](https://astronvim.com/): 280 | 281 | ```lua 282 | return { 283 | "akinsho/toggleterm.nvim", 284 | cmd = { "ToggleTerm", "TermExec" }, 285 | dependencies = { 286 | { 287 | "AstroNvim/astrocore", 288 | opts = function(_, opts) 289 | local maps = opts.mappings 290 | local astro = require "astrocore" 291 | maps.n["t"] = vim.tbl_get(opts, "_map_sections", "t") 292 | 293 | local serpl = { 294 | callback = function() 295 | astro.toggle_term_cmd "serpl" 296 | end, 297 | desc = "ToggleTerm serpl", 298 | } 299 | maps.n["sr"] = { serpl.callback, desc = serpl.desc } 300 | 301 | maps.n["tf"] = { "ToggleTerm direction=float", desc = "ToggleTerm float" } 302 | maps.n["th"] = { "ToggleTerm size=10 direction=horizontal", desc = "ToggleTerm horizontal split" } 303 | maps.n["tv"] = { "ToggleTerm size=80 direction=vertical", desc = "ToggleTerm vertical split" } 304 | maps.n[""] = { 'execute v:count . "ToggleTerm"', desc = "Toggle terminal" } 305 | maps.t[""] = { "ToggleTerm", desc = "Toggle terminal" } 306 | maps.i[""] = { "ToggleTerm", desc = "Toggle terminal" } 307 | maps.n[""] = { 'execute v:count . "ToggleTerm"', desc = "Toggle terminal" } 308 | maps.t[""] = { "ToggleTerm", desc = "Toggle terminal" } 309 | maps.i[""] = { "ToggleTerm", desc = "Toggle terminal" } 310 | end, 311 | }, 312 | }, 313 | opts = { 314 | highlights = { 315 | Normal = { link = "Normal" }, 316 | NormalNC = { link = "NormalNC" }, 317 | NormalFloat = { link = "NormalFloat" }, 318 | FloatBorder = { link = "FloatBorder" }, 319 | StatusLine = { link = "StatusLine" }, 320 | StatusLineNC = { link = "StatusLineNC" }, 321 | WinBar = { link = "WinBar" }, 322 | WinBarNC = { link = "WinBarNC" }, 323 | }, 324 | size = 10, 325 | ---@param t Terminal 326 | on_create = function(t) 327 | vim.opt_local.foldcolumn = "0" 328 | vim.opt_local.signcolumn = "no" 329 | if t.hidden then 330 | local toggle = function() t:toggle() end 331 | vim.keymap.set({ "n", "t", "i" }, "", toggle, { desc = "Toggle terminal", buffer = t.bufnr }) 332 | vim.keymap.set({ "n", "t", "i" }, "", toggle, { desc = "Toggle terminal", buffer = t.bufnr }) 333 | end 334 | end, 335 | shading_factor = 2, 336 | direction = "float", 337 | float_opts = { border = "rounded" }, 338 | }, 339 | } 340 | ``` 341 | 342 | ## Helix integration 343 | 344 | You can add a keybinding like the following to open Serpl inside Helix: 345 | 346 | ```toml 347 | [keys.normal.ret] 348 | t = [":write-all", ":insert-output serpl >/dev/tty", ":redraw", ":reload-all"] 349 | ``` 350 | 351 | ## License 352 | 353 | This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. 354 | 355 | ## Contributing 356 | (WIP) 357 | 358 | ## Acknowledgements 359 | - This project was inspired by the [VS Code](https://code.visualstudio.com/) search and replace functionality. 360 | - This project is built using the awesome [ratatui.rs](https://ratatui.rs) library, and build on top of their [Component Template](https://ratatui.rs/templates/component). 361 | - Thanks to the [ripgrep](https://github.com/BurntSushi/ripgrep) project for providing the search functionality. 362 | - Thanks to the [ast-grep](https://ast-grep.github.io) project for providing the AST Grep functionality. 363 | 364 | ## Similar Projects 365 | - [repgrep](https://github.com/acheronfail/repgrep): An interactive replacer for ripgrep that makes it easy to find and replace across files on the command line. 366 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | fn main() -> Result<(), Box> { 2 | vergen::EmitBuilder::builder().all_build().all_git().emit()?; 3 | Ok(()) 4 | } 5 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "nightly" 3 | -------------------------------------------------------------------------------- /src/action.rs: -------------------------------------------------------------------------------- 1 | use std::{fmt, string::ToString}; 2 | 3 | use serde::{ 4 | de::{self, Deserializer, Visitor}, 5 | Deserialize, Serialize, 6 | }; 7 | use strum::Display; 8 | 9 | use crate::{ 10 | action, 11 | components::notifications::NotificationEnum, 12 | mode::Mode, 13 | redux::{ 14 | action::Action, 15 | state::{Dialog, HelpDialogState}, 16 | thunk::{self, ForceReplace, ThunkAction}, 17 | ActionOrThunk, 18 | }, 19 | tabs::Tab, 20 | }; 21 | 22 | #[derive(Display, Clone, Debug, PartialEq)] 23 | pub enum AppAction { 24 | Tui(TuiAction), 25 | Action(action::Action), 26 | Thunk(thunk::ThunkAction), 27 | } 28 | 29 | #[derive(Display, Debug, Clone, PartialEq)] 30 | pub enum TuiAction { 31 | Tick, 32 | Render, 33 | Resize(u16, u16), 34 | Suspend, 35 | Resume, 36 | Quit, 37 | Refresh, 38 | Error(String), 39 | Help, 40 | 41 | Notify(NotificationEnum), 42 | Status(String), 43 | Reset, 44 | } 45 | 46 | impl<'de> Deserialize<'de> for AppAction { 47 | fn deserialize(deserializer: D) -> Result 48 | where 49 | D: Deserializer<'de>, 50 | { 51 | struct ActionVisitor; 52 | 53 | impl Visitor<'_> for ActionVisitor { 54 | type Value = AppAction; 55 | 56 | fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { 57 | formatter.write_str("a valid string representation of Action") 58 | } 59 | 60 | fn visit_str(self, value: &str) -> Result 61 | where 62 | E: de::Error, 63 | { 64 | match value { 65 | // -- custom actions 66 | // "InputMode" => Ok(TuiAction::ModeChange(Mode::Input)), 67 | // "NormalMode" => Ok(TuiAction::ModeChange(Mode::Normal)), 68 | // "Up" => Ok(TuiAction::Up), 69 | // "Down" => Ok(TuiAction::Down), 70 | // "Left" => Ok(TuiAction::Left), 71 | // "Right" => Ok(TuiAction::Right), 72 | // "Tab" => Ok(TuiAction::Tab), 73 | // "BackTab" => Ok(TuiAction::BackTab), 74 | 75 | // -- default actions 76 | "Tick" => Ok(AppAction::Tui(TuiAction::Tick)), 77 | "Render" => Ok(AppAction::Tui(TuiAction::Render)), 78 | "Suspend" => Ok(AppAction::Tui(TuiAction::Suspend)), 79 | "Resume" => Ok(AppAction::Tui(TuiAction::Resume)), 80 | "Quit" => Ok(AppAction::Tui(TuiAction::Quit)), 81 | "Refresh" => Ok(AppAction::Tui(TuiAction::Refresh)), 82 | "Help" => Ok(AppAction::Tui(TuiAction::Help)), 83 | data if data.starts_with("Error(") => { 84 | let error_msg = data.trim_start_matches("Error(").trim_end_matches(')'); 85 | Ok(AppAction::Tui(TuiAction::Error(error_msg.to_string()))) 86 | }, 87 | data if data.starts_with("Resize(") => { 88 | let parts: Vec<&str> = data.trim_start_matches("Resize(").trim_end_matches(')').split(',').collect(); 89 | if parts.len() == 2 { 90 | let width: u16 = parts[0].trim().parse().map_err(E::custom)?; 91 | let height: u16 = parts[1].trim().parse().map_err(E::custom)?; 92 | Ok(AppAction::Tui(TuiAction::Resize(width, height))) 93 | } else { 94 | Err(E::custom(format!("Invalid Resize format: {value}"))) 95 | } 96 | }, 97 | // Redux actions 98 | "LoopOverTabs" => Ok(AppAction::Action(Action::LoopOverTabs)), 99 | "BackLoopOverTabs" => Ok(AppAction::Action(Action::BackLoopOverTabs)), 100 | "SearchTab" => Ok(AppAction::Action(Action::SetActiveTab { tab: Tab::Search })), 101 | "ReplaceTab" => Ok(AppAction::Action(Action::SetActiveTab { tab: Tab::Replace })), 102 | "SearchResultTab" => Ok(AppAction::Action(Action::SetActiveTab { tab: Tab::SearchResult })), 103 | "InputMode" => Ok(AppAction::Action(Action::ChangeMode { mode: Mode::Input })), 104 | "NormalMode" => Ok(AppAction::Action(Action::ChangeMode { mode: Mode::Normal })), 105 | "ShowHelp" => Ok(AppAction::Action(Action::SetDialog { 106 | dialog: Some(Dialog::HelpDialog(HelpDialogState { show: true })), 107 | })), 108 | // Redux Thunk Actions 109 | "ProcessReplace" => Ok(AppAction::Thunk(ThunkAction::ProcessReplace(ForceReplace(false)))), 110 | _ => Err(E::custom(format!("Unknown Action variant: {value}"))), 111 | } 112 | } 113 | } 114 | 115 | deserializer.deserialize_str(ActionVisitor) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/app.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fs, 3 | path::{Path, PathBuf}, 4 | process::Command, 5 | sync::Arc, 6 | }; 7 | 8 | use color_eyre::eyre::Result; 9 | use crossterm::event::KeyEvent; 10 | use ratatui::prelude::Rect; 11 | use redux_rs::{ 12 | middlewares::thunk::{self, ThunkMiddleware}, 13 | Selector, Store, StoreApi, 14 | }; 15 | use serde::{Deserialize, Serialize}; 16 | use strum::{EnumCount, IntoEnumIterator}; 17 | use tokio::sync::mpsc; 18 | 19 | use crate::{ 20 | action::{AppAction, TuiAction}, 21 | components::{ 22 | confirm_empty_replace_dialog::ConfirmEmptyReplaceDialog, 23 | confirm_git_dir_dialog::ConfirmGitDirDialog, 24 | help_dialog::HelpDialog, 25 | notifications::{NotificationEnum, Notifications}, 26 | preview::Preview, 27 | replace::Replace, 28 | search::Search, 29 | search_result::SearchResult, 30 | small_help::SmallHelp, 31 | status::Status, 32 | Component, 33 | }, 34 | config::Config, 35 | mode::Mode, 36 | redux::{ 37 | action::Action, 38 | reducer::reducer, 39 | state::State, 40 | thunk::{thunk_impl, ThunkAction}, 41 | }, 42 | tabs::Tab, 43 | tui, 44 | }; 45 | 46 | const FILE_COUNT_THRESHOLD: usize = 1000; 47 | 48 | pub struct App { 49 | pub config: Config, 50 | pub tick_rate: f64, 51 | pub frame_rate: f64, 52 | pub components: Vec>, 53 | pub should_quit: bool, 54 | pub should_suspend: bool, 55 | pub mode: Mode, 56 | pub last_tick_key_events: Vec, 57 | pub project_root: PathBuf, 58 | } 59 | 60 | impl App { 61 | pub fn new(project_root: PathBuf) -> Result { 62 | let config = Config::new()?; 63 | let mode = Mode::Normal; 64 | 65 | let search = Search::new(); 66 | let replace = Replace::new(); 67 | let search_result = SearchResult::new(); 68 | let preview = Preview::new(); 69 | let notification = Notifications::new(); 70 | let small_help = SmallHelp::default(); 71 | let confirm_git_dir_dialog = ConfirmGitDirDialog::default(); 72 | let confirm_empty_replace_dialog = ConfirmEmptyReplaceDialog::default(); 73 | let help_dialog = HelpDialog::new(); 74 | let status = Status::default(); 75 | Ok(Self { 76 | tick_rate: 4.0, 77 | frame_rate: 24.0, 78 | components: vec![ 79 | Box::new(search), 80 | Box::new(replace), 81 | Box::new(search_result), 82 | Box::new(preview), 83 | Box::new(notification), 84 | Box::new(small_help), 85 | Box::new(status), 86 | Box::new(confirm_git_dir_dialog), 87 | Box::new(confirm_empty_replace_dialog), 88 | Box::new(help_dialog), 89 | ], 90 | should_quit: false, 91 | should_suspend: false, 92 | config, 93 | mode, 94 | last_tick_key_events: Vec::new(), 95 | project_root, 96 | }) 97 | } 98 | 99 | fn is_large_folder(path: &Path) -> bool { 100 | let output = 101 | Command::new("rg").args(["--files", "--count-matches", "--max-count", "1", path.to_str().unwrap_or("")]).output(); 102 | 103 | match output { 104 | Ok(output) => { 105 | if output.status.success() { 106 | let file_count = String::from_utf8_lossy(&output.stdout).lines().count(); 107 | file_count > FILE_COUNT_THRESHOLD 108 | } else { 109 | log::error!("ripgrep command failed: {}", String::from_utf8_lossy(&output.stderr)); 110 | false 111 | } 112 | }, 113 | Err(e) => { 114 | log::error!("Failed to execute ripgrep: {}", e); 115 | false 116 | }, 117 | } 118 | } 119 | 120 | pub async fn run(&mut self) -> Result<()> { 121 | log::info!("Starting app.."); 122 | let initial_state = State::new(self.project_root.clone()); 123 | let mut state = initial_state.clone(); 124 | 125 | let (action_tx, mut action_rx) = mpsc::unbounded_channel(); 126 | let (redux_action_tx, mut redux_action_rx) = mpsc::unbounded_channel::(); 127 | 128 | let mut tui = tui::Tui::new()?; 129 | // tui.mouse(true); 130 | tui.enter()?; 131 | 132 | for component in self.components.iter_mut() { 133 | let size = tui.size()?; 134 | let rect = Rect::new(0, 0, size.width, size.height); 135 | component.init(rect)?; 136 | } 137 | 138 | // handle big folders 139 | let is_large_folder = Self::is_large_folder(&self.project_root); 140 | state.is_large_folder = is_large_folder; 141 | let store = Store::new_with_state(reducer, state).wrap(ThunkMiddleware).await; 142 | if is_large_folder { 143 | let search_text_action = AppAction::Tui(TuiAction::Notify(NotificationEnum::Info( 144 | "This is a large folder. click 'Enter' to search".to_string(), 145 | ))); 146 | action_tx.send(search_text_action)?; 147 | } 148 | 149 | for component in self.components.iter_mut() { 150 | component.register_action_handler(redux_action_tx.clone())?; 151 | } 152 | 153 | for component in self.components.iter_mut() { 154 | component.register_config_handler(self.config.clone())?; 155 | } 156 | 157 | loop { 158 | let state = store.state_cloned().await; 159 | if let Some(e) = tui.next().await { 160 | match e { 161 | tui::Event::Quit => action_tx.send(AppAction::Tui(TuiAction::Quit))?, 162 | tui::Event::Tick => action_tx.send(AppAction::Tui(TuiAction::Tick))?, 163 | tui::Event::Render => action_tx.send(AppAction::Tui(TuiAction::Render))?, 164 | tui::Event::Resize(x, y) => action_tx.send(AppAction::Tui(TuiAction::Resize(x, y)))?, 165 | tui::Event::Key(key) => { 166 | if let Some(app_action) = self.config.keybindings.get(&vec![key]) { 167 | log::info!("Got action: {app_action:?}"); 168 | match app_action { 169 | AppAction::Tui(action) => action_tx.send(AppAction::Tui(action.clone()))?, 170 | AppAction::Action(action) => redux_action_tx.send(AppAction::Action(action.clone()))?, 171 | AppAction::Thunk(action) => redux_action_tx.send(AppAction::Thunk(action.clone()))?, 172 | } 173 | } else { 174 | // If the key was not handled as a single key action, 175 | // then consider it for multi-key combinations. 176 | self.last_tick_key_events.push(key); 177 | 178 | // Check for multi-key combinations 179 | if let Some(app_action) = self.config.keybindings.get(&self.last_tick_key_events) { 180 | log::info!("Got action: {app_action:?}"); 181 | match app_action { 182 | AppAction::Tui(action) => action_tx.send(AppAction::Tui(action.clone()))?, 183 | AppAction::Action(action) => redux_action_tx.send(AppAction::Action(action.clone()))?, 184 | AppAction::Thunk(action) => redux_action_tx.send(AppAction::Thunk(action.clone()))?, 185 | } 186 | } 187 | } 188 | }, 189 | _ => {}, 190 | } 191 | for component in self.components.iter_mut() { 192 | component.handle_events(Some(e.clone()), &state)?; 193 | } 194 | } 195 | 196 | let mut rendered = false; 197 | while let Ok(action) = action_rx.try_recv() { 198 | if action != AppAction::Tui(TuiAction::Tick) && action != AppAction::Tui(TuiAction::Render) { 199 | log::debug!("{action:?}"); 200 | } 201 | match action { 202 | AppAction::Tui(TuiAction::Tick) => { 203 | self.last_tick_key_events.drain(..); 204 | }, 205 | AppAction::Tui(TuiAction::Quit) => self.should_quit = true, 206 | AppAction::Tui(TuiAction::Suspend) => self.should_suspend = true, 207 | AppAction::Tui(TuiAction::Resume) => self.should_suspend = false, 208 | AppAction::Tui(TuiAction::Resize(w, h)) => { 209 | tui.resize(Rect::new(0, 0, w, h))?; 210 | tui.draw(|f| { 211 | for component in self.components.iter_mut() { 212 | let r = component.draw(f, f.area(), &state); 213 | if let Err(e) = r { 214 | action_tx.send(AppAction::Tui(TuiAction::Error(format!("Failed to draw: {e:?}")))).unwrap(); 215 | } 216 | } 217 | })?; 218 | }, 219 | AppAction::Tui(TuiAction::Render) => { 220 | if !rendered { 221 | rendered = true; 222 | tui.draw(|f| { 223 | for component in self.components.iter_mut() { 224 | let r = component.draw(f, f.area(), &state); 225 | if let Err(e) = r { 226 | action_tx.send(AppAction::Tui(TuiAction::Error(format!("Failed to draw: {e:?}")))).unwrap(); 227 | } 228 | } 229 | })?; 230 | } 231 | }, 232 | _ => {}, 233 | } 234 | for component in self.components.iter_mut() { 235 | if let Some(action) = component.update(action.clone())? { 236 | action_tx.send(action)? 237 | }; 238 | } 239 | } 240 | 241 | while let Ok(action) = redux_action_rx.try_recv() { 242 | match action { 243 | AppAction::Action(action) => { 244 | log::debug!("Redux action: {action:?}"); 245 | store.dispatch(thunk::ActionOrThunk::Action(action)).await; 246 | }, 247 | AppAction::Thunk(action) => { 248 | log::debug!("Thunk action: {action:?}"); 249 | let action_tx_arc = Arc::new(action_tx.clone()); 250 | let thunk = thunk_impl(action, action_tx_arc); 251 | 252 | store.dispatch(thunk::ActionOrThunk::Thunk(thunk)).await; 253 | }, 254 | AppAction::Tui(action) => { 255 | log::debug!("Tui action: {action:?}"); 256 | action_tx.send(AppAction::Tui(action.clone()))?; 257 | }, 258 | } 259 | } 260 | 261 | if self.should_suspend { 262 | tui.suspend()?; 263 | action_tx.send(AppAction::Tui(TuiAction::Resume))?; 264 | tui = tui::Tui::new()?.tick_rate(self.tick_rate).frame_rate(self.frame_rate); 265 | // tui.mouse(true); 266 | tui.enter()?; 267 | } else if self.should_quit { 268 | tui.stop()?; 269 | break; 270 | } 271 | } 272 | tui.exit()?; 273 | Ok(()) 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /src/astgrep.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Deserialize, Serialize, Debug, Clone)] 6 | pub struct AstGrepOutput { 7 | pub text: String, 8 | pub range: Range, 9 | pub file: String, 10 | pub lines: String, 11 | pub replacement: Option, 12 | #[serde(rename = "replacementOffsets")] 13 | pub replacement_offsets: Option, 14 | pub language: String, 15 | #[serde(rename = "metaVariables")] 16 | pub meta_variables: Option, 17 | } 18 | 19 | #[derive(Deserialize, Serialize, Debug, Clone)] 20 | pub struct Range { 21 | #[serde(rename = "byteOffset")] 22 | pub byte_offset: ByteOffset, 23 | pub start: Position, 24 | pub end: Position, 25 | } 26 | 27 | #[derive(Deserialize, Serialize, Debug, Clone)] 28 | pub struct ByteOffset { 29 | pub start: usize, 30 | pub end: usize, 31 | } 32 | 33 | #[derive(Deserialize, Serialize, Debug, Clone)] 34 | pub struct Position { 35 | pub line: usize, 36 | pub column: usize, 37 | } 38 | 39 | #[derive(Deserialize, Serialize, Debug, Clone)] 40 | pub struct ReplacementOffsets { 41 | pub start: usize, 42 | pub end: usize, 43 | } 44 | 45 | #[derive(Deserialize, Serialize, Debug, Clone)] 46 | pub struct MetaVariables { 47 | pub single: HashMap, 48 | pub multi: HashMap>, 49 | pub transformed: HashMap, 50 | } 51 | 52 | #[derive(Deserialize, Serialize, Debug, Clone)] 53 | pub struct MetaVariable { 54 | pub text: String, 55 | pub range: Range, 56 | } 57 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use clap::Parser; 4 | 5 | use crate::utils::version; 6 | 7 | #[derive(Parser, Debug)] 8 | #[command(author, version = version(), about)] 9 | pub struct Cli { 10 | #[arg(short, long, value_name = "PATH", help = "Path to the project root", default_value = ".")] 11 | pub project_root: PathBuf, 12 | } 13 | -------------------------------------------------------------------------------- /src/components.rs: -------------------------------------------------------------------------------- 1 | use color_eyre::eyre::Result; 2 | use crossterm::event::{KeyEvent, MouseEvent}; 3 | use ratatui::layout::Rect; 4 | use tokio::sync::mpsc::UnboundedSender; 5 | 6 | use crate::{ 7 | action::AppAction, 8 | config::Config, 9 | redux::{action::Action, state::State}, 10 | tabs::Tab, 11 | tui::{Event, Frame}, 12 | }; 13 | 14 | pub mod confirm_empty_replace_dialog; 15 | pub mod confirm_git_dir_dialog; 16 | pub mod help_dialog; 17 | pub mod notifications; 18 | pub mod preview; 19 | pub mod replace; 20 | pub mod search; 21 | pub mod search_result; 22 | pub mod small_help; 23 | pub mod status; 24 | 25 | /// `Component` is a trait that represents a visual and interactive element of the user interface. 26 | /// Implementors of this trait can be registered with the main application loop and will be able to receive events, 27 | /// update state, and be rendered on the screen. 28 | pub trait Component { 29 | /// Register an action handler that can send actions for processing if necessary. 30 | /// 31 | /// # Arguments 32 | /// 33 | /// * `tx` - An unbounded sender that can send actions. 34 | /// 35 | /// # Returns 36 | /// 37 | /// * `Result<()>` - An Ok result or an error. 38 | #[allow(unused_variables)] 39 | fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { 40 | Ok(()) 41 | } 42 | /// Register a configuration handler that provides configuration settings if necessary. 43 | /// 44 | /// # Arguments 45 | /// 46 | /// * `config` - Configuration settings. 47 | /// 48 | /// # Returns 49 | /// 50 | /// * `Result<()>` - An Ok result or an error. 51 | #[allow(unused_variables)] 52 | fn register_config_handler(&mut self, config: Config) -> Result<()> { 53 | Ok(()) 54 | } 55 | 56 | /// Initialize the component with a specified area if necessary. 57 | /// 58 | /// # Arguments 59 | /// 60 | /// * `area` - Rectangular area to initialize the component within. 61 | /// 62 | /// # Returns 63 | /// 64 | /// * `Result<()>` - An Ok result or an error. 65 | fn init(&mut self, area: Rect) -> Result<()> { 66 | Ok(()) 67 | } 68 | /// Handle incoming events and produce actions if necessary. 69 | /// 70 | /// # Arguments 71 | /// 72 | /// * `event` - An optional event to be processed. 73 | /// 74 | /// # Returns 75 | /// 76 | /// * `Result>` - An action to be processed or none. 77 | fn handle_events(&mut self, event: Option, state: &State) -> Result> { 78 | let r = match event { 79 | Some(Event::Key(key_event)) => self.handle_key_events(key_event, state)?, 80 | Some(Event::Mouse(mouse_event)) => self.handle_mouse_events(mouse_event, state)?, 81 | _ => None, 82 | }; 83 | Ok(r) 84 | } 85 | /// Handle key events and produce actions if necessary. 86 | /// 87 | /// # Arguments 88 | /// 89 | /// * `key` - A key event to be processed. 90 | /// 91 | /// # Returns 92 | /// 93 | /// * `Result>` - An action to be processed or none. 94 | #[allow(unused_variables)] 95 | fn handle_key_events(&mut self, key: KeyEvent, state: &State) -> Result> { 96 | Ok(None) 97 | } 98 | /// Handle mouse events and produce actions if necessary. 99 | /// 100 | /// # Arguments 101 | /// 102 | /// * `mouse` - A mouse event to be processed. 103 | /// 104 | /// # Returns 105 | /// 106 | /// * `Result>` - An action to be processed or none. 107 | #[allow(unused_variables)] 108 | fn handle_mouse_events(&mut self, mouse: MouseEvent, state: &State) -> Result> { 109 | Ok(None) 110 | } 111 | /// Update the state of the component based on a received action. (REQUIRED) 112 | /// 113 | /// # Arguments 114 | /// 115 | /// * `action` - An action that may modify the state of the component. 116 | /// 117 | /// # Returns 118 | /// 119 | /// * `Result>` - An action to be processed or none. 120 | #[allow(unused_variables)] 121 | fn update(&mut self, action: AppAction) -> Result> { 122 | Ok(None) 123 | } 124 | /// Render the component on the screen. (REQUIRED) 125 | /// 126 | /// # Arguments 127 | /// 128 | /// * `f` - A frame used for rendering. 129 | /// * `area` - The area in which the component should be drawn. 130 | /// 131 | /// # Returns 132 | /// 133 | /// * `Result<()>` - An Ok result or an error. 134 | fn draw(&mut self, f: &mut Frame<'_>, area: Rect, state: &State) -> Result<()>; 135 | } 136 | -------------------------------------------------------------------------------- /src/components/confirm_empty_replace_dialog.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::{HashMap, HashSet}, 3 | process::Command, 4 | time::{Duration, Instant}, 5 | }; 6 | 7 | use color_eyre::eyre::Result; 8 | use crossterm::event::{KeyCode, KeyEvent}; 9 | use ratatui::{ 10 | layout::Position, 11 | prelude::*, 12 | widgets::{block::Title, *}, 13 | }; 14 | use serde::{Deserialize, Serialize}; 15 | use strum::Display; 16 | use tokio::sync::mpsc::UnboundedSender; 17 | use tracing::{event, trace, Level}; 18 | 19 | use super::{Component, Frame}; 20 | use crate::{ 21 | action::{AppAction, TuiAction}, 22 | config::{Config, KeyBindings}, 23 | redux::{ 24 | action::Action, 25 | state::{Dialog, DialogAction, State}, 26 | thunk::{ForceReplace, ThunkAction}, 27 | }, 28 | ripgrep::RipgrepOutput, 29 | tabs::Tab, 30 | ui::confirm_dialog_widget::{ConfirmDialogAction, ConfirmDialogState, ConfirmDialogWidget}, 31 | }; 32 | 33 | #[derive(Default)] 34 | pub struct ConfirmEmptyReplaceDialog { 35 | command_tx: Option>, 36 | config: Config, 37 | dialog_state: ConfirmDialogState, 38 | } 39 | 40 | impl ConfirmEmptyReplaceDialog { 41 | pub fn new() -> Self { 42 | Self::default() 43 | } 44 | 45 | fn handle_input(&self, action: DialogAction, state: &State) { 46 | match action { 47 | DialogAction::ConfirmReplace => { 48 | let process_replace_action = AppAction::Thunk(ThunkAction::ProcessReplace(ForceReplace(true))); 49 | self.command_tx.as_ref().unwrap().send(process_replace_action).unwrap(); 50 | 51 | let hide_dialog = AppAction::Action(Action::SetDialog { dialog: None }); 52 | self.command_tx.as_ref().unwrap().send(hide_dialog).unwrap(); 53 | }, 54 | DialogAction::CancelReplace => { 55 | let hide_dialog = AppAction::Action(Action::SetDialog { dialog: None }); 56 | self.command_tx.as_ref().unwrap().send(hide_dialog).unwrap(); 57 | }, 58 | } 59 | } 60 | } 61 | 62 | impl Component for ConfirmEmptyReplaceDialog { 63 | fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { 64 | self.command_tx = Some(tx); 65 | Ok(()) 66 | } 67 | 68 | fn handle_key_events(&mut self, key: KeyEvent, state: &State) -> Result> { 69 | if let Some(Dialog::ConfirmReplace(dialog)) = &state.dialog { 70 | match key.code { 71 | KeyCode::Tab 72 | | KeyCode::Down 73 | | KeyCode::Up 74 | | KeyCode::Right 75 | | KeyCode::Left 76 | | KeyCode::BackTab 77 | | KeyCode::Char('j') 78 | | KeyCode::Char('k') 79 | | KeyCode::Char('h') 80 | | KeyCode::Char('l') => { 81 | self.dialog_state.loop_selected_button(); 82 | Ok(None) 83 | }, 84 | KeyCode::Enter | KeyCode::Char('y') => { 85 | if let Some(action) = &dialog.on_confirm { 86 | match self.dialog_state.selected_button { 87 | ConfirmDialogAction::Confirm => { 88 | self.handle_input(action.clone(), state); 89 | }, 90 | ConfirmDialogAction::Cancel => { 91 | let hide_dialog = AppAction::Action(Action::SetDialog { dialog: None }); 92 | self.command_tx.as_ref().unwrap().send(hide_dialog).unwrap(); 93 | }, 94 | } 95 | Ok(None) 96 | } else { 97 | Ok(None) 98 | } 99 | }, 100 | KeyCode::Esc | KeyCode::Char('n') => { 101 | if let Some(action) = &dialog.on_cancel { 102 | self.handle_input(action.clone(), state); 103 | Ok(None) 104 | } else { 105 | Ok(None) 106 | } 107 | }, 108 | _ => Ok(None), 109 | } 110 | } else { 111 | Ok(None) 112 | } 113 | } 114 | 115 | fn update(&mut self, action: AppAction) -> Result> { 116 | Ok(None) 117 | } 118 | 119 | fn draw(&mut self, f: &mut Frame<'_>, rect: Rect, state: &State) -> Result<()> { 120 | if let Some(Dialog::ConfirmReplace(dialog)) = &state.dialog { 121 | let dialog_widget = ConfirmDialogWidget::new( 122 | "Confirm Dialog".to_string(), 123 | dialog.message.clone(), 124 | dialog.confirm_label.clone(), 125 | dialog.cancel_label.clone(), 126 | dialog.show_cancel, 127 | ); 128 | 129 | if dialog.show { 130 | f.render_stateful_widget(dialog_widget, rect, &mut self.dialog_state); 131 | } 132 | } 133 | Ok(()) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/components/confirm_git_dir_dialog.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::{HashMap, HashSet}, 3 | process::Command, 4 | time::{Duration, Instant}, 5 | }; 6 | 7 | use color_eyre::eyre::Result; 8 | use crossterm::event::{KeyCode, KeyEvent}; 9 | use ratatui::{ 10 | layout::Position, 11 | prelude::*, 12 | widgets::{block::Title, *}, 13 | }; 14 | use serde::{Deserialize, Serialize}; 15 | use strum::Display; 16 | use tokio::sync::mpsc::UnboundedSender; 17 | use tracing::{event, trace, Level}; 18 | 19 | use super::{Component, Frame}; 20 | use crate::{ 21 | action::{AppAction, TuiAction}, 22 | config::{Config, KeyBindings}, 23 | redux::{ 24 | action::Action, 25 | state::{Dialog, DialogAction, State}, 26 | thunk::{ForceReplace, ThunkAction}, 27 | }, 28 | ripgrep::RipgrepOutput, 29 | tabs::Tab, 30 | ui::confirm_dialog_widget::{ConfirmDialogAction, ConfirmDialogState, ConfirmDialogWidget}, 31 | }; 32 | 33 | #[derive(Default)] 34 | pub struct ConfirmGitDirDialog { 35 | command_tx: Option>, 36 | config: Config, 37 | dialog_state: ConfirmDialogState, 38 | } 39 | 40 | impl ConfirmGitDirDialog { 41 | pub fn new() -> Self { 42 | Self::default() 43 | } 44 | 45 | fn handle_input(&self, action: DialogAction, state: &State) { 46 | match action { 47 | DialogAction::ConfirmReplace => { 48 | let process_replace_action = AppAction::Thunk(ThunkAction::ProcessReplace(ForceReplace(true))); 49 | self.command_tx.as_ref().unwrap().send(process_replace_action).unwrap(); 50 | 51 | let hide_dialog = AppAction::Action(Action::SetDialog { dialog: None }); 52 | self.command_tx.as_ref().unwrap().send(hide_dialog).unwrap(); 53 | }, 54 | DialogAction::CancelReplace => { 55 | let hide_dialog = AppAction::Action(Action::SetDialog { dialog: None }); 56 | self.command_tx.as_ref().unwrap().send(hide_dialog).unwrap(); 57 | }, 58 | } 59 | } 60 | } 61 | 62 | impl Component for ConfirmGitDirDialog { 63 | fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { 64 | self.command_tx = Some(tx); 65 | Ok(()) 66 | } 67 | 68 | fn handle_key_events(&mut self, key: KeyEvent, state: &State) -> Result> { 69 | if let Some(Dialog::ConfirmGitDirectory(dialog)) = &state.dialog { 70 | match key.code { 71 | KeyCode::Tab 72 | | KeyCode::Down 73 | | KeyCode::Up 74 | | KeyCode::Right 75 | | KeyCode::Left 76 | | KeyCode::BackTab 77 | | KeyCode::Char('j') 78 | | KeyCode::Char('k') 79 | | KeyCode::Char('h') 80 | | KeyCode::Char('l') => { 81 | self.dialog_state.loop_selected_button(); 82 | Ok(None) 83 | }, 84 | KeyCode::Enter | KeyCode::Char('y') => { 85 | if let Some(action) = &dialog.on_confirm { 86 | match self.dialog_state.selected_button { 87 | ConfirmDialogAction::Confirm => { 88 | self.handle_input(action.clone(), state); 89 | }, 90 | ConfirmDialogAction::Cancel => { 91 | let hide_dialog = AppAction::Action(Action::SetDialog { dialog: None }); 92 | self.command_tx.as_ref().unwrap().send(hide_dialog).unwrap(); 93 | }, 94 | } 95 | Ok(None) 96 | } else { 97 | Ok(None) 98 | } 99 | }, 100 | KeyCode::Esc | KeyCode::Char('n') => { 101 | if let Some(action) = &dialog.on_cancel { 102 | self.handle_input(action.clone(), state); 103 | Ok(None) 104 | } else { 105 | Ok(None) 106 | } 107 | }, 108 | _ => Ok(None), 109 | } 110 | } else { 111 | Ok(None) 112 | } 113 | } 114 | 115 | fn update(&mut self, action: AppAction) -> Result> { 116 | Ok(None) 117 | } 118 | 119 | fn draw(&mut self, f: &mut Frame<'_>, rect: Rect, state: &State) -> Result<()> { 120 | if let Some(Dialog::ConfirmGitDirectory(dialog)) = &state.dialog { 121 | let dialog_widget = ConfirmDialogWidget::new( 122 | "Confirm Dialog".to_string(), 123 | dialog.message.clone(), 124 | dialog.confirm_label.clone(), 125 | dialog.cancel_label.clone(), 126 | dialog.show_cancel, 127 | ); 128 | 129 | if dialog.show { 130 | f.render_stateful_widget(dialog_widget, rect, &mut self.dialog_state); 131 | } 132 | } 133 | Ok(()) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/components/help_dialog.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use color_eyre::eyre::Result; 4 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; 5 | use ratatui::prelude::*; 6 | use tokio::sync::mpsc::UnboundedSender; 7 | 8 | use super::Component; 9 | use crate::{ 10 | action::AppAction, 11 | config::{key_event_to_string, Config, KeyBindings}, 12 | redux::{ 13 | action::Action, 14 | state::{Dialog, FocusedScreen, HelpDialogState, State}, 15 | }, 16 | ui::help_display_dialog::{HelpDisplayDialogState, HelpDisplayDialogWidget, Tab}, 17 | }; 18 | 19 | #[derive(Default)] 20 | pub struct HelpDialog { 21 | command_tx: Option>, 22 | config: Config, 23 | help_dialog_state: HelpDisplayDialogState, 24 | tabs: Vec, 25 | active_tab: usize, 26 | } 27 | 28 | impl HelpDialog { 29 | pub fn new() -> Self { 30 | let tabs = vec![ 31 | Tab { title: "[1] Global".to_string(), content: Self::global_keybindings() }, 32 | Tab { title: "[2] Navigation".to_string(), content: Self::navigation_keybindings() }, 33 | ]; 34 | Self { tabs, active_tab: 0, ..Default::default() } 35 | } 36 | 37 | // fn global_keybindings(keybindings: &KeyBindings) -> String { 38 | // let mut content = String::from("Global Keybindings:\n"); 39 | // 40 | // for (key_events, action) in keybindings.iter() { 41 | // let key_str = key_events.iter().map(key_event_to_string).collect::>().join(", "); 42 | // 43 | // content.push_str(&format!("- {}: {:?}\n", key_str, action)); 44 | // } 45 | // 46 | // content 47 | // } 48 | 49 | fn global_keybindings() -> String { 50 | "- Ctrl-c: Quit\n- Ctrl-d: Quit\n- Ctrl-b: Help dialog\n- Ctrl-o: Process Replace For All Files\n- Ctrl-n: Loop through search and replace modes\n- Enter: Select/Deselect file\n- d: delete file/delete line from the replace process\n- r: Replace Selected File Or Line".to_string() 51 | } 52 | 53 | fn navigation_keybindings() -> String { 54 | "- Tab: Loop through panes\n- j/UpArrow: Move up\n- k/DownArrow: Move down\n- h/g/LeftArrow: Move to Top\n- l/G/RightArrow: Move to Bottom\n".to_string() 55 | } 56 | } 57 | 58 | impl Component for HelpDialog { 59 | fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { 60 | self.command_tx = Some(tx); 61 | Ok(()) 62 | } 63 | 64 | fn handle_key_events(&mut self, key: KeyEvent, state: &State) -> Result> { 65 | if state.focused_screen == FocusedScreen::HelpDialog { 66 | match (key.code, key.modifiers) { 67 | (KeyCode::Esc, KeyModifiers::NONE) | (KeyCode::Char('q'), KeyModifiers::NONE) => { 68 | log::info!("Drawing HelpDialog 11{:?}", state.focused_screen); 69 | self.help_dialog_state.show = false; 70 | let previous_focused_screen = state.previous_focused_screen.clone(); 71 | let hide_dialog = AppAction::Action(Action::SetDialog { dialog: None }); 72 | self.command_tx.as_ref().unwrap().send(hide_dialog).unwrap(); 73 | let focus_screen = AppAction::Action(Action::SetFocusedScreen { screen: Some(previous_focused_screen) }); 74 | self.command_tx.as_ref().unwrap().send(focus_screen).unwrap(); 75 | log::info!("Drawing HelpDialog 22{:?}", state.focused_screen); 76 | Ok(None) 77 | }, 78 | (KeyCode::Left, KeyModifiers::NONE) 79 | | (KeyCode::Char('h'), KeyModifiers::NONE) 80 | | (KeyCode::Tab, KeyModifiers::SHIFT) => { 81 | self.active_tab = if self.active_tab == 0 { self.tabs.len() - 1 } else { self.active_tab - 1 }; 82 | Ok(None) 83 | }, 84 | (KeyCode::Right, KeyModifiers::NONE) 85 | | (KeyCode::Char('l'), KeyModifiers::NONE) 86 | | (KeyCode::Tab, KeyModifiers::NONE) => { 87 | self.active_tab = (self.active_tab + 1) % self.tabs.len(); 88 | Ok(None) 89 | }, 90 | (KeyCode::Char('1'), KeyModifiers::NONE) => { 91 | self.active_tab = 0; 92 | Ok(None) 93 | }, 94 | (KeyCode::Char('2'), KeyModifiers::NONE) => { 95 | self.active_tab = 1; 96 | Ok(None) 97 | }, 98 | _ => Ok(None), 99 | } 100 | } else { 101 | Ok(None) 102 | } 103 | } 104 | 105 | fn update(&mut self, action: AppAction) -> Result> { 106 | Ok(None) 107 | } 108 | 109 | fn draw(&mut self, f: &mut Frame<'_>, rect: Rect, state: &State) -> Result<()> { 110 | if let Some(Dialog::HelpDialog(HelpDialogState { show: true })) = &state.dialog { 111 | let dialog_widget = HelpDisplayDialogWidget::new(self.tabs.clone(), self.active_tab); 112 | f.render_stateful_widget(dialog_widget, rect, &mut self.help_dialog_state); 113 | } 114 | Ok(()) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/components/notifications.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::{HashMap, HashSet}, 3 | process::Command, 4 | time::{Duration, Instant}, 5 | }; 6 | 7 | use color_eyre::eyre::Result; 8 | use crossterm::event::{KeyCode, KeyEvent}; 9 | use ratatui::{ 10 | layout::Position, 11 | prelude::*, 12 | widgets::{block::Title, *}, 13 | }; 14 | use serde::{Deserialize, Serialize}; 15 | use strum::Display; 16 | use tokio::sync::mpsc::UnboundedSender; 17 | use tracing::{event, trace, Level}; 18 | 19 | use super::{Component, Frame}; 20 | use crate::{ 21 | action::{AppAction, TuiAction}, 22 | config::{Config, KeyBindings}, 23 | layout::{self, get_layout, get_notification_layout}, 24 | redux::{action::Action, state::State, thunk::ThunkAction}, 25 | ripgrep::RipgrepOutput, 26 | tabs::Tab, 27 | ui::notification_box::NotificationBox, 28 | }; 29 | const NOTIFICATION_DURATION: u64 = 3; 30 | 31 | #[derive(Default)] 32 | pub struct Notifications { 33 | command_tx: Option>, 34 | config: Config, 35 | notifications: Vec, 36 | } 37 | 38 | pub type NotificationWithTimestamp = (NotificationEnum, Instant); 39 | 40 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Display, Deserialize)] 41 | pub enum NotificationEnum { 42 | Info(String), 43 | Warning(String), 44 | Error(String), 45 | } 46 | 47 | impl Notifications { 48 | pub fn new() -> Self { 49 | Self::default() 50 | } 51 | 52 | fn app_tick(&mut self) -> Result<()> { 53 | let now = Instant::now(); 54 | self.notifications.retain(|(_, timestamp)| timestamp.elapsed().as_secs() < NOTIFICATION_DURATION); 55 | Ok(()) 56 | } 57 | 58 | fn render_tick(&mut self) -> Result<()> { 59 | Ok(()) 60 | } 61 | } 62 | 63 | impl Component for Notifications { 64 | fn update(&mut self, action: AppAction) -> Result> { 65 | match action { 66 | AppAction::Tui(TuiAction::Tick) => { 67 | self.app_tick()?; 68 | }, 69 | AppAction::Tui(TuiAction::Render) => { 70 | self.render_tick()?; 71 | }, 72 | AppAction::Tui(TuiAction::Notify(notification)) => { 73 | // FIFO push, don't exceed 4 notifications 74 | self.notifications.push((notification, Instant::now())); 75 | if self.notifications.len() > 4 { 76 | self.notifications.remove(0); 77 | } 78 | }, 79 | _ => (), 80 | } 81 | Ok(None) 82 | } 83 | 84 | fn draw(&mut self, f: &mut Frame<'_>, rect: Rect, state: &State) -> Result<()> { 85 | if !self.notifications.is_empty() { 86 | for (i, notification) in self.notifications.iter().enumerate() { 87 | let content = match ¬ification.0 { 88 | NotificationEnum::Info(s) => s, 89 | NotificationEnum::Warning(s) => s, 90 | NotificationEnum::Error(s) => s, 91 | }; 92 | 93 | let notification_box = NotificationBox::new(notification, content); 94 | let rect = get_notification_layout(rect, content, i as u16); 95 | f.render_widget(notification_box, rect); 96 | } 97 | } 98 | Ok(()) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/components/preview.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::HashMap, 3 | path::Path, 4 | process::{Command, Stdio}, 5 | time::Duration, 6 | }; 7 | 8 | use color_eyre::{eyre::Result, owo_colors::OwoColorize}; 9 | use crossterm::event::{KeyCode, KeyEvent}; 10 | use ratatui::{prelude::*, symbols::scrollbar, widgets::*}; 11 | use regex::Regex; 12 | use serde::{Deserialize, Serialize}; 13 | use tokio::sync::mpsc::UnboundedSender; 14 | 15 | use super::{Component, Frame}; 16 | use crate::{ 17 | action::AppAction, 18 | config::{Config, KeyBindings}, 19 | layout::get_layout, 20 | redux::{ 21 | action::Action, 22 | state::{FocusedScreen, ReplaceTextKind, SearchResultState, SearchTextKind, State, SubMatch}, 23 | thunk::ThunkAction, 24 | utils::{apply_replace, get_search_regex}, 25 | }, 26 | tabs::Tab, 27 | }; 28 | 29 | #[derive(Default)] 30 | pub struct Preview { 31 | command_tx: Option>, 32 | config: Config, 33 | lines_state: ListState, 34 | total_lines: usize, 35 | non_divider_lines: Vec, 36 | } 37 | 38 | impl Preview { 39 | pub fn new() -> Self { 40 | Self { 41 | command_tx: None, 42 | config: Config::default(), 43 | lines_state: { 44 | let mut state = ListState::default(); 45 | state.select(Some(0)); 46 | state 47 | }, 48 | total_lines: 0, 49 | non_divider_lines: vec![], 50 | } 51 | } 52 | 53 | fn next(&mut self) { 54 | if let Some(current_index) = self.lines_state.selected() { 55 | let next_index = self 56 | .non_divider_lines 57 | .iter() 58 | .position(|&index| index > current_index) 59 | .map(|pos| self.non_divider_lines[pos]) 60 | .unwrap_or_else(|| self.non_divider_lines[0]); 61 | self.lines_state.select(Some(next_index)); 62 | } else if !self.non_divider_lines.is_empty() { 63 | self.lines_state.select(Some(self.non_divider_lines[0])); 64 | } 65 | } 66 | 67 | fn previous(&mut self) { 68 | if let Some(current_index) = self.lines_state.selected() { 69 | let prev_index = self 70 | .non_divider_lines 71 | .iter() 72 | .rev() 73 | .position(|&index| index < current_index) 74 | .map(|pos| self.non_divider_lines[self.non_divider_lines.len() - 1 - pos]) 75 | .unwrap_or_else(|| *self.non_divider_lines.last().unwrap()); 76 | self.lines_state.select(Some(prev_index)); 77 | } else if !self.non_divider_lines.is_empty() { 78 | self.lines_state.select(Some(*self.non_divider_lines.last().unwrap())); 79 | } 80 | } 81 | 82 | fn top(&mut self, state: &State) { 83 | if !self.non_divider_lines.is_empty() { 84 | self.lines_state.select(Some(self.non_divider_lines[0])); 85 | } 86 | } 87 | 88 | fn bottom(&mut self, state: &State) { 89 | if !self.non_divider_lines.is_empty() { 90 | self.lines_state.select(Some(self.non_divider_lines[self.non_divider_lines.len() - 1])); 91 | } 92 | } 93 | 94 | fn delete_line(&mut self, selected_result_state: &SearchResultState) { 95 | if let Some(selected_index) = self.lines_state.selected() { 96 | let line_index = self.non_divider_lines.iter().position(|&index| index == selected_index).unwrap_or(0); 97 | let file_index = selected_result_state.index.unwrap_or(0); 98 | let remove_line_from_file_thunk = AppAction::Thunk(ThunkAction::RemoveLineFromFile(file_index, line_index)); 99 | self.command_tx.as_ref().unwrap().send(remove_line_from_file_thunk).unwrap(); 100 | } 101 | } 102 | 103 | fn replace_selected_line(&mut self, selected_result_state: &SearchResultState) { 104 | if let Some(selected_index) = self.lines_state.selected() { 105 | let line_index = self.non_divider_lines.iter().position(|&index| index == selected_index).unwrap_or(0); 106 | let file_index = selected_result_state.index.unwrap_or(0); 107 | let replace_line_thunk = AppAction::Thunk(ThunkAction::ProcessLineReplace(file_index, line_index)); 108 | self.command_tx.as_ref().unwrap().send(replace_line_thunk).unwrap(); 109 | } 110 | } 111 | 112 | // disable too_many_arguments clippy 113 | #[allow(clippy::too_many_arguments)] 114 | fn format_match_lines<'a>( 115 | &self, 116 | full_match: &'a str, 117 | submatches: &[SubMatch], 118 | replace_text: &'a str, 119 | replacement: &'a Option, 120 | search_kind: &SearchTextKind, 121 | replace_kind: &ReplaceTextKind, 122 | is_ast_grep: bool, 123 | ) -> Vec> { 124 | let mut lines = Vec::new(); 125 | let match_lines: Vec<&str> = full_match.lines().collect(); 126 | let replacement_lines: Vec<&str> = replacement.as_ref().map(|r| r.lines().collect()).unwrap_or_default(); 127 | 128 | for (i, line) in match_lines.iter().enumerate() { 129 | let line_number = submatches[0].line_start + i; 130 | let mut spans = Vec::new(); 131 | let mut last_end = 0; 132 | if *replace_kind == ReplaceTextKind::DeleteLine { 133 | spans.push(Span::styled( 134 | *line, 135 | Style::default().fg(Color::White).bg(Color::Red).add_modifier(Modifier::CROSSED_OUT), 136 | )); 137 | } else { 138 | for submatch in submatches.iter().filter(|sm| sm.line_start <= line_number && line_number <= sm.line_end) { 139 | let start = if line_number == submatch.line_start { submatch.start } else { 0 }; 140 | let end = if line_number == submatch.line_end { submatch.end } else { line.len() }; 141 | 142 | if start > last_end { 143 | spans.push(Span::raw(&line[last_end..start])); 144 | } 145 | 146 | let matched_text = &line[start..end]; 147 | if is_ast_grep { 148 | let replacement_line = replacement_lines.get(i).unwrap_or(&""); 149 | if replace_text.is_empty() { 150 | spans.push(Span::styled(matched_text, Style::default().bg(Color::Blue))); 151 | } else { 152 | let (common_prefix, common_suffix) = Self::find_common_parts(matched_text, replacement_line); 153 | 154 | spans.push(Span::raw(common_prefix)); 155 | 156 | let search_diff = &matched_text[common_prefix.len()..matched_text.len() - common_suffix.len()]; 157 | if !search_diff.trim().is_empty() { 158 | spans.push(Span::styled( 159 | search_diff, 160 | Style::default().fg(Color::White).bg(Color::LightRed).add_modifier(Modifier::CROSSED_OUT), 161 | )); 162 | } 163 | 164 | let replace_diff = &replacement_line[common_prefix.len()..replacement_line.len() - common_suffix.len()]; 165 | if !replace_diff.trim().is_empty() { 166 | spans.push(Span::styled(replace_diff, Style::default().fg(Color::White).bg(Color::Green))); 167 | } 168 | 169 | spans.push(Span::raw(common_suffix)); 170 | } 171 | } else { 172 | let re = get_search_regex(matched_text, search_kind); 173 | let mut last_match_end = 0; 174 | 175 | for cap in re.captures_iter(matched_text) { 176 | let m = cap.get(0).unwrap(); 177 | let match_start = m.start(); 178 | let match_end = m.end(); 179 | 180 | if match_start > last_match_end { 181 | spans.push(Span::raw(&matched_text[last_match_end..match_start])); 182 | } 183 | 184 | if replace_text.is_empty() { 185 | spans.push(Span::styled(&matched_text[match_start..match_end], Style::default().bg(Color::Blue))); 186 | } else { 187 | let replacement = apply_replace(&matched_text[match_start..match_end], replace_text, replace_kind); 188 | spans.push(Span::styled( 189 | &matched_text[match_start..match_end], 190 | Style::default().fg(Color::White).bg(Color::LightRed).add_modifier(Modifier::CROSSED_OUT), 191 | )); 192 | spans.push(Span::styled(replacement, Style::default().fg(Color::White).bg(Color::Green))); 193 | } 194 | 195 | last_match_end = match_end; 196 | } 197 | 198 | if last_match_end < matched_text.len() { 199 | spans.push(Span::raw(&matched_text[last_match_end..])); 200 | } 201 | } 202 | 203 | last_end = end; 204 | } 205 | 206 | if last_end < line.len() { 207 | spans.push(Span::raw(&line[last_end..])); 208 | } 209 | } 210 | 211 | lines.push(Line::from(spans)); 212 | } 213 | 214 | lines 215 | } 216 | 217 | fn find_common_parts<'a>(s1: &'a str, s2: &'a str) -> (&'a str, &'a str) { 218 | let mut prefix_len = 0; 219 | for (c1, c2) in s1.chars().zip(s2.chars()) { 220 | if c1 == c2 { 221 | prefix_len += 1; 222 | } else { 223 | break; 224 | } 225 | } 226 | 227 | let mut suffix_len = 0; 228 | for (c1, c2) in s1.chars().rev().zip(s2.chars().rev()) { 229 | if c1 == c2 && suffix_len < s1.len() - prefix_len && suffix_len < s2.len() - prefix_len { 230 | suffix_len += 1; 231 | } else { 232 | break; 233 | } 234 | } 235 | 236 | let common_prefix = &s1[..prefix_len]; 237 | let common_suffix = &s1[s1.len() - suffix_len..]; 238 | 239 | (common_prefix, common_suffix) 240 | } 241 | } 242 | 243 | impl Component for Preview { 244 | fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { 245 | self.command_tx = Some(tx); 246 | Ok(()) 247 | } 248 | 249 | fn register_config_handler(&mut self, config: Config) -> Result<()> { 250 | self.config = config; 251 | Ok(()) 252 | } 253 | 254 | fn handle_key_events(&mut self, key: KeyEvent, state: &State) -> Result> { 255 | if state.focused_screen == FocusedScreen::Preview { 256 | match (key.code, key.modifiers) { 257 | (KeyCode::Char('d'), _) => { 258 | self.delete_line(&state.selected_result); 259 | Ok(None) 260 | }, 261 | (KeyCode::Char('g') | KeyCode::Char('h') | KeyCode::Left, _) => { 262 | self.top(state); 263 | Ok(None) 264 | }, 265 | (KeyCode::Char('G') | KeyCode::Char('l') | KeyCode::Right, _) => { 266 | self.bottom(state); 267 | Ok(None) 268 | }, 269 | 270 | (KeyCode::Char('j') | KeyCode::Down, _) => { 271 | self.next(); 272 | Ok(None) 273 | }, 274 | (KeyCode::Char('k') | KeyCode::Up, _) => { 275 | self.previous(); 276 | Ok(None) 277 | }, 278 | (KeyCode::Char('r'), _) => { 279 | self.replace_selected_line(&state.selected_result); 280 | Ok(None) 281 | }, 282 | (KeyCode::Enter, _) | (KeyCode::Esc, _) => { 283 | let action = AppAction::Action(Action::SetActiveTab { tab: Tab::SearchResult }); 284 | self.command_tx.as_ref().unwrap().send(action).unwrap(); 285 | Ok(None) 286 | }, 287 | _ => Ok(None), 288 | } 289 | } else { 290 | Ok(None) 291 | } 292 | } 293 | 294 | fn update(&mut self, action: AppAction) -> Result> { 295 | if let AppAction::Action(Action::SetSelectedResult { result }) = action { 296 | self.lines_state.select(Some(0)); 297 | } 298 | 299 | Ok(None) 300 | } 301 | 302 | fn draw(&mut self, f: &mut Frame<'_>, area: Rect, state: &State) -> Result<()> { 303 | let layout = get_layout(area); 304 | let block = Block::default().borders(Borders::ALL).border_type(BorderType::Rounded).title("Preview"); 305 | let block = if state.focused_screen == FocusedScreen::Preview { 306 | block.border_style(Style::default().fg(Color::Green)) 307 | } else { 308 | block 309 | }; 310 | 311 | let mut lines = vec![]; 312 | self.non_divider_lines.clear(); 313 | 314 | for (match_index, result) in state.selected_result.matches.iter().enumerate() { 315 | let line_number = result.line_number; 316 | let start_index = lines.len(); 317 | let is_selected = self.lines_state.selected().map(|s| s >= start_index).unwrap_or(false); 318 | 319 | for (i, line) in result.context_before.iter().enumerate() { 320 | let line_style = Style::default().fg(Color::DarkGray); 321 | let context_line_number = line_number.saturating_sub(result.context_before.len() - i); 322 | let spans = vec![ 323 | Span::styled(format!("{context_line_number:4} "), Style::default().fg(Color::Blue)), 324 | Span::styled(line, line_style), 325 | ]; 326 | lines.push(Line::from(spans)); 327 | } 328 | 329 | #[cfg(feature = "ast_grep")] 330 | let is_ast_grep = matches!(state.search_text.kind, SearchTextKind::AstGrep); 331 | #[cfg(not(feature = "ast_grep"))] 332 | let is_ast_grep = false; 333 | 334 | let formatted_lines = self.format_match_lines( 335 | &result.lines.as_ref().unwrap().text, 336 | &result.submatches, 337 | &state.replace_text.text, 338 | &result.replacement, 339 | &state.search_text.kind, 340 | &state.replace_text.kind, 341 | is_ast_grep, 342 | ); 343 | for (i, formatted_line) in formatted_lines.clone().into_iter().enumerate() { 344 | let mut spans = vec![Span::styled(format!("{:4} ", line_number + i), Style::default().fg(Color::LightGreen))]; 345 | spans.extend(formatted_line.spans); 346 | self.non_divider_lines.push(lines.len()); 347 | lines.push(Line::from(spans)); 348 | } 349 | 350 | for (i, line) in result.context_after.iter().enumerate() { 351 | let line_style = Style::default().fg(Color::DarkGray); 352 | let spans = vec![ 353 | Span::styled(format!("{:4} ", line_number + formatted_lines.len() + i), Style::default().fg(Color::Blue)), 354 | Span::styled(line, line_style), 355 | ]; 356 | lines.push(Line::from(spans)); 357 | } 358 | 359 | let divider_color = if is_selected { Color::Yellow } else { Color::DarkGray }; 360 | lines.push(Line::from("-".repeat(area.width as usize)).fg(divider_color)); 361 | } 362 | 363 | self.total_lines = lines.len(); 364 | let text = Text::from(lines); 365 | 366 | let highlight_style = Style::default().add_modifier(Modifier::BOLD).fg(Color::White); 367 | 368 | let preview_widget = 369 | List::new(text).highlight_style(highlight_style).block(block).highlight_symbol("> ").scroll_padding(4); 370 | 371 | f.render_stateful_widget(preview_widget, layout.preview, &mut self.lines_state); 372 | 373 | Ok(()) 374 | } 375 | } 376 | -------------------------------------------------------------------------------- /src/components/replace.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, time::Duration}; 2 | 3 | use color_eyre::eyre::Result; 4 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; 5 | use ratatui::{prelude::*, widgets::*}; 6 | use serde::{Deserialize, Serialize}; 7 | use tokio::sync::mpsc::UnboundedSender; 8 | use tui_input::{backend::crossterm::EventHandler, Input}; 9 | 10 | use super::{Component, Frame}; 11 | use crate::{ 12 | action::{AppAction, TuiAction}, 13 | config::{Config, KeyBindings}, 14 | layout::get_layout, 15 | mode::Mode, 16 | redux::{ 17 | action::Action, 18 | state::{FocusedScreen, ReplaceTextKind, SearchTextKind, State}, 19 | thunk::ThunkAction, 20 | }, 21 | tabs::Tab, 22 | utils::is_git_repo, 23 | }; 24 | 25 | #[derive(Default)] 26 | pub struct Replace { 27 | command_tx: Option>, 28 | config: Config, 29 | input: Input, 30 | } 31 | 32 | impl Replace { 33 | pub fn new() -> Self { 34 | Self::default() 35 | } 36 | 37 | fn handle_input(&mut self, key: KeyEvent, state: &State) { 38 | self.input.handle_event(&crossterm::event::Event::Key(key)); 39 | let query = self.input.value(); 40 | let replace_text_action = AppAction::Action(Action::SetReplaceText { text: query.to_string() }); 41 | self.command_tx.as_ref().unwrap().send(replace_text_action).unwrap(); 42 | 43 | #[cfg(feature = "ast_grep")] 44 | if state.replace_text.kind == ReplaceTextKind::AstGrep { 45 | let process_search_thunk = AppAction::Thunk(ThunkAction::ProcessSearch); 46 | self.command_tx.as_ref().unwrap().send(process_search_thunk).unwrap(); 47 | } 48 | } 49 | 50 | fn change_kind(&mut self, replace_text_kind: ReplaceTextKind) { 51 | let replace_text_action = AppAction::Action(Action::SetReplaceTextKind { kind: replace_text_kind }); 52 | self.command_tx.as_ref().unwrap().send(replace_text_action).unwrap(); 53 | 54 | #[cfg(feature = "ast_grep")] 55 | if replace_text_kind == ReplaceTextKind::AstGrep { 56 | let search_text_action = AppAction::Action(Action::SetSearchTextKind { kind: SearchTextKind::AstGrep }); 57 | self.command_tx.as_ref().unwrap().send(search_text_action).unwrap(); 58 | } else if replace_text_kind != ReplaceTextKind::AstGrep { 59 | let search_text_action = AppAction::Action(Action::SetSearchTextKind { kind: SearchTextKind::Simple }); 60 | self.command_tx.as_ref().unwrap().send(search_text_action).unwrap(); 61 | } 62 | 63 | let process_search_thunk = AppAction::Thunk(ThunkAction::ProcessSearch); 64 | self.command_tx.as_ref().unwrap().send(process_search_thunk).unwrap(); 65 | } 66 | } 67 | 68 | impl Component for Replace { 69 | fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { 70 | self.command_tx = Some(tx); 71 | Ok(()) 72 | } 73 | 74 | fn handle_key_events(&mut self, key: KeyEvent, state: &State) -> Result> { 75 | if state.focused_screen == FocusedScreen::ReplaceInput { 76 | match (key.code, key.modifiers) { 77 | (KeyCode::Tab, _) | (KeyCode::BackTab, _) | (KeyCode::Char('b'), KeyModifiers::CONTROL) => Ok(None), 78 | (KeyCode::Char('n'), KeyModifiers::CONTROL) => { 79 | let replace_text_kind = match state.replace_text.kind { 80 | ReplaceTextKind::Simple => ReplaceTextKind::PreserveCase, 81 | ReplaceTextKind::PreserveCase => ReplaceTextKind::DeleteLine, 82 | ReplaceTextKind::DeleteLine => { 83 | #[cfg(feature = "ast_grep")] 84 | { 85 | ReplaceTextKind::AstGrep 86 | } 87 | #[cfg(not(feature = "ast_grep"))] 88 | { 89 | ReplaceTextKind::Simple 90 | } 91 | }, 92 | #[cfg(feature = "ast_grep")] 93 | ReplaceTextKind::AstGrep => ReplaceTextKind::Simple, 94 | }; 95 | self.change_kind(replace_text_kind); 96 | Ok(None) 97 | }, 98 | _ => { 99 | if state.replace_text.kind != ReplaceTextKind::DeleteLine { 100 | self.handle_input(key, state); 101 | } 102 | Ok(None) 103 | }, 104 | } 105 | } else { 106 | Ok(None) 107 | } 108 | } 109 | 110 | fn register_config_handler(&mut self, config: Config) -> Result<()> { 111 | self.config = config; 112 | Ok(()) 113 | } 114 | 115 | fn update(&mut self, action: AppAction) -> Result> { 116 | if let AppAction::Tui(TuiAction::Reset) = action { 117 | self.input.reset() 118 | } 119 | Ok(None) 120 | } 121 | 122 | fn draw(&mut self, f: &mut Frame<'_>, area: Rect, state: &State) -> Result<()> { 123 | let layout = get_layout(area); 124 | 125 | let replace_kind = match state.replace_text.kind { 126 | ReplaceTextKind::Simple => "[Simple]", 127 | ReplaceTextKind::PreserveCase => "[Preserve Case]", 128 | ReplaceTextKind::DeleteLine => "[Delete Line]", 129 | #[cfg(feature = "ast_grep")] 130 | ReplaceTextKind::AstGrep => "[AST Grep]", 131 | }; 132 | 133 | let block = Block::bordered() 134 | .border_type(BorderType::Rounded) 135 | .title_top(Line::from("Replace").left_aligned()) 136 | .title_top(Line::from(replace_kind).right_aligned()); 137 | 138 | let block = if state.focused_screen == FocusedScreen::ReplaceInput { 139 | block.border_style(Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)) 140 | } else { 141 | block 142 | }; 143 | 144 | let width = layout.replace_input.width.max(3) - 3; 145 | let scroll = self.input.visual_scroll(width as usize); 146 | 147 | let replace_text = if state.replace_text.kind == ReplaceTextKind::DeleteLine { 148 | "[Entire line will be deleted]" 149 | } else { 150 | self.input.value() 151 | }; 152 | 153 | let replace_style = if state.replace_text.kind == ReplaceTextKind::DeleteLine { 154 | Style::default().fg(Color::DarkGray) 155 | } else { 156 | Style::default().fg(Color::White) 157 | }; 158 | 159 | let replace_widget = Paragraph::new(replace_text).style(replace_style).scroll((0, scroll as u16)).block(block); 160 | 161 | if state.focused_screen == FocusedScreen::ReplaceInput && state.replace_text.kind != ReplaceTextKind::DeleteLine { 162 | f.set_cursor_position(Position { 163 | x: layout.replace_input.x + ((self.input.visual_cursor()).max(scroll) - scroll) as u16 + 1, 164 | y: layout.replace_input.y + 1, 165 | }); 166 | } 167 | 168 | f.render_widget(replace_widget, layout.replace_input); 169 | Ok(()) 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/components/search.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::{HashMap, HashSet}, 3 | process::Command, 4 | time::Duration, 5 | }; 6 | 7 | use color_eyre::eyre::Result; 8 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; 9 | use ratatui::{layout::Position, prelude::*, widgets::*}; 10 | use serde::{Deserialize, Serialize}; 11 | use tokio::{sync::mpsc::UnboundedSender, time::Instant}; 12 | use tracing::{event, trace, Level}; 13 | use tui_input::{backend::crossterm::EventHandler, Input}; 14 | 15 | use super::{Component, Frame}; 16 | use crate::{ 17 | action::{AppAction, TuiAction}, 18 | components::notifications::NotificationEnum, 19 | config::{Config, KeyBindings}, 20 | layout::get_layout, 21 | redux::{ 22 | action::Action, 23 | state::{FocusedScreen, ReplaceTextKind, SearchResultState, SearchTextKind, State}, 24 | thunk::ThunkAction, 25 | }, 26 | ripgrep::RipgrepOutput, 27 | tabs::Tab, 28 | }; 29 | 30 | const DEBOUNCE_DURATION: Duration = Duration::from_millis(300); 31 | 32 | #[derive(Default)] 33 | pub struct Search { 34 | command_tx: Option>, 35 | config: Config, 36 | input: Input, 37 | debounce_timer: Option>, 38 | } 39 | 40 | impl Search { 41 | pub fn new() -> Self { 42 | Self::default() 43 | } 44 | 45 | fn set_selected_result(&mut self, state: &State) { 46 | let first_result = match state.search_result.list.first() { 47 | Some(result) => result.clone(), 48 | None => SearchResultState::default(), 49 | }; 50 | let selected_result = AppAction::Action(Action::SetSelectedResult { result: first_result.clone() }); 51 | self.command_tx.as_ref().unwrap().send(selected_result).unwrap(); 52 | } 53 | 54 | fn handle_input(&mut self, key: KeyEvent, state: &State) { 55 | let query = self.input.value(); 56 | 57 | if let Some(timer) = self.debounce_timer.take() { 58 | timer.abort(); 59 | } 60 | 61 | let tx = self.command_tx.clone().unwrap(); 62 | let search_text_action = AppAction::Action(Action::SetSearchText { text: query.to_string() }); 63 | let process_search_thunk = AppAction::Thunk(ThunkAction::ProcessSearch); 64 | 65 | if state.is_large_folder && key.code != KeyCode::Enter { 66 | tx.send(search_text_action).unwrap(); 67 | } else if !state.is_large_folder || key.code == KeyCode::Enter { 68 | self.debounce_timer = Some(tokio::spawn(async move { 69 | tokio::time::sleep(DEBOUNCE_DURATION).await; 70 | tx.send(search_text_action).unwrap(); 71 | tx.send(process_search_thunk).unwrap(); 72 | })); 73 | } 74 | } 75 | 76 | fn change_kind(&mut self, search_text_kind: SearchTextKind, state: &State) { 77 | let search_text_action = AppAction::Action(Action::SetSearchTextKind { kind: search_text_kind }); 78 | self.command_tx.as_ref().unwrap().send(search_text_action).unwrap(); 79 | 80 | #[cfg(feature = "ast_grep")] 81 | if search_text_kind == SearchTextKind::AstGrep { 82 | let replace_text_action = AppAction::Action(Action::SetReplaceTextKind { kind: ReplaceTextKind::AstGrep }); 83 | self.command_tx.as_ref().unwrap().send(replace_text_action).unwrap(); 84 | } else if state.replace_text.kind == ReplaceTextKind::AstGrep { 85 | let replace_text_action = AppAction::Action(Action::SetReplaceTextKind { kind: ReplaceTextKind::Simple }); 86 | self.command_tx.as_ref().unwrap().send(replace_text_action).unwrap(); 87 | } 88 | 89 | let process_search_thunk = AppAction::Thunk(ThunkAction::ProcessSearch); 90 | self.command_tx.as_ref().unwrap().send(process_search_thunk).unwrap(); 91 | self.set_selected_result(state); 92 | } 93 | } 94 | 95 | impl Component for Search { 96 | fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { 97 | self.command_tx = Some(tx); 98 | Ok(()) 99 | } 100 | 101 | fn handle_key_events(&mut self, key: KeyEvent, state: &State) -> Result> { 102 | if state.focused_screen == FocusedScreen::SearchInput { 103 | match (key.code, key.modifiers) { 104 | (KeyCode::Tab, _) | (KeyCode::BackTab, _) | (KeyCode::Char('b'), KeyModifiers::CONTROL) => Ok(None), 105 | (KeyCode::Char('n'), KeyModifiers::CONTROL) => { 106 | #[cfg(feature = "ast_grep")] 107 | let search_text_kind = match state.search_text.kind { 108 | SearchTextKind::Simple => SearchTextKind::MatchCase, 109 | SearchTextKind::MatchCase => SearchTextKind::MatchWholeWord, 110 | SearchTextKind::MatchWholeWord => SearchTextKind::MatchCaseWholeWord, 111 | SearchTextKind::MatchCaseWholeWord => SearchTextKind::Regex, 112 | SearchTextKind::Regex => SearchTextKind::AstGrep, 113 | SearchTextKind::AstGrep => SearchTextKind::Simple, 114 | }; 115 | #[cfg(not(feature = "ast_grep"))] 116 | let search_text_kind = match state.search_text.kind { 117 | SearchTextKind::Simple => SearchTextKind::MatchCase, 118 | SearchTextKind::MatchCase => SearchTextKind::MatchWholeWord, 119 | SearchTextKind::MatchWholeWord => SearchTextKind::MatchCaseWholeWord, 120 | SearchTextKind::MatchCaseWholeWord => SearchTextKind::Regex, 121 | SearchTextKind::Regex => SearchTextKind::Simple, 122 | }; 123 | self.change_kind(search_text_kind, state); 124 | Ok(None) 125 | }, 126 | (KeyCode::Enter, _) => { 127 | self.handle_input(key, state); 128 | Ok(None) 129 | }, 130 | (KeyCode::Char(_c), _) => { 131 | self.input.handle_event(&crossterm::event::Event::Key(key)); 132 | let key_bindings = self.config.keybindings.clone(); 133 | let quit_keys = find_keys_for_value(&key_bindings.0, AppAction::Tui(TuiAction::Quit)); 134 | if !is_quit_key(&quit_keys, &key) { 135 | self.handle_input(key, state); 136 | } 137 | Ok(None) 138 | }, 139 | (KeyCode::Backspace | KeyCode::Delete, _) => { 140 | self.input.handle_event(&crossterm::event::Event::Key(key)); 141 | let key_bindings = self.config.keybindings.clone(); 142 | let quit_keys = find_keys_for_value(&key_bindings.0, AppAction::Tui(TuiAction::Quit)); 143 | if !is_quit_key(&quit_keys, &key) { 144 | self.handle_input(key, state); 145 | } 146 | Ok(None) 147 | }, 148 | _ => { 149 | self.input.handle_event(&crossterm::event::Event::Key(key)); 150 | Ok(None) 151 | }, 152 | } 153 | } else { 154 | Ok(None) 155 | } 156 | } 157 | 158 | fn register_config_handler(&mut self, config: Config) -> Result<()> { 159 | self.config = config; 160 | Ok(()) 161 | } 162 | 163 | fn update(&mut self, action: AppAction) -> Result> { 164 | if let AppAction::Tui(TuiAction::Reset) = action { 165 | self.input.reset() 166 | } 167 | Ok(None) 168 | } 169 | 170 | fn draw(&mut self, f: &mut Frame<'_>, area: Rect, state: &State) -> Result<()> { 171 | let layout = get_layout(area); 172 | 173 | let search_kind = match state.search_text.kind { 174 | SearchTextKind::Simple => "[Simple]", 175 | SearchTextKind::MatchCase => "[Match Case]", 176 | SearchTextKind::MatchWholeWord => "[Match Whole Word]", 177 | SearchTextKind::Regex => "[Regex]", 178 | SearchTextKind::MatchCaseWholeWord => "[Match Case Whole Word]", 179 | #[cfg(feature = "ast_grep")] 180 | SearchTextKind::AstGrep => "[AST Grep]", 181 | }; 182 | 183 | let block = Block::bordered() 184 | .border_type(BorderType::Rounded) 185 | .title_top(Line::from("Search").left_aligned()) 186 | .title_top(Line::from(search_kind).right_aligned()); 187 | 188 | let block = if state.focused_screen == FocusedScreen::SearchInput { 189 | block.border_style(Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)) 190 | } else { 191 | block 192 | }; 193 | 194 | let width = layout.search_input.width.max(3) - 3; 195 | let scroll = self.input.visual_scroll(width as usize); 196 | 197 | let search_widget = Paragraph::new(self.input.value()) 198 | .style(Style::default().fg(Color::White)) 199 | .scroll((0, scroll as u16)) 200 | .block(block); 201 | 202 | if state.focused_screen == FocusedScreen::SearchInput { 203 | f.set_cursor_position(Position { 204 | x: layout.search_input.x + ((self.input.visual_cursor()).max(scroll) - scroll) as u16 + 1, 205 | y: layout.search_input.y + 1, 206 | }); 207 | }; 208 | 209 | f.render_widget(search_widget, layout.search_input); 210 | Ok(()) 211 | } 212 | } 213 | 214 | fn find_keys_for_value( 215 | key_bindings: &HashMap, AppAction>, 216 | quit: AppAction, 217 | ) -> Option>> { 218 | let mut quit_keys = Vec::new(); 219 | for (key, value) in key_bindings.iter() { 220 | if value == &quit { 221 | quit_keys.push(key.clone()); 222 | } 223 | } 224 | if quit_keys.is_empty() { 225 | None 226 | } else { 227 | Some(quit_keys) 228 | } 229 | } 230 | 231 | fn is_quit_key(quit_keys: &Option>>, key: &KeyEvent) -> bool { 232 | if let Some(quit_keys) = quit_keys { 233 | for keys in quit_keys { 234 | if keys.contains(key) { 235 | return true; 236 | } 237 | } 238 | } 239 | false 240 | } 241 | -------------------------------------------------------------------------------- /src/components/search_result.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, default, time::Duration}; 2 | 3 | use color_eyre::{eyre::Result, owo_colors::OwoColorize}; 4 | use crossterm::event::{KeyCode, KeyEvent}; 5 | use ratatui::{prelude::*, style::Stylize, widgets::*}; 6 | use serde::{Deserialize, Serialize}; 7 | use tokio::sync::mpsc::UnboundedSender; 8 | use tui_input::{backend::crossterm::EventHandler, Input}; 9 | 10 | use super::{Component, Frame}; 11 | use crate::{ 12 | action::AppAction, 13 | components::search_result, 14 | config::{Config, KeyBindings}, 15 | layout::get_layout, 16 | redux::{ 17 | action::Action, 18 | state::{FocusedScreen, SearchResultState, State}, 19 | thunk::ThunkAction, 20 | }, 21 | tabs::Tab, 22 | }; 23 | 24 | const DEBOUNCE_DURATION: Duration = Duration::from_millis(300); 25 | 26 | #[derive(Default)] 27 | pub struct SearchResult { 28 | command_tx: Option>, 29 | config: Config, 30 | state: ListState, 31 | match_counts: Vec, 32 | search_input: Input, 33 | is_searching: bool, 34 | search_matches: Vec, 35 | current_match_index: usize, 36 | } 37 | 38 | impl SearchResult { 39 | pub fn new() -> Self { 40 | Self::default() 41 | } 42 | 43 | fn delete_file(&mut self, state: &State) { 44 | if let Some(selected_index) = self.state.selected() { 45 | if selected_index < state.search_result.list.len() { 46 | let remove_file_from_list_thunk = AppAction::Thunk(ThunkAction::RemoveFileFromList(selected_index)); 47 | self.command_tx.as_ref().unwrap().send(remove_file_from_list_thunk).unwrap(); 48 | 49 | if state.search_result.list.len() > 1 { 50 | if selected_index >= state.search_result.list.len() - 1 { 51 | self.state.select(Some(state.search_result.list.len() - 2)); 52 | } else { 53 | self.state.select(Some(selected_index)); 54 | } 55 | } else { 56 | self.state.select(None); 57 | } 58 | 59 | self.update_selected_result(state); 60 | } 61 | } 62 | } 63 | 64 | fn next(&mut self, state: &State) { 65 | if state.search_result.list.is_empty() { 66 | return; 67 | } 68 | 69 | let new_index = match self.state.selected() { 70 | Some(i) => { 71 | if i >= state.search_result.list.len() - 1 { 72 | 0 73 | } else { 74 | i + 1 75 | } 76 | }, 77 | None => 0, 78 | }; 79 | self.state.select(Some(new_index)); 80 | self.update_selected_result(state); 81 | } 82 | 83 | fn previous(&mut self, state: &State) { 84 | if state.search_result.list.is_empty() { 85 | return; 86 | } 87 | 88 | let new_index = match self.state.selected() { 89 | Some(i) => { 90 | if i == 0 { 91 | state.search_result.list.len() - 1 92 | } else { 93 | i - 1 94 | } 95 | }, 96 | None => state.search_result.list.len() - 1, 97 | }; 98 | self.state.select(Some(new_index)); 99 | self.update_selected_result(state); 100 | } 101 | 102 | fn update_selected_result(&mut self, state: &State) { 103 | if let Some(selected_index) = self.state.selected() { 104 | if let Some(selected_result) = state.search_result.list.get(selected_index) { 105 | let action = AppAction::Action(Action::SetSelectedResult { 106 | result: SearchResultState { 107 | index: Some(selected_index), 108 | path: selected_result.path.clone(), 109 | matches: selected_result.matches.clone(), 110 | total_matches: selected_result.total_matches, 111 | }, 112 | }); 113 | self.command_tx.as_ref().unwrap().send(action).unwrap(); 114 | } else { 115 | let action = AppAction::Action(Action::SetSelectedResult { result: SearchResultState::default() }); 116 | self.command_tx.as_ref().unwrap().send(action).unwrap(); 117 | self.state.select(None); 118 | } 119 | } else { 120 | let action = AppAction::Action(Action::SetSelectedResult { result: SearchResultState::default() }); 121 | self.command_tx.as_ref().unwrap().send(action).unwrap(); 122 | } 123 | } 124 | 125 | fn set_selected_result(&mut self, state: &State) { 126 | if state.search_result.list.is_empty() { 127 | self.state.select(None); 128 | return; 129 | } 130 | 131 | if let Some(selected_index) = self.state.selected() { 132 | if selected_index >= state.search_result.list.len() { 133 | self.state.select(Some(state.search_result.list.len() - 1)); 134 | } 135 | } else { 136 | self.state.select(Some(0)); 137 | } 138 | 139 | self.update_selected_result(state); 140 | } 141 | 142 | fn top(&mut self, state: &State) { 143 | if state.search_result.list.is_empty() { 144 | return; 145 | } 146 | 147 | self.state.select(Some(0)); 148 | let selected_result = state.search_result.list.first().unwrap(); 149 | let action = AppAction::Action(Action::SetSelectedResult { 150 | result: SearchResultState { 151 | index: selected_result.index, 152 | path: selected_result.path.clone(), 153 | matches: selected_result.matches.clone(), 154 | total_matches: selected_result.total_matches, 155 | }, 156 | }); 157 | self.command_tx.as_ref().unwrap().send(action).unwrap(); 158 | } 159 | 160 | fn bottom(&mut self, state: &State) { 161 | if state.search_result.list.is_empty() { 162 | return; 163 | } 164 | 165 | let i = state.search_result.list.len() - 1; 166 | self.state.select(Some(i)); 167 | let selected_result = state.search_result.list.get(i).unwrap(); 168 | let action = AppAction::Action(Action::SetSelectedResult { 169 | result: SearchResultState { 170 | index: selected_result.index, 171 | path: selected_result.path.clone(), 172 | matches: selected_result.matches.clone(), 173 | total_matches: selected_result.total_matches, 174 | }, 175 | }); 176 | self.command_tx.as_ref().unwrap().send(action).unwrap(); 177 | } 178 | 179 | fn calculate_total_matches(&mut self, search_result_state: &SearchResultState) -> &str { 180 | let total_matches: usize = search_result_state.matches.iter().map(|m| m.submatches.len()).sum(); 181 | let total_matches_str = total_matches.to_string(); 182 | self.match_counts.push(total_matches_str); 183 | self.match_counts.last().unwrap() 184 | } 185 | 186 | fn replace_single_file(&mut self, state: &State) { 187 | if let Some(selected_index) = self.state.selected() { 188 | if selected_index < state.search_result.list.len() { 189 | let process_single_file_replace_thunk = AppAction::Thunk(ThunkAction::ProcessSingleFileReplace(selected_index)); 190 | self.command_tx.as_ref().unwrap().send(process_single_file_replace_thunk).unwrap(); 191 | } 192 | } 193 | } 194 | 195 | fn handle_local_key_events(&mut self, key: KeyEvent, state: &State) { 196 | match (key.code, key.modifiers) { 197 | (KeyCode::Char('d'), _) => { 198 | self.delete_file(state); 199 | }, 200 | (KeyCode::Char('g') | KeyCode::Char('h') | KeyCode::Left, _) => { 201 | self.top(state); 202 | }, 203 | (KeyCode::Char('G') | KeyCode::Char('l') | KeyCode::Right, _) => { 204 | self.bottom(state); 205 | }, 206 | (KeyCode::Char('j') | KeyCode::Down, _) => { 207 | self.next(state); 208 | }, 209 | (KeyCode::Char('k') | KeyCode::Up, _) => { 210 | self.previous(state); 211 | }, 212 | (KeyCode::Char('r'), _) => { 213 | self.replace_single_file(state); 214 | }, 215 | (KeyCode::Esc, _) => { 216 | self.is_searching = false; 217 | self.search_matches.clear(); 218 | self.search_input.reset(); 219 | self.current_match_index = 0; 220 | }, 221 | (KeyCode::Enter, _) => { 222 | let action = AppAction::Action(Action::SetActiveTab { tab: Tab::Preview }); 223 | self.command_tx.as_ref().unwrap().send(action).unwrap(); 224 | }, 225 | (KeyCode::Char('n'), _) => { 226 | self.next_match(state); 227 | }, 228 | (KeyCode::Char('p'), _) => { 229 | self.previous_match(state); 230 | }, 231 | _ => {}, 232 | } 233 | } 234 | 235 | fn handle_search_input(&mut self, key: KeyEvent, state: &State) { 236 | match key.code { 237 | KeyCode::Esc | KeyCode::Enter => { 238 | self.is_searching = false; 239 | }, 240 | _ => { 241 | self.search_input.handle_event(&crossterm::event::Event::Key(key)); 242 | self.perform_search(state); 243 | }, 244 | } 245 | } 246 | 247 | fn perform_search(&mut self, state: &State) { 248 | let search_term = self.search_input.value().to_lowercase(); 249 | self.search_matches.clear(); 250 | self.current_match_index = 0; 251 | 252 | for (index, result) in state.search_result.list.iter().enumerate() { 253 | if result.path.to_lowercase().contains(&search_term) { 254 | let result_index = result.index.unwrap(); 255 | self.search_matches.push(result_index); 256 | } 257 | } 258 | log::info!("111Search matches: {:?}", self.search_matches); 259 | 260 | if !self.search_matches.is_empty() { 261 | self.state.select(Some(self.search_matches[0])); 262 | self.update_selected_result(state); 263 | } 264 | } 265 | 266 | fn next_match(&mut self, state: &State) { 267 | log::info!("Next match"); 268 | log::info!("Search matches: {:?}", self.search_matches); 269 | if !self.search_matches.is_empty() { 270 | self.current_match_index = (self.current_match_index + 1) % self.search_matches.len(); 271 | let next_index = self.search_matches[self.current_match_index]; 272 | self.state.select(Some(next_index)); 273 | self.update_selected_result(state); 274 | } 275 | } 276 | 277 | fn previous_match(&mut self, state: &State) { 278 | if !self.search_matches.is_empty() { 279 | self.current_match_index = (self.current_match_index + self.search_matches.len() - 1) % self.search_matches.len(); 280 | let prev_index = self.search_matches[self.current_match_index]; 281 | self.state.select(Some(prev_index)); 282 | self.update_selected_result(state); 283 | } 284 | } 285 | } 286 | 287 | impl Component for SearchResult { 288 | fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { 289 | self.command_tx = Some(tx); 290 | Ok(()) 291 | } 292 | 293 | fn register_config_handler(&mut self, config: Config) -> Result<()> { 294 | self.config = config; 295 | Ok(()) 296 | } 297 | 298 | fn handle_key_events(&mut self, key: KeyEvent, state: &State) -> Result> { 299 | if state.focused_screen == FocusedScreen::SearchResultList { 300 | match key.code { 301 | KeyCode::Char('/') => { 302 | self.is_searching = true; 303 | self.search_input.reset(); 304 | Ok(None) 305 | }, 306 | _ if self.is_searching => { 307 | self.handle_search_input(key, state); 308 | Ok(None) 309 | }, 310 | _ => { 311 | self.handle_local_key_events(key, state); 312 | Ok(None) 313 | }, 314 | } 315 | } else { 316 | Ok(None) 317 | } 318 | } 319 | 320 | fn draw(&mut self, f: &mut Frame<'_>, area: Rect, state: &State) -> Result<()> { 321 | let layout = get_layout(area); 322 | 323 | let block = Block::bordered().border_type(BorderType::Rounded).title(Line::from("Result List").left_aligned()); 324 | let block = if state.focused_screen == FocusedScreen::SearchResultList { 325 | block.border_style(Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)) 326 | } else { 327 | block 328 | }; 329 | 330 | let project_root = state.project_root.to_string_lossy(); 331 | let results_to_display = &state.search_result.list; 332 | let search_term = self.search_input.value().to_lowercase(); 333 | 334 | let list_items: Vec = results_to_display 335 | .iter() 336 | .enumerate() 337 | .map(|(index, s)| { 338 | let path = s.path.strip_prefix(format!("{project_root}/").as_str()).unwrap_or(&s.path); 339 | let mut spans = Vec::new(); 340 | let mut start = 0; 341 | 342 | if !search_term.is_empty() { 343 | for (idx, _) in path.to_lowercase().match_indices(&search_term) { 344 | if start < idx { 345 | spans.push(Span::raw(&path[start..idx])); 346 | } 347 | spans.push(Span::styled( 348 | &path[idx..idx + search_term.len()], 349 | Style::default().bg(Color::Yellow).fg(Color::Black), 350 | )); 351 | start = idx + search_term.len(); 352 | } 353 | } 354 | 355 | if start < path.len() { 356 | spans.push(Span::raw(&path[start..])); 357 | } 358 | 359 | spans.push(Span::raw(" (")); 360 | spans.push(Span::styled(s.total_matches.to_string(), Style::default().fg(Color::Yellow))); 361 | spans.push(Span::raw(")")); 362 | 363 | ListItem::new(Line::from(spans)) 364 | }) 365 | .collect(); 366 | 367 | let internal_selected = self.state.selected().unwrap_or(0); 368 | 369 | let details_widget = List::new(list_items) 370 | .style(Style::default().fg(Color::White)) 371 | .highlight_style(Style::default().bg(Color::Blue)) 372 | .block(block); 373 | f.render_stateful_widget(details_widget, layout.search_details, &mut self.state); 374 | 375 | if self.is_searching { 376 | let search_input = Paragraph::new(self.search_input.value()) 377 | .style(Style::default().fg(Color::White)) 378 | .block(Block::default().borders(Borders::ALL).title("Search")); 379 | let input_area = Rect::new( 380 | layout.search_details.x, 381 | layout.search_details.y + layout.search_details.height - 3, 382 | layout.search_details.width, 383 | 3, 384 | ); 385 | f.render_widget(search_input, input_area); 386 | f.set_cursor_position(Position { x: input_area.x + self.search_input.cursor() as u16 + 1, y: input_area.y + 1 }); 387 | } 388 | 389 | Ok(()) 390 | } 391 | } 392 | -------------------------------------------------------------------------------- /src/components/small_help.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::{HashMap, HashSet}, 3 | process::Command, 4 | time::Duration, 5 | }; 6 | 7 | use color_eyre::eyre::Result; 8 | use crossterm::event::{KeyCode, KeyEvent}; 9 | use ratatui::prelude::*; 10 | use serde::{Deserialize, Serialize}; 11 | use tokio::sync::mpsc::UnboundedSender; 12 | use tracing::{event, trace, Level}; 13 | use tui_input::{backend::crossterm::EventHandler, Input}; 14 | 15 | use super::{Component, Frame}; 16 | use crate::{ 17 | action::{AppAction, TuiAction}, 18 | components::notifications::NotificationEnum, 19 | config::{Config, KeyBindings}, 20 | layout::get_layout, 21 | redux::{ 22 | action::Action, 23 | state::{FocusedScreen, State}, 24 | thunk::ThunkAction, 25 | }, 26 | ripgrep::RipgrepOutput, 27 | tabs::Tab, 28 | ui::small_help_widget::SmallHelpWidget, 29 | }; 30 | 31 | #[derive(Default)] 32 | pub struct SmallHelp { 33 | command_tx: Option>, 34 | config: Config, 35 | input: Input, 36 | } 37 | 38 | impl SmallHelp { 39 | pub fn new() -> Self { 40 | Self::default() 41 | } 42 | } 43 | 44 | impl Component for SmallHelp { 45 | fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { 46 | self.command_tx = Some(tx); 47 | Ok(()) 48 | } 49 | 50 | fn register_config_handler(&mut self, config: Config) -> Result<()> { 51 | self.config = config; 52 | Ok(()) 53 | } 54 | 55 | fn draw(&mut self, f: &mut Frame<'_>, area: Rect, state: &State) -> Result<()> { 56 | let layout = get_layout(area); 57 | let content = match state.focused_screen { 58 | FocusedScreen::SearchInput => "Help: | Search: | Toggle search mode: ", 59 | FocusedScreen::ReplaceInput => "Help: | Replace: | Toggle replace mode: ", 60 | FocusedScreen::SearchResultList => "Help: | Open File: | Replace File: | Next: | Previous: | Top: | Bottom: | Delete file: ", 61 | FocusedScreen::Preview => "Help: | Back to list: | Replace Line: | Next: | Previous: | Top: | Bottom: | Delete line: ", 62 | FocusedScreen::ConfirmReplaceDialog => "Confirm Replace: | Cancel Replace: , Left: , Right: , Loop: ", 63 | FocusedScreen::ConfirmGitDirectoryDialog => "Confirm Replace: | Cancel Replace: , Left: , Right: , Loop: ", 64 | FocusedScreen::HelpDialog => "Close Help: | Next Tab: | Previous Tab: ", 65 | }; 66 | 67 | let small_help = SmallHelpWidget::new(content.to_string(), Color::Blue, Alignment::Left); 68 | f.render_widget(small_help, layout.status_left); 69 | Ok(()) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/components/status.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::{HashMap, HashSet}, 3 | process::Command, 4 | time::Duration, 5 | }; 6 | 7 | use color_eyre::eyre::Result; 8 | use crossterm::event::{KeyCode, KeyEvent}; 9 | use ratatui::prelude::*; 10 | use serde::{Deserialize, Serialize}; 11 | use tokio::sync::mpsc::UnboundedSender; 12 | use tracing::{event, trace, Level}; 13 | use tui_input::{backend::crossterm::EventHandler, Input}; 14 | 15 | use super::{Component, Frame}; 16 | use crate::{ 17 | action::{AppAction, TuiAction}, 18 | components::notifications::NotificationEnum, 19 | config::{Config, KeyBindings}, 20 | layout::get_layout, 21 | redux::{action::Action, state::State, thunk::ThunkAction}, 22 | ripgrep::RipgrepOutput, 23 | tabs::Tab, 24 | ui::small_help_widget::SmallHelpWidget, 25 | }; 26 | 27 | #[derive(Default)] 28 | pub struct Status { 29 | command_tx: Option>, 30 | config: Config, 31 | input: Input, 32 | content: String, 33 | } 34 | 35 | impl Status { 36 | pub fn new() -> Self { 37 | Self::default() 38 | } 39 | } 40 | 41 | impl Component for Status { 42 | fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { 43 | self.command_tx = Some(tx); 44 | Ok(()) 45 | } 46 | 47 | fn register_config_handler(&mut self, config: Config) -> Result<()> { 48 | self.config = config; 49 | Ok(()) 50 | } 51 | 52 | fn update(&mut self, action: AppAction) -> Result> { 53 | if let AppAction::Tui(TuiAction::Status(content)) = action { 54 | self.content = content; 55 | } 56 | Ok(None) 57 | } 58 | 59 | fn draw(&mut self, f: &mut Frame<'_>, area: Rect, state: &State) -> Result<()> { 60 | let layout = get_layout(area); 61 | 62 | let small_help = SmallHelpWidget::new(self.content.clone(), Color::Yellow, Alignment::Right); 63 | f.render_widget(small_help, layout.status_right); 64 | Ok(()) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, fmt, path::PathBuf}; 2 | 3 | use color_eyre::eyre::Result; 4 | use config::Value; 5 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; 6 | use derive_deref::{Deref, DerefMut}; 7 | use ratatui::style::{Color, Modifier, Style}; 8 | use serde::{ 9 | de::{self, Deserializer, MapAccess, Visitor}, 10 | Deserialize, Serialize, 11 | }; 12 | use serde_json::Value as JsonValue; 13 | 14 | use crate::{ 15 | action::{AppAction, TuiAction}, 16 | mode::Mode, 17 | }; 18 | 19 | const CONFIG: &str = include_str!("../.config/config.json5"); 20 | 21 | #[derive(Clone, Debug, Deserialize, Default)] 22 | pub struct AppConfig { 23 | #[serde(default)] 24 | pub _data_dir: PathBuf, 25 | #[serde(default)] 26 | pub _config_dir: PathBuf, 27 | } 28 | 29 | #[derive(Clone, Debug, Default, Deserialize)] 30 | pub struct Config { 31 | #[serde(default, flatten)] 32 | pub config: AppConfig, 33 | #[serde(default)] 34 | pub keybindings: KeyBindings, 35 | #[serde(default)] 36 | pub styles: Styles, 37 | } 38 | 39 | impl Config { 40 | pub fn new() -> Result { 41 | let default_config: Config = json5::from_str(CONFIG).unwrap(); 42 | let data_dir = crate::utils::get_data_dir(); 43 | let config_dir = crate::utils::get_config_dir(); 44 | let mut builder = config::Config::builder() 45 | .set_default("_data_dir", data_dir.to_str().unwrap())? 46 | .set_default("_config_dir", config_dir.to_str().unwrap())?; 47 | 48 | let config_files = [ 49 | ("config.json5", config::FileFormat::Json5), 50 | ("config.json", config::FileFormat::Json), 51 | ("config.yaml", config::FileFormat::Yaml), 52 | ("config.toml", config::FileFormat::Toml), 53 | ("config.ini", config::FileFormat::Ini), 54 | ]; 55 | let mut found_config = false; 56 | for (file, format) in &config_files { 57 | builder = builder.add_source(config::File::from(config_dir.join(file)).format(*format).required(false)); 58 | if config_dir.join(file).exists() { 59 | found_config = true 60 | } 61 | } 62 | if !found_config { 63 | log::error!("No configuration file found. Application may not behave as expected"); 64 | } 65 | 66 | let mut cfg: Self = builder.build()?.try_deserialize()?; 67 | 68 | let user_bindings = &mut cfg.keybindings; 69 | for (key, cmd) in default_config.keybindings.iter() { 70 | user_bindings.entry(key.clone()).or_insert_with(|| cmd.clone()); 71 | } 72 | 73 | for (mode, default_styles) in default_config.styles.iter() { 74 | let user_styles = cfg.styles.entry(*mode).or_default(); 75 | for (style_key, style) in default_styles.iter() { 76 | user_styles.entry(style_key.clone()).or_insert(*style); 77 | } 78 | } 79 | 80 | Ok(cfg) 81 | } 82 | } 83 | 84 | #[derive(Clone, Debug, Default, Deref, DerefMut)] 85 | pub struct KeyBindings(pub HashMap, AppAction>); 86 | 87 | impl<'de> Deserialize<'de> for KeyBindings { 88 | fn deserialize(deserializer: D) -> Result 89 | where 90 | D: Deserializer<'de>, 91 | { 92 | let parsed_map = HashMap::::deserialize(deserializer)?; 93 | let keybindings = 94 | parsed_map.into_iter().map(|(key_str, cmd)| (parse_key_sequence(&key_str).unwrap(), cmd)).collect(); 95 | 96 | Ok(KeyBindings(keybindings)) 97 | } 98 | } 99 | 100 | fn parse_key_event(raw: &str) -> Result { 101 | let raw_lower = raw.to_ascii_lowercase(); 102 | let (remaining, modifiers) = extract_modifiers(&raw_lower); 103 | parse_key_code_with_modifiers(remaining, modifiers) 104 | } 105 | 106 | fn extract_modifiers(raw: &str) -> (&str, KeyModifiers) { 107 | let mut modifiers = KeyModifiers::empty(); 108 | let mut current = raw; 109 | 110 | loop { 111 | match current { 112 | rest if rest.starts_with("ctrl-") => { 113 | modifiers.insert(KeyModifiers::CONTROL); 114 | current = &rest[5..]; 115 | }, 116 | rest if rest.starts_with("alt-") => { 117 | modifiers.insert(KeyModifiers::ALT); 118 | current = &rest[4..]; 119 | }, 120 | rest if rest.starts_with("shift-") => { 121 | modifiers.insert(KeyModifiers::SHIFT); 122 | current = &rest[6..]; 123 | }, 124 | _ => break, // break out of the loop if no known prefix is detected 125 | }; 126 | } 127 | 128 | (current, modifiers) 129 | } 130 | 131 | fn parse_key_code_with_modifiers(raw: &str, mut modifiers: KeyModifiers) -> Result { 132 | let c = match raw { 133 | "esc" => KeyCode::Esc, 134 | "enter" => KeyCode::Enter, 135 | "left" => KeyCode::Left, 136 | "right" => KeyCode::Right, 137 | "up" => KeyCode::Up, 138 | "down" => KeyCode::Down, 139 | "home" => KeyCode::Home, 140 | "end" => KeyCode::End, 141 | "pageup" => KeyCode::PageUp, 142 | "pagedown" => KeyCode::PageDown, 143 | "backtab" => { 144 | modifiers.insert(KeyModifiers::SHIFT); 145 | KeyCode::BackTab 146 | }, 147 | "backspace" => KeyCode::Backspace, 148 | "delete" => KeyCode::Delete, 149 | "insert" => KeyCode::Insert, 150 | "f1" => KeyCode::F(1), 151 | "f2" => KeyCode::F(2), 152 | "f3" => KeyCode::F(3), 153 | "f4" => KeyCode::F(4), 154 | "f5" => KeyCode::F(5), 155 | "f6" => KeyCode::F(6), 156 | "f7" => KeyCode::F(7), 157 | "f8" => KeyCode::F(8), 158 | "f9" => KeyCode::F(9), 159 | "f10" => KeyCode::F(10), 160 | "f11" => KeyCode::F(11), 161 | "f12" => KeyCode::F(12), 162 | "space" => KeyCode::Char(' '), 163 | "hyphen" => KeyCode::Char('-'), 164 | "minus" => KeyCode::Char('-'), 165 | "tab" => KeyCode::Tab, 166 | c if c.len() == 1 => { 167 | let mut c = c.chars().next().unwrap(); 168 | if modifiers.contains(KeyModifiers::SHIFT) { 169 | c = c.to_ascii_uppercase(); 170 | } 171 | KeyCode::Char(c) 172 | }, 173 | _ => return Err(format!("Unable to parse {raw}")), 174 | }; 175 | Ok(KeyEvent::new(c, modifiers)) 176 | } 177 | 178 | pub fn key_event_to_string(key_event: &KeyEvent) -> String { 179 | let char; 180 | let key_code = match key_event.code { 181 | KeyCode::Backspace => "backspace", 182 | KeyCode::Enter => "enter", 183 | KeyCode::Left => "left", 184 | KeyCode::Right => "right", 185 | KeyCode::Up => "up", 186 | KeyCode::Down => "down", 187 | KeyCode::Home => "home", 188 | KeyCode::End => "end", 189 | KeyCode::PageUp => "pageup", 190 | KeyCode::PageDown => "pagedown", 191 | KeyCode::Tab => "tab", 192 | KeyCode::BackTab => "backtab", 193 | KeyCode::Delete => "delete", 194 | KeyCode::Insert => "insert", 195 | KeyCode::F(c) => { 196 | char = format!("f({c})"); 197 | &char 198 | }, 199 | KeyCode::Char(' ') => "space", 200 | KeyCode::Char(c) => { 201 | char = c.to_string(); 202 | &char 203 | }, 204 | KeyCode::Esc => "esc", 205 | KeyCode::Null => "", 206 | KeyCode::CapsLock => "", 207 | KeyCode::Menu => "", 208 | KeyCode::ScrollLock => "", 209 | KeyCode::Media(_) => "", 210 | KeyCode::NumLock => "", 211 | KeyCode::PrintScreen => "", 212 | KeyCode::Pause => "", 213 | KeyCode::KeypadBegin => "", 214 | KeyCode::Modifier(_) => "", 215 | }; 216 | 217 | let mut modifiers = Vec::with_capacity(3); 218 | 219 | if key_event.modifiers.intersects(KeyModifiers::CONTROL) { 220 | modifiers.push("ctrl"); 221 | } 222 | 223 | if key_event.modifiers.intersects(KeyModifiers::SHIFT) { 224 | modifiers.push("shift"); 225 | } 226 | 227 | if key_event.modifiers.intersects(KeyModifiers::ALT) { 228 | modifiers.push("alt"); 229 | } 230 | 231 | let mut key = modifiers.join("-"); 232 | 233 | if !key.is_empty() { 234 | key.push('-'); 235 | } 236 | key.push_str(key_code); 237 | 238 | key 239 | } 240 | 241 | pub fn parse_key_sequence(raw: &str) -> Result, String> { 242 | if raw.chars().filter(|c| *c == '>').count() != raw.chars().filter(|c| *c == '<').count() { 243 | return Err(format!("Unable to parse `{raw}`")); 244 | } 245 | let raw = if !raw.contains("><") { 246 | let raw = raw.strip_prefix('<').unwrap_or(raw); 247 | let raw = raw.strip_prefix('>').unwrap_or(raw); 248 | raw 249 | } else { 250 | raw 251 | }; 252 | let sequences = raw 253 | .split("><") 254 | .map(|seq| { 255 | if let Some(s) = seq.strip_prefix('<') { 256 | s 257 | } else if let Some(s) = seq.strip_suffix('>') { 258 | s 259 | } else { 260 | seq 261 | } 262 | }) 263 | .collect::>(); 264 | 265 | sequences.into_iter().map(parse_key_event).collect() 266 | } 267 | 268 | #[derive(Clone, Debug, Default, Deref, DerefMut)] 269 | pub struct Styles(pub HashMap>); 270 | 271 | impl<'de> Deserialize<'de> for Styles { 272 | fn deserialize(deserializer: D) -> Result 273 | where 274 | D: Deserializer<'de>, 275 | { 276 | let parsed_map = HashMap::>::deserialize(deserializer)?; 277 | 278 | let styles = parsed_map 279 | .into_iter() 280 | .map(|(mode, inner_map)| { 281 | let converted_inner_map = inner_map.into_iter().map(|(str, style)| (str, parse_style(&style))).collect(); 282 | (mode, converted_inner_map) 283 | }) 284 | .collect(); 285 | 286 | Ok(Styles(styles)) 287 | } 288 | } 289 | 290 | pub fn parse_style(line: &str) -> Style { 291 | let (foreground, background) = line.split_at(line.to_lowercase().find("on ").unwrap_or(line.len())); 292 | let foreground = process_color_string(foreground); 293 | let background = process_color_string(&background.replace("on ", "")); 294 | 295 | let mut style = Style::default(); 296 | if let Some(fg) = parse_color(&foreground.0) { 297 | style = style.fg(fg); 298 | } 299 | if let Some(bg) = parse_color(&background.0) { 300 | style = style.bg(bg); 301 | } 302 | style = style.add_modifier(foreground.1 | background.1); 303 | style 304 | } 305 | 306 | fn process_color_string(color_str: &str) -> (String, Modifier) { 307 | let color = color_str 308 | .replace("grey", "gray") 309 | .replace("bright ", "") 310 | .replace("bold ", "") 311 | .replace("underline ", "") 312 | .replace("inverse ", ""); 313 | 314 | let mut modifiers = Modifier::empty(); 315 | if color_str.contains("underline") { 316 | modifiers |= Modifier::UNDERLINED; 317 | } 318 | if color_str.contains("bold") { 319 | modifiers |= Modifier::BOLD; 320 | } 321 | if color_str.contains("inverse") { 322 | modifiers |= Modifier::REVERSED; 323 | } 324 | 325 | (color, modifiers) 326 | } 327 | 328 | fn parse_color(s: &str) -> Option { 329 | let s = s.trim_start(); 330 | let s = s.trim_end(); 331 | if s.contains("bright color") { 332 | let s = s.trim_start_matches("bright "); 333 | let c = s.trim_start_matches("color").parse::().unwrap_or_default(); 334 | Some(Color::Indexed(c.wrapping_shl(8))) 335 | } else if s.contains("color") { 336 | let c = s.trim_start_matches("color").parse::().unwrap_or_default(); 337 | Some(Color::Indexed(c)) 338 | } else if s.contains("gray") { 339 | let c = 232 + s.trim_start_matches("gray").parse::().unwrap_or_default(); 340 | Some(Color::Indexed(c)) 341 | } else if s.contains("rgb") { 342 | let red = (s.as_bytes()[3] as char).to_digit(10).unwrap_or_default() as u8; 343 | let green = (s.as_bytes()[4] as char).to_digit(10).unwrap_or_default() as u8; 344 | let blue = (s.as_bytes()[5] as char).to_digit(10).unwrap_or_default() as u8; 345 | let c = 16 + red * 36 + green * 6 + blue; 346 | Some(Color::Indexed(c)) 347 | } else if s == "bold black" { 348 | Some(Color::Indexed(8)) 349 | } else if s == "bold red" { 350 | Some(Color::Indexed(9)) 351 | } else if s == "bold green" { 352 | Some(Color::Indexed(10)) 353 | } else if s == "bold yellow" { 354 | Some(Color::Indexed(11)) 355 | } else if s == "bold blue" { 356 | Some(Color::Indexed(12)) 357 | } else if s == "bold magenta" { 358 | Some(Color::Indexed(13)) 359 | } else if s == "bold cyan" { 360 | Some(Color::Indexed(14)) 361 | } else if s == "bold white" { 362 | Some(Color::Indexed(15)) 363 | } else if s == "black" { 364 | Some(Color::Indexed(0)) 365 | } else if s == "red" { 366 | Some(Color::Indexed(1)) 367 | } else if s == "green" { 368 | Some(Color::Indexed(2)) 369 | } else if s == "yellow" { 370 | Some(Color::Indexed(3)) 371 | } else if s == "blue" { 372 | Some(Color::Indexed(4)) 373 | } else if s == "magenta" { 374 | Some(Color::Indexed(5)) 375 | } else if s == "cyan" { 376 | Some(Color::Indexed(6)) 377 | } else if s == "white" { 378 | Some(Color::Indexed(7)) 379 | } else { 380 | None 381 | } 382 | } 383 | 384 | #[cfg(test)] 385 | mod tests { 386 | use pretty_assertions::assert_eq; 387 | 388 | use super::*; 389 | 390 | #[test] 391 | fn test_parse_style_default() { 392 | let style = parse_style(""); 393 | assert_eq!(style, Style::default()); 394 | } 395 | 396 | #[test] 397 | fn test_parse_style_foreground() { 398 | let style = parse_style("red"); 399 | assert_eq!(style.fg, Some(Color::Indexed(1))); 400 | } 401 | 402 | #[test] 403 | fn test_parse_style_background() { 404 | let style = parse_style("on blue"); 405 | assert_eq!(style.bg, Some(Color::Indexed(4))); 406 | } 407 | 408 | #[test] 409 | fn test_parse_style_modifiers() { 410 | let style = parse_style("underline red on blue"); 411 | assert_eq!(style.fg, Some(Color::Indexed(1))); 412 | assert_eq!(style.bg, Some(Color::Indexed(4))); 413 | } 414 | 415 | #[test] 416 | fn test_process_color_string() { 417 | let (color, modifiers) = process_color_string("underline bold inverse gray"); 418 | assert_eq!(color, "gray"); 419 | assert!(modifiers.contains(Modifier::UNDERLINED)); 420 | assert!(modifiers.contains(Modifier::BOLD)); 421 | assert!(modifiers.contains(Modifier::REVERSED)); 422 | } 423 | 424 | #[test] 425 | fn test_parse_color_rgb() { 426 | let color = parse_color("rgb123"); 427 | let expected = 16 + 36 + 2 * 6 + 3; 428 | assert_eq!(color, Some(Color::Indexed(expected))); 429 | } 430 | 431 | #[test] 432 | fn test_parse_color_unknown() { 433 | let color = parse_color("unknown"); 434 | assert_eq!(color, None); 435 | } 436 | 437 | // #[test] 438 | // fn test_config() -> Result<()> { 439 | // let c = Config::new()?; 440 | // assert_eq!( 441 | // c.keybindings.get(&Mode::Home).unwrap().get(&parse_key_sequence("").unwrap_or_default()).unwrap(), 442 | // &Action::Quit 443 | // ); 444 | // Ok(()) 445 | // } 446 | 447 | #[test] 448 | fn test_simple_keys() { 449 | assert_eq!(parse_key_event("a").unwrap(), KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())); 450 | 451 | assert_eq!(parse_key_event("enter").unwrap(), KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())); 452 | 453 | assert_eq!(parse_key_event("esc").unwrap(), KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())); 454 | } 455 | 456 | #[test] 457 | fn test_with_modifiers() { 458 | assert_eq!(parse_key_event("ctrl-a").unwrap(), KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)); 459 | 460 | assert_eq!(parse_key_event("alt-enter").unwrap(), KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT)); 461 | 462 | assert_eq!(parse_key_event("shift-esc").unwrap(), KeyEvent::new(KeyCode::Esc, KeyModifiers::SHIFT)); 463 | } 464 | 465 | #[test] 466 | fn test_multiple_modifiers() { 467 | assert_eq!( 468 | parse_key_event("ctrl-alt-a").unwrap(), 469 | KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL | KeyModifiers::ALT) 470 | ); 471 | 472 | assert_eq!( 473 | parse_key_event("ctrl-shift-enter").unwrap(), 474 | KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT) 475 | ); 476 | } 477 | 478 | #[test] 479 | fn test_reverse_multiple_modifiers() { 480 | assert_eq!( 481 | key_event_to_string(&KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL | KeyModifiers::ALT)), 482 | "ctrl-alt-a".to_string() 483 | ); 484 | } 485 | 486 | #[test] 487 | fn test_invalid_keys() { 488 | assert!(parse_key_event("invalid-key").is_err()); 489 | assert!(parse_key_event("ctrl-invalid-key").is_err()); 490 | } 491 | 492 | #[test] 493 | fn test_case_insensitivity() { 494 | assert_eq!(parse_key_event("CTRL-a").unwrap(), KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)); 495 | 496 | assert_eq!(parse_key_event("AlT-eNtEr").unwrap(), KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT)); 497 | } 498 | } 499 | -------------------------------------------------------------------------------- /src/layout.rs: -------------------------------------------------------------------------------- 1 | use std::cmp::min; 2 | 3 | use ratatui::{prelude::*, widgets::*}; 4 | 5 | const VERTICAL_CONSTRAINTS: [Constraint; 3] = [ 6 | Constraint::Length(3), // Search input height 7 | Constraint::Length(3), // Replace input height 8 | Constraint::Min(0), // Remaining space for search details 9 | ]; 10 | 11 | const HORIZONTAL_CONSTRAINTS: [Constraint; 2] = [ 12 | Constraint::Percentage(30), // Left column 13 | Constraint::Percentage(70), // Right column 14 | ]; 15 | 16 | const STATUS_HORIZONTAL_CONSTRAINTS: [Constraint; 2] = [ 17 | Constraint::Percentage(70), // status/help note 18 | Constraint::Percentage(30), // Status/spinning loader 19 | ]; 20 | 21 | const STATUS_CONSTRAINT: Constraint = Constraint::Length(1); 22 | 23 | pub struct LayoutRects { 24 | pub search_input: Rect, 25 | pub replace_input: Rect, 26 | pub search_details: Rect, 27 | pub status_left: Rect, 28 | pub status_right: Rect, 29 | pub preview: Rect, 30 | } 31 | 32 | pub fn get_layout(area: Rect) -> LayoutRects { 33 | // Split the area into main content and status 34 | let main_layout = Layout::default() 35 | .direction(Direction::Vertical) 36 | .constraints([Constraint::Min(0), STATUS_CONSTRAINT].as_ref()) 37 | .split(area); 38 | 39 | // Split the main content into left and right columns 40 | let horizontal_layout = Layout::default() 41 | .direction(Direction::Horizontal) 42 | .constraints(HORIZONTAL_CONSTRAINTS.as_ref()) 43 | .split(main_layout[0]); 44 | 45 | // Split the left column into vertical sections 46 | let vertical_layout = Layout::default() 47 | .direction(Direction::Vertical) 48 | .constraints(VERTICAL_CONSTRAINTS.as_ref()) 49 | .split(horizontal_layout[0]); 50 | 51 | // Split the status area into left and right parts 52 | let status_layout = Layout::default() 53 | .direction(Direction::Horizontal) 54 | .constraints(STATUS_HORIZONTAL_CONSTRAINTS.as_ref()) 55 | .split(main_layout[1]); 56 | 57 | LayoutRects { 58 | search_input: vertical_layout[0], 59 | replace_input: vertical_layout[1], 60 | search_details: vertical_layout[2], 61 | preview: horizontal_layout[1], 62 | status_left: status_layout[0], 63 | status_right: status_layout[1], 64 | } 65 | } 66 | 67 | pub fn get_notification_layout(rect: Rect, content: &str, i: u16) -> Rect { 68 | let line_width = content.lines().map(|line| line.len()).max().unwrap_or(0) as u16 + 4; 69 | let line_height = content.lines().count() as u16 + 2; 70 | let right = rect.width - line_width - 1; 71 | let bottom = rect.height - line_height - i * 3 - 2; 72 | 73 | Rect::new(right, bottom, line_width, line_height) 74 | } 75 | -------------------------------------------------------------------------------- /src/macros.rs: -------------------------------------------------------------------------------- 1 | #[macro_export] 2 | macro_rules! widgets { 3 | ( 4 | $name:ident; 5 | $( 6 | $widget:ident: 7 | $( 8 | [$mode:pat_param] 9 | => 10 | )? 11 | $struc:ident, 12 | )+ 13 | [popups]: { 14 | $( 15 | $(#[$docs:meta])* 16 | $pwidget:ident: 17 | $( 18 | [$pmode:pat_param] 19 | => 20 | )? 21 | $pstruc:ident, 22 | )+ 23 | } 24 | ) => { 25 | #[derive(Default)] 26 | pub struct $name { 27 | $( 28 | pub $widget: $struc, 29 | )+ 30 | $( 31 | $(#[$docs])* 32 | pub $pwidget: $pstruc, 33 | )+ 34 | } 35 | 36 | impl $name { 37 | fn draw_popups(&mut self, ctx: &$crate::app::Context, f: &mut ratatui::Frame) { 38 | match ctx.mode { 39 | $( 40 | $(#[$docs])* 41 | $($pmode => self.$pwidget.draw(f, ctx, f.size()),)? 42 | )+ 43 | _ => {} 44 | } 45 | 46 | } 47 | 48 | fn get_help(&self, mode: &$crate::app::Mode) -> Option> { 49 | match mode { 50 | $( 51 | $($mode => $struc::get_help(),)? 52 | )+ 53 | $( 54 | $(#[$docs])* 55 | $($pmode => $pstruc::get_help(),)? 56 | )+ 57 | _ => None, 58 | } 59 | } 60 | 61 | fn handle_event(&mut self, ctx: &mut $crate::app::Context, evt: &crossterm::event::Event) { 62 | match ctx.mode { 63 | $( 64 | $($mode => self.$widget.handle_event(ctx, evt),)? 65 | )+ 66 | $( 67 | $(#[$docs])* 68 | $($pmode => self.$pwidget.handle_event(ctx, evt),)? 69 | )+ 70 | _ => {} 71 | }; 72 | } 73 | } 74 | } 75 | } 76 | 77 | #[macro_export] 78 | macro_rules! cats { 79 | ( 80 | $( 81 | $cats:expr => {$($idx:expr => ($icon:expr, $disp:expr, $conf:expr, $col:tt$(.$colext:tt)*);)+} 82 | )+ 83 | ) => {{ 84 | let v = vec![ 85 | $( 86 | $crate::widget::category::CatStruct { 87 | name: $cats.to_string(), 88 | entries: vec![$($crate::widget::category::CatEntry::new( 89 | $disp, 90 | $conf, 91 | $idx, 92 | $icon, 93 | |theme: &$crate::theme::Theme| {theme.$col$(.$colext)*}, 94 | ), 95 | )+], 96 | },)+ 97 | ]; 98 | v 99 | }} 100 | } 101 | 102 | #[macro_export] 103 | macro_rules! style { 104 | ( 105 | $($method:ident$(:$value:expr)?),* $(,)? 106 | ) => {{ 107 | #[allow(unused_imports)] 108 | use ratatui::style::Stylize; 109 | 110 | let style = ratatui::style::Style::new() 111 | $(.$method($($value)?))?; 112 | style 113 | }}; 114 | } 115 | 116 | #[macro_export] 117 | macro_rules! title { 118 | // Single input 119 | ($arg:expr) => {{ 120 | let res = format!("{}", $arg); 121 | res 122 | }}; 123 | 124 | // format-like 125 | ($($arg:expr),*$(,)?) => {{ 126 | let res = format!("{}", format!($($arg),*)); 127 | res 128 | }}; 129 | 130 | // vec-like 131 | ($($arg:expr);*$(;)?) => {{ 132 | let res = vec![ 133 | $($arg,)* 134 | ]; 135 | res 136 | }}; 137 | } 138 | 139 | #[macro_export] 140 | macro_rules! collection { 141 | // map-like 142 | ($($k:expr => $v:expr),* $(,)?) => {{ 143 | core::convert::From::from([$(($k, $v),)*]) 144 | }}; 145 | // set-like 146 | ($($v:expr),* $(,)?) => {{ 147 | core::convert::From::from([$($v,)*]) 148 | }}; 149 | } 150 | 151 | #[macro_export] 152 | macro_rules! raw { 153 | ( 154 | $text:expr 155 | ) => {{ 156 | let raw = Text::raw($text); 157 | raw 158 | }}; 159 | } 160 | 161 | #[macro_export] 162 | macro_rules! cond_vec { 163 | ($($cond:expr => $x:expr),+ $(,)?) => {{ 164 | let v = vec![$(($cond, $x)),*].iter().filter_map(|(c, x)| { 165 | if *c { Some(x.to_owned()) } else { None } 166 | }).collect::>(); 167 | v 168 | }}; 169 | ($cond:expr ; $x:expr) => {{ 170 | let v = $cond 171 | .iter() 172 | .zip($x) 173 | .filter_map(|(c, val)| if *c { Some(val.to_owned()) } else { None }) 174 | .collect::>(); 175 | v 176 | }}; 177 | } 178 | 179 | #[macro_export] 180 | macro_rules! sel { 181 | ( 182 | $text:expr 183 | ) => {{ 184 | let raw = Selector::parse($text).map_err(|e| e.to_string()); 185 | raw 186 | }}; 187 | } 188 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | #![allow(unused_imports)] 3 | #![allow(unused_variables)] 4 | 5 | pub mod action; 6 | pub mod app; 7 | pub mod astgrep; 8 | pub mod cli; 9 | pub mod components; 10 | pub mod config; 11 | pub mod layout; 12 | pub mod macros; 13 | pub mod mode; 14 | pub mod redux; 15 | pub mod ripgrep; 16 | pub mod tabs; 17 | pub mod tui; 18 | pub mod ui; 19 | pub mod utils; 20 | 21 | use std::process::Command; 22 | 23 | use clap::Parser; 24 | use cli::Cli; 25 | use color_eyre::eyre::{eyre, Result}; 26 | use log::LevelFilter; 27 | 28 | use crate::{ 29 | app::App, 30 | utils::{initialize_logging, initialize_panic_handler, version}, 31 | }; 32 | 33 | fn check_dependency(command: &str) -> bool { 34 | Command::new(command).arg("--version").output().is_ok() 35 | } 36 | 37 | async fn tokio_main() -> Result<()> { 38 | // let _ = simple_logging::log_to_file("serpl.log", LevelFilter::Info); 39 | 40 | if !check_dependency("rg") { 41 | eprintln!("\x1b[31mError: ripgrep (rg) is not installed. Please install it to use serpl.\x1b[0m"); 42 | return Err(eyre!("ripgrep is not installed")); 43 | } 44 | 45 | #[cfg(feature = "ast_grep")] 46 | if !check_dependency("ast-grep") { 47 | eprintln!("\x1b[31mError: ast-grep is not installed. Please install it to use serpl with AST features.\x1b[0m"); 48 | return Err(eyre!("ast-grep is not installed")); 49 | } 50 | initialize_panic_handler()?; 51 | 52 | let args = Cli::parse(); 53 | let mut app = App::new(args.project_root)?; 54 | app.run().await?; 55 | 56 | Ok(()) 57 | } 58 | 59 | #[tokio::main] 60 | async fn main() -> Result<()> { 61 | if let Err(e) = tokio_main().await { 62 | eprintln!("{} error: Something went wrong", env!("CARGO_PKG_NAME")); 63 | Err(e) 64 | } else { 65 | Ok(()) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/mode.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 4 | pub enum Mode { 5 | #[default] 6 | Normal, 7 | Input, 8 | } 9 | -------------------------------------------------------------------------------- /src/redux.rs: -------------------------------------------------------------------------------- 1 | pub mod action; 2 | pub mod reducer; 3 | pub mod state; 4 | pub mod thunk; 5 | pub mod utils; 6 | 7 | #[derive(Debug)] 8 | pub enum ActionOrThunk { 9 | Action(action::Action), 10 | Thunk(thunk::ThunkAction), 11 | } 12 | 13 | impl From for ActionOrThunk { 14 | fn from(action: action::Action) -> Self { 15 | Self::Action(action) 16 | } 17 | } 18 | 19 | impl From for ActionOrThunk { 20 | fn from(thunk: thunk::ThunkAction) -> Self { 21 | Self::Thunk(thunk) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/redux/action.rs: -------------------------------------------------------------------------------- 1 | use std::{fmt, string::ToString}; 2 | 3 | use ratatui::style::Color; 4 | use serde::{ 5 | de::{self, Deserializer, Visitor}, 6 | Deserialize, Serialize, 7 | }; 8 | use strum::Display; 9 | 10 | use crate::{ 11 | mode::Mode, 12 | redux::state::{Dialog, FocusedScreen, ReplaceTextKind, SearchListState, SearchResultState, SearchTextKind}, 13 | tabs::Tab, 14 | }; 15 | 16 | #[derive(Debug, Clone, PartialEq)] 17 | pub enum Action { 18 | SetSearchList { search_list: SearchListState }, 19 | SetSelectedResult { result: SearchResultState }, 20 | SetSearchText { text: String }, 21 | SetReplaceText { text: String }, 22 | SetSearchTextKind { kind: SearchTextKind }, 23 | SetReplaceTextKind { kind: ReplaceTextKind }, 24 | SetActiveTab { tab: Tab }, 25 | LoopOverTabs, 26 | BackLoopOverTabs, 27 | ChangeMode { mode: Mode }, 28 | SetGlobalLoading { global_loading: bool }, 29 | ResetState, 30 | SetNotification { message: String, show: bool, ttl: u64, color: Color }, 31 | SetDialog { dialog: Option }, 32 | SetFocusedScreen { screen: Option }, 33 | RemoveFileFromList { index: usize }, 34 | RemoveLineFromFile { file_index: usize, line_index: usize }, 35 | } 36 | -------------------------------------------------------------------------------- /src/redux/reducer.rs: -------------------------------------------------------------------------------- 1 | use std::{fs, io::Write, process::Command}; 2 | 3 | use regex::Regex; 4 | 5 | use super::{action::Action, state::State}; 6 | use crate::{ 7 | mode::Mode, 8 | redux::state::{ 9 | Dialog, FocusedScreen, NotificationState, ReplaceTextState, SearchListState, SearchResultState, SearchTextKind, 10 | SearchTextState, 11 | }, 12 | tabs::Tab, 13 | }; 14 | 15 | pub fn reducer(state: State, action: Action) -> State { 16 | match action { 17 | Action::SetSearchList { search_list } => State { search_result: search_list, ..state }, 18 | Action::SetSelectedResult { result } => State { selected_result: result, ..state }, 19 | Action::SetSearchText { text } => { 20 | let is_dialog_visible = check_dialog_visible(&state); 21 | if is_dialog_visible { 22 | return state; 23 | } 24 | let search_kind = &state.search_text.kind; 25 | State { search_text: SearchTextState { text, kind: *search_kind }, ..state } 26 | }, 27 | Action::SetReplaceText { text } => { 28 | let is_dialog_visible = check_dialog_visible(&state); 29 | if is_dialog_visible { 30 | return state; 31 | } 32 | let replace_kind = &state.replace_text.kind; 33 | State { replace_text: ReplaceTextState { text, kind: *replace_kind }, ..state } 34 | }, 35 | Action::SetSearchTextKind { kind } => { 36 | let is_dialog_visible = check_dialog_visible(&state); 37 | if is_dialog_visible { 38 | return state; 39 | } 40 | State { search_text: SearchTextState { kind, text: state.search_text.text.clone() }, ..state } 41 | }, 42 | Action::SetReplaceTextKind { kind } => { 43 | let is_dialog_visible = check_dialog_visible(&state); 44 | if is_dialog_visible { 45 | return state; 46 | } 47 | State { replace_text: ReplaceTextState { kind, text: state.replace_text.text.clone() }, ..state } 48 | }, 49 | Action::SetActiveTab { tab } => { 50 | let is_dialog_visible = check_dialog_visible(&state); 51 | if is_dialog_visible { 52 | return state; 53 | } 54 | State { 55 | active_tab: tab, 56 | previous_focused_screen: state.focused_screen, 57 | focused_screen: match tab { 58 | Tab::Search => FocusedScreen::SearchInput, 59 | Tab::Replace => FocusedScreen::ReplaceInput, 60 | Tab::SearchResult => FocusedScreen::SearchResultList, 61 | Tab::Preview => FocusedScreen::Preview, 62 | }, 63 | ..state 64 | } 65 | }, 66 | Action::LoopOverTabs => { 67 | let is_dialog_visible = check_dialog_visible(&state); 68 | if is_dialog_visible { 69 | return state; 70 | } 71 | State { 72 | previous_focused_screen: state.focused_screen, 73 | active_tab: match state.active_tab { 74 | Tab::Search => Tab::Replace, 75 | Tab::Replace => Tab::SearchResult, 76 | Tab::SearchResult => Tab::Search, 77 | Tab::Preview => Tab::Preview, 78 | }, 79 | focused_screen: match state.active_tab { 80 | Tab::Search => FocusedScreen::ReplaceInput, 81 | Tab::Replace => FocusedScreen::SearchResultList, 82 | Tab::SearchResult => FocusedScreen::SearchInput, 83 | Tab::Preview => FocusedScreen::Preview, 84 | }, 85 | ..state 86 | } 87 | }, 88 | Action::BackLoopOverTabs => { 89 | let is_dialog_visible = check_dialog_visible(&state); 90 | if is_dialog_visible { 91 | return state; 92 | } 93 | State { 94 | previous_focused_screen: state.focused_screen, 95 | active_tab: match state.active_tab { 96 | Tab::Search => Tab::SearchResult, 97 | Tab::Replace => Tab::Search, 98 | Tab::SearchResult => Tab::Replace, 99 | Tab::Preview => Tab::Preview, 100 | }, 101 | focused_screen: match state.active_tab { 102 | Tab::Search => FocusedScreen::SearchResultList, 103 | Tab::Replace => FocusedScreen::SearchInput, 104 | Tab::SearchResult => FocusedScreen::ReplaceInput, 105 | Tab::Preview => FocusedScreen::Preview, 106 | }, 107 | ..state 108 | } 109 | }, 110 | Action::ChangeMode { mode } => State { mode, ..state }, 111 | Action::SetGlobalLoading { global_loading } => State { global_loading, ..state }, 112 | Action::ResetState => State::new(state.project_root.clone()), 113 | Action::SetNotification { message, show, ttl, color } => { 114 | State { notification: NotificationState { message, show, ttl, color }, ..state } 115 | }, 116 | Action::SetDialog { dialog } => { 117 | let temporary_dialog = dialog.clone(); 118 | State { 119 | dialog, 120 | previous_focused_screen: state.focused_screen.clone(), 121 | focused_screen: match temporary_dialog { 122 | Some(Dialog::ConfirmGitDirectory(_)) => FocusedScreen::ConfirmGitDirectoryDialog, 123 | Some(Dialog::ConfirmReplace(_)) => FocusedScreen::ConfirmReplaceDialog, 124 | Some(Dialog::HelpDialog(_)) => FocusedScreen::HelpDialog, 125 | _ => state.focused_screen, 126 | }, 127 | ..state 128 | } 129 | }, 130 | Action::SetFocusedScreen { screen } => State { 131 | previous_focused_screen: state.focused_screen, 132 | focused_screen: screen.unwrap_or(FocusedScreen::SearchInput), 133 | ..state 134 | }, 135 | Action::RemoveFileFromList { index } => { 136 | let mut new_search_result = state.search_result.clone(); 137 | if index < new_search_result.list.len() { 138 | new_search_result.list.remove(index); 139 | } 140 | State { search_result: new_search_result, ..state } 141 | }, 142 | 143 | Action::RemoveLineFromFile { file_index, line_index } => { 144 | let mut new_search_result = state.search_result.clone(); 145 | if file_index < new_search_result.list.len() { 146 | let file_result = &mut new_search_result.list[file_index]; 147 | if line_index < file_result.matches.len() { 148 | file_result.matches.remove(line_index); 149 | file_result.total_matches -= 1; 150 | 151 | if file_result.matches.is_empty() { 152 | new_search_result.list.remove(file_index); 153 | } 154 | 155 | let new_selected_result = if !new_search_result.list.is_empty() { 156 | let new_selected_index = file_index.min(new_search_result.list.len() - 1); 157 | new_search_result.list[new_selected_index].clone() 158 | } else { 159 | SearchResultState::default() 160 | }; 161 | 162 | State { search_result: new_search_result, selected_result: new_selected_result, ..state } 163 | } else { 164 | state 165 | } 166 | } else { 167 | state 168 | } 169 | }, 170 | } 171 | } 172 | 173 | fn check_dialog_visible(state: &State) -> bool { 174 | match &state.dialog { 175 | Some(dialog) => match dialog { 176 | Dialog::ConfirmGitDirectory(dialog) => dialog.show, 177 | Dialog::ConfirmReplace(dialog) => dialog.show, 178 | Dialog::HelpDialog(dialog) => dialog.show, 179 | }, 180 | None => false, 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/redux/state.rs: -------------------------------------------------------------------------------- 1 | #[allow(dead_code)] 2 | use std::{ 3 | collections::{HashMap, HashSet}, 4 | path::PathBuf, 5 | time::{Duration, SystemTime}, 6 | }; 7 | 8 | use ratatui::style::Color; 9 | use serde::{Deserialize, Serialize}; 10 | 11 | use crate::{mode::Mode, ripgrep::RipgrepLines, tabs::Tab}; 12 | 13 | #[derive(Default, Clone, PartialEq, Debug)] 14 | pub struct State { 15 | pub search_result: SearchListState, 16 | pub selected_result: SearchResultState, 17 | pub search_text: SearchTextState, 18 | pub replace_text: ReplaceTextState, 19 | pub active_tab: Tab, 20 | pub mode: Mode, 21 | pub global_loading: bool, 22 | pub notification: NotificationState, 23 | pub dialog: Option, 24 | pub project_root: PathBuf, 25 | pub focused_screen: FocusedScreen, 26 | pub previous_focused_screen: FocusedScreen, 27 | pub help_dialog_visible: bool, 28 | pub is_large_folder: bool, 29 | } 30 | 31 | #[derive(Default, Clone, PartialEq, Eq, Debug)] 32 | pub enum FocusedScreen { 33 | #[default] 34 | SearchInput, 35 | ReplaceInput, 36 | SearchResultList, 37 | Preview, 38 | ConfirmGitDirectoryDialog, 39 | ConfirmReplaceDialog, 40 | HelpDialog, 41 | } 42 | 43 | #[derive(Default, Deserialize, Serialize, Debug, Clone, PartialEq)] 44 | pub struct SearchTextState { 45 | pub text: String, 46 | pub kind: SearchTextKind, 47 | } 48 | 49 | #[derive(Default, Deserialize, Serialize, Debug, Clone, PartialEq, Copy)] 50 | pub enum SearchTextKind { 51 | #[default] 52 | Simple, 53 | MatchCase, 54 | MatchWholeWord, 55 | MatchCaseWholeWord, 56 | Regex, 57 | #[cfg(feature = "ast_grep")] 58 | AstGrep, 59 | } 60 | 61 | #[derive(Default, Deserialize, Serialize, Debug, Clone, PartialEq)] 62 | pub struct ReplaceTextState { 63 | pub text: String, 64 | pub kind: ReplaceTextKind, 65 | } 66 | 67 | #[derive(Default, Deserialize, Serialize, Debug, Clone, PartialEq, Copy)] 68 | pub enum ReplaceTextKind { 69 | #[default] 70 | Simple, 71 | PreserveCase, 72 | DeleteLine, 73 | #[cfg(feature = "ast_grep")] 74 | AstGrep, 75 | } 76 | 77 | #[derive(Clone, PartialEq, Eq, Debug)] 78 | pub enum Dialog { 79 | ConfirmGitDirectory(ConfirmDialogState), 80 | ConfirmReplace(ConfirmDialogState), 81 | HelpDialog(HelpDialogState), 82 | } 83 | 84 | #[derive(Clone, PartialEq, Eq, Debug)] 85 | pub struct HelpDialogState { 86 | pub show: bool, 87 | } 88 | 89 | #[derive(Clone, PartialEq, Eq, Debug)] 90 | pub struct ConfirmDialogState { 91 | pub message: String, 92 | pub on_confirm: Option, 93 | pub on_cancel: Option, 94 | pub confirm_label: String, 95 | pub cancel_label: String, 96 | pub show_cancel: bool, 97 | pub show: bool, 98 | } 99 | 100 | #[derive(Clone, PartialEq, Eq, Debug)] 101 | pub enum DialogAction { 102 | ConfirmReplace, 103 | CancelReplace, 104 | } 105 | 106 | #[derive(Default, Deserialize, Serialize, Debug, Clone, PartialEq)] 107 | pub struct NotificationState { 108 | pub message: String, 109 | pub show: bool, 110 | pub ttl: u64, 111 | pub color: Color, 112 | } 113 | 114 | #[derive(Default, Deserialize, Serialize, Debug, Clone, PartialEq)] 115 | pub struct SearchListState { 116 | pub list: Vec, 117 | pub metadata: Metadata, 118 | } 119 | #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Default)] 120 | pub struct Metadata { 121 | pub elapsed_time: u64, 122 | pub matched_lines: usize, 123 | pub matches: usize, 124 | pub searches: usize, 125 | pub searches_with_match: usize, 126 | } 127 | 128 | #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Default)] 129 | pub struct SearchResultState { 130 | pub index: Option, 131 | pub path: String, 132 | pub matches: Vec, 133 | pub total_matches: usize, 134 | } 135 | 136 | #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Default)] 137 | pub struct Match { 138 | pub line_number: usize, 139 | pub lines: Option, 140 | pub context_before: Vec, 141 | pub context_after: Vec, 142 | pub absolute_offset: usize, 143 | pub submatches: Vec, 144 | pub replacement: Option, 145 | } 146 | 147 | #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Default)] 148 | pub struct SubMatch { 149 | pub start: usize, 150 | pub end: usize, 151 | pub line_start: usize, 152 | pub line_end: usize, 153 | } 154 | 155 | impl State { 156 | pub fn new(project_root: PathBuf) -> Self { 157 | Self { project_root, is_large_folder: false, ..Default::default() } 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/redux/thunk.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use redux_rs::{middlewares::thunk::Thunk, StoreApi}; 4 | use tokio::sync::mpsc::UnboundedSender; 5 | 6 | use super::{action::Action, state::State}; 7 | use crate::action::{AppAction, TuiAction}; 8 | 9 | pub mod process_line_replace; 10 | pub mod process_replace; 11 | pub mod process_search; 12 | pub mod process_single_file_replace; 13 | pub mod remove_file_from_list; 14 | pub mod remove_line_from_file; 15 | 16 | #[derive(Debug, Clone, PartialEq)] 17 | pub enum ThunkAction { 18 | ProcessSearch, 19 | ProcessReplace(ForceReplace), 20 | RemoveFileFromList(usize), 21 | RemoveLineFromFile(usize, usize), 22 | ProcessSingleFileReplace(usize), 23 | ProcessLineReplace(usize, usize), 24 | } 25 | 26 | #[derive(Debug, Clone, PartialEq)] 27 | pub struct ForceReplace(pub bool); 28 | 29 | pub fn thunk_impl( 30 | action: ThunkAction, 31 | command_tx: Arc>, 32 | ) -> Box + Send + Sync> 33 | where 34 | Api: StoreApi + Send + Sync + 'static, 35 | { 36 | match action { 37 | ThunkAction::ProcessSearch => Box::new(process_search::ProcessSearchThunk::new()), 38 | ThunkAction::ProcessReplace(force_replace) => { 39 | Box::new(process_replace::ProcessReplaceThunk::new(command_tx, force_replace)) 40 | }, 41 | ThunkAction::ProcessSingleFileReplace(index) => { 42 | Box::new(process_single_file_replace::ProcessSingleFileReplaceThunk::new(command_tx, index)) 43 | }, 44 | ThunkAction::ProcessLineReplace(file_index, line_index) => { 45 | Box::new(process_line_replace::ProcessLineReplaceThunk::new(command_tx, file_index, line_index)) 46 | }, 47 | ThunkAction::RemoveFileFromList(index) => Box::new(remove_file_from_list::RemoveFileFromListThunk::new(index)), 48 | ThunkAction::RemoveLineFromFile(file_index, line_index) => { 49 | Box::new(remove_line_from_file::RemoveLineFromFileThunk::new(file_index, line_index)) 50 | }, 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/redux/thunk/process_line_replace.rs: -------------------------------------------------------------------------------- 1 | use std::{fs, process::Command, sync::Arc}; 2 | 3 | use async_trait::async_trait; 4 | use redux_rs::{middlewares::thunk::Thunk, StoreApi}; 5 | use regex::RegexBuilder; 6 | use serde_json::from_str; 7 | use tokio::sync::mpsc::UnboundedSender; 8 | 9 | use crate::{ 10 | action::{AppAction, TuiAction}, 11 | astgrep::AstGrepOutput, 12 | components::notifications::NotificationEnum, 13 | redux::{ 14 | action::Action, 15 | state::{Match, ReplaceTextKind, ReplaceTextState, SearchTextKind, SearchTextState, State}, 16 | thunk::ThunkAction, 17 | utils::{apply_replace, get_search_regex}, 18 | }, 19 | }; 20 | 21 | pub struct ProcessLineReplaceThunk { 22 | command_tx: Arc>, 23 | file_index: usize, 24 | line_index: usize, 25 | } 26 | 27 | impl ProcessLineReplaceThunk { 28 | pub fn new(command_tx: Arc>, file_index: usize, line_index: usize) -> Self { 29 | Self { command_tx, file_index, line_index } 30 | } 31 | 32 | async fn process_replace_line(&self, store: &Arc + Send + Sync + 'static>) { 33 | let search_list = store.select(|state: &State| state.search_result.clone()).await; 34 | let search_text_state = store.select(|state: &State| state.search_text.clone()).await; 35 | let replace_text_state = store.select(|state: &State| state.replace_text.clone()).await; 36 | 37 | if let Some(search_result) = search_list.list.get(self.file_index) { 38 | if let Some(match_info) = search_result.matches.get(self.line_index) { 39 | let file_path = &search_result.path; 40 | 41 | #[cfg(feature = "ast_grep")] 42 | if search_text_state.kind == SearchTextKind::AstGrep { 43 | self 44 | .process_ast_grep_replace( 45 | file_path, 46 | &search_text_state.text, 47 | &replace_text_state.text, 48 | match_info.line_number, 49 | ) 50 | .await; 51 | } else { 52 | process_normal_replace(search_text_state, match_info, replace_text_state, file_path); 53 | } 54 | 55 | #[cfg(not(feature = "ast_grep"))] 56 | process_normal_replace(search_text_state, match_info, replace_text_state, file_path); 57 | } 58 | } 59 | } 60 | 61 | async fn process_ast_grep_replace( 62 | &self, 63 | file_path: &str, 64 | search_pattern: &str, 65 | replace_pattern: &str, 66 | line_number: usize, 67 | ) { 68 | let output = Command::new("ast-grep") 69 | .args(["run", "-p", search_pattern, "-r", replace_pattern, "--json=compact", file_path]) 70 | .output() 71 | .expect("Failed to execute ast-grep for replacement"); 72 | 73 | let stdout = String::from_utf8_lossy(&output.stdout); 74 | let ast_grep_results: Vec = from_str(&stdout).expect("Failed to parse ast-grep output"); 75 | 76 | let mut content = fs::read_to_string(file_path).expect("Unable to read file"); 77 | 78 | for result in ast_grep_results.iter().rev() { 79 | if result.range.start.line == line_number { 80 | if let (Some(replacement), Some(offsets)) = (&result.replacement, &result.replacement_offsets) { 81 | let start = offsets.start; 82 | let end = offsets.end; 83 | content.replace_range(start..end, replacement); 84 | } 85 | } 86 | } 87 | 88 | fs::write(file_path, content).expect("Unable to write file"); 89 | } 90 | } 91 | 92 | fn process_normal_replace( 93 | search_text_state: SearchTextState, 94 | match_info: &Match, 95 | replace_text_state: ReplaceTextState, 96 | file_path: &str, 97 | ) { 98 | let content = fs::read_to_string(file_path).expect("Unable to read file"); 99 | let mut lines: Vec = content.lines().map(String::from).collect(); 100 | 101 | if replace_text_state.kind == ReplaceTextKind::DeleteLine { 102 | if match_info.line_number > 0 && match_info.line_number <= lines.len() { 103 | lines.remove(match_info.line_number - 1); 104 | } 105 | } else { 106 | let re = get_search_regex(&search_text_state.text, &search_text_state.kind); 107 | 108 | if let Some(line) = lines.get_mut(match_info.line_number - 1) { 109 | let replaced_line = re.replace_all(line, |caps: ®ex::Captures| { 110 | let matched_text = caps.get(0).unwrap().as_str(); 111 | apply_replace(matched_text, &replace_text_state.text, &replace_text_state.kind) 112 | }); 113 | *line = replaced_line.into_owned(); 114 | } 115 | } 116 | 117 | let new_content = lines.join("\n"); 118 | fs::write(file_path, new_content).expect("Unable to write file"); 119 | } 120 | 121 | #[async_trait] 122 | impl Thunk for ProcessLineReplaceThunk 123 | where 124 | Api: StoreApi + Send + Sync + 'static, 125 | { 126 | async fn execute(&self, store: Arc) { 127 | let processing_status_action = AppAction::Tui(TuiAction::Status("Processing line replacement...".to_string())); 128 | self.command_tx.send(processing_status_action).unwrap(); 129 | 130 | self.process_replace_line(&store).await; 131 | 132 | store.dispatch(Action::RemoveLineFromFile { file_index: self.file_index, line_index: self.line_index }).await; 133 | 134 | let done_processing_status_action = AppAction::Tui(TuiAction::Status("".to_string())); 135 | self.command_tx.send(done_processing_status_action).unwrap(); 136 | 137 | let notification_action = 138 | AppAction::Tui(TuiAction::Notify(NotificationEnum::Info("Line replacement completed successfully".to_string()))); 139 | self.command_tx.send(notification_action).unwrap(); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/redux/thunk/process_replace.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashSet, fs, io::Write, path::PathBuf, process::Command, sync::Arc, time::Duration}; 2 | 3 | use async_trait::async_trait; 4 | use color_eyre::eyre::Result; 5 | use ratatui::style::Color; 6 | use redux_rs::{ 7 | middlewares::thunk::{self, Thunk}, 8 | StoreApi, 9 | }; 10 | use regex::RegexBuilder; 11 | use serde_json::from_str; 12 | use tokio::sync::mpsc::UnboundedSender; 13 | 14 | use crate::{ 15 | action::{AppAction, TuiAction}, 16 | astgrep::AstGrepOutput, 17 | components::notifications::NotificationEnum, 18 | redux::{ 19 | action::Action, 20 | state::{ConfirmDialogState, Dialog, DialogAction, ReplaceTextKind, SearchTextKind, State}, 21 | thunk::{ForceReplace, ThunkAction}, 22 | utils::{get_search_regex, replace_file_ast, replace_file_normal}, 23 | }, 24 | utils::is_git_repo, 25 | }; 26 | 27 | pub struct ProcessReplaceThunk { 28 | command_tx: Arc>, 29 | force_replace: ForceReplace, 30 | } 31 | 32 | impl ProcessReplaceThunk { 33 | pub fn new(command_tx: Arc>, force_replace: ForceReplace) -> Self { 34 | Self { command_tx, force_replace } 35 | } 36 | 37 | async fn process_ast_grep_replace(&self, store: &Arc + Send + Sync + 'static>) { 38 | let search_list = store.select(|state: &State| state.search_result.clone()).await; 39 | let search_text_state = store.select(|state: &State| state.search_text.clone()).await; 40 | let replace_text_state = store.select(|state: &State| state.replace_text.clone()).await; 41 | 42 | for search_result in &search_list.list { 43 | replace_file_ast(search_result, &search_text_state, &replace_text_state); 44 | } 45 | } 46 | 47 | async fn process_normal_replace(&self, store: &Arc + Send + Sync + 'static>) { 48 | let search_list = store.select(|state: &State| state.search_result.clone()).await; 49 | let search_text_state = store.select(|state: &State| state.search_text.clone()).await; 50 | let replace_text_state = store.select(|state: &State| state.replace_text.clone()).await; 51 | 52 | let processing_status_action = AppAction::Tui(TuiAction::Status("Processing search and replace..".to_string())); 53 | self.command_tx.send(processing_status_action).unwrap(); 54 | 55 | let re = get_search_regex(&search_text_state.text, &search_text_state.kind); 56 | 57 | for search_result in &search_list.list { 58 | replace_file_normal(search_result, &search_text_state, &replace_text_state); 59 | } 60 | } 61 | 62 | async fn handle_confirm + Send + Sync + 'static>(&self, store: Arc) { 63 | let search_list = store.select(|state: &State| state.search_result.clone()).await; 64 | let search_text_state = store.select(|state: &State| state.search_text.clone()).await; 65 | let replace_text_state = store.select(|state: &State| state.replace_text.clone()).await; 66 | 67 | #[cfg(feature = "ast_grep")] 68 | if search_text_state.kind == SearchTextKind::AstGrep { 69 | self.process_ast_grep_replace(&store).await; 70 | } else { 71 | self.process_normal_replace(&store).await; 72 | } 73 | 74 | #[cfg(not(feature = "ast_grep"))] 75 | self.process_normal_replace(&store).await; 76 | 77 | store.dispatch(Action::ResetState).await; 78 | let reset_action = AppAction::Tui(TuiAction::Reset); 79 | self.command_tx.send(reset_action).unwrap(); 80 | let done_processing_status_action = AppAction::Tui(TuiAction::Status("".to_string())); 81 | self.command_tx.send(done_processing_status_action).unwrap(); 82 | 83 | let search_text_action = AppAction::Tui(TuiAction::Notify(NotificationEnum::Info( 84 | "Search and replace completed successfully".to_string(), 85 | ))); 86 | self.command_tx.send(search_text_action).unwrap(); 87 | } 88 | 89 | async fn handle_cancel(&self, store: Arc>) { 90 | let reset_action = Action::ResetState; 91 | store.dispatch(reset_action).await; 92 | } 93 | } 94 | 95 | #[async_trait] 96 | impl Thunk for ProcessReplaceThunk 97 | where 98 | Api: StoreApi + Send + Sync + 'static, 99 | { 100 | async fn execute(&self, store: Arc) { 101 | let project_root = store.select(|state: &State| state.project_root.clone()).await; 102 | let force_replace = self.force_replace.0; 103 | let replace_text_state = store.select(|state: &State| state.replace_text.clone()).await; 104 | let search_text_state = store.select(|state: &State| state.search_text.clone()).await; 105 | if force_replace { 106 | self.handle_confirm(store).await; 107 | } else if search_text_state.text.is_empty() { 108 | let search_text_action = 109 | AppAction::Tui(TuiAction::Notify(NotificationEnum::Error("Search text cannot be empty".to_string()))); 110 | self.command_tx.send(search_text_action).unwrap(); 111 | 112 | return; 113 | } else if replace_text_state.text.is_empty() { 114 | let confirm_dialog = Action::SetDialog { 115 | dialog: Some(Dialog::ConfirmReplace(ConfirmDialogState { 116 | message: "Replace text is empty, and replacing with an empty string will remove the matched text.\n Are you sure you want to continue?" 117 | .to_string(), 118 | on_confirm: Some(DialogAction::ConfirmReplace), 119 | on_cancel: Some(DialogAction::CancelReplace), 120 | confirm_label: "Continue".to_string(), 121 | cancel_label: "Cancel".to_string(), 122 | show_cancel: true, 123 | show: true, 124 | })), 125 | }; 126 | 127 | store.dispatch(confirm_dialog).await; 128 | 129 | return; 130 | } else if is_git_repo(project_root) { 131 | self.handle_confirm(store.clone()).await; 132 | } else { 133 | let confirm_dialog = Action::SetDialog { 134 | dialog: Some(Dialog::ConfirmGitDirectory(ConfirmDialogState { 135 | message: "This action will modify the files in this directory.\n Are you sure you want to continue?" 136 | .to_string(), 137 | on_confirm: Some(DialogAction::ConfirmReplace), 138 | on_cancel: Some(DialogAction::CancelReplace), 139 | confirm_label: "Continue".to_string(), 140 | cancel_label: "Cancel".to_string(), 141 | show_cancel: true, 142 | show: true, 143 | })), 144 | }; 145 | 146 | store.dispatch(confirm_dialog).await; 147 | 148 | return; 149 | } 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/redux/thunk/process_search.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::{HashMap, HashSet, VecDeque}, 3 | fs, 4 | path::PathBuf, 5 | process::Command, 6 | sync::Arc, 7 | }; 8 | 9 | use async_trait::async_trait; 10 | use redux_rs::{ 11 | middlewares::thunk::{self, Thunk}, 12 | StoreApi, 13 | }; 14 | use serde_json::from_str; 15 | 16 | use crate::{ 17 | astgrep::AstGrepOutput, 18 | redux::{ 19 | action::Action, 20 | state::{Match, Metadata, SearchListState, SearchResultState, SearchTextKind, SearchTextState, State, SubMatch}, 21 | }, 22 | ripgrep::{RipgrepLines, RipgrepOutput, RipgrepSummary}, 23 | }; 24 | 25 | pub struct ProcessSearchThunk {} 26 | 27 | impl ProcessSearchThunk { 28 | pub fn new() -> Self { 29 | Self {} 30 | } 31 | 32 | fn get_context(lines: &[&str], start: usize, count: usize, forward: bool) -> Vec { 33 | let mut context = Vec::new(); 34 | let mut current = start; 35 | 36 | for _ in 0..count { 37 | if forward { 38 | if current >= lines.len() { 39 | break; 40 | } 41 | context.push(lines[current].to_string()); 42 | current += 1; 43 | } else { 44 | if current == 0 { 45 | break; 46 | } 47 | current -= 1; 48 | context.insert(0, lines[current].to_string()); 49 | } 50 | } 51 | 52 | context 53 | } 54 | 55 | async fn process_ast_grep_search(&self, store: &Arc + Send + Sync + 'static>) { 56 | let search_text_state = store.select(|state: &State| state.search_text.clone()).await; 57 | let replace_text_state = store.select(|state: &State| state.replace_text.clone()).await; 58 | let replace_text = replace_text_state.text.clone(); 59 | let project_root = store.select(|state: &State| state.project_root.clone()).await; 60 | 61 | let mut args = vec!["run", "-p", &search_text_state.text, "--json=compact", project_root.to_str().unwrap()]; 62 | if !replace_text.is_empty() { 63 | args.push("-r"); 64 | args.push(&replace_text); 65 | } 66 | let output = Command::new("ast-grep").args(args).output().expect("Failed to execute ast-grep"); 67 | let stdout = String::from_utf8_lossy(&output.stdout); 68 | 69 | let ast_grep_results: Vec = from_str(&stdout).expect("Failed to parse ast-grep output"); 70 | let mut aggregated_results: HashMap = HashMap::new(); 71 | for result in ast_grep_results { 72 | let file_content = fs::read_to_string(&result.file).unwrap_or_default(); 73 | let lines: Vec<&str> = file_content.lines().collect(); 74 | 75 | let context_before = Self::get_context(&lines, result.range.start.line, 3, false); 76 | let context_after = Self::get_context(&lines, result.range.end.line, 3, true); 77 | 78 | aggregated_results 79 | .entry(result.file.clone()) 80 | .or_insert_with(|| SearchResultState { 81 | index: None, 82 | path: result.file.clone(), 83 | matches: Vec::new(), 84 | total_matches: 0, 85 | }) 86 | .matches 87 | .push(Match { 88 | line_number: result.range.start.line, 89 | lines: Some(RipgrepLines { text: result.lines }), 90 | absolute_offset: result.range.byte_offset.start, 91 | submatches: vec![SubMatch { 92 | start: result.range.start.column, 93 | end: result.range.end.column, 94 | line_start: result.range.start.line, 95 | line_end: result.range.end.line, 96 | }], 97 | replacement: result.replacement, 98 | context_before, 99 | context_after, 100 | }); 101 | } 102 | 103 | let mut search_results: Vec = aggregated_results.into_values().collect(); 104 | for (index, result) in search_results.iter_mut().enumerate() { 105 | result.index = Some(index); 106 | result.total_matches = result.matches.len(); 107 | } 108 | 109 | let search_list_state = SearchListState { 110 | list: search_results.clone(), 111 | metadata: Metadata { 112 | elapsed_time: 0, 113 | matched_lines: search_results.iter().map(|r| r.total_matches).sum(), 114 | matches: search_results.iter().map(|r| r.total_matches).sum(), 115 | searches: 1, 116 | searches_with_match: if search_results.is_empty() { 0 } else { 1 }, 117 | }, 118 | }; 119 | 120 | store.dispatch(Action::SetSearchList { search_list: search_list_state }).await; 121 | } 122 | 123 | async fn process_normal_search(&self, store: &Arc + Send + Sync + 'static>) { 124 | let search_text_state = store.select(|state: &State| state.search_text.clone()).await; 125 | let project_root = store.select(|state: &State| state.project_root.clone()).await; 126 | let mut rg_args = vec!["--json", "-C", "3"]; 127 | 128 | match search_text_state.kind { 129 | SearchTextKind::Regex => rg_args.push(&search_text_state.text), 130 | SearchTextKind::MatchCase => rg_args.extend(&["-s", &search_text_state.text]), 131 | SearchTextKind::MatchWholeWord => rg_args.extend(&["-w", "-i", &search_text_state.text]), 132 | SearchTextKind::MatchCaseWholeWord => rg_args.extend(&["-w", "-s", &search_text_state.text]), 133 | SearchTextKind::Simple => rg_args.extend(&["-i", "-F", &search_text_state.text]), 134 | #[cfg(feature = "ast_grep")] 135 | SearchTextKind::AstGrep => {}, 136 | } 137 | 138 | let project_root_str = project_root.to_string_lossy(); 139 | rg_args.push(&project_root_str); 140 | 141 | let output = Command::new("rg").args(&rg_args).output().expect("Failed to execute ripgrep"); 142 | 143 | let stdout = String::from_utf8_lossy(&output.stdout); 144 | 145 | let mut results = Vec::new(); 146 | let mut path_to_result: HashMap = HashMap::new(); 147 | let mut summary: Option = None; 148 | 149 | let mut context_buffer: VecDeque<(usize, String)> = VecDeque::new(); 150 | 151 | for line in stdout.lines() { 152 | if let Ok(rg_output) = serde_json::from_str::(line) { 153 | match rg_output.kind.as_str() { 154 | "match" | "context" => { 155 | if let Some(data) = rg_output.data { 156 | let path = data.path.unwrap().text; 157 | let line_number = data.line_number.unwrap_or_default() as usize; 158 | let absolute_offset = data.absolute_offset.unwrap_or_default(); 159 | 160 | let search_result_index = path_to_result.entry(path.clone()).or_insert_with(|| { 161 | let index = results.len(); 162 | results.push(SearchResultState { 163 | index: Some(index), 164 | path: path.clone(), 165 | matches: Vec::new(), 166 | total_matches: 0, 167 | }); 168 | index 169 | }); 170 | 171 | let result = &mut results[*search_result_index]; 172 | 173 | if rg_output.kind == "match" { 174 | let submatches: Vec = data 175 | .submatches 176 | .unwrap_or_default() 177 | .into_iter() 178 | .map(|sm| SubMatch { start: sm.start as usize, end: sm.end as usize, line_start: 0, line_end: 0 }) 179 | .collect(); 180 | 181 | let mut context_before: Vec = context_buffer.drain(..).map(|(_, line)| line).collect(); 182 | if context_before.len() > 3 { 183 | context_before = context_before.clone().into_iter().skip(context_before.len() - 3).collect(); 184 | } 185 | 186 | result.matches.push(Match { 187 | lines: data.lines.clone(), 188 | line_number, 189 | context_before, 190 | context_after: Vec::new(), 191 | absolute_offset: absolute_offset as usize, 192 | submatches: submatches.clone(), 193 | replacement: None, 194 | }); 195 | result.total_matches += submatches.len(); 196 | 197 | context_buffer.push_back((line_number, data.lines.unwrap().text)); 198 | } else { 199 | context_buffer.push_back((line_number, data.lines.clone().unwrap().text)); 200 | if context_buffer.len() > 4 { 201 | context_buffer.pop_front(); 202 | } 203 | 204 | if let Some(last_match) = result.matches.last_mut() { 205 | if line_number > last_match.line_number && last_match.context_after.len() < 3 { 206 | last_match.context_after.push(data.lines.unwrap().text); 207 | } 208 | } 209 | } 210 | } 211 | }, 212 | "summary" => { 213 | if let Some(data) = rg_output.data { 214 | summary = Some(RipgrepSummary { 215 | elapsed_time: data.elapsed_total.unwrap().nanos, 216 | matched_lines: data.stats.as_ref().unwrap().matched_lines, 217 | matches: data.stats.as_ref().unwrap().matches, 218 | searches: data.stats.as_ref().unwrap().searches, 219 | searches_with_match: data.stats.as_ref().unwrap().searches_with_match, 220 | }); 221 | } 222 | }, 223 | _ => {}, 224 | } 225 | } 226 | } 227 | 228 | let metadata = if let Some(s) = summary { 229 | Metadata { 230 | elapsed_time: s.elapsed_time, 231 | matched_lines: s.matched_lines, 232 | matches: s.matches, 233 | searches: s.searches, 234 | searches_with_match: s.searches_with_match, 235 | } 236 | } else { 237 | Metadata::default() 238 | }; 239 | 240 | let search_list_state = SearchListState { list: results, metadata }; 241 | 242 | store.dispatch(Action::SetSearchList { search_list: search_list_state }).await; 243 | } 244 | } 245 | 246 | impl Default for ProcessSearchThunk { 247 | fn default() -> Self { 248 | Self::new() 249 | } 250 | } 251 | 252 | #[async_trait] 253 | impl Thunk for ProcessSearchThunk 254 | where 255 | Api: StoreApi + Send + Sync + 'static, 256 | { 257 | async fn execute(&self, store: Arc) { 258 | let search_text_state = store.select(|state: &State| state.search_text.clone()).await; 259 | 260 | if !search_text_state.text.is_empty() { 261 | store.dispatch(Action::SetSearchList { search_list: SearchListState::default() }).await; 262 | 263 | #[cfg(feature = "ast_grep")] 264 | if search_text_state.kind == SearchTextKind::AstGrep { 265 | self.process_ast_grep_search(&store).await; 266 | } else { 267 | self.process_normal_search(&store).await; 268 | } 269 | #[cfg(not(feature = "ast_grep"))] 270 | self.process_normal_search(&store).await; 271 | } 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /src/redux/thunk/process_single_file_replace.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashSet, fs, io::Write, path::PathBuf, process::Command, sync::Arc, time::Duration}; 2 | 3 | use async_trait::async_trait; 4 | use color_eyre::eyre::Result; 5 | use ratatui::style::Color; 6 | use redux_rs::{ 7 | middlewares::thunk::{self, Thunk}, 8 | StoreApi, 9 | }; 10 | use regex::RegexBuilder; 11 | use serde_json::from_str; 12 | use tokio::sync::mpsc::UnboundedSender; 13 | 14 | use crate::{ 15 | action::{AppAction, TuiAction}, 16 | astgrep::AstGrepOutput, 17 | components::notifications::NotificationEnum, 18 | redux::{ 19 | action::Action, 20 | state::{ConfirmDialogState, Dialog, DialogAction, ReplaceTextKind, SearchTextKind, State}, 21 | thunk::{ForceReplace, ThunkAction}, 22 | utils::{replace_file_ast, replace_file_normal}, 23 | }, 24 | utils::is_git_repo, 25 | }; 26 | 27 | pub struct ProcessSingleFileReplaceThunk { 28 | command_tx: Arc>, 29 | file_index: usize, 30 | } 31 | 32 | impl ProcessSingleFileReplaceThunk { 33 | pub fn new(command_tx: Arc>, file_index: usize) -> Self { 34 | Self { command_tx, file_index } 35 | } 36 | 37 | async fn process_ast_grep_replace(&self, store: &Arc + Send + Sync + 'static>) { 38 | let search_list = store.select(|state: &State| state.search_result.clone()).await; 39 | let search_text_state = store.select(|state: &State| state.search_text.clone()).await; 40 | let replace_text_state = store.select(|state: &State| state.replace_text.clone()).await; 41 | 42 | if let Some(search_result) = search_list.list.get(self.file_index) { 43 | replace_file_ast(search_result, &search_text_state, &replace_text_state); 44 | } 45 | } 46 | 47 | async fn process_normal_replace(&self, store: &Arc + Send + Sync + 'static>) { 48 | let search_list = store.select(|state: &State| state.search_result.clone()).await; 49 | let search_text_state = store.select(|state: &State| state.search_text.clone()).await; 50 | let replace_text_state = store.select(|state: &State| state.replace_text.clone()).await; 51 | 52 | if let Some(search_result) = search_list.list.get(self.file_index) { 53 | let file_path = &search_result.path; 54 | let content = fs::read_to_string(file_path).expect("Unable to read file"); 55 | let mut lines: Vec = content.lines().map(String::from).collect(); 56 | 57 | if replace_text_state.kind == ReplaceTextKind::DeleteLine { 58 | let matched_lines: Vec = search_result.matches.iter().map(|m| m.line_number - 1).collect(); 59 | for &line_index in matched_lines.iter().rev() { 60 | if line_index < lines.len() { 61 | lines.remove(line_index); 62 | } 63 | } 64 | let new_content = lines.join("\n"); 65 | fs::write(file_path, new_content).expect("Unable to write file"); 66 | } else { 67 | replace_file_normal(search_result, &search_text_state, &replace_text_state); 68 | } 69 | } 70 | } 71 | } 72 | 73 | #[async_trait] 74 | impl Thunk for ProcessSingleFileReplaceThunk 75 | where 76 | Api: StoreApi + Send + Sync + 'static, 77 | { 78 | async fn execute(&self, store: Arc) { 79 | let search_text_state = store.select(|state: &State| state.search_text.clone()).await; 80 | let replace_text_state = store.select(|state: &State| state.replace_text.clone()).await; 81 | 82 | #[cfg(feature = "ast_grep")] 83 | if search_text_state.kind == SearchTextKind::AstGrep { 84 | self.process_ast_grep_replace(&store).await; 85 | } else { 86 | self.process_normal_replace(&store).await; 87 | } 88 | 89 | #[cfg(not(feature = "ast_grep"))] 90 | self.process_normal_replace(&store).await; 91 | 92 | store.dispatch(Action::RemoveFileFromList { index: self.file_index }).await; 93 | 94 | let done_processing_status_action = AppAction::Tui(TuiAction::Status("".to_string())); 95 | self.command_tx.send(done_processing_status_action).unwrap(); 96 | 97 | let notification_action = 98 | AppAction::Tui(TuiAction::Notify(NotificationEnum::Info("File replacement completed successfully".to_string()))); 99 | self.command_tx.send(notification_action).unwrap(); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/redux/thunk/remove_file_from_list.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use async_trait::async_trait; 4 | use redux_rs::{ 5 | middlewares::thunk::{self, Thunk}, 6 | StoreApi, 7 | }; 8 | 9 | use crate::redux::{ 10 | action::Action, 11 | state::{SearchListState, SearchResultState, State}, 12 | }; 13 | 14 | pub struct RemoveFileFromListThunk { 15 | pub index: usize, 16 | } 17 | 18 | impl RemoveFileFromListThunk { 19 | pub fn new(index: usize) -> Self { 20 | Self { index } 21 | } 22 | } 23 | 24 | impl Default for RemoveFileFromListThunk { 25 | fn default() -> Self { 26 | Self::new(0) 27 | } 28 | } 29 | 30 | #[async_trait] 31 | impl Thunk for RemoveFileFromListThunk 32 | where 33 | Api: StoreApi + Send + Sync + 'static, 34 | { 35 | async fn execute(&self, store: Arc) { 36 | // Get the current state 37 | let search_list = store.select(|state: &State| state.search_result.clone()).await; 38 | 39 | // Ensure the index is within bounds 40 | if self.index < search_list.list.len() { 41 | // Remove the file from the list in the state 42 | let mut updated_list = search_list.list.clone(); 43 | updated_list.remove(self.index); 44 | 45 | // Update the state with the new list 46 | let updated_search_list = SearchListState { list: updated_list.clone(), ..search_list }; 47 | store.dispatch(Action::SetSearchList { search_list: updated_search_list }).await; 48 | 49 | // Update the selected result to None or the next available item 50 | let new_selected_result = if updated_list.is_empty() { 51 | SearchResultState::default() 52 | } else { 53 | updated_list[self.index.min(updated_list.len() - 1)].clone() 54 | }; 55 | store.dispatch(Action::SetSelectedResult { result: new_selected_result }).await; 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/redux/thunk/remove_line_from_file.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use async_trait::async_trait; 4 | use redux_rs::{ 5 | middlewares::thunk::{self, Thunk}, 6 | StoreApi, 7 | }; 8 | 9 | use crate::redux::{ 10 | action::Action, 11 | state::{SearchListState, SearchResultState, State}, 12 | }; 13 | 14 | pub struct RemoveLineFromFileThunk { 15 | pub file_index: usize, 16 | pub line_index: usize, 17 | } 18 | 19 | impl RemoveLineFromFileThunk { 20 | pub fn new(file_index: usize, line_index: usize) -> Self { 21 | Self { file_index, line_index } 22 | } 23 | } 24 | 25 | #[async_trait] 26 | impl Thunk for RemoveLineFromFileThunk 27 | where 28 | Api: StoreApi + Send + Sync + 'static, 29 | { 30 | async fn execute(&self, store: Arc) { 31 | let mut search_list = store.select(|state: &State| state.search_result.clone()).await; 32 | 33 | if self.file_index < search_list.list.len() { 34 | let file_result = &mut search_list.list[self.file_index]; 35 | if self.line_index < file_result.matches.len() { 36 | file_result.matches.remove(self.line_index); 37 | file_result.total_matches -= 1; 38 | 39 | if file_result.matches.is_empty() { 40 | search_list.list.remove(self.file_index); 41 | } 42 | 43 | store.dispatch(Action::SetSearchList { search_list: search_list.clone() }).await; 44 | 45 | if !search_list.list.is_empty() { 46 | let new_selected_index = self.file_index.min(search_list.list.len() - 1); 47 | let new_selected_result = search_list.list[new_selected_index].clone(); 48 | store.dispatch(Action::SetSelectedResult { result: new_selected_result }).await; 49 | } else { 50 | store.dispatch(Action::SetSelectedResult { result: SearchResultState::default() }).await; 51 | } 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/redux/utils.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | 3 | use regex::RegexBuilder; 4 | use serde_json::from_str; 5 | 6 | use crate::{ 7 | astgrep::AstGrepOutput, 8 | redux::state::{ReplaceTextKind, SearchTextKind}, 9 | }; 10 | 11 | pub fn replace_file_ast( 12 | search_result: &crate::redux::state::SearchResultState, 13 | search_text_state: &crate::redux::state::SearchTextState, 14 | replace_text_state: &crate::redux::state::ReplaceTextState, 15 | ) { 16 | let file_path = &search_result.path; 17 | 18 | let mut content = fs::read_to_string(file_path).expect("Unable to read file"); 19 | let lines: Vec<&str> = content.lines().collect(); 20 | 21 | let lines_to_replace: std::collections::HashSet = 22 | search_result.matches.iter().map(|m| m.line_number).collect(); 23 | 24 | let output = std::process::Command::new("ast-grep") 25 | .args(["run", "-p", &search_text_state.text, "-r", &replace_text_state.text, "--json=compact", file_path]) 26 | .output() 27 | .expect("Failed to execute ast-grep for replacement"); 28 | 29 | let stdout = String::from_utf8_lossy(&output.stdout); 30 | let ast_grep_results: Vec = from_str(&stdout).expect("Failed to parse ast-grep output"); 31 | 32 | for result in ast_grep_results.iter().rev() { 33 | if let (Some(replacement), Some(offsets)) = (&result.replacement, &result.replacement_offsets) { 34 | if lines_to_replace.contains(&result.range.start.line) { 35 | let start = offsets.start; 36 | let end = offsets.end; 37 | content.replace_range(start..end, replacement); 38 | } 39 | } 40 | } 41 | 42 | fs::write(file_path, content).expect("Unable to write file"); 43 | } 44 | 45 | pub fn replace_file_normal( 46 | search_result: &crate::redux::state::SearchResultState, 47 | search_text_state: &crate::redux::state::SearchTextState, 48 | replace_text_state: &crate::redux::state::ReplaceTextState, 49 | ) { 50 | let file_path = &search_result.path; 51 | 52 | let content = fs::read_to_string(file_path).expect("Unable to read file"); 53 | let lines: Vec<&str> = content.lines().collect(); 54 | 55 | let new_content = if replace_text_state.kind == ReplaceTextKind::DeleteLine { 56 | let matched_lines: std::collections::HashSet = 57 | search_result.matches.iter().map(|m| m.line_number - 1).collect(); 58 | 59 | lines 60 | .iter() 61 | .enumerate() 62 | .filter(|(i, _)| !matched_lines.contains(i)) 63 | .map(|(_, line)| *line) 64 | .collect::>() 65 | .join("\n") 66 | } else { 67 | let re = get_search_regex(&search_text_state.text, &search_text_state.kind); 68 | 69 | re.replace_all(&content, |caps: ®ex::Captures| { 70 | let matched_text = caps.get(0).unwrap().as_str(); 71 | apply_replace(matched_text, &replace_text_state.text, &replace_text_state.kind) 72 | }) 73 | .to_string() 74 | }; 75 | 76 | fs::write(file_path, new_content).expect("Unable to write file"); 77 | } 78 | 79 | pub fn get_search_regex(search_text: &str, search_kind: &SearchTextKind) -> regex::Regex { 80 | let escaped_search_text = regex::escape(search_text); 81 | 82 | match search_kind { 83 | SearchTextKind::Simple => { 84 | RegexBuilder::new(&escaped_search_text).case_insensitive(true).build().expect("Invalid regex") 85 | }, 86 | SearchTextKind::MatchCase => { 87 | RegexBuilder::new(&escaped_search_text).case_insensitive(false).build().expect("Invalid regex") 88 | }, 89 | SearchTextKind::MatchWholeWord => { 90 | RegexBuilder::new(&format!(r"\b{escaped_search_text}\b")).case_insensitive(true).build().expect("Invalid regex") 91 | }, 92 | SearchTextKind::MatchCaseWholeWord => { 93 | RegexBuilder::new(&format!(r"\b{escaped_search_text}\b")).case_insensitive(false).build().expect("Invalid regex") 94 | }, 95 | SearchTextKind::Regex => { 96 | RegexBuilder::new(&escaped_search_text).case_insensitive(true).build().expect("Invalid regex") 97 | }, 98 | #[cfg(feature = "ast_grep")] 99 | SearchTextKind::AstGrep => unreachable!("AST Grep doesn't use regex"), 100 | } 101 | } 102 | 103 | pub fn apply_replace(matched_text: &str, replace_text: &str, replace_kind: &ReplaceTextKind) -> String { 104 | match replace_kind { 105 | ReplaceTextKind::Simple => replace_text.to_string(), 106 | ReplaceTextKind::DeleteLine => String::new(), 107 | ReplaceTextKind::PreserveCase => { 108 | let first_char = matched_text.chars().next().unwrap_or_default(); 109 | if matched_text.chars().all(char::is_uppercase) { 110 | replace_text.to_uppercase() 111 | } else if first_char.is_uppercase() { 112 | let mut result = String::new(); 113 | for (i, c) in replace_text.chars().enumerate() { 114 | if i == 0 { 115 | result.push(c.to_uppercase().next().unwrap()); 116 | } else { 117 | result.push(c.to_lowercase().next().unwrap()); 118 | } 119 | } 120 | result 121 | } else { 122 | replace_text.to_lowercase() 123 | } 124 | }, 125 | #[cfg(feature = "ast_grep")] 126 | ReplaceTextKind::AstGrep => unreachable!(), 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/ripgrep.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Deserialize, Debug)] 4 | pub struct RipgrepOutput { 5 | #[serde(rename = "type")] 6 | pub kind: String, 7 | pub data: Option, 8 | } 9 | 10 | #[derive(Deserialize, Debug)] 11 | pub struct RipgrepData { 12 | pub path: Option, 13 | pub lines: Option, 14 | #[serde(rename = "line_number")] 15 | pub line_number: Option, 16 | #[serde(rename = "absolute_offset")] 17 | pub absolute_offset: Option, 18 | pub submatches: Option>, 19 | pub binary_offset: Option>, // Note: binary_offset can be null 20 | pub stats: Option, 21 | pub elapsed_total: Option, 22 | } 23 | 24 | #[derive(Deserialize, Debug)] 25 | pub struct RipgrepPath { 26 | pub text: String, 27 | } 28 | 29 | #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Default)] 30 | pub struct RipgrepLines { 31 | pub text: String, 32 | } 33 | 34 | #[derive(Deserialize, Debug)] 35 | pub struct RipgrepSubmatch { 36 | pub r#match: RipgrepMatch, 37 | pub start: u32, 38 | pub end: u32, 39 | } 40 | 41 | #[derive(Deserialize, Debug)] 42 | pub struct RipgrepMatch { 43 | pub text: String, 44 | } 45 | 46 | #[derive(Deserialize, Debug)] 47 | pub struct RipgrepElapsedTotal { 48 | pub human: String, 49 | pub nanos: u64, 50 | pub secs: u64, 51 | } 52 | 53 | #[derive(Deserialize, Debug)] 54 | pub struct RipgrepStats { 55 | pub bytes_printed: u64, 56 | pub bytes_searched: u64, 57 | pub elapsed: RipgrepElapsedTotal, 58 | pub matched_lines: usize, 59 | pub matches: usize, 60 | pub searches: usize, 61 | pub searches_with_match: usize, 62 | } 63 | 64 | pub struct RipgrepSummary { 65 | pub elapsed_time: u64, 66 | pub matched_lines: usize, 67 | pub matches: usize, 68 | pub searches: usize, 69 | pub searches_with_match: usize, 70 | } 71 | -------------------------------------------------------------------------------- /src/tabs.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use strum::{Display, EnumCount, EnumIter, FromRepr}; 3 | 4 | #[derive(Default, Clone, Copy, Display, FromRepr, EnumIter, EnumCount, PartialEq, Debug)] 5 | pub enum Tab { 6 | #[default] 7 | Search, 8 | Replace, 9 | SearchResult, 10 | Preview, 11 | } 12 | -------------------------------------------------------------------------------- /src/tui.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | ops::{Deref, DerefMut}, 3 | time::Duration, 4 | }; 5 | 6 | use color_eyre::eyre::Result; 7 | use crossterm::{ 8 | cursor, 9 | event::{ 10 | DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, Event as CrosstermEvent, 11 | KeyEvent, KeyEventKind, MouseEvent, 12 | }, 13 | terminal::{EnterAlternateScreen, LeaveAlternateScreen}, 14 | }; 15 | use futures::{FutureExt, StreamExt}; 16 | use ratatui::backend::CrosstermBackend as Backend; 17 | use serde::{Deserialize, Serialize}; 18 | use tokio::{ 19 | sync::mpsc::{self, UnboundedReceiver, UnboundedSender}, 20 | task::JoinHandle, 21 | }; 22 | use tokio_util::sync::CancellationToken; 23 | 24 | pub type IO = std::io::Stdout; 25 | pub fn io() -> IO { 26 | std::io::stdout() 27 | } 28 | pub type Frame<'a> = ratatui::Frame<'a>; 29 | 30 | #[derive(Clone, Debug, Serialize, Deserialize)] 31 | pub enum Event { 32 | Init, 33 | Quit, 34 | Error, 35 | Closed, 36 | Tick, 37 | Render, 38 | FocusGained, 39 | FocusLost, 40 | Paste(String), 41 | Key(KeyEvent), 42 | Mouse(MouseEvent), 43 | Resize(u16, u16), 44 | } 45 | 46 | pub struct Tui { 47 | pub terminal: ratatui::Terminal>, 48 | pub task: JoinHandle<()>, 49 | pub cancellation_token: CancellationToken, 50 | pub event_rx: UnboundedReceiver, 51 | pub event_tx: UnboundedSender, 52 | pub frame_rate: f64, 53 | pub tick_rate: f64, 54 | pub mouse: bool, 55 | pub paste: bool, 56 | } 57 | 58 | impl Tui { 59 | pub fn new() -> Result { 60 | let tick_rate = 4.0; 61 | let frame_rate = 24.0; 62 | let terminal = ratatui::Terminal::new(Backend::new(io()))?; 63 | let (event_tx, event_rx) = mpsc::unbounded_channel(); 64 | let cancellation_token = CancellationToken::new(); 65 | let task = tokio::spawn(async {}); 66 | let mouse = false; 67 | let paste = false; 68 | Ok(Self { terminal, task, cancellation_token, event_rx, event_tx, frame_rate, tick_rate, mouse, paste }) 69 | } 70 | 71 | pub fn tick_rate(mut self, tick_rate: f64) -> Self { 72 | self.tick_rate = tick_rate; 73 | self 74 | } 75 | 76 | pub fn frame_rate(mut self, frame_rate: f64) -> Self { 77 | self.frame_rate = frame_rate; 78 | self 79 | } 80 | 81 | pub fn mouse(mut self, mouse: bool) -> Self { 82 | self.mouse = mouse; 83 | self 84 | } 85 | 86 | pub fn paste(mut self, paste: bool) -> Self { 87 | self.paste = paste; 88 | self 89 | } 90 | 91 | pub fn start(&mut self) { 92 | let tick_delay = std::time::Duration::from_secs_f64(1.0 / self.tick_rate); 93 | let render_delay = std::time::Duration::from_secs_f64(1.0 / self.frame_rate); 94 | self.cancel(); 95 | self.cancellation_token = CancellationToken::new(); 96 | let _cancellation_token = self.cancellation_token.clone(); 97 | let _event_tx = self.event_tx.clone(); 98 | self.task = tokio::spawn(async move { 99 | let mut reader = crossterm::event::EventStream::new(); 100 | let mut tick_interval = tokio::time::interval(tick_delay); 101 | tick_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); 102 | let mut render_interval = tokio::time::interval(render_delay); 103 | _event_tx.send(Event::Init).unwrap(); 104 | loop { 105 | let tick_delay = tick_interval.tick(); 106 | let render_delay = render_interval.tick(); 107 | let crossterm_event = reader.next().fuse(); 108 | tokio::select! { 109 | _ = _cancellation_token.cancelled() => { 110 | break; 111 | } 112 | maybe_event = crossterm_event => { 113 | match maybe_event { 114 | Some(Ok(evt)) => { 115 | match evt { 116 | CrosstermEvent::Key(key) => { 117 | if key.kind == KeyEventKind::Press { 118 | _event_tx.send(Event::Key(key)).unwrap(); 119 | } 120 | }, 121 | CrosstermEvent::Mouse(mouse) => { 122 | _event_tx.send(Event::Mouse(mouse)).unwrap(); 123 | }, 124 | CrosstermEvent::Resize(x, y) => { 125 | _event_tx.send(Event::Resize(x, y)).unwrap(); 126 | }, 127 | CrosstermEvent::FocusLost => { 128 | _event_tx.send(Event::FocusLost).unwrap(); 129 | }, 130 | CrosstermEvent::FocusGained => { 131 | _event_tx.send(Event::FocusGained).unwrap(); 132 | }, 133 | CrosstermEvent::Paste(s) => { 134 | _event_tx.send(Event::Paste(s)).unwrap(); 135 | }, 136 | } 137 | } 138 | Some(Err(_)) => { 139 | _event_tx.send(Event::Error).unwrap(); 140 | } 141 | None => {}, 142 | } 143 | }, 144 | _ = tick_delay => { 145 | _event_tx.send(Event::Tick).unwrap(); 146 | }, 147 | _ = render_delay => { 148 | _event_tx.send(Event::Render).unwrap(); 149 | }, 150 | } 151 | } 152 | }); 153 | } 154 | 155 | pub fn stop(&self) -> Result<()> { 156 | self.cancel(); 157 | let mut counter = 0; 158 | while !self.task.is_finished() { 159 | std::thread::sleep(Duration::from_millis(1)); 160 | counter += 1; 161 | if counter > 50 { 162 | self.task.abort(); 163 | } 164 | if counter > 100 { 165 | log::error!("Failed to abort task in 100 milliseconds for unknown reason"); 166 | break; 167 | } 168 | } 169 | Ok(()) 170 | } 171 | 172 | pub fn enter(&mut self) -> Result<()> { 173 | crossterm::terminal::enable_raw_mode()?; 174 | crossterm::execute!(io(), EnterAlternateScreen, cursor::Hide)?; 175 | if self.mouse { 176 | crossterm::execute!(io(), EnableMouseCapture)?; 177 | } 178 | if self.paste { 179 | crossterm::execute!(io(), EnableBracketedPaste)?; 180 | } 181 | self.start(); 182 | Ok(()) 183 | } 184 | 185 | pub fn exit(&mut self) -> Result<()> { 186 | self.stop()?; 187 | if crossterm::terminal::is_raw_mode_enabled()? { 188 | self.flush()?; 189 | if self.paste { 190 | crossterm::execute!(io(), DisableBracketedPaste)?; 191 | } 192 | if self.mouse { 193 | crossterm::execute!(io(), DisableMouseCapture)?; 194 | } 195 | crossterm::execute!(io(), LeaveAlternateScreen, cursor::Show)?; 196 | crossterm::terminal::disable_raw_mode()?; 197 | } 198 | Ok(()) 199 | } 200 | 201 | pub fn cancel(&self) { 202 | self.cancellation_token.cancel(); 203 | } 204 | 205 | pub fn suspend(&mut self) -> Result<()> { 206 | self.exit()?; 207 | #[cfg(not(windows))] 208 | signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP)?; 209 | Ok(()) 210 | } 211 | 212 | pub fn resume(&mut self) -> Result<()> { 213 | self.enter()?; 214 | Ok(()) 215 | } 216 | 217 | pub async fn next(&mut self) -> Option { 218 | self.event_rx.recv().await 219 | } 220 | } 221 | 222 | impl Deref for Tui { 223 | type Target = ratatui::Terminal>; 224 | 225 | fn deref(&self) -> &Self::Target { 226 | &self.terminal 227 | } 228 | } 229 | 230 | impl DerefMut for Tui { 231 | fn deref_mut(&mut self) -> &mut Self::Target { 232 | &mut self.terminal 233 | } 234 | } 235 | 236 | impl Drop for Tui { 237 | fn drop(&mut self) { 238 | self.exit().unwrap(); 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /src/ui.rs: -------------------------------------------------------------------------------- 1 | pub mod confirm_dialog_widget; 2 | pub mod divider; 3 | pub mod help_display_dialog; 4 | pub mod notification_box; 5 | pub mod small_help_widget; 6 | -------------------------------------------------------------------------------- /src/ui/confirm_dialog_widget.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | buffer::Buffer, 3 | layout::{Alignment, Constraint, Direction, Layout, Rect}, 4 | style::{Color, Modifier, Style, Stylize}, 5 | text::{Line, Text}, 6 | widgets::{Block, BorderType, Borders, Clear, Padding, Paragraph, StatefulWidget, Widget, Wrap}, 7 | }; 8 | 9 | use crate::{redux::state::DialogAction, utils::centered_rect_with_size}; 10 | 11 | #[derive(Default, Debug, Clone)] 12 | pub struct ConfirmDialogWidget { 13 | pub title: String, 14 | pub message: String, 15 | pub confirm_label: String, 16 | pub cancel_label: String, 17 | pub show_cancel: bool, 18 | } 19 | 20 | #[derive(Default, Debug, Clone)] 21 | pub enum ConfirmDialogAction { 22 | Confirm, 23 | #[default] 24 | Cancel, 25 | } 26 | 27 | #[derive(Default, Debug, Clone)] 28 | pub struct ConfirmDialogState { 29 | pub selected_button: ConfirmDialogAction, 30 | } 31 | 32 | impl ConfirmDialogState { 33 | pub fn new() -> Self { 34 | Self { selected_button: ConfirmDialogAction::Cancel } 35 | } 36 | 37 | pub fn loop_selected_button(&mut self) { 38 | self.selected_button = match self.selected_button { 39 | ConfirmDialogAction::Confirm => ConfirmDialogAction::Cancel, 40 | ConfirmDialogAction::Cancel => ConfirmDialogAction::Confirm, 41 | }; 42 | } 43 | 44 | pub fn set_selected_button(&mut self, action: ConfirmDialogAction) { 45 | self.selected_button = action; 46 | } 47 | } 48 | 49 | impl ConfirmDialogWidget { 50 | pub fn new(title: String, message: String, confirm_label: String, cancel_label: String, show_cancel: bool) -> Self { 51 | Self { title, message, confirm_label, cancel_label, show_cancel } 52 | } 53 | } 54 | 55 | impl StatefulWidget for ConfirmDialogWidget { 56 | type State = ConfirmDialogState; 57 | 58 | fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { 59 | let horizontal_padding = 2u16; 60 | let vertical_padding = 2u16; 61 | let buttons_padding = 2u16; 62 | 63 | let block = Block::default() 64 | .title(self.title) 65 | .title_alignment(Alignment::Center) 66 | .borders(Borders::ALL) 67 | .border_type(BorderType::Rounded) 68 | .border_style(Style::default().fg(Color::Yellow)); 69 | 70 | let confirm_button_style = match state.selected_button { 71 | ConfirmDialogAction::Confirm => Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), 72 | ConfirmDialogAction::Cancel => Style::default().fg(Color::White), 73 | }; 74 | let cancel_button_style = match state.selected_button { 75 | ConfirmDialogAction::Confirm => Style::default().fg(Color::White), 76 | ConfirmDialogAction::Cancel => Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), 77 | }; 78 | let confirm_button = Paragraph::new(self.confirm_label.to_string()) 79 | .style(confirm_button_style) 80 | .alignment(Alignment::Center) 81 | .block(Block::default().borders(Borders::BOTTOM).border_style(Style::default().fg(Color::Yellow))); 82 | let cancel_button = Paragraph::new(self.cancel_label.to_string()) 83 | .style(cancel_button_style) 84 | .alignment(Alignment::Center) 85 | .block(Block::default().borders(Borders::BOTTOM).border_style(Style::default().fg(Color::Yellow))); 86 | 87 | let confirm_button_size = (self.confirm_label.len() + buttons_padding as usize) as u16; 88 | let cancel_button_size = (self.cancel_label.len() + buttons_padding as usize) as u16; 89 | 90 | let text = self.message; 91 | 92 | let width = text.lines().map(|line| line.len()).max().unwrap_or(0) as u16 + 4; 93 | let height = text.lines().count() as u16 + 6; 94 | 95 | let lines = Text::from(text); 96 | let text_widget = Paragraph::new(lines) 97 | .block(Block::new().padding(Padding::new( 98 | horizontal_padding, 99 | horizontal_padding, 100 | vertical_padding, 101 | vertical_padding, 102 | ))) 103 | .alignment(Alignment::Center) 104 | .style(Style::new().white()) 105 | .wrap(Wrap { trim: true }); 106 | 107 | let centered_area = centered_rect_with_size(width, height, area); 108 | 109 | let main_layout = Layout::default() 110 | .direction(Direction::Vertical) 111 | .constraints([Constraint::Min(1), Constraint::Max(2)]) 112 | .split(centered_area); 113 | 114 | Clear.render(centered_area, buf); 115 | text_widget.render(main_layout[0], buf); 116 | block.render(centered_area, buf); 117 | 118 | let buttons_layout = Layout::default().direction(Direction::Horizontal); 119 | let c = (main_layout[1].width - (confirm_button_size + cancel_button_size)) / 2; // 19 120 | let buttons_layout = buttons_layout 121 | .constraints([ 122 | Constraint::Length(c), 123 | Constraint::Max(confirm_button_size), 124 | Constraint::Max(cancel_button_size), 125 | Constraint::Length(c), 126 | ]) 127 | .split(main_layout[1]); 128 | confirm_button.render(buttons_layout[1], buf); 129 | cancel_button.render(buttons_layout[2], buf); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/ui/divider.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | buffer::Buffer, 3 | layout::Rect, 4 | style::{Color, Stylize}, 5 | text::Line, 6 | widgets::Widget, 7 | }; 8 | 9 | const DIVIDER_COLOR: Color = Color::DarkGray; 10 | 11 | #[derive(Debug)] 12 | pub struct Divider { 13 | char: &'static str, 14 | color: Color, 15 | } 16 | 17 | impl Default for Divider { 18 | fn default() -> Self { 19 | Self { char: "─", color: DIVIDER_COLOR } 20 | } 21 | } 22 | 23 | impl Widget for Divider { 24 | fn render(self, area: Rect, buf: &mut Buffer) { 25 | let line = Line::from(self.char.repeat(area.width as usize)).fg(self.color); 26 | line.render(area, buf); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/ui/help_display_dialog.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | buffer::Buffer, 3 | layout::{Alignment, Constraint, Direction, Layout, Rect}, 4 | style::{Color, Modifier, Style, Stylize}, 5 | text::{Line, Text}, 6 | widgets::{block::Title, Block, BorderType, Borders, Clear, Padding, Paragraph, StatefulWidget, Widget, Wrap}, 7 | }; 8 | 9 | use crate::{redux::state::DialogAction, utils::centered_rect_with_size}; 10 | 11 | #[derive(Default, Debug, Clone)] 12 | pub struct HelpDisplayDialogWidget { 13 | pub tabs: Vec, 14 | pub active_tab: usize, 15 | } 16 | #[derive(Default, Debug, Clone)] 17 | pub struct Tab { 18 | pub title: String, 19 | pub content: String, 20 | } 21 | 22 | #[derive(Default, Debug, Clone)] 23 | pub struct HelpDisplayDialogState { 24 | pub show: bool, 25 | } 26 | 27 | impl HelpDisplayDialogState { 28 | pub fn new() -> Self { 29 | Self { show: false } 30 | } 31 | } 32 | 33 | impl HelpDisplayDialogWidget { 34 | pub fn new(tabs: Vec, active_tab: usize) -> Self { 35 | Self { tabs, active_tab } 36 | } 37 | } 38 | 39 | impl StatefulWidget for HelpDisplayDialogWidget { 40 | type State = HelpDisplayDialogState; 41 | 42 | fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { 43 | let horizontal_padding = 2u16; 44 | let vertical_padding = 2u16; 45 | let buttons_padding = 2u16; 46 | 47 | let block = Block::default() 48 | .title(Line::from("Press 'q' to close").alignment(Alignment::Right)) 49 | .borders(Borders::ALL) 50 | .border_type(BorderType::Rounded) 51 | .border_style(Style::default().fg(Color::Yellow)); 52 | 53 | let block_with_tabs = self.tabs.iter().enumerate().fold(block, |acc_block, (index, tab)| { 54 | let title_style = if index == self.active_tab { Style::default().fg(Color::Green) } else { Style::default() }; 55 | acc_block.title(Line::from(format!(" {} ", tab.title)).style(title_style)) 56 | }); 57 | 58 | let text = self.tabs[self.active_tab].content.clone(); 59 | 60 | let width = 80; 61 | let height = 15; 62 | 63 | let lines = Text::from(text); 64 | let text_widget = Paragraph::new(lines) 65 | .block(Block::new().padding(Padding::new( 66 | horizontal_padding, 67 | horizontal_padding, 68 | vertical_padding, 69 | vertical_padding, 70 | ))) 71 | .alignment(Alignment::Left) 72 | .style(Style::new().white()) 73 | .wrap(Wrap { trim: true }); 74 | 75 | let centered_area = centered_rect_with_size(width, height, area); 76 | 77 | let main_layout = Layout::default() 78 | .direction(Direction::Vertical) 79 | .constraints([Constraint::Min(1), Constraint::Max(2)]) 80 | .split(centered_area); 81 | 82 | Clear.render(centered_area, buf); 83 | text_widget.render(main_layout[0], buf); 84 | block_with_tabs.render(centered_area, buf); 85 | 86 | let buttons_layout = Layout::default().direction(Direction::Horizontal); 87 | let c = main_layout[1].width; 88 | let buttons_layout = 89 | buttons_layout.constraints([Constraint::Length(c), Constraint::Length(c)]).split(main_layout[1]); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/ui/notification_box.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | buffer::Buffer, 3 | layout::{Alignment, Rect}, 4 | style::{Color, Modifier, Style, Stylize}, 5 | text::{Line, Text}, 6 | widgets::{Block, BorderType, Clear, Padding, Paragraph, Widget, Wrap}, 7 | }; 8 | 9 | use crate::components::notifications::{self, NotificationEnum, NotificationWithTimestamp}; 10 | 11 | #[derive(Debug)] 12 | pub struct NotificationBox<'a> { 13 | notification: &'a NotificationWithTimestamp, 14 | content: &'a String, 15 | } 16 | impl<'a> NotificationBox<'a> { 17 | pub fn new(notification: &'a NotificationWithTimestamp, content: &'a String) -> Self { 18 | Self { notification, content } 19 | } 20 | } 21 | 22 | impl Widget for NotificationBox<'_> { 23 | fn render(self, area: Rect, buf: &mut Buffer) { 24 | Clear.render(area, buf); 25 | 26 | let color = match &self.notification.0 { 27 | NotificationEnum::Info(_) => Color::Blue, 28 | NotificationEnum::Warning(_) => Color::Yellow, 29 | NotificationEnum::Error(_) => Color::Red, 30 | }; 31 | let block = Block::bordered() 32 | .border_type(BorderType::Rounded) 33 | .title("Notification") 34 | .border_style(Style::default().fg(color).add_modifier(Modifier::BOLD)); 35 | 36 | let notification_text = Text::from(self.content.to_string()) 37 | .style(Style::default().fg(color).bg(Color::Reset)) 38 | .alignment(Alignment::Right); 39 | 40 | let notification = Paragraph::new(notification_text.clone()).wrap(Wrap { trim: true }).block(block.clone()); 41 | 42 | notification.render(area, buf); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/ui/small_help_widget.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | buffer::Buffer, 3 | layout::{Alignment, Rect}, 4 | style::{Color, Modifier, Style, Stylize}, 5 | text::{Line, Text}, 6 | widgets::{Block, BorderType, Clear, Padding, Paragraph, Widget, Wrap}, 7 | }; 8 | 9 | #[derive(Default, Debug)] 10 | pub struct SmallHelpWidget { 11 | content: String, 12 | color: Color, 13 | alignment: Alignment, 14 | } 15 | impl SmallHelpWidget { 16 | pub fn new(content: String, color: Color, alignment: Alignment) -> Self { 17 | Self { content, color, alignment } 18 | } 19 | } 20 | 21 | impl Widget for SmallHelpWidget { 22 | fn render(self, area: Rect, buf: &mut Buffer) { 23 | let small_help_text = Text::from(self.content).style(Style::default().fg(self.color).bg(Color::Reset)); 24 | 25 | let small_help = Paragraph::new(small_help_text).wrap(Wrap { trim: true }).alignment(self.alignment); 26 | // .block(Block::default().padding(if self.alignment == Alignment::Left { Padding::left(1) } else { Padding::right(1) })); 27 | small_help.render(area, buf); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use anyhow::{Context, Result}; 4 | use color_eyre::eyre::Result as EyreResult; 5 | use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs}; 6 | use lazy_static::lazy_static; 7 | use tracing::error; 8 | use tracing_error::ErrorLayer; 9 | use tracing_subscriber::{self, prelude::__tracing_subscriber_SubscriberExt, util::SubscriberInitExt, Layer}; 10 | 11 | const VERSION_MESSAGE: &str = 12 | concat!(env!("CARGO_PKG_VERSION"), "-", env!("VERGEN_GIT_DESCRIBE"), " (", env!("VERGEN_BUILD_DATE"), ")"); 13 | 14 | lazy_static! { 15 | pub static ref PROJECT_NAME: String = env!("CARGO_CRATE_NAME").to_uppercase().to_string(); 16 | pub static ref DATA_FOLDER: Option = 17 | std::env::var(format!("{}_DATA", PROJECT_NAME.clone())).ok().map(PathBuf::from); 18 | pub static ref CONFIG_FOLDER: Option = 19 | std::env::var(format!("{}_CONFIG", PROJECT_NAME.clone())).ok().map(PathBuf::from); 20 | pub static ref LOG_ENV: String = format!("{}_LOGLEVEL", PROJECT_NAME.clone()); 21 | pub static ref LOG_FILE: String = format!("{}.log", env!("CARGO_PKG_NAME")); 22 | } 23 | 24 | const APP_QUALIFIER: &str = "com"; 25 | const APP_ORG: &str = "yassinebridi"; 26 | const APP_NAME: &str = "serpl"; 27 | 28 | pub fn strategy() -> Result { 29 | choose_app_strategy(AppStrategyArgs { 30 | top_level_domain: APP_QUALIFIER.to_owned(), 31 | author: APP_ORG.to_owned(), 32 | app_name: APP_NAME.to_owned(), 33 | }) 34 | .context("Failed to create app strategy") 35 | } 36 | 37 | pub fn config_directory() -> Result { 38 | Ok(strategy()?.config_dir()) 39 | } 40 | pub fn data_directory() -> Result { 41 | Ok(strategy()?.data_dir()) 42 | } 43 | 44 | pub fn state() -> Result { 45 | let strategy = strategy()?; 46 | match strategy.state_dir() { 47 | Some(path) => Ok(path), 48 | None => Ok(strategy.data_dir()), 49 | } 50 | } 51 | 52 | pub fn initialize_panic_handler() -> EyreResult<()> { 53 | let (panic_hook, eyre_hook) = color_eyre::config::HookBuilder::default() 54 | .panic_section(format!("This is a bug. Consider reporting it at {}", env!("CARGO_PKG_REPOSITORY"))) 55 | .capture_span_trace_by_default(false) 56 | .display_location_section(false) 57 | .display_env_section(false) 58 | .into_hooks(); 59 | eyre_hook.install()?; 60 | std::panic::set_hook(Box::new(move |panic_info| { 61 | if let Ok(mut t) = crate::tui::Tui::new() { 62 | if let Err(r) = t.exit() { 63 | error!("Unable to exit Terminal: {:?}", r); 64 | } 65 | } 66 | 67 | #[cfg(not(debug_assertions))] 68 | { 69 | use human_panic::{handle_dump, print_msg, Metadata}; 70 | let meta = Metadata { 71 | version: env!("CARGO_PKG_VERSION").into(), 72 | name: env!("CARGO_PKG_NAME").into(), 73 | authors: env!("CARGO_PKG_AUTHORS").replace(':', ", ").into(), 74 | homepage: env!("CARGO_PKG_HOMEPAGE").into(), 75 | }; 76 | 77 | let file_path = handle_dump(&meta, panic_info); 78 | // prints human-panic message 79 | print_msg(file_path, &meta).expect("human-panic: printing error message to console failed"); 80 | eprintln!("{}", panic_hook.panic_report(panic_info)); // prints color-eyre stack trace to stderr 81 | } 82 | let msg = format!("{}", panic_hook.panic_report(panic_info)); 83 | log::error!("Error: {}", strip_ansi_escapes::strip_str(msg)); 84 | 85 | #[cfg(debug_assertions)] 86 | { 87 | // Better Panic stacktrace that is only enabled when debugging. 88 | better_panic::Settings::auto() 89 | .most_recent_first(false) 90 | .lineno_suffix(true) 91 | .verbosity(better_panic::Verbosity::Full) 92 | .create_panic_handler()(panic_info); 93 | } 94 | 95 | std::process::exit(libc::EXIT_FAILURE); 96 | })); 97 | Ok(()) 98 | } 99 | 100 | pub fn get_data_dir() -> PathBuf { 101 | if let Some(s) = DATA_FOLDER.clone() { 102 | s 103 | } else if let Ok(data_dir) = data_directory() { 104 | data_dir 105 | } else { 106 | PathBuf::from(".").join(".data") 107 | } 108 | } 109 | 110 | pub fn get_config_dir() -> PathBuf { 111 | if let Some(s) = CONFIG_FOLDER.clone() { 112 | s 113 | } else if let Ok(config_dir) = config_directory() { 114 | config_dir 115 | } else { 116 | PathBuf::from(".").join(".config") 117 | } 118 | } 119 | 120 | pub fn initialize_logging() -> Result<()> { 121 | let directory = get_data_dir(); 122 | std::fs::create_dir_all(directory.clone())?; 123 | let log_path = directory.join(LOG_FILE.clone()); 124 | let log_file = std::fs::File::create(log_path)?; 125 | std::env::set_var( 126 | "RUST_LOG", 127 | std::env::var("RUST_LOG") 128 | .or_else(|_| std::env::var(LOG_ENV.clone())) 129 | .unwrap_or_else(|_| format!("{}=info", env!("CARGO_CRATE_NAME"))), 130 | ); 131 | let file_subscriber = tracing_subscriber::fmt::layer() 132 | .with_file(true) 133 | .with_line_number(true) 134 | .with_writer(log_file) 135 | .with_target(false) 136 | .with_ansi(false) 137 | .with_filter(tracing_subscriber::filter::EnvFilter::from_default_env()); 138 | tracing_subscriber::registry().with(file_subscriber).with(ErrorLayer::default()).init(); 139 | Ok(()) 140 | } 141 | 142 | /// Similar to the `std::dbg!` macro, but generates `tracing` events rather 143 | /// than printing to stdout. 144 | /// 145 | /// By default, the verbosity level for the generated events is `DEBUG`, but 146 | /// this can be customized. 147 | #[macro_export] 148 | macro_rules! trace_dbg { 149 | (target: $target:expr, level: $level:expr, $ex:expr) => {{ 150 | match $ex { 151 | value => { 152 | tracing::event!(target: $target, $level, ?value, stringify!($ex)); 153 | value 154 | } 155 | } 156 | }}; 157 | (level: $level:expr, $ex:expr) => { 158 | trace_dbg!(target: module_path!(), level: $level, $ex) 159 | }; 160 | (target: $target:expr, $ex:expr) => { 161 | trace_dbg!(target: $target, level: tracing::Level::DEBUG, $ex) 162 | }; 163 | ($ex:expr) => { 164 | trace_dbg!(level: tracing::Level::DEBUG, $ex) 165 | }; 166 | } 167 | 168 | pub fn version() -> String { 169 | let author = clap::crate_authors!(); 170 | 171 | // let current_exe_path = PathBuf::from(clap::crate_name!()).display().to_string(); 172 | let config_dir_path = get_config_dir().display().to_string(); 173 | let data_dir_path = get_data_dir().display().to_string(); 174 | 175 | format!( 176 | "\ 177 | {VERSION_MESSAGE} 178 | 179 | Authors: {author} 180 | 181 | Config directory: {config_dir_path}" 182 | ) 183 | } 184 | 185 | pub fn is_git_repo(path: PathBuf) -> bool { 186 | let git_dir = path.join(".git"); 187 | git_dir.exists() 188 | } 189 | 190 | use ratatui::layout::{Constraint, Direction, Layout, Rect}; 191 | 192 | /// helper function to create a centered rect using up certain percentage of the available rect `r` 193 | #[allow(dead_code)] 194 | pub(crate) fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { 195 | let popup_layout = Layout::default() 196 | .direction(Direction::Vertical) 197 | .constraints([ 198 | Constraint::Percentage((100 - percent_y) / 2), 199 | Constraint::Percentage(percent_y), 200 | Constraint::Percentage((100 - percent_y) / 2), 201 | ]) 202 | .split(r); 203 | 204 | Layout::default() 205 | .direction(Direction::Horizontal) 206 | .constraints([ 207 | Constraint::Percentage((100 - percent_x) / 2), 208 | Constraint::Percentage(percent_x), 209 | Constraint::Percentage((100 - percent_x) / 2), 210 | ]) 211 | .split(popup_layout[1])[1] 212 | } 213 | 214 | #[allow(dead_code)] 215 | pub(crate) fn centered_rect_with_size(width: u16, height: u16, r: Rect) -> Rect { 216 | let width = width.min(r.width); 217 | let height = height.min(r.height); 218 | let remaining_width = r.width.saturating_sub(width); 219 | let remaining_height = r.height.saturating_sub(height); 220 | 221 | let popup_layout = Layout::default() 222 | .direction(Direction::Vertical) 223 | .constraints([ 224 | Constraint::Max(remaining_height / 2), 225 | Constraint::Length(height), 226 | Constraint::Max(remaining_height / 2), 227 | ]) 228 | .split(r); 229 | 230 | Layout::default() 231 | .direction(Direction::Horizontal) 232 | .constraints([ 233 | Constraint::Max(remaining_width / 2), 234 | Constraint::Length(width), 235 | Constraint::Max(remaining_width / 2), 236 | ]) 237 | .split(popup_layout[1])[1] 238 | } 239 | --------------------------------------------------------------------------------