Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac8df4c9e4 | |||
| e3e534f4ad | |||
| 764e3ecf18 | |||
| 10a27f9678 | |||
| 66e712e066 | |||
| c5a3f89c5e | |||
| 29f81a141f | |||
| d2e3993398 | |||
| a411100fa4 | |||
| 0a993d7a30 | |||
| 7c2d46f1e2 | |||
| c3ed9fb17b | |||
| 0f40c66eb7 | |||
| 7298978bcb | |||
| 8f69f987a4 | |||
| 3e9b8655b7 | |||
| 727935ede6 | |||
| 9a2612b1b5 | |||
| 7c0d999144 | |||
| 81d98f6b9a | |||
| 46e87e5928 | |||
| 9bc928db65 |
+3
-8
@@ -10,12 +10,7 @@ rustflags = ["-C", "target-feature=-crt-static"]
|
||||
[target.aarch64-unknown-linux-musl]
|
||||
rustflags = ["-C", "target-feature=-crt-static"]
|
||||
|
||||
# Android/Termux: no hardcoded linker so native Termux builds use the system cc.
|
||||
# For CI cross-compilation the linker is set via CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER env var.
|
||||
[target.aarch64-linux-android]
|
||||
rustflags = [
|
||||
"-C",
|
||||
"linker=aarch64-linux-android-clang",
|
||||
"-C",
|
||||
"link-args=-rdynamic",
|
||||
"-C",
|
||||
"default-linker-libraries",
|
||||
]
|
||||
rustflags = ["-C", "link-args=-rdynamic"]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Lua E2E Tests
|
||||
name: e2e Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -12,8 +12,10 @@ env:
|
||||
|
||||
jobs:
|
||||
lua-tests:
|
||||
name: Lua E2E (${{ matrix.os }})
|
||||
name: e2e (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
# e2e tests could be flaky on CI so we do not block release creation if they failed
|
||||
continue-on-error: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -24,6 +26,7 @@ jobs:
|
||||
target: x86_64-pc-windows-msvc
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install Zig
|
||||
uses: mlugg/setup-zig@v2
|
||||
@@ -41,7 +44,7 @@ jobs:
|
||||
|
||||
- name: Build Rust binary (Windows)
|
||||
if: matrix.target
|
||||
run: cargo build --release --target ${{ matrix.target }} -p fff-nvim
|
||||
run: cargo build --release --target ${{ matrix.target }} -p fff-nvim --features zlob
|
||||
|
||||
- name: Copy binary to target/release (Windows)
|
||||
if: matrix.target
|
||||
@@ -68,7 +71,7 @@ jobs:
|
||||
|
||||
- name: Build Rust binary
|
||||
if: ${{ !matrix.target }}
|
||||
run: cargo build --release -p fff-nvim
|
||||
run: cargo build --release -p fff-nvim --features zlob
|
||||
|
||||
- name: Install Neovim
|
||||
uses: rhysd/action-setup-vim@v1
|
||||
@@ -82,6 +85,8 @@ jobs:
|
||||
|
||||
- name: Run Lua tests
|
||||
shell: bash
|
||||
run: |
|
||||
nvim --headless -u tests/minimal_init.lua \
|
||||
-c "PlenaryBustedFile tests/fff_core_spec.lua" 2>&1
|
||||
run: make test-lua
|
||||
|
||||
- name: Run bun tests
|
||||
shell: bash
|
||||
run: make test-bun
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Lua CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
lua-ls:
|
||||
name: lua-language-server type check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Neovim
|
||||
run: |
|
||||
curl -L https://github.com/neovim/neovim/releases/download/v0.11.5/nvim-linux-x86_64.tar.gz -o /opt/nvim.tar.gz
|
||||
mkdir /opt/nvim
|
||||
tar xzf /opt/nvim.tar.gz -C /opt/nvim
|
||||
mv /opt/nvim/nvim-linux-x86_64/* /opt/nvim
|
||||
echo "/opt/nvim/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Install lua-language-server
|
||||
run: |
|
||||
curl -L "https://github.com/LuaLS/lua-language-server/releases/download/3.17.1/lua-language-server-3.17.1-linux-x64.tar.gz" -o /opt/lls.tar.gz
|
||||
mkdir /opt/lls
|
||||
tar -xzf /opt/lls.tar.gz -C /opt/lls
|
||||
echo "/opt/lls/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Clone snacks.nvim
|
||||
run: git clone --depth=1 https://github.com/folke/snacks.nvim /opt/snacks.nvim
|
||||
|
||||
- name: Run lua-language-server
|
||||
run: lua-language-server --configpath .luarc.ci.json --check=.
|
||||
|
||||
luacheck:
|
||||
name: luacheck lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install luacheck
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y luarocks
|
||||
sudo luarocks install luacheck
|
||||
|
||||
- name: Run luacheck
|
||||
run: luacheck lua/
|
||||
@@ -2,7 +2,7 @@ name: Prebuild
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, feat/binaries]
|
||||
branches: [main, feat/interchangable-ffi, feat/termux]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
@@ -36,6 +36,12 @@ jobs:
|
||||
artifact_name: target/aarch64-unknown-linux-musl/release/libfff_nvim.so
|
||||
ext: so
|
||||
|
||||
## Android (Termux)
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-linux-android
|
||||
artifact_name: target/aarch64-linux-android/release/libfff_nvim.so
|
||||
ext: so
|
||||
|
||||
## macOS builds
|
||||
- os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
@@ -73,22 +79,40 @@ jobs:
|
||||
run: cargo install cargo-zigbuild
|
||||
|
||||
- name: Build for Linux
|
||||
if: contains(matrix.os, 'ubuntu')
|
||||
if: contains(matrix.os, 'ubuntu') && !contains(matrix.target, 'android')
|
||||
run: |
|
||||
cargo zigbuild --release --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-nvim
|
||||
cargo zigbuild --release --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-nvim --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Build for Android (Termux)
|
||||
if: contains(matrix.target, 'android')
|
||||
run: |
|
||||
NDK_BIN="$ANDROID_NDK/toolchains/llvm/prebuilt/linux-x86_64/bin"
|
||||
|
||||
# NDK clang for C deps (libgit2, lmdb, blake3) that need Bionic sysroot headers
|
||||
export CC_aarch64_linux_android="$NDK_BIN/aarch64-linux-android24-clang"
|
||||
export CXX_aarch64_linux_android="$NDK_BIN/aarch64-linux-android24-clang++"
|
||||
export AR_aarch64_linux_android="$NDK_BIN/llvm-ar"
|
||||
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$NDK_BIN/aarch64-linux-android24-clang"
|
||||
|
||||
cargo build --release --target ${{ matrix.target }} -p fff-nvim --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Build for macOS
|
||||
if: contains(matrix.os, 'macos')
|
||||
run: |
|
||||
MACOSX_DEPLOYMENT_TARGET="13" cargo build --release --target ${{ matrix.target }} -p fff-nvim
|
||||
MACOSX_DEPLOYMENT_TARGET="13" cargo build --release --target ${{ matrix.target }} -p fff-nvim --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Ad-hoc sign macOS binary
|
||||
if: contains(matrix.os, 'macos')
|
||||
run: codesign --force --sign - "${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Build for Windows
|
||||
if: contains(matrix.os, 'windows')
|
||||
shell: bash
|
||||
run: |
|
||||
cargo build --release --target ${{ matrix.target }} -p fff-nvim
|
||||
cargo build --release --target ${{ matrix.target }} -p fff-nvim --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Upload artifacts
|
||||
@@ -133,6 +157,13 @@ jobs:
|
||||
lib_filename: libfff_c.so
|
||||
ext: so
|
||||
|
||||
## Android (Termux)
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-linux-android
|
||||
artifact_name: target/aarch64-linux-android/release/libfff_c.so
|
||||
lib_filename: libfff_c.so
|
||||
ext: so
|
||||
|
||||
## macOS builds
|
||||
- os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
@@ -179,25 +210,43 @@ jobs:
|
||||
run: cargo install cargo-zigbuild
|
||||
|
||||
- name: Build for Linux
|
||||
if: contains(matrix.os, 'ubuntu')
|
||||
if: contains(matrix.os, 'ubuntu') && !contains(matrix.target, 'android')
|
||||
run: |
|
||||
cargo zigbuild --release --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-c
|
||||
cargo zigbuild --release --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-c --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Build for Android (Termux)
|
||||
if: contains(matrix.target, 'android')
|
||||
run: |
|
||||
NDK_BIN="$ANDROID_NDK/toolchains/llvm/prebuilt/linux-x86_64/bin"
|
||||
|
||||
export CC_aarch64_linux_android="$NDK_BIN/aarch64-linux-android24-clang"
|
||||
export CXX_aarch64_linux_android="$NDK_BIN/aarch64-linux-android24-clang++"
|
||||
export AR_aarch64_linux_android="$NDK_BIN/llvm-ar"
|
||||
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$NDK_BIN/aarch64-linux-android24-clang"
|
||||
|
||||
cargo build --release --target ${{ matrix.target }} -p fff-c --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Build for macOS
|
||||
if: contains(matrix.os, 'macos')
|
||||
run: |
|
||||
MACOSX_DEPLOYMENT_TARGET="13" cargo build --release --target ${{ matrix.target }} -p fff-c
|
||||
MACOSX_DEPLOYMENT_TARGET="13" cargo build --release --target ${{ matrix.target }} -p fff-c --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Ad-hoc sign macOS binary
|
||||
if: contains(matrix.os, 'macos')
|
||||
run: codesign --force --sign - "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Build for Windows
|
||||
if: contains(matrix.os, 'windows')
|
||||
shell: bash
|
||||
run: |
|
||||
cargo build --release --target ${{ matrix.target }} -p fff-c
|
||||
cargo build --release --target ${{ matrix.target }} -p fff-c --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Prepare npm package
|
||||
if: "!contains(matrix.target, 'android')"
|
||||
shell: bash
|
||||
run: |
|
||||
# Copy the built binary into the platform npm package directory
|
||||
@@ -210,6 +259,7 @@ jobs:
|
||||
path: c-lib-${{ matrix.target }}.*
|
||||
|
||||
- name: Upload npm package artifact
|
||||
if: "!contains(matrix.target, 'android')"
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: npm-${{ matrix.npm_package }}
|
||||
@@ -219,6 +269,8 @@ jobs:
|
||||
name: Release
|
||||
needs: [build-nvim, build-c]
|
||||
runs-on: ubuntu-latest
|
||||
# do not create releases on the forks (no permissions)
|
||||
if: github.repository == 'dmtrKovalenko/fff.nvim'
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
@@ -305,8 +357,8 @@ jobs:
|
||||
needs: [build-c]
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
(github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/feat/binaries'))
|
||||
|| (github.event_name == 'pull_request' && (github.head_ref == 'main' || github.head_ref == 'feat/binaries'))
|
||||
(github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/feat/interchangable-ffi'))
|
||||
|| (github.event_name == 'pull_request' && (github.head_ref == 'main' || github.head_ref == 'feat/interchangable-ffi'))
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
@@ -395,7 +447,8 @@ jobs:
|
||||
name: Comment on PR
|
||||
needs: [build-nvim, build-c]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
# comments doesn't work on forks
|
||||
if: github.event_name == 'pull_request' && github.repository == 'dmtrKovalenko/fff.nvim'
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
|
||||
@@ -37,7 +37,9 @@ jobs:
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --verbose --workspace --exclude fff-nvim
|
||||
run: |
|
||||
cargo test --verbose -p fff-core -p fff-query-parser -p fff-c --features zlob
|
||||
cargo test --verbose -p grep-searcher
|
||||
|
||||
fmt:
|
||||
name: cargo fmt
|
||||
@@ -71,4 +73,6 @@ jobs:
|
||||
components: clippy
|
||||
|
||||
- name: Run clippy
|
||||
run: cargo clippy -- -D warnings
|
||||
run: |
|
||||
cargo clippy -p fff-core -p fff-query-parser -p fff-nvim -p fff-c --features zlob -- -D warnings
|
||||
cargo clippy -p grep-searcher -- -D warnings
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
-- luacheck configuration for fff.nvim
|
||||
-- https://luacheck.readthedocs.io/en/stable/config.html
|
||||
|
||||
-- Neovim globals
|
||||
globals = { "vim" }
|
||||
|
||||
-- Standard library
|
||||
std = "luajit"
|
||||
|
||||
-- Ignore line length (handled by stylua)
|
||||
max_line_length = false
|
||||
|
||||
-- Ignore unused self argument in methods
|
||||
self = false
|
||||
|
||||
-- Files/directories to ignore
|
||||
exclude_files = {
|
||||
".luarocks/",
|
||||
}
|
||||
|
||||
-- Warn about unused variables, but allow _ prefix convention
|
||||
unused_args = true
|
||||
ignore = {
|
||||
"212", -- unused argument (too noisy for callback-heavy code)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json",
|
||||
"runtime": {
|
||||
"version": "LuaJIT",
|
||||
"pathStrict": true
|
||||
},
|
||||
"workspace": {
|
||||
"library": [
|
||||
"/opt/nvim/share/nvim/runtime/lua/vim/_meta",
|
||||
"/opt/nvim/share/nvim/runtime/lua/vim/shared.lua",
|
||||
"${3rd}/luv/library",
|
||||
"${3rd}/busted/library",
|
||||
"/opt/snacks.nvim/lua"
|
||||
],
|
||||
"checkThirdParty": false
|
||||
},
|
||||
"diagnostics": {
|
||||
"severity": {
|
||||
"undefined-global": "Error",
|
||||
"undefined-field": "Warning",
|
||||
"missing-return": "Warning",
|
||||
"redundant-parameter": "Warning",
|
||||
"param-type-mismatch": "Warning",
|
||||
"assign-type-mismatch": "Warning",
|
||||
"cast-type-mismatch": "Warning",
|
||||
"deprecated": "Warning",
|
||||
"undefined-doc-param": "Warning"
|
||||
},
|
||||
"neededFileStatus": {
|
||||
"undefined-global": "Any",
|
||||
"undefined-field": "Any",
|
||||
"missing-return": "Any",
|
||||
"redundant-parameter": "Any",
|
||||
"param-type-mismatch": "Any",
|
||||
"assign-type-mismatch": "Any",
|
||||
"cast-type-mismatch": "Any",
|
||||
"deprecated": "Any",
|
||||
"undefined-doc-param": "Any"
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
"checkTableShape": true
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json",
|
||||
"runtime": {
|
||||
"version": "LuaJIT"
|
||||
},
|
||||
"workspace": {
|
||||
"library": [
|
||||
"/opt/homebrew/share/nvim/runtime/lua/vim/_meta",
|
||||
"/opt/homebrew/share/nvim/runtime/lua/vim/shared.lua",
|
||||
"${3rd}/luv/library",
|
||||
"${3rd}/busted/library"
|
||||
],
|
||||
"checkThirdParty": false
|
||||
},
|
||||
"diagnostics": {
|
||||
"severity": {
|
||||
"undefined-global": "Error",
|
||||
"undefined-field": "Warning",
|
||||
"missing-return": "Warning",
|
||||
"redundant-parameter": "Warning",
|
||||
"param-type-mismatch": "Warning",
|
||||
"assign-type-mismatch": "Warning",
|
||||
"cast-type-mismatch": "Warning",
|
||||
"deprecated": "Warning",
|
||||
"undefined-doc-param": "Warning"
|
||||
},
|
||||
"neededFileStatus": {
|
||||
"undefined-global": "Any",
|
||||
"undefined-field": "Any",
|
||||
"missing-return": "Any",
|
||||
"redundant-parameter": "Any",
|
||||
"param-type-mismatch": "Any",
|
||||
"assign-type-mismatch": "Any",
|
||||
"cast-type-mismatch": "Any",
|
||||
"deprecated": "Any",
|
||||
"undefined-doc-param": "Any"
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
"checkTableShape": true
|
||||
}
|
||||
}
|
||||
Generated
+3
-69
@@ -463,7 +463,6 @@ dependencies = [
|
||||
"fff-query-parser",
|
||||
"git2",
|
||||
"mimalloc",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tracing",
|
||||
@@ -483,6 +482,7 @@ dependencies = [
|
||||
"fff-query-parser",
|
||||
"git2",
|
||||
"glidesort",
|
||||
"globset",
|
||||
"grep-matcher",
|
||||
"grep-searcher",
|
||||
"heed",
|
||||
@@ -493,7 +493,6 @@ dependencies = [
|
||||
"notify",
|
||||
"notify-debouncer-full 0.7.0",
|
||||
"once_cell",
|
||||
"openssl",
|
||||
"parking_lot",
|
||||
"pathdiff",
|
||||
"rand",
|
||||
@@ -532,7 +531,6 @@ dependencies = [
|
||||
"notify",
|
||||
"notify-debouncer-full 0.6.0",
|
||||
"once_cell",
|
||||
"openssl",
|
||||
"pathdiff",
|
||||
"rand",
|
||||
"rayon",
|
||||
@@ -544,7 +542,6 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"zlob",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -565,21 +562,6 @@ dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||
dependencies = [
|
||||
"foreign-types-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.1"
|
||||
@@ -1300,54 +1282,6 @@ version = "11.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.73"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"cfg-if",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-src"
|
||||
version = "300.5.1+3.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "735230c832b28c000e3bc117119e6466a663ec73506bc0a9907ea4187508e42a"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.109"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"openssl-src",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
@@ -2575,9 +2509,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zlob"
|
||||
version = "1.2.9"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "674f4e74544c0a00887c0dff7862fe13742de8c5dbcf6db9eac0643362554d44"
|
||||
checksum = "07934fb13f0f4e14281bab6b0e984cfc03891d2cf5b0a8bd0ab14fd240e21106"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"bitflags 2.9.1",
|
||||
|
||||
+2
-1
@@ -22,13 +22,14 @@ git2 = { version = "0.20.2", default-features = false, features = [
|
||||
"vendored-libgit2",
|
||||
] }
|
||||
glidesort = "0.1"
|
||||
globset = "0.4"
|
||||
grep-matcher = "0.1.8"
|
||||
grep-searcher = { path = "crates/fff-searcher" }
|
||||
heed = "0.22.0"
|
||||
ignore = "0.4.22"
|
||||
memmap2 = "0.9"
|
||||
mimalloc = "0.1.47"
|
||||
zlob = "1.2.9"
|
||||
zlob = "1.3.0"
|
||||
|
||||
mlua = { version = "0.11.1", features = ["module", "luajit"] }
|
||||
neo_frizbee = "0.8.1"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
PLENARY_DIR ?= ../plenary.nvim
|
||||
|
||||
.PHONY: build test test-rust test-lua test-setup
|
||||
.PHONY: build test test-rust test-lua test-bun test-setup prepare-bun
|
||||
|
||||
build:
|
||||
cargo build --release
|
||||
cargo build --release --features zlob
|
||||
|
||||
test-setup:
|
||||
@if [ ! -d "$(PLENARY_DIR)" ]; then \
|
||||
@@ -12,13 +12,23 @@ test-setup:
|
||||
fi
|
||||
|
||||
test-rust:
|
||||
cargo test --verbose --workspace --exclude fff-nvim
|
||||
cargo test --workspace --features zlob
|
||||
|
||||
test-lua: test-setup build
|
||||
nvim --headless -u tests/minimal_init.lua \
|
||||
-c "PlenaryBustedFile tests/fff_core_spec.lua" 2>&1
|
||||
|
||||
test: test-rust test-lua
|
||||
prepare-bun: build
|
||||
mkdir -p packages/fff-bun/bin
|
||||
cp target/release/libfff_c.dylib packages/fff-bun/bin/ 2>/dev/null; \
|
||||
cp target/release/libfff_c.so packages/fff-bun/bin/ 2>/dev/null; \
|
||||
cp target/release/fff_c.dll packages/fff-bun/bin/ 2>/dev/null; \
|
||||
true
|
||||
|
||||
test-bun: prepare-bun
|
||||
cd packages/fff-bun && bun test src/
|
||||
|
||||
test: test-rust test-lua test-bun
|
||||
|
||||
format-rust:
|
||||
cargo fmt --all
|
||||
|
||||
@@ -78,12 +78,17 @@ FFF.nvim requires:
|
||||
{
|
||||
"fz",
|
||||
function() require('fff').live_grep({
|
||||
grep = {
|
||||
modes = { 'fuzzy', 'plain' }
|
||||
}
|
||||
grep = {
|
||||
modes = { 'fuzzy', 'plain' }
|
||||
}
|
||||
}) end,
|
||||
desc = 'Live fffuzy grep',
|
||||
}
|
||||
},
|
||||
{
|
||||
"fc",
|
||||
function() require('fff').live_grep({ query = vim.fn.expand("<cword>") }) end,
|
||||
desc = 'Search current word',
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -136,6 +141,10 @@ require('fff').setup({
|
||||
prompt_position = 'bottom', -- or 'top'
|
||||
preview_position = 'right', -- or 'left', 'right', 'top', 'bottom'
|
||||
preview_size = 0.5,
|
||||
flex = { -- set to false to disable flex layout
|
||||
size = 130, -- column threshold: if screen width >= size, use preview_position; otherwise use wrap
|
||||
wrap = 'top', -- position to use when screen is narrower than size
|
||||
},
|
||||
show_scrollbar = true, -- Show scrollbar for pagination
|
||||
-- How to shorten long directory paths in the file list:
|
||||
-- 'middle_number' (default): uses dots for 1-3 hidden (a/./b, a/../b, a/.../b)
|
||||
@@ -151,6 +160,7 @@ require('fff').setup({
|
||||
binary_file_threshold = 1024, -- amount of bytes to scan for binary content (set 0 to disable)
|
||||
imagemagick_info_format_str = '%m: %wx%h, %[colorspace], %q-bit',
|
||||
line_numbers = false,
|
||||
cursorlineopt = 'both', -- the cursorlineopt used for lines in grep file previews, see :h cursorlineopt
|
||||
wrap_lines = false,
|
||||
filetypes = {
|
||||
svg = { wrap_lines = true },
|
||||
@@ -170,27 +180,29 @@ require('fff').setup({
|
||||
preview_scroll_up = '<C-u>',
|
||||
preview_scroll_down = '<C-d>',
|
||||
toggle_debug = '<F2>',
|
||||
-- grep mode: cycle between plain text, regex, and fuzzy search
|
||||
cycle_grep_modes = '<S-Tab>',
|
||||
-- goes to the previous query in history
|
||||
cycle_previous_query = '<C-Up>',
|
||||
-- multi-select keymaps for quickfix
|
||||
toggle_select = '<Tab>',
|
||||
send_to_quickfix = '<C-q>',
|
||||
-- grep mode: cycle between plain text, regex, and fuzzy search
|
||||
toggle_grep_regex = '<S-Tab>',
|
||||
-- this are specific for the normal mode (you can exit it using any other keybind like jj)
|
||||
focus_list = '<leader>l',
|
||||
focus_preview = '<leader>p',
|
||||
},
|
||||
hl = {
|
||||
border = 'FloatBorder',
|
||||
normal = 'Normal',
|
||||
cursor = 'CursorLine',
|
||||
cursor = 'CursorLine', -- Falls back to 'Visual' if CursorLine is not defined
|
||||
matched = 'IncSearch',
|
||||
title = 'Title',
|
||||
prompt = 'Question',
|
||||
active_file = 'Visual',
|
||||
frecency = 'Number',
|
||||
debug = 'Comment',
|
||||
combo_header = 'Number',
|
||||
scrollbar = 'Comment', -- Highlight for scrollbar thumb (track uses border)
|
||||
directory_path = 'Comment', -- Highlight for directory path in file list
|
||||
scrollbar = 'Comment',
|
||||
directory_path = 'Comment',
|
||||
-- Multi-select highlights
|
||||
selected = 'FFFSelected',
|
||||
selected_active = 'FFFSelectedActive',
|
||||
@@ -219,9 +231,10 @@ require('fff').setup({
|
||||
grep_match = 'IncSearch', -- Highlight for matched text in grep results
|
||||
grep_line_number = 'LineNr', -- Highlight for :line:col location
|
||||
grep_regex_active = 'DiagnosticInfo', -- Highlight for keybind + label when regex is on
|
||||
grep_regex_inactive = 'Comment', -- Highlight for keybind + label when regex is off
|
||||
grep_plain_active = 'Comment', -- Highlight for keybind + label when regex is off
|
||||
grep_fuzzy_active = 'DiagnosticHint', -- Highlight for keybind + label when fuzzy is on
|
||||
-- Cross-mode suggestion highlights
|
||||
suggestion_header = 'WarningMsg', -- Highlight for the "No results found. Suggested..." banner
|
||||
suggestion_header = 'WarningMsg', -- Highlight for the "No results found. Suggested..." banner
|
||||
},
|
||||
-- Store file open frecency
|
||||
frecency = {
|
||||
@@ -232,8 +245,8 @@ require('fff').setup({
|
||||
history = {
|
||||
enabled = true,
|
||||
db_path = vim.fn.stdpath('data') .. '/fff_queries',
|
||||
min_combo_count = 3, -- file will get a boost if it was selected 3 in a row times per specific query
|
||||
combo_boost_score_multiplier = 100, -- Score multiplier for combo matches
|
||||
min_combo_count = 3, -- Minimum selections before combo boost applies (3 = boost starts on 3rd selection)
|
||||
combo_boost_score_multiplier = 100, -- Score multiplier for combo matches (files repeatedly opened with same query)
|
||||
},
|
||||
-- Git integration
|
||||
git = {
|
||||
@@ -249,15 +262,19 @@ require('fff').setup({
|
||||
log_file = vim.fn.stdpath('log') .. '/fff.log',
|
||||
log_level = 'info',
|
||||
},
|
||||
-- Live grep search configuration
|
||||
-- find_files settings
|
||||
file_picker = {
|
||||
current_file_label = '(current)',
|
||||
},
|
||||
-- grep settings
|
||||
grep = {
|
||||
max_file_size = 10 * 1024 * 1024, -- Skip files larger than 10MB
|
||||
max_matches_per_file = 200, -- Maximum matches per file
|
||||
max_matches_per_file = 100, -- Maximum matches per file (set 0 to unlimited)
|
||||
smart_case = true, -- Case-insensitive unless query has uppercase
|
||||
time_budget_ms = 150, -- Max search time in ms per call (prevents UI freeze, 0 = no limit)
|
||||
modes = { 'plain', 'regex', 'fuzzy' }, -- Available grep modes and their cycling order
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Key Features
|
||||
@@ -265,8 +282,7 @@ require('fff').setup({
|
||||
#### Available Methods
|
||||
|
||||
```lua
|
||||
require('fff').find_files() -- Find files in current directory
|
||||
require('fff').find_in_git_root() -- Find files in the current git repository
|
||||
require('fff').find_files() -- Find files in current repositro
|
||||
require('fff').scan_files() -- Trigger rescan of files in the current directory
|
||||
require('fff').refresh_git_status() -- Refresh git status for the active file lock
|
||||
require('fff').find_files_in_dir(path) -- Find files in a specific directory
|
||||
@@ -342,6 +358,9 @@ require('fff').live_grep({
|
||||
modes = { 'fuzzy' },
|
||||
}
|
||||
})
|
||||
|
||||
-- Pre-fill the search with an initial query
|
||||
require('fff').live_grep({ query = 'search term' })
|
||||
```
|
||||
|
||||
When only one mode is configured, the mode indicator is hidden completely and the cycle keybind does nothing.
|
||||
|
||||
@@ -8,9 +8,12 @@ license = "MIT"
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
zlob = ["fff-core/zlob"]
|
||||
|
||||
[dependencies]
|
||||
mimalloc.workspace = true
|
||||
once_cell.workspace = true
|
||||
tracing.workspace = true
|
||||
git2.workspace = true
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! These types use #[repr(C)] for C ABI compatibility and implement
|
||||
//! serde traits for JSON serialization.
|
||||
|
||||
use std::ffi::{CString, c_char};
|
||||
use std::ffi::{CString, c_char, c_void};
|
||||
use std::ptr;
|
||||
|
||||
use fff_core::git::format_git_status;
|
||||
@@ -20,6 +20,8 @@ pub struct FffResult {
|
||||
pub data: *mut c_char,
|
||||
/// Error message on failure (null-terminated string, caller must free)
|
||||
pub error: *mut c_char,
|
||||
/// Opaque handle pointer (used by fff_create to return the instance)
|
||||
pub handle: *mut c_void,
|
||||
}
|
||||
|
||||
impl FffResult {
|
||||
@@ -29,6 +31,7 @@ impl FffResult {
|
||||
success: true,
|
||||
data: ptr::null_mut(),
|
||||
error: ptr::null_mut(),
|
||||
handle: ptr::null_mut(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -38,6 +41,17 @@ impl FffResult {
|
||||
success: true,
|
||||
data: CString::new(data).unwrap_or_default().into_raw(),
|
||||
error: ptr::null_mut(),
|
||||
handle: ptr::null_mut(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Create a successful result carrying an opaque instance handle.
|
||||
pub fn ok_handle(handle: *mut c_void) -> *mut Self {
|
||||
Box::into_raw(Box::new(FffResult {
|
||||
success: true,
|
||||
data: ptr::null_mut(),
|
||||
error: ptr::null_mut(),
|
||||
handle,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -47,6 +61,7 @@ impl FffResult {
|
||||
success: false,
|
||||
data: ptr::null_mut(),
|
||||
error: CString::new(error).unwrap_or_default().into_raw(),
|
||||
handle: ptr::null_mut(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -326,7 +341,7 @@ impl GrepResultJson {
|
||||
GrepMatchJson::from_grep_match(m, file)
|
||||
})
|
||||
.collect(),
|
||||
total_matched: result.total_match_count,
|
||||
total_matched: result.matches.len(),
|
||||
total_files_searched: result.total_files_searched,
|
||||
total_files: result.total_files,
|
||||
filtered_file_count: result.filtered_file_count,
|
||||
|
||||
+390
-242
@@ -3,12 +3,21 @@
|
||||
//! This crate provides C-compatible FFI exports that can be used from any language
|
||||
//! with C FFI support (Bun, Node.js, Python, Ruby, etc.).
|
||||
//!
|
||||
//! All functions return a pointer to a heap-allocated `FffResult` struct containing
|
||||
//! success status and either data (as JSON string) or an error message.
|
||||
//! Memory must be freed using `fff_free_result`.
|
||||
//! # Instance-based API
|
||||
//!
|
||||
//! All state is owned by an opaque `FffInstance` fff_handle. Callers create an instance
|
||||
//! with `fff_create`, pass the fff_handle to every subsequent call, and free it with
|
||||
//! `fff_destroy`. Multiple independent instances can coexist in the same process.
|
||||
//!
|
||||
//! # Memory management
|
||||
//!
|
||||
//! * Every `fff_*` function that returns `*mut FffResult` requires the caller to
|
||||
//! free the result with `fff_free_result`.
|
||||
//! * The instance itself must be freed with `fff_destroy`.
|
||||
|
||||
use std::ffi::{CStr, CString, c_char};
|
||||
use std::ffi::{CStr, CString, c_char, c_void};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
mod ffi_types;
|
||||
@@ -17,13 +26,23 @@ use fff_core::file_picker::FilePicker;
|
||||
use fff_core::frecency::FrecencyTracker;
|
||||
use fff_core::query_tracker::QueryTracker;
|
||||
use fff_core::{DbHealthChecker, FuzzySearchOptions, PaginationArgs, QueryParser};
|
||||
use fff_core::{FILE_PICKER, FRECENCY, QUERY_TRACKER};
|
||||
use fff_core::{SharedFrecency, SharedPicker};
|
||||
use ffi_types::{FffResult, GrepSearchOptionsJson, InitOptions, ScanProgress, SearchOptions};
|
||||
use mimalloc::MiMalloc;
|
||||
|
||||
#[global_allocator]
|
||||
static GLOBAL: MiMalloc = MiMalloc;
|
||||
|
||||
/// Opaque fff_handle holding all per-instance state.
|
||||
///
|
||||
/// The caller receives this as `*mut c_void` and must pass it to every FFI call.
|
||||
/// The fff_handle is freed by `fff_destroy`.
|
||||
struct FffInstance {
|
||||
picker: SharedPicker,
|
||||
frecency: SharedFrecency,
|
||||
query_tracker: Arc<RwLock<Option<QueryTracker>>>,
|
||||
}
|
||||
|
||||
/// Helper to convert C string to Rust &str.
|
||||
///
|
||||
/// Returns `None` if the pointer is null or the string is not valid UTF-8.
|
||||
@@ -38,12 +57,28 @@ unsafe fn cstr_to_str<'a>(s: *const c_char) -> Option<&'a str> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize the file finder with the given options (JSON string)
|
||||
/// Recover a `&FffInstance` from the opaque pointer.
|
||||
///
|
||||
/// Returns an error `FffResult` if the pointer is null.
|
||||
unsafe fn instance_ref<'a>(fff_handle: *mut c_void) -> Result<&'a FffInstance, *mut FffResult> {
|
||||
if fff_handle.is_null() {
|
||||
Err(FffResult::err(
|
||||
"Instance handle is null. Create one with fff_create first.",
|
||||
))
|
||||
} else {
|
||||
Ok(unsafe { &*(fff_handle as *const FffInstance) })
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new file finder instance.
|
||||
///
|
||||
/// Returns an opaque pointer that must be passed to all other `fff_*` calls
|
||||
/// and eventually freed with `fff_destroy`.
|
||||
///
|
||||
/// # Safety
|
||||
/// `opts_json` must be a valid null-terminated UTF-8 string
|
||||
/// `opts_json` must be a valid null-terminated UTF-8 string.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_init(opts_json: *const c_char) -> *mut FffResult {
|
||||
pub unsafe extern "C" fn fff_create(opts_json: *const c_char) -> *mut FffResult {
|
||||
let opts_str = match unsafe { cstr_to_str(opts_json) } {
|
||||
Some(s) => s,
|
||||
None => return FffResult::err("Options JSON is null or invalid UTF-8"),
|
||||
@@ -54,104 +89,120 @@ pub unsafe extern "C" fn fff_init(opts_json: *const c_char) -> *mut FffResult {
|
||||
Err(e) => return FffResult::err(&format!("Failed to parse options: {}", e)),
|
||||
};
|
||||
|
||||
// Create shared state that background threads will write into.
|
||||
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
|
||||
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
|
||||
let query_tracker: Arc<RwLock<Option<QueryTracker>>> = Arc::new(RwLock::new(None));
|
||||
|
||||
// Initialize frecency tracker if path is provided
|
||||
if let Some(frecency_path) = opts.frecency_db_path {
|
||||
// Ensure directory exists
|
||||
if let Some(parent) = PathBuf::from(&frecency_path).parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
|
||||
let mut frecency = match FRECENCY.write() {
|
||||
Ok(f) => f,
|
||||
Err(e) => return FffResult::err(&format!("Failed to acquire frecency lock: {}", e)),
|
||||
};
|
||||
*frecency = None;
|
||||
match FrecencyTracker::new(&frecency_path, opts.use_unsafe_no_lock) {
|
||||
Ok(tracker) => *frecency = Some(tracker),
|
||||
Ok(tracker) => {
|
||||
let mut guard = match shared_frecency.write() {
|
||||
Ok(g) => g,
|
||||
Err(e) => {
|
||||
return FffResult::err(&format!("Failed to acquire frecency lock: {}", e));
|
||||
}
|
||||
};
|
||||
*guard = Some(tracker);
|
||||
}
|
||||
Err(e) => return FffResult::err(&format!("Failed to init frecency db: {}", e)),
|
||||
}
|
||||
drop(frecency);
|
||||
}
|
||||
|
||||
// Initialize query tracker if path is provided
|
||||
if let Some(history_path) = opts.history_db_path {
|
||||
// Ensure directory exists
|
||||
if let Some(parent) = PathBuf::from(&history_path).parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
|
||||
let mut query_tracker = match QUERY_TRACKER.write() {
|
||||
Ok(q) => q,
|
||||
Err(e) => {
|
||||
return FffResult::err(&format!("Failed to acquire query tracker lock: {}", e));
|
||||
}
|
||||
};
|
||||
*query_tracker = None;
|
||||
match QueryTracker::new(&history_path, opts.use_unsafe_no_lock) {
|
||||
Ok(tracker) => *query_tracker = Some(tracker),
|
||||
Ok(tracker) => {
|
||||
let mut guard = match query_tracker.write() {
|
||||
Ok(g) => g,
|
||||
Err(e) => {
|
||||
return FffResult::err(&format!(
|
||||
"Failed to acquire query tracker lock: {}",
|
||||
e
|
||||
));
|
||||
}
|
||||
};
|
||||
*guard = Some(tracker);
|
||||
}
|
||||
Err(e) => return FffResult::err(&format!("Failed to init query tracker db: {}", e)),
|
||||
}
|
||||
drop(query_tracker);
|
||||
}
|
||||
|
||||
// Initialize file picker
|
||||
let mut file_picker = match FILE_PICKER.write() {
|
||||
Ok(f) => f,
|
||||
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
|
||||
};
|
||||
|
||||
if file_picker.is_some() {
|
||||
// Already initialized, clean up first
|
||||
if let Some(mut picker) = file_picker.take() {
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
// Initialize file picker (writes directly into shared_picker)
|
||||
if let Err(e) = FilePicker::new_with_shared_state(
|
||||
opts.base_path,
|
||||
opts.warmup_mmap_cache,
|
||||
Arc::clone(&shared_picker),
|
||||
Arc::clone(&shared_frecency),
|
||||
) {
|
||||
return FffResult::err(&format!("Failed to init file picker: {}", e));
|
||||
}
|
||||
|
||||
match FilePicker::with_options(opts.base_path, opts.warmup_mmap_cache) {
|
||||
Ok(picker) => {
|
||||
*file_picker = Some(picker);
|
||||
FffResult::ok_empty()
|
||||
}
|
||||
Err(e) => FffResult::err(&format!("Failed to init file picker: {}", e)),
|
||||
}
|
||||
let instance = Box::new(FffInstance {
|
||||
picker: shared_picker,
|
||||
frecency: shared_frecency,
|
||||
query_tracker,
|
||||
});
|
||||
|
||||
// Return the instance pointer inside the data field of FffResult.
|
||||
// We encode the pointer as a hex string so consumers can store it as an
|
||||
// opaque token. The actual pointer is also returned as the `data` pointer
|
||||
// for FFI consumers that can directly use it.
|
||||
let fff_handle = Box::into_raw(instance) as *mut c_void;
|
||||
FffResult::ok_handle(fff_handle)
|
||||
}
|
||||
|
||||
/// Destroy all resources and clean up
|
||||
/// Destroy a file finder instance and free all its resources.
|
||||
///
|
||||
/// # Safety
|
||||
/// `fff_handle` must be a valid pointer returned by `fff_create`, or null (no-op).
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn fff_destroy() -> *mut FffResult {
|
||||
// Clean up file picker
|
||||
if let Ok(mut file_picker) = FILE_PICKER.write()
|
||||
&& let Some(mut picker) = file_picker.take()
|
||||
pub unsafe extern "C" fn fff_destroy(fff_handle: *mut c_void) {
|
||||
if fff_handle.is_null() {
|
||||
return;
|
||||
}
|
||||
|
||||
let instance = unsafe { Box::from_raw(fff_handle as *mut FffInstance) };
|
||||
|
||||
if let Ok(mut guard) = instance.picker.write()
|
||||
&& let Some(mut picker) = guard.take()
|
||||
{
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
|
||||
// Clean up frecency
|
||||
if let Ok(mut frecency) = FRECENCY.write() {
|
||||
*frecency = None;
|
||||
if let Ok(mut guard) = instance.frecency.write() {
|
||||
*guard = None;
|
||||
}
|
||||
|
||||
// Clean up query tracker
|
||||
if let Ok(mut query_tracker) = QUERY_TRACKER.write() {
|
||||
*query_tracker = None;
|
||||
if let Ok(mut guard) = instance.query_tracker.write() {
|
||||
*guard = None;
|
||||
}
|
||||
|
||||
FffResult::ok_empty()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Search Functions
|
||||
// ============================================================================
|
||||
|
||||
/// Perform fuzzy search on indexed files
|
||||
/// Perform fuzzy search on indexed files.
|
||||
///
|
||||
/// # Safety
|
||||
/// `query` and `opts_json` must be valid null-terminated UTF-8 strings
|
||||
/// * `fff_handle` must be a valid instance pointer from `fff_create`.
|
||||
/// * `query` and `opts_json` must be valid null-terminated UTF-8 strings.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_search(
|
||||
fff_handle: *mut c_void,
|
||||
query: *const c_char,
|
||||
opts_json: *const c_char,
|
||||
) -> *mut FffResult {
|
||||
let inst = match unsafe { instance_ref(fff_handle) } {
|
||||
Ok(i) => i,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let query_str = match unsafe { cstr_to_str(query) } {
|
||||
Some(s) => s,
|
||||
None => return FffResult::err("Query is null or invalid UTF-8"),
|
||||
@@ -165,14 +216,14 @@ pub unsafe extern "C" fn fff_search(
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
let file_picker_guard = match FILE_PICKER.read() {
|
||||
Ok(f) => f,
|
||||
let picker_guard = match inst.picker.read() {
|
||||
Ok(g) => g,
|
||||
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
|
||||
};
|
||||
|
||||
let picker = match file_picker_guard.as_ref() {
|
||||
let picker = match picker_guard.as_ref() {
|
||||
Some(p) => p,
|
||||
None => return FffResult::err("File picker not initialized. Call fff_init first."),
|
||||
None => return FffResult::err("File picker not initialized. Call fff_create first."),
|
||||
};
|
||||
|
||||
let base_path = picker.base_path();
|
||||
@@ -180,12 +231,12 @@ pub unsafe extern "C" fn fff_search(
|
||||
|
||||
// Get last same query entry for combo matching
|
||||
let last_same_query_entry = {
|
||||
let query_tracker = match QUERY_TRACKER.read() {
|
||||
let qt_guard = match inst.query_tracker.read() {
|
||||
Ok(q) => q,
|
||||
Err(_) => return FffResult::err("Failed to acquire query tracker lock"),
|
||||
};
|
||||
|
||||
query_tracker.as_ref().and_then(|tracker| {
|
||||
qt_guard.as_ref().and_then(|tracker| {
|
||||
tracker
|
||||
.get_last_query_entry(query_str, base_path, min_combo_count)
|
||||
.ok()
|
||||
@@ -193,7 +244,6 @@ pub unsafe extern "C" fn fff_search(
|
||||
})
|
||||
};
|
||||
|
||||
// Parse the query
|
||||
let parser = QueryParser::default();
|
||||
let parsed = parser.parse(query_str);
|
||||
|
||||
@@ -215,7 +265,6 @@ pub unsafe extern "C" fn fff_search(
|
||||
},
|
||||
);
|
||||
|
||||
// Convert to JSON
|
||||
let json_result = ffi_types::SearchResultJson::from_search_result(&results);
|
||||
match serde_json::to_string(&json_result) {
|
||||
Ok(json) => FffResult::ok_data(&json),
|
||||
@@ -223,24 +272,22 @@ pub unsafe extern "C" fn fff_search(
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform content search (grep) across indexed files
|
||||
///
|
||||
/// Searches file contents using the specified mode:
|
||||
/// - "plain" (default): SIMD-accelerated literal text matching
|
||||
/// - "regex": Regular expression matching
|
||||
/// - "fuzzy": Smith-Waterman fuzzy matching per line
|
||||
///
|
||||
/// Results include file metadata and match locations with byte offsets
|
||||
/// for highlighting. Supports file-based pagination via `file_offset`
|
||||
/// and `next_file_offset` in the result.
|
||||
/// Perform content search (grep) across indexed files.
|
||||
///
|
||||
/// # Safety
|
||||
/// `query` and `opts_json` must be valid null-terminated UTF-8 strings
|
||||
/// * `fff_handle` must be a valid instance pointer from `fff_create`.
|
||||
/// * `query` and `opts_json` must be valid null-terminated UTF-8 strings.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_live_grep(
|
||||
fff_handle: *mut c_void,
|
||||
query: *const c_char,
|
||||
opts_json: *const c_char,
|
||||
) -> *mut FffResult {
|
||||
let inst = match unsafe { instance_ref(fff_handle) } {
|
||||
Ok(i) => i,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let query_str = match unsafe { cstr_to_str(query) } {
|
||||
Some(s) => s,
|
||||
None => return FffResult::err("Query is null or invalid UTF-8"),
|
||||
@@ -254,14 +301,14 @@ pub unsafe extern "C" fn fff_live_grep(
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
let file_picker_guard = match FILE_PICKER.read() {
|
||||
Ok(f) => f,
|
||||
let picker_guard = match inst.picker.read() {
|
||||
Ok(g) => g,
|
||||
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
|
||||
};
|
||||
|
||||
let picker = match file_picker_guard.as_ref() {
|
||||
let picker = match picker_guard.as_ref() {
|
||||
Some(p) => p,
|
||||
None => return FffResult::err("File picker not initialized. Call fff_init first."),
|
||||
None => return FffResult::err("File picker not initialized. Call fff_create first."),
|
||||
};
|
||||
|
||||
let mode = match opts.mode.as_deref() {
|
||||
@@ -274,7 +321,7 @@ pub unsafe extern "C" fn fff_live_grep(
|
||||
|
||||
let options = fff_core::GrepSearchOptions {
|
||||
max_file_size: opts.max_file_size.unwrap_or(10 * 1024 * 1024),
|
||||
max_matches_per_file: opts.max_matches_per_file.unwrap_or(200),
|
||||
max_matches_per_file: opts.max_matches_per_file.unwrap_or(0),
|
||||
smart_case: opts.smart_case.unwrap_or(true),
|
||||
file_offset: opts.file_offset.unwrap_or(0),
|
||||
page_limit: opts.page_limit.unwrap_or(50),
|
||||
@@ -291,48 +338,68 @@ pub unsafe extern "C" fn fff_live_grep(
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// File Index Functions
|
||||
// ============================================================================
|
||||
|
||||
/// Trigger a rescan of the file index
|
||||
/// Trigger a rescan of the file index.
|
||||
///
|
||||
/// # Safety
|
||||
/// `fff_handle` must be a valid instance pointer from `fff_create`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn fff_scan_files() -> *mut FffResult {
|
||||
let mut file_picker = match FILE_PICKER.write() {
|
||||
Ok(f) => f,
|
||||
pub unsafe extern "C" fn fff_scan_files(fff_handle: *mut c_void) -> *mut FffResult {
|
||||
let inst = match unsafe { instance_ref(fff_handle) } {
|
||||
Ok(i) => i,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let mut guard = match inst.picker.write() {
|
||||
Ok(g) => g,
|
||||
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
|
||||
};
|
||||
|
||||
let picker = match file_picker.as_mut() {
|
||||
let picker = match guard.as_mut() {
|
||||
Some(p) => p,
|
||||
None => return FffResult::err("File picker not initialized"),
|
||||
};
|
||||
|
||||
match picker.trigger_rescan() {
|
||||
match picker.trigger_rescan(&inst.frecency) {
|
||||
Ok(_) => FffResult::ok_empty(),
|
||||
Err(e) => FffResult::err(&format!("Failed to trigger rescan: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a scan is currently in progress
|
||||
/// Check if a scan is currently in progress.
|
||||
///
|
||||
/// # Safety
|
||||
/// `fff_handle` must be a valid instance pointer from `fff_create`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn fff_is_scanning() -> bool {
|
||||
FILE_PICKER
|
||||
pub unsafe extern "C" fn fff_is_scanning(fff_handle: *mut c_void) -> bool {
|
||||
let inst = match unsafe { instance_ref(fff_handle) } {
|
||||
Ok(i) => i,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
inst.picker
|
||||
.read()
|
||||
.ok()
|
||||
.and_then(|guard| guard.as_ref().map(|p| p.is_scan_active()))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Get scan progress information
|
||||
/// Get scan progress information.
|
||||
///
|
||||
/// # Safety
|
||||
/// `fff_handle` must be a valid instance pointer from `fff_create`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn fff_get_scan_progress() -> *mut FffResult {
|
||||
let file_picker = match FILE_PICKER.read() {
|
||||
Ok(f) => f,
|
||||
pub unsafe extern "C" fn fff_get_scan_progress(fff_handle: *mut c_void) -> *mut FffResult {
|
||||
let inst = match unsafe { instance_ref(fff_handle) } {
|
||||
Ok(i) => i,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let guard = match inst.picker.read() {
|
||||
Ok(g) => g,
|
||||
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
|
||||
};
|
||||
|
||||
let picker = match file_picker.as_ref() {
|
||||
let picker = match guard.as_ref() {
|
||||
Some(p) => p,
|
||||
None => return FffResult::err("File picker not initialized"),
|
||||
};
|
||||
@@ -349,24 +416,42 @@ pub extern "C" fn fff_get_scan_progress() -> *mut FffResult {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for initial scan to complete
|
||||
/// Wait for initial scan to complete.
|
||||
///
|
||||
/// # Safety
|
||||
/// `fff_handle` must be a valid instance pointer from `fff_create`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn fff_wait_for_scan(timeout_ms: u64) -> *mut FffResult {
|
||||
let file_picker = match FILE_PICKER.read() {
|
||||
Ok(f) => f,
|
||||
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
|
||||
pub unsafe extern "C" fn fff_wait_for_scan(
|
||||
fff_handle: *mut c_void,
|
||||
timeout_ms: u64,
|
||||
) -> *mut FffResult {
|
||||
let inst = match unsafe { instance_ref(fff_handle) } {
|
||||
Ok(i) => i,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let picker = match file_picker.as_ref() {
|
||||
Some(p) => p,
|
||||
None => return FffResult::err("File picker not initialized"),
|
||||
// Clone the scanning flag so we can drop the picker lock before polling.
|
||||
// Otherwise the read lock blocks the scan thread from writing results.
|
||||
let scan_signal = {
|
||||
let guard = match inst.picker.read() {
|
||||
Ok(g) => g,
|
||||
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
|
||||
};
|
||||
|
||||
let picker = match guard.as_ref() {
|
||||
Some(p) => p,
|
||||
None => return FffResult::err("File picker not initialized"),
|
||||
};
|
||||
|
||||
picker.scan_signal()
|
||||
// guard is dropped here, releasing the read lock
|
||||
};
|
||||
|
||||
let timeout = Duration::from_millis(timeout_ms);
|
||||
let start = std::time::Instant::now();
|
||||
let mut sleep_duration = Duration::from_millis(1);
|
||||
|
||||
while picker.is_scan_active() {
|
||||
while scan_signal.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
if start.elapsed() >= timeout {
|
||||
return FffResult::ok_data("false");
|
||||
}
|
||||
@@ -377,12 +462,21 @@ pub extern "C" fn fff_wait_for_scan(timeout_ms: u64) -> *mut FffResult {
|
||||
FffResult::ok_data("true")
|
||||
}
|
||||
|
||||
/// Restart indexing in a new directory
|
||||
/// Restart indexing in a new directory.
|
||||
///
|
||||
/// # Safety
|
||||
/// `new_path` must be a valid null-terminated UTF-8 string
|
||||
/// * `fff_handle` must be a valid instance pointer from `fff_create`.
|
||||
/// * `new_path` must be a valid null-terminated UTF-8 string.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_restart_index(new_path: *const c_char) -> *mut FffResult {
|
||||
pub unsafe extern "C" fn fff_restart_index(
|
||||
fff_handle: *mut c_void,
|
||||
new_path: *const c_char,
|
||||
) -> *mut FffResult {
|
||||
let inst = match unsafe { instance_ref(fff_handle) } {
|
||||
Ok(i) => i,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let path_str = match unsafe { cstr_to_str(new_path) } {
|
||||
Some(s) => s,
|
||||
None => return FffResult::err("Path is null or invalid UTF-8"),
|
||||
@@ -398,13 +492,13 @@ pub unsafe extern "C" fn fff_restart_index(new_path: *const c_char) -> *mut FffR
|
||||
Err(e) => return FffResult::err(&format!("Failed to canonicalize path: {}", e)),
|
||||
};
|
||||
|
||||
let mut file_picker = match FILE_PICKER.write() {
|
||||
Ok(f) => f,
|
||||
let mut guard = match inst.picker.write() {
|
||||
Ok(g) => g,
|
||||
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
|
||||
};
|
||||
|
||||
// Stop existing picker, preserving warmup setting
|
||||
let warmup = if let Some(mut picker) = file_picker.take() {
|
||||
let warmup = if let Some(mut picker) = guard.take() {
|
||||
let warmup = picker.warmup_mmap_cache();
|
||||
picker.stop_background_monitor();
|
||||
warmup
|
||||
@@ -412,26 +506,37 @@ pub unsafe extern "C" fn fff_restart_index(new_path: *const c_char) -> *mut FffR
|
||||
false
|
||||
};
|
||||
|
||||
// Create new picker
|
||||
match FilePicker::with_options(canonical_path.to_string_lossy().to_string(), warmup) {
|
||||
Ok(picker) => {
|
||||
*file_picker = Some(picker);
|
||||
FffResult::ok_empty()
|
||||
}
|
||||
// Drop the write lock before calling new_with_shared_state,
|
||||
// which will acquire its own write lock to place the picker.
|
||||
drop(guard);
|
||||
|
||||
// Create new picker backed by the same shared state
|
||||
match FilePicker::new_with_shared_state(
|
||||
canonical_path.to_string_lossy().to_string(),
|
||||
warmup,
|
||||
Arc::clone(&inst.picker),
|
||||
Arc::clone(&inst.frecency),
|
||||
) {
|
||||
Ok(()) => FffResult::ok_empty(),
|
||||
Err(e) => FffResult::err(&format!("Failed to init file picker: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Frecency Functions
|
||||
// ============================================================================
|
||||
|
||||
/// Track file access for frecency scoring
|
||||
/// Track file access for frecency scoring.
|
||||
///
|
||||
/// # Safety
|
||||
/// `file_path` must be a valid null-terminated UTF-8 string
|
||||
/// * `fff_handle` must be a valid instance pointer from `fff_create`.
|
||||
/// * `file_path` must be a valid null-terminated UTF-8 string.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_track_access(file_path: *const c_char) -> *mut FffResult {
|
||||
pub unsafe extern "C" fn fff_track_access(
|
||||
fff_handle: *mut c_void,
|
||||
file_path: *const c_char,
|
||||
) -> *mut FffResult {
|
||||
let inst = match unsafe { instance_ref(fff_handle) } {
|
||||
Ok(i) => i,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let path_str = match unsafe { cstr_to_str(file_path) } {
|
||||
Some(s) => s,
|
||||
None => return FffResult::err("File path is null or invalid UTF-8"),
|
||||
@@ -440,14 +545,14 @@ pub unsafe extern "C" fn fff_track_access(file_path: *const c_char) -> *mut FffR
|
||||
let file_path = PathBuf::from(&path_str);
|
||||
|
||||
// Track in frecency DB
|
||||
let frecency_guard = match FRECENCY.read() {
|
||||
let frecency_guard = match inst.frecency.read() {
|
||||
Ok(f) => f,
|
||||
Err(e) => return FffResult::err(&format!("Failed to acquire frecency lock: {}", e)),
|
||||
};
|
||||
|
||||
let frecency = match frecency_guard.as_ref() {
|
||||
Some(f) => f,
|
||||
None => return FffResult::ok_data("false"), // Frecency not initialized, skip
|
||||
None => return FffResult::ok_data("false"),
|
||||
};
|
||||
|
||||
if let Err(e) = frecency.track_access(&file_path) {
|
||||
@@ -456,17 +561,17 @@ pub unsafe extern "C" fn fff_track_access(file_path: *const c_char) -> *mut FffR
|
||||
drop(frecency_guard);
|
||||
|
||||
// Update in file picker
|
||||
let mut file_picker = match FILE_PICKER.write() {
|
||||
Ok(f) => f,
|
||||
let mut picker_guard = match inst.picker.write() {
|
||||
Ok(g) => g,
|
||||
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
|
||||
};
|
||||
|
||||
let picker = match file_picker.as_mut() {
|
||||
let picker = match picker_guard.as_mut() {
|
||||
Some(p) => p,
|
||||
None => return FffResult::ok_data("false"),
|
||||
};
|
||||
|
||||
let frecency_guard = match FRECENCY.read() {
|
||||
let frecency_guard = match inst.frecency.read() {
|
||||
Ok(f) => f,
|
||||
Err(_) => return FffResult::ok_data("false"),
|
||||
};
|
||||
@@ -478,32 +583,41 @@ pub unsafe extern "C" fn fff_track_access(file_path: *const c_char) -> *mut FffR
|
||||
FffResult::ok_data("true")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Git Functions
|
||||
// ============================================================================
|
||||
|
||||
/// Refresh git status cache
|
||||
/// Refresh git status cache.
|
||||
///
|
||||
/// # Safety
|
||||
/// `fff_handle` must be a valid instance pointer from `fff_create`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn fff_refresh_git_status() -> *mut FffResult {
|
||||
match FilePicker::refresh_git_status_global() {
|
||||
pub unsafe extern "C" fn fff_refresh_git_status(fff_handle: *mut c_void) -> *mut FffResult {
|
||||
let inst = match unsafe { instance_ref(fff_handle) } {
|
||||
Ok(i) => i,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
match FilePicker::refresh_git_status(&inst.picker, &inst.frecency) {
|
||||
Ok(count) => FffResult::ok_data(&count.to_string()),
|
||||
Err(e) => FffResult::err(&format!("Failed to refresh git status: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Query Tracking Functions
|
||||
// ============================================================================
|
||||
|
||||
/// Track query completion for smart suggestions
|
||||
/// Track query completion for smart suggestions.
|
||||
///
|
||||
/// # Safety
|
||||
/// `query` and `file_path` must be valid null-terminated UTF-8 strings
|
||||
/// * `fff_handle` must be a valid instance pointer from `fff_create`.
|
||||
/// * `query` and `file_path` must be valid null-terminated UTF-8 strings.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_track_query(
|
||||
fff_handle: *mut c_void,
|
||||
query: *const c_char,
|
||||
file_path: *const c_char,
|
||||
) -> *mut FffResult {
|
||||
let inst = match unsafe { instance_ref(fff_handle) } {
|
||||
Ok(i) => i,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let query_str = match unsafe { cstr_to_str(query) } {
|
||||
Some(s) => s,
|
||||
None => return FffResult::err("Query is null or invalid UTF-8"),
|
||||
@@ -520,22 +634,22 @@ pub unsafe extern "C" fn fff_track_query(
|
||||
};
|
||||
|
||||
let project_path = {
|
||||
let file_picker = match FILE_PICKER.read() {
|
||||
Ok(f) => f,
|
||||
let guard = match inst.picker.read() {
|
||||
Ok(g) => g,
|
||||
Err(_) => return FffResult::ok_data("false"),
|
||||
};
|
||||
match file_picker.as_ref() {
|
||||
match guard.as_ref() {
|
||||
Some(p) => p.base_path().to_path_buf(),
|
||||
None => return FffResult::ok_data("false"),
|
||||
}
|
||||
};
|
||||
|
||||
let mut query_tracker = match QUERY_TRACKER.write() {
|
||||
let mut qt_guard = match inst.query_tracker.write() {
|
||||
Ok(q) => q,
|
||||
Err(_) => return FffResult::ok_data("false"),
|
||||
};
|
||||
|
||||
if let Some(ref mut tracker) = *query_tracker
|
||||
if let Some(ref mut tracker) = *qt_guard
|
||||
&& let Err(e) = tracker.track_query_completion(query_str, &project_path, &file_path)
|
||||
{
|
||||
return FffResult::err(&format!("Failed to track query: {}", e));
|
||||
@@ -544,26 +658,37 @@ pub unsafe extern "C" fn fff_track_query(
|
||||
FffResult::ok_data("true")
|
||||
}
|
||||
|
||||
/// Get historical query by offset (0 = most recent)
|
||||
/// Get historical query by offset (0 = most recent).
|
||||
///
|
||||
/// # Safety
|
||||
/// `fff_handle` must be a valid instance pointer from `fff_create`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn fff_get_historical_query(offset: u64) -> *mut FffResult {
|
||||
pub unsafe extern "C" fn fff_get_historical_query(
|
||||
fff_handle: *mut c_void,
|
||||
offset: u64,
|
||||
) -> *mut FffResult {
|
||||
let inst = match unsafe { instance_ref(fff_handle) } {
|
||||
Ok(i) => i,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let project_path = {
|
||||
let file_picker = match FILE_PICKER.read() {
|
||||
Ok(f) => f,
|
||||
let guard = match inst.picker.read() {
|
||||
Ok(g) => g,
|
||||
Err(_) => return FffResult::ok_data("null"),
|
||||
};
|
||||
match file_picker.as_ref() {
|
||||
match guard.as_ref() {
|
||||
Some(p) => p.base_path().to_path_buf(),
|
||||
None => return FffResult::ok_data("null"),
|
||||
}
|
||||
};
|
||||
|
||||
let query_tracker = match QUERY_TRACKER.read() {
|
||||
let qt_guard = match inst.query_tracker.read() {
|
||||
Ok(q) => q,
|
||||
Err(_) => return FffResult::ok_data("null"),
|
||||
};
|
||||
|
||||
let tracker = match query_tracker.as_ref() {
|
||||
let tracker = match qt_guard.as_ref() {
|
||||
Some(t) => t,
|
||||
None => return FffResult::ok_data("null"),
|
||||
};
|
||||
@@ -578,12 +703,17 @@ pub extern "C" fn fff_get_historical_query(offset: u64) -> *mut FffResult {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get health check information
|
||||
/// Get health check information.
|
||||
///
|
||||
/// # Safety
|
||||
/// `test_path` can be null or a valid null-terminated UTF-8 string
|
||||
/// * `fff_handle` must be a valid instance pointer from `fff_create`, or null for
|
||||
/// a limited health check (version + git only).
|
||||
/// * `test_path` can be null or a valid null-terminated UTF-8 string.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_health_check(test_path: *const c_char) -> *mut FffResult {
|
||||
pub unsafe extern "C" fn fff_health_check(
|
||||
fff_handle: *mut c_void,
|
||||
test_path: *const c_char,
|
||||
) -> *mut FffResult {
|
||||
let test_path = unsafe { cstr_to_str(test_path) }
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(PathBuf::from)
|
||||
@@ -632,36 +762,47 @@ pub unsafe extern "C" fn fff_health_check(test_path: *const c_char) -> *mut FffR
|
||||
}
|
||||
health.insert("git".to_string(), serde_json::Value::Object(git_info));
|
||||
|
||||
// Resolve the instance once (None when handle is null).
|
||||
let inst: Option<&FffInstance> = if fff_handle.is_null() {
|
||||
None
|
||||
} else {
|
||||
Some(unsafe { &*(fff_handle as *const FffInstance) })
|
||||
};
|
||||
|
||||
// File picker info
|
||||
let mut picker_info = serde_json::Map::new();
|
||||
match FILE_PICKER.read() {
|
||||
Ok(guard) => {
|
||||
if let Some(ref picker) = *guard {
|
||||
picker_info.insert("initialized".to_string(), serde_json::Value::Bool(true));
|
||||
picker_info.insert(
|
||||
"base_path".to_string(),
|
||||
serde_json::Value::String(picker.base_path().to_string_lossy().to_string()),
|
||||
);
|
||||
picker_info.insert(
|
||||
"is_scanning".to_string(),
|
||||
serde_json::Value::Bool(picker.is_scan_active()),
|
||||
);
|
||||
let progress = picker.get_scan_progress();
|
||||
picker_info.insert(
|
||||
"indexed_files".to_string(),
|
||||
serde_json::Value::Number(progress.scanned_files_count.into()),
|
||||
);
|
||||
} else {
|
||||
if let Some(inst) = inst {
|
||||
match inst.picker.read() {
|
||||
Ok(guard) => {
|
||||
if let Some(ref picker) = *guard {
|
||||
picker_info.insert("initialized".to_string(), serde_json::Value::Bool(true));
|
||||
picker_info.insert(
|
||||
"base_path".to_string(),
|
||||
serde_json::Value::String(picker.base_path().to_string_lossy().to_string()),
|
||||
);
|
||||
picker_info.insert(
|
||||
"is_scanning".to_string(),
|
||||
serde_json::Value::Bool(picker.is_scan_active()),
|
||||
);
|
||||
let progress = picker.get_scan_progress();
|
||||
picker_info.insert(
|
||||
"indexed_files".to_string(),
|
||||
serde_json::Value::Number(progress.scanned_files_count.into()),
|
||||
);
|
||||
} else {
|
||||
picker_info.insert("initialized".to_string(), serde_json::Value::Bool(false));
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
picker_info.insert("initialized".to_string(), serde_json::Value::Bool(false));
|
||||
picker_info.insert(
|
||||
"error".to_string(),
|
||||
serde_json::Value::String("Failed to acquire lock".to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
picker_info.insert("initialized".to_string(), serde_json::Value::Bool(false));
|
||||
picker_info.insert(
|
||||
"error".to_string(),
|
||||
serde_json::Value::String("Failed to acquire lock".to_string()),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
picker_info.insert("initialized".to_string(), serde_json::Value::Bool(false));
|
||||
}
|
||||
health.insert(
|
||||
"file_picker".to_string(),
|
||||
@@ -670,33 +811,37 @@ pub unsafe extern "C" fn fff_health_check(test_path: *const c_char) -> *mut FffR
|
||||
|
||||
// Frecency info
|
||||
let mut frecency_info = serde_json::Map::new();
|
||||
match FRECENCY.read() {
|
||||
Ok(guard) => {
|
||||
frecency_info.insert(
|
||||
"initialized".to_string(),
|
||||
serde_json::Value::Bool(guard.is_some()),
|
||||
);
|
||||
if let Some(ref frecency) = *guard
|
||||
&& let Ok(health_data) = frecency.get_health()
|
||||
{
|
||||
let mut db_health = serde_json::Map::new();
|
||||
db_health.insert(
|
||||
"path".to_string(),
|
||||
serde_json::Value::String(health_data.path),
|
||||
);
|
||||
db_health.insert(
|
||||
"disk_size".to_string(),
|
||||
serde_json::Value::Number(health_data.disk_size.into()),
|
||||
);
|
||||
if let Some(inst) = inst {
|
||||
match inst.frecency.read() {
|
||||
Ok(guard) => {
|
||||
frecency_info.insert(
|
||||
"db_healthcheck".to_string(),
|
||||
serde_json::Value::Object(db_health),
|
||||
"initialized".to_string(),
|
||||
serde_json::Value::Bool(guard.is_some()),
|
||||
);
|
||||
if let Some(ref frecency) = *guard
|
||||
&& let Ok(health_data) = frecency.get_health()
|
||||
{
|
||||
let mut db_health = serde_json::Map::new();
|
||||
db_health.insert(
|
||||
"path".to_string(),
|
||||
serde_json::Value::String(health_data.path),
|
||||
);
|
||||
db_health.insert(
|
||||
"disk_size".to_string(),
|
||||
serde_json::Value::Number(health_data.disk_size.into()),
|
||||
);
|
||||
frecency_info.insert(
|
||||
"db_healthcheck".to_string(),
|
||||
serde_json::Value::Object(db_health),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
frecency_info.insert("initialized".to_string(), serde_json::Value::Bool(false));
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
frecency_info.insert("initialized".to_string(), serde_json::Value::Bool(false));
|
||||
}
|
||||
} else {
|
||||
frecency_info.insert("initialized".to_string(), serde_json::Value::Bool(false));
|
||||
}
|
||||
health.insert(
|
||||
"frecency".to_string(),
|
||||
@@ -705,33 +850,37 @@ pub unsafe extern "C" fn fff_health_check(test_path: *const c_char) -> *mut FffR
|
||||
|
||||
// Query tracker info
|
||||
let mut query_info = serde_json::Map::new();
|
||||
match QUERY_TRACKER.read() {
|
||||
Ok(guard) => {
|
||||
query_info.insert(
|
||||
"initialized".to_string(),
|
||||
serde_json::Value::Bool(guard.is_some()),
|
||||
);
|
||||
if let Some(ref tracker) = *guard
|
||||
&& let Ok(health_data) = tracker.get_health()
|
||||
{
|
||||
let mut db_health = serde_json::Map::new();
|
||||
db_health.insert(
|
||||
"path".to_string(),
|
||||
serde_json::Value::String(health_data.path),
|
||||
);
|
||||
db_health.insert(
|
||||
"disk_size".to_string(),
|
||||
serde_json::Value::Number(health_data.disk_size.into()),
|
||||
);
|
||||
if let Some(inst) = inst {
|
||||
match inst.query_tracker.read() {
|
||||
Ok(guard) => {
|
||||
query_info.insert(
|
||||
"db_healthcheck".to_string(),
|
||||
serde_json::Value::Object(db_health),
|
||||
"initialized".to_string(),
|
||||
serde_json::Value::Bool(guard.is_some()),
|
||||
);
|
||||
if let Some(ref tracker) = *guard
|
||||
&& let Ok(health_data) = tracker.get_health()
|
||||
{
|
||||
let mut db_health = serde_json::Map::new();
|
||||
db_health.insert(
|
||||
"path".to_string(),
|
||||
serde_json::Value::String(health_data.path),
|
||||
);
|
||||
db_health.insert(
|
||||
"disk_size".to_string(),
|
||||
serde_json::Value::Number(health_data.disk_size.into()),
|
||||
);
|
||||
query_info.insert(
|
||||
"db_healthcheck".to_string(),
|
||||
serde_json::Value::Object(db_health),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
query_info.insert("initialized".to_string(), serde_json::Value::Bool(false));
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
query_info.insert("initialized".to_string(), serde_json::Value::Bool(false));
|
||||
}
|
||||
} else {
|
||||
query_info.insert("initialized".to_string(), serde_json::Value::Bool(false));
|
||||
}
|
||||
health.insert(
|
||||
"query_tracker".to_string(),
|
||||
@@ -744,10 +893,10 @@ pub unsafe extern "C" fn fff_health_check(test_path: *const c_char) -> *mut FffR
|
||||
}
|
||||
}
|
||||
|
||||
/// Free a result returned by any fff_* function
|
||||
/// Free a result returned by any `fff_*` function.
|
||||
///
|
||||
/// # Safety
|
||||
/// `result_ptr` must be a valid pointer returned by a fff_* function
|
||||
/// `result_ptr` must be a valid pointer returned by a `fff_*` function.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_free_result(result_ptr: *mut FffResult) {
|
||||
if result_ptr.is_null() {
|
||||
@@ -762,14 +911,13 @@ pub unsafe extern "C" fn fff_free_result(result_ptr: *mut FffResult) {
|
||||
if !result.error.is_null() {
|
||||
drop(CString::from_raw(result.error));
|
||||
}
|
||||
// Box will be dropped here, freeing the FffResult struct
|
||||
}
|
||||
}
|
||||
|
||||
/// Free a string returned by fff_* functions
|
||||
/// Free a string returned by `fff_*` functions.
|
||||
///
|
||||
/// # Safety
|
||||
/// `s` must be a valid C string allocated by this library
|
||||
/// `s` must be a valid C string allocated by this library.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_free_string(s: *mut c_char) {
|
||||
unsafe {
|
||||
|
||||
@@ -13,6 +13,9 @@ crate-type = ["rlib", "staticlib", "cdylib"]
|
||||
default = []
|
||||
# Enable C FFI exports
|
||||
ffi = []
|
||||
# Use zlob (Zig-compiled C globbing library) for glob matching.
|
||||
# Requires Zig to be installed. When disabled, falls back to globset (pure Rust).
|
||||
zlob = ["dep:zlob", "fff-query-parser/zlob"]
|
||||
|
||||
[dependencies]
|
||||
# Workspace dependencies
|
||||
@@ -23,7 +26,7 @@ thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
# Local crates
|
||||
fff-query-parser = { path = "../fff-query-parser" }
|
||||
fff-query-parser = { path = "../fff-query-parser", default-features = false }
|
||||
|
||||
# External dependencies
|
||||
bindet = { workspace = true }
|
||||
@@ -32,6 +35,7 @@ chrono = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
git2 = { workspace = true }
|
||||
glidesort = { workspace = true }
|
||||
globset = { workspace = true }
|
||||
grep-matcher = { workspace = true }
|
||||
grep-searcher = { workspace = true }
|
||||
memchr = "2"
|
||||
@@ -49,12 +53,7 @@ serde = { version = "1.0", features = ["derive"] }
|
||||
smartstring = { version = "1.0.1", features = ["serde"] }
|
||||
tracing-appender = "0.2"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
zlob = { workspace = true }
|
||||
|
||||
# Platform-specific: Use vendored OpenSSL on non-Windows (Linux, macOS)
|
||||
[target.'cfg(not(windows))'.dependencies]
|
||||
openssl = { version = "0.10", features = ["vendored"] }
|
||||
|
||||
zlob = { workspace = true, optional = true }
|
||||
# Platform-specific: dunce for Windows to avoid \\?\ extended path prefix
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
dunce = { workspace = true }
|
||||
|
||||
+42
-12
@@ -1,16 +1,46 @@
|
||||
fn main() {
|
||||
// When the `zlob` feature is enabled (Zig-compiled C library):
|
||||
// On Windows MSVC, explicitly link the C runtime libraries.
|
||||
// This is needed because Zig-compiled static libraries (zlob) don't emit
|
||||
// /DEFAULTLIB directives for the MSVC CRT. Without this, symbols like
|
||||
// strcmp, memcpy, memchr etc. from vendored C libraries (libgit2, lmdb)
|
||||
// are unresolved when linking the cdylib.
|
||||
//
|
||||
// We link both msvcrt (classic CRT) and ucrt (Universal CRT where memchr,
|
||||
// strcmp etc. live on newer MSVC/ARM64 targets).
|
||||
let target = std::env::var("TARGET").unwrap_or_default();
|
||||
if target.contains("windows") && target.contains("msvc") {
|
||||
println!("cargo:rustc-link-lib=msvcrt");
|
||||
println!("cargo:rustc-link-lib=ucrt");
|
||||
println!("cargo:rustc-link-lib=vcruntime");
|
||||
// Zig-compiled static libraries don't emit /DEFAULTLIB directives for the
|
||||
// MSVC CRT, so symbols like strcmp, memcpy etc. would be unresolved.
|
||||
if std::env::var("CARGO_FEATURE_ZLOB").is_ok() {
|
||||
let target = std::env::var("TARGET").unwrap_or_default();
|
||||
if target.contains("windows") && target.contains("msvc") {
|
||||
println!("cargo:rustc-link-lib=msvcrt");
|
||||
println!("cargo:rustc-link-lib=ucrt");
|
||||
println!("cargo:rustc-link-lib=vcruntime");
|
||||
}
|
||||
} else if std::env::var("CI").is_ok() {
|
||||
// CI must always build with zlob for production-quality binaries.
|
||||
if !zig_available() {
|
||||
panic!(
|
||||
"CI detected but Zig is not installed. \
|
||||
Please install Zig and build with `--features zlob`."
|
||||
);
|
||||
}
|
||||
panic!(
|
||||
"CI detected but `zlob` feature is not enabled. \
|
||||
Build with `--features zlob`."
|
||||
);
|
||||
} else {
|
||||
// Hint: if Zig is available but the zlob feature wasn't enabled,
|
||||
// let the developer know they can get faster glob matching.
|
||||
if zig_available() {
|
||||
println!(
|
||||
"cargo:warning=Zig detected but `zlob` feature is not enabled. \
|
||||
Build with `--features zlob` for faster glob matching."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe the system for a working Zig installation.
|
||||
fn zig_available() -> bool {
|
||||
std::process::Command::new("zig")
|
||||
.arg("version")
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
use crate::FILE_PICKER;
|
||||
use crate::error::Error;
|
||||
use crate::file_picker::FilePicker;
|
||||
use crate::git::GitStatusCache;
|
||||
use crate::sort_buffer::sort_with_buffer;
|
||||
use crate::{SharedFrecency, SharedPicker};
|
||||
use git2::Repository;
|
||||
use notify::event::{AccessKind, AccessMode};
|
||||
use notify::{Config, EventKind, RecursiveMode};
|
||||
use notify_debouncer_full::{
|
||||
DebounceEventResult, DebouncedEvent, RecommendedCache, new_debouncer_opt,
|
||||
};
|
||||
use notify_debouncer_full::{DebounceEventResult, DebouncedEvent, NoCache, new_debouncer_opt};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tracing::{Level, error, info, warn};
|
||||
use tracing::{Level, debug, error, info, warn};
|
||||
|
||||
type Debouncer = notify_debouncer_full::Debouncer<notify::RecommendedWatcher, RecommendedCache>;
|
||||
type Debouncer = notify_debouncer_full::Debouncer<notify::RecommendedWatcher, NoCache>;
|
||||
|
||||
pub struct BackgroundWatcher {
|
||||
debouncer: Arc<Mutex<Option<Debouncer>>>,
|
||||
@@ -25,13 +23,19 @@ const MAX_PATHS_THRESHOLD: usize = 1024;
|
||||
const MAX_SELECTIVE_WATCH_DIRS: usize = 100;
|
||||
|
||||
impl BackgroundWatcher {
|
||||
pub fn new(base_path: PathBuf, git_workdir: Option<PathBuf>) -> Result<Self, Error> {
|
||||
pub fn new(
|
||||
base_path: PathBuf,
|
||||
git_workdir: Option<PathBuf>,
|
||||
shared_picker: SharedPicker,
|
||||
shared_frecency: SharedFrecency,
|
||||
) -> Result<Self, Error> {
|
||||
info!(
|
||||
"Initializing background watcher for path: {}",
|
||||
base_path.display()
|
||||
);
|
||||
|
||||
let debouncer = Self::create_debouncer(base_path, git_workdir)?;
|
||||
let debouncer =
|
||||
Self::create_debouncer(base_path, git_workdir, shared_picker, shared_frecency)?;
|
||||
info!("Background file watcher initialized successfully");
|
||||
|
||||
Ok(Self {
|
||||
@@ -42,26 +46,38 @@ impl BackgroundWatcher {
|
||||
fn create_debouncer(
|
||||
base_path: PathBuf,
|
||||
git_workdir: Option<PathBuf>,
|
||||
shared_picker: SharedPicker,
|
||||
shared_frecency: SharedFrecency,
|
||||
) -> Result<Debouncer, Error> {
|
||||
// do not follow symlinks as then notifiers spawns a bunch of events for symlinked
|
||||
// files that could be git ignored, we have to property differentiate those and if
|
||||
// the file was edited through a
|
||||
let config = Config::default().with_follow_symlinks(false);
|
||||
|
||||
let git_workdir_for_handler = git_workdir.clone();
|
||||
let mut debouncer = new_debouncer_opt(
|
||||
DEBOUNCE_TIMEOUT,
|
||||
Some(DEBOUNCE_TIMEOUT / 2), // tick rate for the event span
|
||||
{
|
||||
move |result: DebounceEventResult| match result {
|
||||
Ok(events) => {
|
||||
handle_debounced_events(events, &git_workdir);
|
||||
handle_debounced_events(
|
||||
events,
|
||||
&git_workdir_for_handler,
|
||||
&shared_picker,
|
||||
&shared_frecency,
|
||||
);
|
||||
}
|
||||
Err(errors) => {
|
||||
error!("File watcher errors: {:?}", errors);
|
||||
}
|
||||
}
|
||||
},
|
||||
RecommendedCache::new(),
|
||||
// There is an issue with recommended cache implementation on macos
|
||||
// it keeps track of all the files added to the watcher which is not a problem
|
||||
// for us because any rename to the file will anyway require the removing from the
|
||||
// ordedred index and adding it back with the new name
|
||||
NoCache::new(),
|
||||
config,
|
||||
)?;
|
||||
|
||||
@@ -91,6 +107,11 @@ impl BackgroundWatcher {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// In selective mode the .git directory is excluded from the non-ignored
|
||||
// dirs, but we still need to observe changes that affect git status
|
||||
// (staging, unstaging, committing, branch switches, merges, etc.).
|
||||
watch_git_status_paths(&mut debouncer, git_workdir.as_ref());
|
||||
}
|
||||
|
||||
info!(
|
||||
@@ -124,8 +145,13 @@ impl Drop for BackgroundWatcher {
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "fs_events", skip(events), level = Level::DEBUG)]
|
||||
fn handle_debounced_events(events: Vec<DebouncedEvent>, git_workdir: &Option<PathBuf>) {
|
||||
#[tracing::instrument(name = "fs_events", skip(events, shared_picker, shared_frecency), level = Level::DEBUG)]
|
||||
fn handle_debounced_events(
|
||||
events: Vec<DebouncedEvent>,
|
||||
git_workdir: &Option<PathBuf>,
|
||||
shared_picker: &SharedPicker,
|
||||
shared_frecency: &SharedFrecency,
|
||||
) {
|
||||
// this will be called very often, we have to minimiy the lock time for file picker
|
||||
let repo = git_workdir.as_ref().and_then(|p| Repository::open(p).ok());
|
||||
let mut need_full_rescan = false;
|
||||
@@ -175,14 +201,30 @@ fn handle_debounced_events(events: Vec<DebouncedEvent>, git_workdir: &Option<Pat
|
||||
need_full_git_rescan = true;
|
||||
}
|
||||
|
||||
if !should_include_file(path, &repo) {
|
||||
if is_git_file(path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !path.exists() {
|
||||
// Use a combination of event kind and filesystem state to decide
|
||||
// whether a path is an addition/modification or a removal.
|
||||
//
|
||||
// We cannot rely on `path.exists()` alone because:
|
||||
// - A freshly created file might not be visible yet (race).
|
||||
// - macOS FSEvents uses Modify(Name(Any)) for both rename-in
|
||||
// and rename-out, so we must stat the path to disambiguate.
|
||||
//
|
||||
// We cannot rely on event kind alone because:
|
||||
// - Remove events are not always emitted (macOS often sends
|
||||
// Modify(Name(Any)) instead of Remove).
|
||||
let is_removal = matches!(debounced_event.event.kind, EventKind::Remove(_));
|
||||
|
||||
if is_removal || !path.exists() {
|
||||
paths_to_remove.push(path.as_path());
|
||||
} else {
|
||||
paths_to_add_or_modify.push(path.as_path());
|
||||
// For additions/modifications, still filter gitignored files.
|
||||
if should_include_file(path, &repo) {
|
||||
paths_to_add_or_modify.push(path.as_path());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,7 +246,7 @@ fn handle_debounced_events(events: Vec<DebouncedEvent>, git_workdir: &Option<Pat
|
||||
|
||||
if need_full_rescan {
|
||||
info!(?affected_paths_count, "Triggering full rescan");
|
||||
trigger_full_rescan();
|
||||
trigger_full_rescan(shared_picker, shared_frecency);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -220,99 +262,120 @@ fn handle_debounced_events(events: Vec<DebouncedEvent>, git_workdir: &Option<Pat
|
||||
paths_to_add_or_modify.len()
|
||||
);
|
||||
|
||||
// Apply file index updates (add/remove) unconditionally — these must
|
||||
// happen even when there is no git repository.
|
||||
let files_to_update_git_status =
|
||||
if !paths_to_remove.is_empty() || !paths_to_add_or_modify.is_empty() {
|
||||
debug!(
|
||||
"Applying file index changes: {} to remove, {} to add/modify",
|
||||
paths_to_remove.len(),
|
||||
paths_to_add_or_modify.len(),
|
||||
);
|
||||
|
||||
let apply_changes = |picker: &mut FilePicker| -> Vec<PathBuf> {
|
||||
for path in &paths_to_remove {
|
||||
let removed = picker.remove_file_by_path(path);
|
||||
debug!("remove_file_by_path({:?}) -> {}", path, removed);
|
||||
}
|
||||
|
||||
let mut files_to_update = Vec::with_capacity(paths_to_add_or_modify.len());
|
||||
for path in &paths_to_add_or_modify {
|
||||
let result = picker.on_create_or_modify(path);
|
||||
match result {
|
||||
Some(file) => {
|
||||
debug!(
|
||||
"on_create_or_modify({:?}) -> Some({})",
|
||||
path,
|
||||
file.path.display()
|
||||
);
|
||||
files_to_update.push(file.path.clone());
|
||||
}
|
||||
None => {
|
||||
error!("on_create_or_modify({:?}) -> None (file not added!)", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
info!(
|
||||
"apply_changes complete: {} files to update git status",
|
||||
files_to_update.len()
|
||||
);
|
||||
files_to_update
|
||||
};
|
||||
|
||||
let Ok(mut guard) = shared_picker.write() else {
|
||||
error!("Failed to acquire file picker write lock");
|
||||
return;
|
||||
};
|
||||
let Some(ref mut picker) = *guard else {
|
||||
error!("File picker not initialized");
|
||||
return;
|
||||
};
|
||||
apply_changes(picker)
|
||||
} else {
|
||||
debug!("No file index changes to apply");
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// Git status updates require a repository.
|
||||
let Some(repo) = repo.as_ref() else {
|
||||
info!("No git repo, skipping git status updates");
|
||||
debug!("No git repo available, skipping git status updates");
|
||||
return;
|
||||
};
|
||||
|
||||
if need_full_git_rescan {
|
||||
info!("Triggering full git rescan");
|
||||
|
||||
if let Err(e) = FilePicker::refresh_git_status_global() {
|
||||
let result = FilePicker::refresh_git_status(shared_picker, shared_frecency);
|
||||
if let Err(e) = result {
|
||||
error!("Failed to refresh git status: {:?}", e);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if paths_to_remove.is_empty() && paths_to_add_or_modify.is_empty() {
|
||||
return;
|
||||
}
|
||||
if !files_to_update_git_status.is_empty() {
|
||||
info!(
|
||||
"Fetching git status for {} files",
|
||||
files_to_update_git_status.len()
|
||||
);
|
||||
|
||||
let files_to_update_git_status = {
|
||||
let Ok(mut file_picker_guard) = FILE_PICKER.write() else {
|
||||
error!("Failed to acquire file picker write lock");
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(ref mut picker) = *file_picker_guard else {
|
||||
error!("File picker not initialized");
|
||||
return;
|
||||
};
|
||||
|
||||
// Apply file removals
|
||||
for path in paths_to_remove {
|
||||
picker.remove_file_by_path(path);
|
||||
// No need to invalidate mmap — the FileItem (and its mmap) is dropped
|
||||
}
|
||||
|
||||
// Apply file additions/modifications and collect paths for git status update
|
||||
let mut files_to_update_git_status = Vec::with_capacity(paths_to_add_or_modify.len());
|
||||
for path in paths_to_add_or_modify {
|
||||
// on_create_or_modify clears the mmap internally when modified time changes
|
||||
if let Some(file) = picker.on_create_or_modify(path) {
|
||||
files_to_update_git_status.push(file.path.clone());
|
||||
let status = match GitStatusCache::git_status_for_paths(repo, &files_to_update_git_status) {
|
||||
Ok(status) => status,
|
||||
Err(e) => {
|
||||
tracing::error!(?e, "Failed to query git status");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
files_to_update_git_status
|
||||
};
|
||||
|
||||
info!(
|
||||
"Fetching git status for {} files",
|
||||
files_to_update_git_status.len()
|
||||
);
|
||||
|
||||
let status = match GitStatusCache::git_status_for_paths(repo, &files_to_update_git_status) {
|
||||
Ok(status) => status,
|
||||
Err(e) => {
|
||||
tracing::error!(?e, "Failed to query git statue");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// only lock the picker for theshortest possitble time
|
||||
if let Ok(mut file_picker_guard) = FILE_PICKER.write()
|
||||
&& let Some(ref mut picker) = *file_picker_guard
|
||||
{
|
||||
if let Err(e) = picker.update_git_statuses(status) {
|
||||
error!("Failed to update git statuses: {:?}", e);
|
||||
if let Ok(mut guard) = shared_picker.write()
|
||||
&& let Some(ref mut picker) = *guard
|
||||
{
|
||||
if let Err(e) = picker.update_git_statuses(status, shared_frecency) {
|
||||
error!("Failed to update git statuses: {:?}", e);
|
||||
} else {
|
||||
info!("Successfully updated git statuses in picker");
|
||||
}
|
||||
} else {
|
||||
info!("Successfully updated git statuses in picker");
|
||||
error!("Failed to acquire picker lock for git status update");
|
||||
}
|
||||
} else {
|
||||
error!("Failed to acquire picker lock for git status update");
|
||||
}
|
||||
}
|
||||
|
||||
fn trigger_full_rescan() {
|
||||
fn trigger_full_rescan(shared_picker: &SharedPicker, shared_frecency: &SharedFrecency) {
|
||||
info!("Triggering full filesystem rescan");
|
||||
|
||||
// Note: no need to clear mmaps — they are backed by the kernel page cache
|
||||
// and automatically reflect file changes. Old FileItems (and their mmaps)
|
||||
// are dropped when the picker rebuilds its file list.
|
||||
|
||||
let Ok(mut file_picker_guard) = FILE_PICKER.write() else {
|
||||
let Ok(mut guard) = shared_picker.write() else {
|
||||
error!("Failed to acquire file picker write lock for full rescan");
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(ref mut picker) = *file_picker_guard else {
|
||||
let Some(ref mut picker) = *guard else {
|
||||
error!("File picker not initialized, cannot trigger rescan");
|
||||
return;
|
||||
};
|
||||
|
||||
if let Err(e) = picker.trigger_rescan() {
|
||||
if let Err(e) = picker.trigger_rescan(shared_frecency) {
|
||||
error!("Failed to trigger full rescan: {:?}", e);
|
||||
} else {
|
||||
info!("Full filesystem rescan completed successfully");
|
||||
@@ -320,12 +383,17 @@ fn trigger_full_rescan() {
|
||||
}
|
||||
|
||||
fn should_include_file(path: &Path, repo: &Option<Repository>) -> bool {
|
||||
if !path.is_file() || is_git_file(path) {
|
||||
// Directories are not indexed — only regular files (and symlinks to files).
|
||||
if path.is_dir() {
|
||||
return false;
|
||||
}
|
||||
|
||||
repo.as_ref()
|
||||
.is_some_and(|repo| repo.is_path_ignored(path) == Ok(false))
|
||||
// If there is a git repo, respect its ignore rules.
|
||||
// If there is no repo (or the check fails), include the file.
|
||||
match repo.as_ref() {
|
||||
Some(repo) => repo.is_path_ignored(path) != Ok(true),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
@@ -375,6 +443,40 @@ fn is_ignore_definition_path(path: &Path) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn watch_git_status_paths(debouncer: &mut Debouncer, git_workdir: Option<&PathBuf>) {
|
||||
let Some(workdir) = git_workdir else {
|
||||
return;
|
||||
};
|
||||
|
||||
let git_dir = workdir.join(".git");
|
||||
if !git_dir.is_dir() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Watch .git/ non-recursively to catch top-level files:
|
||||
// index, index.lock, HEAD, packed-refs, MERGE_HEAD, CHERRY_PICK_HEAD, REVERT_HEAD
|
||||
if let Err(e) = debouncer.watch(&git_dir, RecursiveMode::NonRecursive) {
|
||||
warn!("Failed to watch .git directory: {}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Watch refs/ recursively to catch branch/tag changes
|
||||
let refs_dir = git_dir.join("refs");
|
||||
if refs_dir.is_dir()
|
||||
&& let Err(e) = debouncer.watch(&refs_dir, RecursiveMode::Recursive)
|
||||
{
|
||||
warn!("Failed to watch .git/refs: {}", e);
|
||||
}
|
||||
|
||||
// Watch info/ non-recursively for exclude and sparse-checkout
|
||||
let info_dir = git_dir.join("info");
|
||||
if info_dir.is_dir()
|
||||
&& let Err(e) = debouncer.watch(&info_dir, RecursiveMode::NonRecursive)
|
||||
{
|
||||
warn!("Failed to watch .git/info: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Collects immediate non-ignored subdirectories of `base_path` using the `ignore` crate
|
||||
/// to respect .gitignore, .ignore, and global gitignore rules. This is used to set up
|
||||
/// selective file watching — only non-ignored directories get a recursive watcher,
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
use ahash::AHashSet;
|
||||
use fff_query_parser::{Constraint, GitStatusFilter};
|
||||
use smallvec::SmallVec;
|
||||
use zlob::{ZlobFlags, zlob_match_paths};
|
||||
|
||||
use crate::git::is_modified_status;
|
||||
|
||||
@@ -235,32 +234,8 @@ fn collect_glob_indices<'a>(
|
||||
) {
|
||||
match constraint {
|
||||
Constraint::Glob(pattern) => {
|
||||
if let Ok(Some(matches)) = zlob_match_paths(pattern, paths, ZlobFlags::RECOMMENDED) {
|
||||
let matched_set: AHashSet<usize> =
|
||||
matches.iter().map(|s| s.as_ptr() as usize).collect();
|
||||
|
||||
let indices: AHashSet<usize> = if paths.len() >= PAR_THRESHOLD {
|
||||
use rayon::prelude::*;
|
||||
paths
|
||||
.par_iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| matched_set.contains(&(p.as_ptr() as usize)))
|
||||
.map(|(i, _)| i)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.collect()
|
||||
} else {
|
||||
paths
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| matched_set.contains(&(p.as_ptr() as usize)))
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
};
|
||||
results.push((is_negated, indices));
|
||||
} else {
|
||||
results.push((is_negated, AHashSet::new()));
|
||||
}
|
||||
let indices = match_glob_pattern(pattern, paths);
|
||||
results.push((is_negated, indices));
|
||||
}
|
||||
Constraint::Not(inner) => {
|
||||
collect_glob_indices(inner, paths, results, !is_negated);
|
||||
@@ -269,6 +244,66 @@ fn collect_glob_indices<'a>(
|
||||
}
|
||||
}
|
||||
|
||||
/// Match a glob pattern against a list of paths, returning the set of matching indices.
|
||||
///
|
||||
/// When the `zlob` feature is enabled, delegates to `zlob::zlob_match_paths` (Zig-compiled
|
||||
/// C library, fastest). Otherwise falls back to `globset::Glob` (pure Rust).
|
||||
#[cfg(feature = "zlob")]
|
||||
fn match_glob_pattern(pattern: &str, paths: &[&str]) -> AHashSet<usize> {
|
||||
let Ok(Some(matches)) = zlob::zlob_match_paths(pattern, paths, zlob::ZlobFlags::RECOMMENDED)
|
||||
else {
|
||||
return AHashSet::new();
|
||||
};
|
||||
|
||||
let matched_set: AHashSet<usize> = matches.iter().map(|s| s.as_ptr() as usize).collect();
|
||||
|
||||
if paths.len() >= PAR_THRESHOLD {
|
||||
use rayon::prelude::*;
|
||||
paths
|
||||
.par_iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| matched_set.contains(&(p.as_ptr() as usize)))
|
||||
.map(|(i, _)| i)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.collect()
|
||||
} else {
|
||||
paths
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| matched_set.contains(&(p.as_ptr() as usize)))
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "zlob"))]
|
||||
fn match_glob_pattern(pattern: &str, paths: &[&str]) -> AHashSet<usize> {
|
||||
let Ok(glob) = globset::Glob::new(pattern) else {
|
||||
return AHashSet::new();
|
||||
};
|
||||
let matcher = glob.compile_matcher();
|
||||
|
||||
if paths.len() >= PAR_THRESHOLD {
|
||||
use rayon::prelude::*;
|
||||
paths
|
||||
.par_iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| matcher.is_match(p))
|
||||
.map(|(i, _)| i)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.collect()
|
||||
} else {
|
||||
paths
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| matcher.is_match(p))
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+257
-108
@@ -5,6 +5,7 @@ use crate::git::GitStatusCache;
|
||||
use crate::query_tracker::QueryMatchEntry;
|
||||
use crate::score::match_and_score_files;
|
||||
use crate::types::{FileItem, PaginationArgs, ScoringContext, SearchResult};
|
||||
use crate::{SharedFrecency, SharedPicker};
|
||||
use fff_query_parser::FFFQuery;
|
||||
use git2::{Repository, Status, StatusOptions};
|
||||
use rayon::prelude::*;
|
||||
@@ -18,8 +19,6 @@ use std::sync::{
|
||||
use std::time::SystemTime;
|
||||
use tracing::{Level, debug, error, info, warn};
|
||||
|
||||
use crate::{FILE_PICKER, FRECENCY};
|
||||
|
||||
/// Detect if a file is binary by checking for NUL bytes in the first 512 bytes.
|
||||
/// This is the same heuristic used by git and grep — simple, fast, and sufficient.
|
||||
#[inline]
|
||||
@@ -52,7 +51,8 @@ pub struct FuzzySearchOptions<'a> {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct FileSync {
|
||||
pub files: Vec<FileItem>,
|
||||
/// Files sorted by path for binary search
|
||||
files: Vec<FileItem>,
|
||||
pub git_workdir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
@@ -64,9 +64,68 @@ impl FileSync {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all files (read-only). Files are sorted by path.
|
||||
#[inline]
|
||||
fn files(&self) -> &[FileItem] {
|
||||
&self.files
|
||||
}
|
||||
|
||||
fn get_file(&self, index: usize) -> Option<&FileItem> {
|
||||
self.files.get(index)
|
||||
}
|
||||
|
||||
/// Get mutable file at index
|
||||
#[inline]
|
||||
fn get_file_mut(&mut self, index: usize) -> Option<&mut FileItem> {
|
||||
self.files.get_mut(index)
|
||||
}
|
||||
|
||||
/// Find file index by path using binary search - O(log n)
|
||||
#[inline]
|
||||
fn find_file_index(&self, path: &Path) -> Result<usize, usize> {
|
||||
self.files
|
||||
.binary_search_by(|file| file.path.as_os_str().cmp(path.as_os_str()))
|
||||
self.files.binary_search_by(|f| f.path.as_path().cmp(path))
|
||||
}
|
||||
|
||||
/// Get file count
|
||||
#[inline]
|
||||
#[allow(dead_code)]
|
||||
fn len(&self) -> usize {
|
||||
self.files.len()
|
||||
}
|
||||
|
||||
/// Insert a file at position. Simple - no HashMap to maintain!
|
||||
fn insert_file(&mut self, position: usize, file: FileItem) {
|
||||
self.files.insert(position, file);
|
||||
}
|
||||
|
||||
/// Remove file at index. Simple - no HashMap to maintain!
|
||||
fn remove_file(&mut self, index: usize) {
|
||||
if index < self.files.len() {
|
||||
self.files.remove(index);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove files matching predicate.
|
||||
/// Returns number of files removed.
|
||||
fn retain_files<F>(&mut self, predicate: F) -> usize
|
||||
where
|
||||
F: FnMut(&FileItem) -> bool,
|
||||
{
|
||||
let initial_len = self.files.len();
|
||||
self.files.retain(predicate);
|
||||
initial_len - self.files.len()
|
||||
}
|
||||
|
||||
/// Insert a file in sorted order (by path).
|
||||
/// Returns true if inserted, false if file already exists.
|
||||
fn insert_file_sorted(&mut self, file: FileItem) -> bool {
|
||||
match self.find_file_index(&file.path) {
|
||||
Ok(_) => false, // File already exists
|
||||
Err(position) => {
|
||||
self.insert_file(position, file);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,16 +177,6 @@ impl FileItem {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Locks the tracker and updates frecensy score for one file. If need multiple files updates
|
||||
/// use `update_frecency_scores` instead.
|
||||
pub fn update_frecency_scores_global(&mut self) -> Result<(), Error> {
|
||||
let Some(ref frecency) = *FRECENCY.read().map_err(|_| Error::AcquireFrecencyLock)? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
self.update_frecency_scores(frecency)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FilePicker {
|
||||
@@ -137,6 +186,7 @@ pub struct FilePicker {
|
||||
scanned_files_count: Arc<AtomicUsize>,
|
||||
background_watcher: Option<BackgroundWatcher>,
|
||||
warmup_mmap_cache: bool,
|
||||
cancelled: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for FilePicker {
|
||||
@@ -166,21 +216,26 @@ impl FilePicker {
|
||||
self.sync_data.git_workdir.as_deref()
|
||||
}
|
||||
|
||||
/// Get all indexed files sorted by path.
|
||||
/// Note: Files are stored sorted by PATH for efficient insert/remove.
|
||||
/// For frecency-sorted results, use search() which sorts matched results.
|
||||
pub fn get_files(&self) -> &[FileItem] {
|
||||
&self.sync_data.files
|
||||
self.sync_data.files()
|
||||
}
|
||||
|
||||
pub fn new(base_path: String) -> Result<Self, Error> {
|
||||
Self::with_options(base_path, false)
|
||||
}
|
||||
|
||||
/// Create a new FilePicker with explicit options.
|
||||
/// Create a new FilePicker and place it into the provided shared handle.
|
||||
///
|
||||
/// When `warmup_mmap_cache` is `true`, all non-binary files will be mmap'd
|
||||
/// and their pages paged in immediately after the initial scan completes.
|
||||
/// This makes the first grep search as fast as subsequent ones at the cost
|
||||
/// of a longer startup time and higher initial memory pressure.
|
||||
pub fn with_options(base_path: String, warmup_mmap_cache: bool) -> Result<Self, Error> {
|
||||
/// The background scan thread and file-system watcher write into the
|
||||
/// provided `SharedPicker` and read frecency data from the provided
|
||||
/// `SharedFrecency`.
|
||||
///
|
||||
/// Multiple independent instances can coexist in the same process.
|
||||
pub fn new_with_shared_state(
|
||||
base_path: String,
|
||||
warmup_mmap_cache: bool,
|
||||
shared_picker: SharedPicker,
|
||||
shared_frecency: SharedFrecency,
|
||||
) -> Result<(), Error> {
|
||||
info!(
|
||||
"Initializing FilePicker with base_path: {}, warmup: {}",
|
||||
base_path, warmup_mmap_cache
|
||||
@@ -191,26 +246,41 @@ impl FilePicker {
|
||||
return Err(Error::InvalidPath(path));
|
||||
}
|
||||
|
||||
let scan_signal = Arc::new(AtomicBool::new(false));
|
||||
// Initialize scan_signal to `true` so that any `wait_for_scan` call
|
||||
// that races with the background thread sees "scanning in progress"
|
||||
// rather than a stale `false` (the thread hasn't started yet).
|
||||
let scan_signal = Arc::new(AtomicBool::new(true));
|
||||
let synced_files_count = Arc::new(AtomicUsize::new(0));
|
||||
let cancelled = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let picker = Self {
|
||||
let picker = FilePicker {
|
||||
base_path: path.clone(),
|
||||
sync_data: FileSync::new(),
|
||||
is_scanning: Arc::clone(&scan_signal),
|
||||
scanned_files_count: Arc::clone(&synced_files_count),
|
||||
background_watcher: None,
|
||||
warmup_mmap_cache,
|
||||
cancelled: Arc::clone(&cancelled),
|
||||
};
|
||||
|
||||
// Place the picker into the shared handle before spawning the
|
||||
// background thread so the thread can find it immediately.
|
||||
{
|
||||
let mut guard = shared_picker.write().map_err(|_| Error::AcquireItemLock)?;
|
||||
*guard = Some(picker);
|
||||
}
|
||||
|
||||
spawn_scan_and_watcher(
|
||||
path.clone(),
|
||||
Arc::clone(&scan_signal),
|
||||
Arc::clone(&synced_files_count),
|
||||
warmup_mmap_cache,
|
||||
shared_picker,
|
||||
shared_frecency,
|
||||
cancelled,
|
||||
);
|
||||
|
||||
Ok(picker)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Perform fuzzy search on files with a pre-parsed query.
|
||||
@@ -304,38 +374,46 @@ impl FilePicker {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_git_statuses(&mut self, status_cache: GitStatusCache) -> Result<(), Error> {
|
||||
/// Update git statuses for files, using the provided shared frecency tracker.
|
||||
pub fn update_git_statuses(
|
||||
&mut self,
|
||||
status_cache: GitStatusCache,
|
||||
shared_frecency: &SharedFrecency,
|
||||
) -> Result<(), Error> {
|
||||
debug!(
|
||||
statuses_count = status_cache.statuses_len(),
|
||||
"Updating git status",
|
||||
);
|
||||
|
||||
let frecency = FRECENCY.read().map_err(|_| Error::AcquireFrecencyLock)?;
|
||||
let frecency = shared_frecency
|
||||
.read()
|
||||
.map_err(|_| Error::AcquireFrecencyLock)?;
|
||||
status_cache
|
||||
.into_iter()
|
||||
.try_for_each(|(path, status)| -> Result<(), Error> {
|
||||
if let Some(file) = self.get_mut_file_by_path(&path) {
|
||||
file.git_status = Some(status);
|
||||
|
||||
if let Some(frecency) = frecency.as_ref() {
|
||||
file.update_frecency_scores(frecency)?;
|
||||
if let Some(ref f) = *frecency {
|
||||
file.update_frecency_scores(f)?;
|
||||
}
|
||||
} else {
|
||||
error!(?path, "Couldn't update the git status for path");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetches all the git statuses first and updates the global FILE_PICKER
|
||||
/// with the new statuses with the smallest possible lock time.
|
||||
pub fn refresh_git_status_global() -> Result<usize, Error> {
|
||||
/// Refreshes git statuses using the provided shared picker and frecency handles.
|
||||
pub fn refresh_git_status(
|
||||
shared_picker: &SharedPicker,
|
||||
shared_frecency: &SharedFrecency,
|
||||
) -> Result<usize, Error> {
|
||||
let git_status = {
|
||||
let Some(ref picker) = *FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)? else {
|
||||
return Err(Error::FilePickerMissing)?;
|
||||
let guard = shared_picker.read().map_err(|_| Error::AcquireItemLock)?;
|
||||
let Some(ref picker) = *guard else {
|
||||
return Err(Error::FilePickerMissing);
|
||||
};
|
||||
|
||||
debug!(
|
||||
@@ -343,29 +421,22 @@ impl FilePicker {
|
||||
picker.git_root()
|
||||
);
|
||||
|
||||
// we keep here readonly lock but allowing querying the index while it scan lasts
|
||||
GitStatusCache::read_git_status(
|
||||
picker.git_root(),
|
||||
StatusOptions::new()
|
||||
.include_untracked(true)
|
||||
.recurse_untracked_dirs(true)
|
||||
// when manually refreshing git status we want to include all unmodified file
|
||||
// to make sure that their status is correctly updated when user
|
||||
// commited/stashed/removed changes
|
||||
.include_unmodified(true)
|
||||
.exclude_submodules(true),
|
||||
)
|
||||
};
|
||||
|
||||
let mut file_picker = FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)?;
|
||||
let picker = file_picker
|
||||
.as_mut()
|
||||
.ok_or_else(|| Error::FilePickerMissing)?;
|
||||
let mut guard = shared_picker.write().map_err(|_| Error::AcquireItemLock)?;
|
||||
let picker = guard.as_mut().ok_or(Error::FilePickerMissing)?;
|
||||
|
||||
let statuses_count = if let Some(git_status) = git_status {
|
||||
let count = git_status.statuses_len();
|
||||
picker.update_git_statuses(git_status)?;
|
||||
|
||||
picker.update_git_statuses(git_status, shared_frecency)?;
|
||||
count
|
||||
} else {
|
||||
0
|
||||
@@ -380,7 +451,7 @@ impl FilePicker {
|
||||
frecency_tracker: &FrecencyTracker,
|
||||
) -> Result<(), Error> {
|
||||
if let Ok(index) = self.sync_data.find_file_index(file_path.as_ref())
|
||||
&& let Some(file) = self.sync_data.files.get_mut(index)
|
||||
&& let Some(file) = self.sync_data.get_file_mut(index)
|
||||
{
|
||||
file.update_frecency_scores(frecency_tracker)?;
|
||||
}
|
||||
@@ -392,35 +463,38 @@ impl FilePicker {
|
||||
self.sync_data
|
||||
.find_file_index(path.as_ref())
|
||||
.ok()
|
||||
.and_then(|index| self.sync_data.files.get(index))
|
||||
.and_then(|index| self.sync_data.files().get(index))
|
||||
}
|
||||
|
||||
pub fn get_mut_file_by_path(&mut self, path: impl AsRef<Path>) -> Option<&mut FileItem> {
|
||||
self.sync_data
|
||||
.find_file_index(path.as_ref())
|
||||
.ok()
|
||||
.and_then(|index| self.sync_data.files.get_mut(index))
|
||||
.and_then(|index| self.sync_data.get_file_mut(index))
|
||||
}
|
||||
|
||||
/// Add a file to the picker's files in sorted order (used by background watcher)
|
||||
pub fn add_file_sorted(&mut self, file: FileItem) -> Option<&FileItem> {
|
||||
match self
|
||||
.sync_data
|
||||
.files
|
||||
.binary_search_by(|f| f.relative_path.cmp(&file.relative_path))
|
||||
{
|
||||
Ok(position) => {
|
||||
warn!(
|
||||
"Trying to insert a file that already exists: {}",
|
||||
file.relative_path
|
||||
);
|
||||
let path = file.path.clone();
|
||||
|
||||
self.sync_data.files.get(position)
|
||||
}
|
||||
Err(position) => {
|
||||
self.sync_data.files.insert(position, file);
|
||||
self.sync_data.files.get(position)
|
||||
}
|
||||
if self.sync_data.insert_file_sorted(file) {
|
||||
// File was inserted, look it up
|
||||
self.sync_data
|
||||
.find_file_index(&path)
|
||||
.ok()
|
||||
.and_then(|idx| self.sync_data.get_file_mut(idx))
|
||||
.map(|file_mut| &*file_mut) // Convert &mut to &
|
||||
} else {
|
||||
// File already exists
|
||||
warn!(
|
||||
"Trying to insert a file that already exists: {}",
|
||||
path.display()
|
||||
);
|
||||
self.sync_data
|
||||
.find_file_index(&path)
|
||||
.ok()
|
||||
.and_then(|idx| self.sync_data.get_file_mut(idx))
|
||||
.map(|file_mut| &*file_mut) // Convert &mut to &
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,8 +503,12 @@ impl FilePicker {
|
||||
let path = path.as_ref();
|
||||
match self.sync_data.find_file_index(path) {
|
||||
Ok(pos) => {
|
||||
// safe to read because we are in lock and binary search returned valid position
|
||||
let file = &mut self.sync_data.files[pos];
|
||||
debug!(
|
||||
"on_create_or_modify: file EXISTS at index {}, updating metadata",
|
||||
pos
|
||||
);
|
||||
// File exists - update its metadata (doesn't change indices, safe)
|
||||
let file = self.sync_data.get_file_mut(pos)?;
|
||||
|
||||
let modified = match std::fs::metadata(path) {
|
||||
Ok(metadata) => metadata
|
||||
@@ -456,21 +534,40 @@ impl FilePicker {
|
||||
}
|
||||
}
|
||||
|
||||
Some(file)
|
||||
Some(&*file) // Convert &mut to &
|
||||
}
|
||||
Err(pos) => {
|
||||
let file_item = FileItem::new(path.to_path_buf(), &self.base_path, None);
|
||||
self.sync_data.files.insert(pos, file_item);
|
||||
debug!(
|
||||
"on_create_or_modify: file NEW, inserting at index {} (total files: {})",
|
||||
pos,
|
||||
self.sync_data.files().len()
|
||||
);
|
||||
|
||||
self.sync_data.files.get(pos)
|
||||
let file_item = FileItem::new(path.to_path_buf(), &self.base_path, None);
|
||||
let path_buf = file_item.path.clone();
|
||||
|
||||
self.sync_data.insert_file(pos, file_item);
|
||||
let result = self.sync_data.get_file(pos);
|
||||
|
||||
if result.is_none() {
|
||||
error!(
|
||||
"on_create_or_modify: FAILED to find file after insert! path={:?}",
|
||||
path_buf
|
||||
);
|
||||
} else {
|
||||
debug!("on_create_or_modify: successfully inserted and found file");
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_file_by_path(&mut self, path: impl AsRef<Path>) -> bool {
|
||||
match self.sync_data.find_file_index(path.as_ref()) {
|
||||
let path = path.as_ref();
|
||||
match self.sync_data.find_file_index(path) {
|
||||
Ok(index) => {
|
||||
self.sync_data.files.remove(index);
|
||||
self.sync_data.remove_file(index);
|
||||
true
|
||||
}
|
||||
Err(_) => false,
|
||||
@@ -480,13 +577,14 @@ impl FilePicker {
|
||||
// TODO make this O(n)
|
||||
pub fn remove_all_files_in_dir(&mut self, dir: impl AsRef<Path>) -> usize {
|
||||
let dir_path = dir.as_ref();
|
||||
let initial_len = self.sync_data.files.len();
|
||||
|
||||
// Use the safe retain_files method which maintains both indices
|
||||
self.sync_data
|
||||
.files
|
||||
.retain(|file| !file.path.starts_with(dir_path));
|
||||
.retain_files(|file| !file.path.starts_with(dir_path))
|
||||
}
|
||||
|
||||
initial_len - self.sync_data.files.len()
|
||||
/// We use this to prevent any substantial background threads from acquiring the locks
|
||||
pub fn cancel(&self) {
|
||||
self.cancelled.store(true, Ordering::Release);
|
||||
}
|
||||
|
||||
pub fn stop_background_monitor(&mut self) {
|
||||
@@ -495,7 +593,7 @@ impl FilePicker {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn trigger_rescan(&mut self) -> Result<(), Error> {
|
||||
pub fn trigger_rescan(&mut self, shared_frecency: &SharedFrecency) -> Result<(), Error> {
|
||||
if self.is_scanning.load(Ordering::Relaxed) {
|
||||
debug!("Scan already in progress, skipping trigger_rescan");
|
||||
return Ok(());
|
||||
@@ -504,7 +602,8 @@ impl FilePicker {
|
||||
self.is_scanning.store(true, Ordering::Relaxed);
|
||||
self.scanned_files_count.store(0, Ordering::Relaxed);
|
||||
|
||||
let scan_result = scan_filesystem(&self.base_path, &self.scanned_files_count);
|
||||
let scan_result =
|
||||
scan_filesystem(&self.base_path, &self.scanned_files_count, shared_frecency);
|
||||
match scan_result {
|
||||
Ok(sync) => {
|
||||
info!(
|
||||
@@ -515,7 +614,11 @@ impl FilePicker {
|
||||
self.sync_data = sync;
|
||||
|
||||
if self.warmup_mmap_cache {
|
||||
warmup_mmaps(&self.sync_data.files);
|
||||
// Warmup in background to avoid blocking
|
||||
let files = self.sync_data.files().to_vec(); // Clone all files
|
||||
std::thread::spawn(move || {
|
||||
warmup_mmaps(&files);
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(error) => error!(?error, "Failed to scan file system"),
|
||||
@@ -528,6 +631,12 @@ impl FilePicker {
|
||||
pub fn is_scan_active(&self) -> bool {
|
||||
self.is_scanning.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Return a clone of the scanning flag so callers can poll it without
|
||||
/// holding a lock on the picker.
|
||||
pub fn scan_signal(&self) -> Arc<AtomicBool> {
|
||||
Arc::clone(&self.is_scanning)
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
@@ -542,28 +651,49 @@ fn spawn_scan_and_watcher(
|
||||
scan_signal: Arc<AtomicBool>,
|
||||
synced_files_count: Arc<AtomicUsize>,
|
||||
warmup_mmap_cache: bool,
|
||||
shared_picker: SharedPicker,
|
||||
shared_frecency: SharedFrecency,
|
||||
cancelled: Arc<AtomicBool>,
|
||||
) {
|
||||
std::thread::spawn(move || {
|
||||
scan_signal.store(true, Ordering::Relaxed);
|
||||
// scan_signal is already `true` (set by the caller before spawning)
|
||||
// so waiters see "scanning" even before this thread is scheduled.
|
||||
info!("Starting initial file scan");
|
||||
|
||||
let mut git_workdir = None;
|
||||
match scan_filesystem(&base_path, &synced_files_count) {
|
||||
match scan_filesystem(&base_path, &synced_files_count, &shared_frecency) {
|
||||
Ok(sync) => {
|
||||
if cancelled.load(Ordering::Acquire) {
|
||||
info!("Scan completed but picker was replaced, discarding results");
|
||||
scan_signal.store(false, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
|
||||
info!(
|
||||
"Initial filesystem scan completed: found {} files",
|
||||
sync.files.len()
|
||||
);
|
||||
|
||||
git_workdir = sync.git_workdir.clone();
|
||||
if let Ok(mut file_picker_guard) = crate::FILE_PICKER.write()
|
||||
&& let Some(ref mut picker) = *file_picker_guard
|
||||
{
|
||||
picker.sync_data = sync;
|
||||
|
||||
if warmup_mmap_cache {
|
||||
warmup_mmaps(&picker.sync_data.files);
|
||||
// Write results into the provided shared handle.
|
||||
let write_result = shared_picker.write().ok().map(|mut guard| {
|
||||
if let Some(ref mut picker) = *guard {
|
||||
picker.sync_data = sync;
|
||||
}
|
||||
});
|
||||
|
||||
if write_result.is_none() {
|
||||
error!("Failed to write scan results into picker");
|
||||
}
|
||||
|
||||
// OPTIMIZATION: Warmup mmap cache in background to avoid blocking first grep.
|
||||
if warmup_mmap_cache
|
||||
&& !cancelled.load(Ordering::Acquire)
|
||||
&& let Ok(guard) = shared_picker.read()
|
||||
&& let Some(ref picker) = *guard
|
||||
{
|
||||
warmup_mmaps(picker.sync_data.files());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -572,14 +702,38 @@ fn spawn_scan_and_watcher(
|
||||
}
|
||||
scan_signal.store(false, Ordering::Relaxed);
|
||||
|
||||
match BackgroundWatcher::new(base_path, git_workdir) {
|
||||
// Don't create a watcher if this picker instance was already replaced
|
||||
if cancelled.load(Ordering::Acquire) {
|
||||
info!("Picker was replaced, skipping background watcher creation");
|
||||
return;
|
||||
}
|
||||
|
||||
match BackgroundWatcher::new(
|
||||
base_path,
|
||||
git_workdir,
|
||||
shared_picker.clone(),
|
||||
shared_frecency.clone(),
|
||||
) {
|
||||
Ok(watcher) => {
|
||||
info!("Background file watcher initialized successfully");
|
||||
|
||||
if let Ok(mut file_picker_guard) = crate::FILE_PICKER.write()
|
||||
&& let Some(ref mut picker) = *file_picker_guard
|
||||
{
|
||||
picker.background_watcher = Some(watcher);
|
||||
// Final cancellation check: if the picker was replaced between
|
||||
// watcher creation and this write, drop the watcher instead of
|
||||
// storing it in the wrong picker.
|
||||
if cancelled.load(Ordering::Acquire) {
|
||||
info!("Picker was replaced, dropping orphaned watcher");
|
||||
drop(watcher);
|
||||
return;
|
||||
}
|
||||
|
||||
let write_result = shared_picker.write().ok().map(|mut guard| {
|
||||
if let Some(ref mut picker) = *guard {
|
||||
picker.background_watcher = Some(watcher);
|
||||
}
|
||||
});
|
||||
|
||||
if write_result.is_none() {
|
||||
error!("Failed to store background watcher in picker");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -596,8 +750,8 @@ fn spawn_scan_and_watcher(
|
||||
///
|
||||
/// Each file is mmap'd and a single byte is read to trigger the page fault.
|
||||
/// This runs in parallel using rayon.
|
||||
#[tracing::instrument(skip(files), name = "warmup_mmaps", level = Level::DEBUG)]
|
||||
fn warmup_mmaps(files: &[FileItem]) {
|
||||
let warmup_start = std::time::Instant::now();
|
||||
let warmed = std::sync::atomic::AtomicUsize::new(0);
|
||||
|
||||
files.par_iter().for_each(|file| {
|
||||
@@ -614,18 +768,12 @@ fn warmup_mmaps(files: &[FileItem]) {
|
||||
warmed.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
});
|
||||
|
||||
let warmed_count = warmed.load(Ordering::Relaxed);
|
||||
info!(
|
||||
"Mmap warmup completed: {warmed_count}/{} files in {:?}",
|
||||
files.len(),
|
||||
warmup_start.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
fn scan_filesystem(
|
||||
base_path: &Path,
|
||||
synced_files_count: &Arc<AtomicUsize>,
|
||||
shared_frecency: &SharedFrecency,
|
||||
) -> Result<FileSync, Error> {
|
||||
use ignore::{WalkBuilder, WalkState};
|
||||
use std::thread;
|
||||
@@ -712,7 +860,9 @@ fn scan_filesystem(
|
||||
Error::ThreadPanic
|
||||
})?;
|
||||
|
||||
let frecency = FRECENCY.read().map_err(|_| Error::AcquireFrecencyLock)?;
|
||||
let frecency = shared_frecency
|
||||
.read()
|
||||
.map_err(|_| Error::AcquireFrecencyLock)?;
|
||||
files
|
||||
.par_iter_mut()
|
||||
.try_for_each(|file| -> Result<(), Error> {
|
||||
@@ -734,7 +884,6 @@ fn scan_filesystem(
|
||||
files.len()
|
||||
);
|
||||
|
||||
// Sort by OsStr instead of Path to avoid expensive component-by-component comparison
|
||||
files.par_sort_unstable_by(|a, b| a.path.as_os_str().cmp(b.path.as_os_str()));
|
||||
Ok(FileSync { files, git_workdir })
|
||||
})
|
||||
|
||||
@@ -127,7 +127,7 @@ pub fn is_modified_status(status: Status) -> bool {
|
||||
|
||||
pub fn format_git_status(status: Option<Status>) -> &'static str {
|
||||
match status {
|
||||
None => "clear",
|
||||
None => "clean",
|
||||
Some(status) => {
|
||||
if status.contains(Status::WT_NEW) {
|
||||
"untracked"
|
||||
|
||||
+353
-649
File diff suppressed because it is too large
Load Diff
+14
-15
@@ -1,7 +1,13 @@
|
||||
//! fff-core - High-performance file finder library
|
||||
//!
|
||||
//! This crate provides the core file indexing and fuzzy search functionality.
|
||||
//! It maintains global state for the file picker, frecency tracker, and query tracker.
|
||||
//!
|
||||
//! # State management
|
||||
//!
|
||||
//! All state is instance-based. Callers create their own `SharedPicker` /
|
||||
//! `SharedFrecency` / `SharedQueryTracker` and pass them into
|
||||
//! `FilePicker::new_with_shared_state`. Multiple independent instances can
|
||||
//! coexist in the same process.
|
||||
|
||||
mod background_watcher;
|
||||
pub mod constraints;
|
||||
@@ -19,25 +25,18 @@ pub mod types;
|
||||
|
||||
use file_picker::FilePicker;
|
||||
use frecency::FrecencyTracker;
|
||||
use once_cell::sync::Lazy;
|
||||
use query_tracker::QueryTracker;
|
||||
use std::sync::RwLock;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
// Global state - same pattern as fff-nvim
|
||||
pub static FRECENCY: Lazy<RwLock<Option<FrecencyTracker>>> = Lazy::new(|| RwLock::new(None));
|
||||
pub static FILE_PICKER: Lazy<RwLock<Option<FilePicker>>> = Lazy::new(|| RwLock::new(None));
|
||||
pub static QUERY_TRACKER: Lazy<RwLock<Option<QueryTracker>>> = Lazy::new(|| RwLock::new(None));
|
||||
pub type SharedPicker = Arc<RwLock<Option<FilePicker>>>;
|
||||
pub type SharedFrecency = Arc<RwLock<Option<FrecencyTracker>>>;
|
||||
pub type SharedQueryTracker = Arc<RwLock<Option<QueryTracker>>>;
|
||||
|
||||
// Re-export main types for convenience
|
||||
pub use db_healthcheck::{DbHealth, DbHealthChecker};
|
||||
pub use error::{Error, Result};
|
||||
pub use file_picker::{FuzzySearchOptions, ScanProgress};
|
||||
pub use types::{FileItem, PaginationArgs, Score, ScoringContext, SearchResult};
|
||||
|
||||
// Re-export grep types
|
||||
pub use grep::{GrepMatch, GrepMode, GrepResult, GrepSearchOptions};
|
||||
|
||||
// Re-export query parser types (including Location which moved there)
|
||||
pub use fff_query_parser::{
|
||||
Constraint, FFFQuery, FuzzyQuery, Location, QueryParser, location::parse_location,
|
||||
};
|
||||
pub use file_picker::{FuzzySearchOptions, ScanProgress};
|
||||
pub use grep::{GrepMatch, GrepMode, GrepResult, GrepSearchOptions};
|
||||
pub use types::{FileItem, PaginationArgs, Score, ScoringContext, SearchResult};
|
||||
|
||||
@@ -188,7 +188,6 @@ fn plain_text_across_multiple_files() {
|
||||
|
||||
let result = grep_search(&files, "use std", None, &plain_opts());
|
||||
|
||||
assert_eq!(result.total_match_count, 3);
|
||||
assert_eq!(result.matches.len(), 3);
|
||||
// Should match in files a.txt and b.txt
|
||||
assert_eq!(result.files.len(), 2);
|
||||
@@ -279,17 +278,28 @@ fn plain_text_page_limit() {
|
||||
|
||||
let result = grep_search(&files, "target", None, &opts);
|
||||
|
||||
// page_limit is a soft minimum: we always finish the current file, so we
|
||||
// get at least page_limit matches (no data loss) and at most
|
||||
// max_matches_per_file (200) from a single file.
|
||||
assert!(
|
||||
result.matches.len() <= 10,
|
||||
"should respect page_limit: got {}",
|
||||
result.matches.len() >= opts.page_limit,
|
||||
"should return at least page_limit matches: got {}",
|
||||
result.matches.len()
|
||||
);
|
||||
assert!(
|
||||
result.matches.len() <= opts.max_matches_per_file,
|
||||
"should never exceed max_matches_per_file: got {}",
|
||||
result.matches.len()
|
||||
);
|
||||
// Single file with 100 lines all matching — all should be returned.
|
||||
assert_eq!(result.matches.len(), 100, "all 100 lines must be returned");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_text_file_offset_pagination() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
// Create many files so file-based pagination works
|
||||
// Create many files (1 match per file) so file-based pagination exercises
|
||||
// offset tracking across files with and without matches.
|
||||
let mut files = Vec::new();
|
||||
for i in 0..20 {
|
||||
files.push(create_file(
|
||||
@@ -302,14 +312,51 @@ fn plain_text_file_offset_pagination() {
|
||||
let mut opts = plain_opts();
|
||||
opts.page_limit = 5;
|
||||
|
||||
let result1 = grep_search(&files, "unique_token", None, &opts);
|
||||
assert!(result1.matches.len() > 0);
|
||||
// Collect ALL matches across all pages and verify no duplicates and full coverage.
|
||||
let mut all_line_texts: Vec<String> = Vec::new();
|
||||
let mut pages = 0;
|
||||
let max_pages = 20; // safety limit
|
||||
|
||||
if result1.next_file_offset > 0 {
|
||||
opts.file_offset = result1.next_file_offset;
|
||||
let result2 = grep_search(&files, "unique_token", None, &opts);
|
||||
assert!(result2.matches.len() > 0, "second page should have results");
|
||||
loop {
|
||||
let result = grep_search(&files, "unique_token", None, &opts);
|
||||
|
||||
for m in &result.matches {
|
||||
let text = m.line_content.trim().to_string();
|
||||
assert!(
|
||||
!all_line_texts.contains(&text),
|
||||
"duplicate match across pages: '{}'",
|
||||
text
|
||||
);
|
||||
all_line_texts.push(text);
|
||||
}
|
||||
|
||||
pages += 1;
|
||||
assert!(pages <= max_pages, "pagination did not terminate");
|
||||
|
||||
if result.next_file_offset == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
// Offset must strictly advance
|
||||
assert!(
|
||||
result.next_file_offset > opts.file_offset,
|
||||
"next_file_offset ({}) did not advance past current ({})",
|
||||
result.next_file_offset,
|
||||
opts.file_offset
|
||||
);
|
||||
opts.file_offset = result.next_file_offset;
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
all_line_texts.len(),
|
||||
20,
|
||||
"pagination should find all 20 matches across all pages, got {}",
|
||||
all_line_texts.len()
|
||||
);
|
||||
assert!(
|
||||
pages > 1,
|
||||
"should require multiple pages with page_limit=5 and 20 files"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1064,14 +1111,29 @@ fn fuzzy_respects_page_limit() {
|
||||
|
||||
let mut opts = fuzzy_opts();
|
||||
opts.page_limit = 10;
|
||||
opts.max_matches_per_file = 50;
|
||||
|
||||
let result = grep_search(&files, "target", None, &opts);
|
||||
|
||||
// page_limit is a soft minimum: we always finish the current file, so we
|
||||
// get at least page_limit matches (no data loss) and at most
|
||||
// max_matches_per_file (200) from a single file.
|
||||
assert!(
|
||||
result.matches.len() <= 10,
|
||||
"should respect page_limit: got {}",
|
||||
result.matches.len() >= opts.page_limit,
|
||||
"should return at least page_limit matches: got {}",
|
||||
result.matches.len()
|
||||
);
|
||||
assert!(
|
||||
result.matches.len() <= opts.max_matches_per_file,
|
||||
"should never exceed max_matches_per_file: got {}",
|
||||
result.matches.len()
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
result.matches.len(),
|
||||
opts.max_matches_per_file,
|
||||
"all limit of lines must be returned"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -7,6 +7,10 @@ edition = "2024"
|
||||
path = "src/lib.rs"
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
zlob = ["fff-core/zlob"]
|
||||
|
||||
[[bin]]
|
||||
name = "test_watcher"
|
||||
path = "src/bin/test_watcher.rs"
|
||||
@@ -63,7 +67,6 @@ serde = { version = "1.0", features = ["derive"] }
|
||||
smartstring = { version = "1.0.1", features = ["serde"] }
|
||||
tracing-appender = "0.2"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
zlob = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
@@ -78,7 +81,3 @@ harness = false
|
||||
name = "query_tracker_bench"
|
||||
harness = false
|
||||
|
||||
# Platform-specific: Use vendored OpenSSL on non-Windows (Linux, macOS)
|
||||
# On Windows, git2 uses the native SChannel TLS backend
|
||||
[target.'cfg(not(windows))'.dependencies]
|
||||
openssl = { version = "0.10", features = ["vendored"] }
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
|
||||
use fff_nvim::FILE_PICKER;
|
||||
use fff_nvim::file_picker::{FilePicker, FuzzySearchOptions};
|
||||
use fff_nvim::types::PaginationArgs;
|
||||
use fff_core::file_picker::FilePicker;
|
||||
use fff_core::types::{FileItem, PaginationArgs};
|
||||
use fff_core::{FuzzySearchOptions, SharedFrecency, SharedPicker};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Initialize tracing to output to console
|
||||
@@ -19,20 +20,26 @@ fn init_tracing() {
|
||||
// .try_init();
|
||||
}
|
||||
|
||||
/// Initialize FilePicker and insert into global state
|
||||
fn init_file_picker_internal(path: &str) -> Result<(), String> {
|
||||
let picker = FilePicker::new(path.to_string())
|
||||
.map_err(|e| format!("Failed to create FilePicker: {:?}", e))?;
|
||||
|
||||
let mut picker_guard = FILE_PICKER
|
||||
.write()
|
||||
.map_err(|_| "Failed to acquire write lock")?;
|
||||
*picker_guard = Some(picker);
|
||||
Ok(())
|
||||
/// Initialize FilePicker using shared state
|
||||
fn init_file_picker_internal(
|
||||
path: &str,
|
||||
shared_picker: &SharedPicker,
|
||||
shared_frecency: &SharedFrecency,
|
||||
) -> Result<(), String> {
|
||||
FilePicker::new_with_shared_state(
|
||||
path.to_string(),
|
||||
false,
|
||||
Arc::clone(shared_picker),
|
||||
Arc::clone(shared_frecency),
|
||||
)
|
||||
.map_err(|e| format!("Failed to create FilePicker: {:?}", e))
|
||||
}
|
||||
|
||||
/// Helper function to wait for scanning to complete and get file count
|
||||
fn wait_for_scan_completion(timeout_secs: u64) -> Result<usize, String> {
|
||||
fn wait_for_scan_completion(
|
||||
shared_picker: &SharedPicker,
|
||||
timeout_secs: u64,
|
||||
) -> Result<usize, String> {
|
||||
let start = std::time::Instant::now();
|
||||
let timeout = Duration::from_secs(timeout_secs);
|
||||
let mut last_log = std::time::Instant::now();
|
||||
@@ -42,7 +49,7 @@ fn wait_for_scan_completion(timeout_secs: u64) -> Result<usize, String> {
|
||||
iteration += 1;
|
||||
|
||||
{
|
||||
let picker_guard = FILE_PICKER
|
||||
let picker_guard = shared_picker
|
||||
.read()
|
||||
.map_err(|_| "Failed to acquire read lock")?;
|
||||
if let Some(ref picker) = *picker_guard {
|
||||
@@ -91,9 +98,9 @@ fn wait_for_scan_completion(timeout_secs: u64) -> Result<usize, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get files from the global FILE_PICKER
|
||||
fn get_files_snapshot() -> Result<Vec<fff_nvim::types::FileItem>, String> {
|
||||
let picker_guard = FILE_PICKER
|
||||
/// Get files from the shared picker
|
||||
fn get_files_snapshot(shared_picker: &SharedPicker) -> Result<Vec<FileItem>, String> {
|
||||
let picker_guard = shared_picker
|
||||
.read()
|
||||
.map_err(|_| "Failed to acquire read lock")?;
|
||||
if let Some(ref picker) = *picker_guard {
|
||||
@@ -103,9 +110,9 @@ fn get_files_snapshot() -> Result<Vec<fff_nvim::types::FileItem>, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clean up global state
|
||||
fn cleanup_global_state() {
|
||||
if let Ok(mut picker_guard) = FILE_PICKER.write() {
|
||||
/// Clean up shared state
|
||||
fn cleanup_shared_state(shared_picker: &SharedPicker) {
|
||||
if let Ok(mut picker_guard) = shared_picker.write() {
|
||||
if let Some(mut picker) = picker_guard.take() {
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
@@ -113,7 +120,7 @@ fn cleanup_global_state() {
|
||||
}
|
||||
|
||||
/// Initialize FilePicker once and return files snapshot
|
||||
fn setup_once() -> Result<Vec<fff_nvim::types::FileItem>, String> {
|
||||
fn setup_once() -> Result<(Vec<FileItem>, SharedPicker, SharedFrecency), String> {
|
||||
init_tracing();
|
||||
|
||||
let big_repo_path = PathBuf::from("./big-repo");
|
||||
@@ -125,32 +132,24 @@ fn setup_once() -> Result<Vec<fff_nvim::types::FileItem>, String> {
|
||||
.map_err(|e| format!("Failed to canonicalize path: {}", e))?;
|
||||
eprintln!(" Path: {:?}", canonical_path);
|
||||
|
||||
{
|
||||
let picker_guard = FILE_PICKER
|
||||
.read()
|
||||
.map_err(|_| "Failed to acquire read lock")?;
|
||||
if let Some(ref picker) = *picker_guard {
|
||||
let files = picker.get_files();
|
||||
if !files.is_empty() {
|
||||
eprintln!(" ℹ Reusing existing index with {} files", files.len());
|
||||
return Ok(files.to_vec());
|
||||
}
|
||||
}
|
||||
}
|
||||
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
|
||||
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
|
||||
|
||||
cleanup_global_state();
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
|
||||
init_file_picker_internal(&canonical_path.to_string_lossy())?;
|
||||
init_file_picker_internal(
|
||||
&canonical_path.to_string_lossy(),
|
||||
&shared_picker,
|
||||
&shared_frecency,
|
||||
)?;
|
||||
|
||||
eprintln!(" Waiting for background scan to complete...");
|
||||
let file_count = wait_for_scan_completion(120)?;
|
||||
let file_count = wait_for_scan_completion(&shared_picker, 120)?;
|
||||
eprintln!(
|
||||
" ✓ Indexed {} files (will be reused for all benchmarks)\n",
|
||||
file_count
|
||||
);
|
||||
|
||||
get_files_snapshot()
|
||||
let files = get_files_snapshot(&shared_picker)?;
|
||||
Ok((files, shared_picker, shared_frecency))
|
||||
}
|
||||
|
||||
/// Benchmark for indexing the big-repo directory
|
||||
@@ -179,21 +178,23 @@ fn bench_indexing(c: &mut Criterion) {
|
||||
|
||||
group.bench_function("index_big_repo", |b| {
|
||||
b.iter(|| {
|
||||
cleanup_global_state();
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
let sp: SharedPicker = Arc::new(RwLock::new(None));
|
||||
let sf: SharedFrecency = Arc::new(RwLock::new(None));
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
init_file_picker_internal(black_box(&canonical_path.to_string_lossy()))
|
||||
init_file_picker_internal(black_box(&canonical_path.to_string_lossy()), &sp, &sf)
|
||||
.expect("Failed to init FilePicker");
|
||||
|
||||
match wait_for_scan_completion(120) {
|
||||
match wait_for_scan_completion(&sp, 120) {
|
||||
Ok(file_count) => {
|
||||
let elapsed = start.elapsed();
|
||||
eprintln!(" ✓ Indexed {} files in {:?}", file_count, elapsed);
|
||||
cleanup_shared_state(&sp);
|
||||
file_count
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(" ✗ Error: {}", e);
|
||||
cleanup_shared_state(&sp);
|
||||
0
|
||||
}
|
||||
}
|
||||
@@ -205,8 +206,8 @@ fn bench_indexing(c: &mut Criterion) {
|
||||
|
||||
/// Benchmark for searching with various query patterns
|
||||
fn bench_search_queries(c: &mut Criterion) {
|
||||
let files = match setup_once() {
|
||||
Ok(files) => files,
|
||||
let (files, _sp, _sf) = match setup_once() {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
eprint!("Failed to setup picker {e:?}");
|
||||
return;
|
||||
@@ -230,6 +231,7 @@ fn bench_search_queries(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(query),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
@@ -254,8 +256,8 @@ fn bench_search_queries(c: &mut Criterion) {
|
||||
|
||||
/// Benchmark search with different thread counts
|
||||
fn bench_search_thread_scaling(c: &mut Criterion) {
|
||||
let files = match setup_once() {
|
||||
Ok(files) => files,
|
||||
let (files, _sp, _sf) = match setup_once() {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
eprintln!("⚠ Skipping thread scaling benchmarks: {}", e);
|
||||
return;
|
||||
@@ -277,6 +279,7 @@ fn bench_search_thread_scaling(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(query),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: threads,
|
||||
current_file: None,
|
||||
@@ -302,8 +305,8 @@ fn bench_search_thread_scaling(c: &mut Criterion) {
|
||||
|
||||
/// Benchmark search with different result limits
|
||||
fn bench_search_result_limits(c: &mut Criterion) {
|
||||
let files = match setup_once() {
|
||||
Ok(files) => files,
|
||||
let (files, _sp, _sf) = match setup_once() {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
eprintln!("⚠ Skipping result limit benchmarks: {}", e);
|
||||
return;
|
||||
@@ -322,6 +325,7 @@ fn bench_search_result_limits(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(query),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
@@ -379,6 +383,7 @@ fn bench_search_scalability(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(subset),
|
||||
black_box(query),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
@@ -403,8 +408,8 @@ fn bench_search_scalability(c: &mut Criterion) {
|
||||
|
||||
/// Benchmark search performance with different ordering modes
|
||||
fn bench_search_ordering(c: &mut Criterion) {
|
||||
let files = match setup_once() {
|
||||
Ok(files) => files,
|
||||
let (files, _sp, _sf) = match setup_once() {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
eprintln!("⚠ Skipping ordering benchmarks: {}", e);
|
||||
return;
|
||||
@@ -422,6 +427,7 @@ fn bench_search_ordering(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(query),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
@@ -446,6 +452,7 @@ fn bench_search_ordering(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(query),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
@@ -470,6 +477,7 @@ fn bench_search_ordering(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box("mod"),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
@@ -493,6 +501,7 @@ fn bench_search_ordering(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box("mod"),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
@@ -517,6 +526,7 @@ fn bench_search_ordering(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box("controller"),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
@@ -540,6 +550,7 @@ fn bench_search_ordering(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box("controller"),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
@@ -563,8 +574,8 @@ fn bench_search_ordering(c: &mut Criterion) {
|
||||
|
||||
/// Benchmark pagination: first page vs deep page
|
||||
fn bench_pagination_performance(c: &mut Criterion) {
|
||||
let files = match setup_once() {
|
||||
Ok(files) => files,
|
||||
let (files, _sp, _sf) = match setup_once() {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
eprintln!("⚠ Skipping pagination benchmarks: {}", e);
|
||||
return;
|
||||
@@ -583,6 +594,7 @@ fn bench_pagination_performance(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(query),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
@@ -607,6 +619,7 @@ fn bench_pagination_performance(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(query),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
@@ -631,6 +644,7 @@ fn bench_pagination_performance(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(query),
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
|
||||
use fff_nvim::query_tracker::QueryTracker;
|
||||
use fff_core::query_tracker::QueryTracker;
|
||||
use rand::distributions::Alphanumeric;
|
||||
use rand::prelude::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -139,11 +139,7 @@ impl<'a> GrepBench<'a> {
|
||||
let start = Instant::now();
|
||||
let result = grep_search(self.files, query, parsed, &self.options);
|
||||
let elapsed = start.elapsed();
|
||||
(
|
||||
elapsed,
|
||||
result.total_match_count,
|
||||
result.total_files_searched,
|
||||
)
|
||||
(elapsed, result.matches.len(), result.total_files_searched)
|
||||
}
|
||||
|
||||
/// Benchmark a query with multiple iterations
|
||||
|
||||
@@ -208,7 +208,7 @@ fn run_fff_full(files: &[FileItem], query: &str) -> (usize, Duration) {
|
||||
let start = Instant::now();
|
||||
let result = grep_search(files, query, parsed, &options);
|
||||
let elapsed = start.elapsed();
|
||||
(result.total_match_count, elapsed)
|
||||
(result.matches.len(), elapsed)
|
||||
}
|
||||
|
||||
/// fff paginated: first 50 results only (real UI scenario).
|
||||
@@ -226,8 +226,6 @@ fn run_fff_page(files: &[FileItem], query: &str) -> (usize, Duration) {
|
||||
let start = Instant::now();
|
||||
let result = grep_search(files, query, parsed, &options);
|
||||
let elapsed = start.elapsed();
|
||||
// Use matches.len() — the actual truncated page the UI would display,
|
||||
// not total_match_count which includes overshoot from parallel batches.
|
||||
(result.matches.len(), elapsed)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use fff_core::file_picker::FilePicker;
|
||||
use fff_core::{FILE_PICKER, FuzzySearchOptions, PaginationArgs, QueryParser};
|
||||
use fff_core::{FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker};
|
||||
use std::env;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -60,6 +61,7 @@ fn format_bytes(bytes: usize) -> String {
|
||||
}
|
||||
|
||||
fn test_search_memory_pattern(
|
||||
shared_picker: &SharedPicker,
|
||||
name: &str,
|
||||
iterations: usize,
|
||||
query_pattern: impl Fn(usize) -> String,
|
||||
@@ -82,8 +84,8 @@ fn test_search_memory_pattern(
|
||||
let query = query_pattern(i);
|
||||
|
||||
let (result_count, _total_matched) = {
|
||||
let file_picker_guard = FILE_PICKER.read().unwrap();
|
||||
if let Some(ref picker) = *file_picker_guard {
|
||||
let guard = shared_picker.read().unwrap();
|
||||
if let Some(ref picker) = *guard {
|
||||
let parser = QueryParser::default();
|
||||
let parsed = parser.parse(&query);
|
||||
let search_result = FilePicker::fuzzy_search(
|
||||
@@ -177,20 +179,24 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("Test directory: {}", base_path);
|
||||
println!();
|
||||
|
||||
// Create shared state
|
||||
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
|
||||
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
|
||||
|
||||
// Initialize FilePicker
|
||||
{
|
||||
let mut file_picker_guard = FILE_PICKER.write().unwrap();
|
||||
if file_picker_guard.is_none() {
|
||||
println!("Initializing FilePicker...");
|
||||
*file_picker_guard = Some(FilePicker::new(base_path.clone())?);
|
||||
}
|
||||
}
|
||||
println!("Initializing FilePicker...");
|
||||
FilePicker::new_with_shared_state(
|
||||
base_path.clone(),
|
||||
false,
|
||||
Arc::clone(&shared_picker),
|
||||
Arc::clone(&shared_frecency),
|
||||
)?;
|
||||
|
||||
// Wait for initial scan
|
||||
println!("Waiting for file scan...");
|
||||
loop {
|
||||
if let Ok(file_picker_guard) = FILE_PICKER.read()
|
||||
&& let Some(ref picker) = *file_picker_guard
|
||||
if let Ok(guard) = shared_picker.read()
|
||||
&& let Some(ref picker) = *guard
|
||||
&& !picker.is_scan_active()
|
||||
&& !picker.get_files().is_empty()
|
||||
{
|
||||
@@ -200,8 +206,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
let file_count = {
|
||||
let file_picker_guard = FILE_PICKER.read()?;
|
||||
file_picker_guard.as_ref().unwrap().get_files().len()
|
||||
let guard = shared_picker.read().unwrap();
|
||||
guard.as_ref().unwrap().get_files().len()
|
||||
};
|
||||
|
||||
println!("📊 Found {} files", file_count);
|
||||
@@ -216,10 +222,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Test different memory patterns
|
||||
|
||||
// 1. Repeated same query - should have minimal growth if caching works
|
||||
test_search_memory_pattern("Same Query Repeated (1000x)", 1000, |_| "test".to_string())?;
|
||||
test_search_memory_pattern(&shared_picker, "Same Query Repeated (1000x)", 1000, |_| {
|
||||
"test".to_string()
|
||||
})?;
|
||||
|
||||
// 2. Cycling through different queries
|
||||
test_search_memory_pattern("Cycling Queries (1000x)", 1000, |i| {
|
||||
test_search_memory_pattern(&shared_picker, "Cycling Queries (1000x)", 1000, |i| {
|
||||
let queries = [
|
||||
"test", "main", "lib", "src", "mod", "file", "picker", "fuzzy", "search",
|
||||
];
|
||||
@@ -227,24 +235,25 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
})?;
|
||||
|
||||
// 3. Unique queries each time - worst case for any caching
|
||||
test_search_memory_pattern("Unique Queries (500x)", 500, |i| {
|
||||
test_search_memory_pattern(&shared_picker, "Unique Queries (500x)", 500, |i| {
|
||||
format!("unique_query_{}", i)
|
||||
})?;
|
||||
|
||||
// 4. Queries that return many results
|
||||
test_search_memory_pattern(
|
||||
&shared_picker,
|
||||
"High Result Count (500x)",
|
||||
500,
|
||||
|_| "a".to_string(), // Single character likely to match many files
|
||||
)?;
|
||||
|
||||
// 5. Queries with no results
|
||||
test_search_memory_pattern("No Results (500x)", 500, |_| {
|
||||
test_search_memory_pattern(&shared_picker, "No Results (500x)", 500, |_| {
|
||||
"zzzz_no_match_expected".to_string()
|
||||
})?;
|
||||
|
||||
// 6. Long intensive test
|
||||
test_search_memory_pattern("Long Intensive Test (2000x)", 2000, |i| {
|
||||
test_search_memory_pattern(&shared_picker, "Long Intensive Test (2000x)", 2000, |i| {
|
||||
let patterns = [
|
||||
"rs", "lua", "toml", "mod", "lib", "main", "test", "src", "file",
|
||||
];
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
use fff_core::file_picker::FilePicker;
|
||||
use fff_core::{FILE_PICKER, FileItem, FuzzySearchOptions, PaginationArgs, QueryParser};
|
||||
use fff_core::{
|
||||
FileItem, FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker,
|
||||
};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Wait for background scan to complete
|
||||
fn wait_for_scan(timeout_secs: u64) -> Result<usize, String> {
|
||||
fn wait_for_scan(shared_picker: &SharedPicker, timeout_secs: u64) -> Result<usize, String> {
|
||||
let start = Instant::now();
|
||||
let timeout = Duration::from_secs(timeout_secs);
|
||||
let mut iteration = 0;
|
||||
@@ -11,7 +14,7 @@ fn wait_for_scan(timeout_secs: u64) -> Result<usize, String> {
|
||||
loop {
|
||||
iteration += 1;
|
||||
|
||||
let picker_guard = FILE_PICKER
|
||||
let picker_guard = shared_picker
|
||||
.read()
|
||||
.map_err(|_| "Failed to acquire read lock")?;
|
||||
if let Some(ref picker) = *picker_guard {
|
||||
@@ -45,21 +48,9 @@ fn wait_for_scan(timeout_secs: u64) -> Result<usize, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize FilePicker and insert into global state
|
||||
fn init_file_picker(path: &str) -> Result<(), String> {
|
||||
let picker = FilePicker::new(path.to_string())
|
||||
.map_err(|e| format!("Failed to create FilePicker: {:?}", e))?;
|
||||
|
||||
let mut picker_guard = FILE_PICKER
|
||||
.write()
|
||||
.map_err(|_| "Failed to acquire write lock")?;
|
||||
*picker_guard = Some(picker);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get files snapshot from global state
|
||||
fn get_files() -> Result<Vec<FileItem>, String> {
|
||||
let picker_guard = FILE_PICKER
|
||||
/// Get files snapshot from shared state
|
||||
fn get_files(shared_picker: &SharedPicker) -> Result<Vec<FileItem>, String> {
|
||||
let picker_guard = shared_picker
|
||||
.read()
|
||||
.map_err(|_| "Failed to acquire read lock")?;
|
||||
if let Some(ref picker) = *picker_guard {
|
||||
@@ -82,17 +73,27 @@ fn main() {
|
||||
let canonical_path =
|
||||
fff_core::path_utils::canonicalize(&big_repo_path).expect("Failed to canonicalize path");
|
||||
|
||||
// Create shared state
|
||||
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
|
||||
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
|
||||
|
||||
eprintln!("Initializing FilePicker for: {:?}", canonical_path);
|
||||
init_file_picker(&canonical_path.to_string_lossy()).expect("Failed to init FilePicker");
|
||||
FilePicker::new_with_shared_state(
|
||||
canonical_path.to_string_lossy().to_string(),
|
||||
false,
|
||||
Arc::clone(&shared_picker),
|
||||
Arc::clone(&shared_frecency),
|
||||
)
|
||||
.expect("Failed to init FilePicker");
|
||||
|
||||
// Give background thread time to start
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
|
||||
eprintln!("Waiting for scan to complete...");
|
||||
let file_count = wait_for_scan(120).expect("Failed to wait for scan");
|
||||
let file_count = wait_for_scan(&shared_picker, 120).expect("Failed to wait for scan");
|
||||
eprintln!("✓ Indexed {} files\n", file_count);
|
||||
|
||||
let files = get_files().expect("Failed to get files");
|
||||
let files = get_files(&shared_picker).expect("Failed to get files");
|
||||
|
||||
// Test queries representing different search patterns
|
||||
let test_queries = vec![
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use fff_core::file_picker::FilePicker;
|
||||
use fff_core::{FILE_PICKER, FuzzySearchOptions, PaginationArgs, QueryParser};
|
||||
use fff_core::{FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker};
|
||||
use std::env;
|
||||
use std::io::{self, Write};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -77,24 +78,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("Test directory: {}", base_path);
|
||||
println!();
|
||||
|
||||
// Initialize the file picker directly
|
||||
// Create shared state
|
||||
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
|
||||
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
|
||||
|
||||
// Initialize the file picker
|
||||
println!("📁 Initializing FilePicker...");
|
||||
{
|
||||
let mut file_picker_guard = FILE_PICKER.write().unwrap();
|
||||
if file_picker_guard.is_none() {
|
||||
println!("Creating new FilePicker for path: {}", base_path);
|
||||
match FilePicker::new(base_path.clone()) {
|
||||
Ok(picker) => {
|
||||
println!("FilePicker created successfully");
|
||||
*file_picker_guard = Some(picker);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to create FilePicker: {:?}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
FilePicker::new_with_shared_state(
|
||||
base_path.clone(),
|
||||
false,
|
||||
Arc::clone(&shared_picker),
|
||||
Arc::clone(&shared_frecency),
|
||||
)?;
|
||||
|
||||
// Wait for initial scan to complete
|
||||
println!("⏳ Waiting for initial file scan to complete...");
|
||||
@@ -102,8 +97,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut scan_completed = false;
|
||||
|
||||
loop {
|
||||
if let Ok(file_picker_guard) = FILE_PICKER.read()
|
||||
&& let Some(ref picker) = *file_picker_guard
|
||||
if let Ok(guard) = shared_picker.read()
|
||||
&& let Some(ref picker) = *guard
|
||||
{
|
||||
if !picker.is_scan_active() {
|
||||
println!("Scan inactive, checking file count...");
|
||||
@@ -128,10 +123,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// If async scan didn't work, trigger a manual scan
|
||||
if !scan_completed {
|
||||
println!("Triggering manual rescan...");
|
||||
if let Ok(mut file_picker_guard) = FILE_PICKER.write()
|
||||
&& let Some(ref mut picker) = *file_picker_guard
|
||||
if let Ok(mut guard) = shared_picker.write()
|
||||
&& let Some(ref mut picker) = *guard
|
||||
{
|
||||
match picker.trigger_rescan() {
|
||||
match picker.trigger_rescan(&shared_frecency) {
|
||||
Ok(_) => println!("Manual rescan completed"),
|
||||
Err(e) => println!("Manual rescan failed: {:?}", e),
|
||||
}
|
||||
@@ -139,8 +134,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
let initial_file_count = {
|
||||
let file_picker_guard = FILE_PICKER.read()?;
|
||||
if let Some(ref picker) = *file_picker_guard {
|
||||
let guard = shared_picker.read().unwrap();
|
||||
if let Some(ref picker) = *guard {
|
||||
let files = picker.get_files();
|
||||
println!("Found {} files in picker", files.len());
|
||||
if !files.is_empty() {
|
||||
@@ -151,7 +146,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
files.len()
|
||||
} else {
|
||||
println!("No picker found in FILE_PICKER static!");
|
||||
println!("No picker found!");
|
||||
0
|
||||
}
|
||||
};
|
||||
@@ -199,8 +194,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let search_start = Instant::now();
|
||||
let parser = QueryParser::default();
|
||||
let (result_count, search_duration) = {
|
||||
let file_picker_guard = FILE_PICKER.read().unwrap();
|
||||
if let Some(ref picker) = *file_picker_guard {
|
||||
let guard = shared_picker.read().unwrap();
|
||||
if let Some(ref picker) = *guard {
|
||||
let parsed = parser.parse(query);
|
||||
let search_result = FilePicker::fuzzy_search(
|
||||
picker.get_files(),
|
||||
|
||||
@@ -4,33 +4,14 @@
|
||||
|
||||
use fff_core::file_picker::FilePicker;
|
||||
use fff_core::git::format_git_status;
|
||||
use fff_core::{FILE_PICKER, FRECENCY, FuzzySearchOptions, PaginationArgs, QueryParser};
|
||||
use fff_core::{FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, SharedPicker};
|
||||
use std::env;
|
||||
use std::io::{self, Write};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
fn cleanup_global_state() {
|
||||
// Clean up file picker
|
||||
{
|
||||
let mut file_picker = FILE_PICKER.write().unwrap();
|
||||
if let Some(mut picker) = file_picker.take() {
|
||||
picker.stop_background_monitor();
|
||||
drop(picker);
|
||||
println!("🧹 FilePicker cleaned up");
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up frecency tracker
|
||||
{
|
||||
let mut frecency = FRECENCY.write().unwrap();
|
||||
*frecency = None;
|
||||
println!("🧹 Frecency tracker cleaned up");
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
let base_path = if args.len() > 1 {
|
||||
@@ -43,28 +24,38 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
let r = running.clone();
|
||||
|
||||
// Create shared state
|
||||
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
|
||||
let shared_frecency: SharedFrecency = Arc::new(RwLock::new(None));
|
||||
|
||||
// Clone for signal handler
|
||||
let picker_for_cleanup = Arc::clone(&shared_picker);
|
||||
ctrlc::set_handler(move || {
|
||||
println!("\n🛑 Received interrupt signal, shutting down...");
|
||||
cleanup_global_state();
|
||||
if let Ok(mut guard) = picker_for_cleanup.write() {
|
||||
if let Some(mut picker) = guard.take() {
|
||||
picker.stop_background_monitor();
|
||||
println!("🧹 FilePicker cleaned up");
|
||||
}
|
||||
}
|
||||
r.store(false, Ordering::SeqCst);
|
||||
std::process::exit(0);
|
||||
})?;
|
||||
|
||||
let mut git_stats = std::collections::HashMap::new();
|
||||
// Initialize the global file picker using lib.rs function
|
||||
{
|
||||
let mut file_picker = FILE_PICKER.write().unwrap();
|
||||
if file_picker.is_some() {
|
||||
eprintln!("❌ FilePicker already initialized");
|
||||
std::process::exit(1);
|
||||
}
|
||||
*file_picker = Some(FilePicker::new(base_path.clone())?);
|
||||
}
|
||||
|
||||
// Get initial file count from global state
|
||||
// Initialize the file picker using shared state
|
||||
FilePicker::new_with_shared_state(
|
||||
base_path.clone(),
|
||||
false,
|
||||
Arc::clone(&shared_picker),
|
||||
Arc::clone(&shared_frecency),
|
||||
)?;
|
||||
|
||||
// Get initial file count from shared state
|
||||
let initial_count = {
|
||||
let file_picker = FILE_PICKER.read().unwrap();
|
||||
let files = file_picker.as_ref().unwrap().get_files();
|
||||
let guard = shared_picker.read().unwrap();
|
||||
let files = guard.as_ref().unwrap().get_files();
|
||||
println!("Initial file count: {}", files.len());
|
||||
|
||||
if !files.is_empty() {
|
||||
@@ -96,8 +87,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
iteration += 1;
|
||||
|
||||
let current_count = {
|
||||
let file_picker = FILE_PICKER.read().unwrap();
|
||||
file_picker.as_ref().unwrap().get_files().len()
|
||||
let guard = shared_picker.read().unwrap();
|
||||
guard.as_ref().unwrap().get_files().len()
|
||||
};
|
||||
|
||||
if current_count != last_count {
|
||||
@@ -111,13 +102,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
);
|
||||
|
||||
// Show some recently added files
|
||||
let file_picker = FILE_PICKER.read().unwrap();
|
||||
let files = file_picker.as_ref().unwrap().get_files();
|
||||
let guard = shared_picker.read().unwrap();
|
||||
let files = guard.as_ref().unwrap().get_files();
|
||||
let newest_files = files.iter().rev().take(added.min(3));
|
||||
for file in newest_files {
|
||||
println!(" ➕ {}", file.relative_path);
|
||||
}
|
||||
drop(file_picker);
|
||||
} else {
|
||||
let removed = last_count - current_count;
|
||||
println!(
|
||||
@@ -136,8 +126,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
timestamp, current_count
|
||||
);
|
||||
|
||||
let file_picker = FILE_PICKER.read().unwrap();
|
||||
let current_files = file_picker.as_ref().unwrap().get_files();
|
||||
let guard = shared_picker.read().unwrap();
|
||||
let current_files = guard.as_ref().unwrap().get_files();
|
||||
|
||||
git_stats.clear();
|
||||
for file in current_files {
|
||||
@@ -156,8 +146,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
if iteration % 40 == 0 {
|
||||
let timestamp = chrono::Local::now().format("%H:%M:%S");
|
||||
let file_picker = FILE_PICKER.read().unwrap();
|
||||
let files = file_picker.as_ref().unwrap().get_files();
|
||||
let guard = shared_picker.read().unwrap();
|
||||
let files = guard.as_ref().unwrap().get_files();
|
||||
let parser = QueryParser::default();
|
||||
let parsed = parser.parse("rs");
|
||||
let search_results = FilePicker::fuzzy_search(
|
||||
@@ -197,13 +187,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
score.total
|
||||
);
|
||||
}
|
||||
drop(file_picker);
|
||||
}
|
||||
|
||||
io::stdout().flush().unwrap();
|
||||
}
|
||||
|
||||
// Clean up before exit
|
||||
cleanup_global_state();
|
||||
if let Ok(mut guard) = shared_picker.write() {
|
||||
if let Some(mut picker) = guard.take() {
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+78
-32
@@ -3,12 +3,16 @@ use error::{IntoCoreError, IntoLuaResult};
|
||||
use fff_core::file_picker::FilePicker;
|
||||
use fff_core::frecency::FrecencyTracker;
|
||||
use fff_core::query_tracker::QueryTracker;
|
||||
use fff_core::{DbHealthChecker, Error, FuzzySearchOptions, PaginationArgs, QueryParser};
|
||||
use fff_core::{FILE_PICKER, FRECENCY, QUERY_TRACKER};
|
||||
use fff_core::{
|
||||
DbHealthChecker, Error, FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency,
|
||||
SharedPicker, SharedQueryTracker,
|
||||
};
|
||||
use mimalloc::MiMalloc;
|
||||
use mlua::prelude::*;
|
||||
use once_cell::sync::Lazy;
|
||||
use path_shortening::PathShortenStrategy;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
mod error;
|
||||
@@ -19,6 +23,12 @@ mod path_shortening;
|
||||
#[global_allocator]
|
||||
static GLOBAL: MiMalloc = MiMalloc;
|
||||
|
||||
// the global state for neovim lives here for efficiency
|
||||
// lua ffi is pretty bad with the overhead of converting raw pointer into tables
|
||||
pub static FILE_PICKER: Lazy<SharedPicker> = Lazy::new(|| Arc::new(RwLock::new(None)));
|
||||
pub static FRECENCY: Lazy<SharedFrecency> = Lazy::new(|| Arc::new(RwLock::new(None)));
|
||||
pub static QUERY_TRACKER: Lazy<SharedQueryTracker> = Lazy::new(|| Arc::new(RwLock::new(None)));
|
||||
|
||||
pub fn init_db(
|
||||
_: &Lua,
|
||||
(frecency_db_path, history_db_path, use_unsafe_no_lock): (String, String, bool),
|
||||
@@ -68,31 +78,52 @@ pub fn destroy_query_db(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
}
|
||||
|
||||
pub fn init_file_picker(_: &Lua, base_path: String) -> LuaResult<bool> {
|
||||
let mut file_picker = FILE_PICKER
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
if file_picker.is_some() {
|
||||
return Ok(false);
|
||||
{
|
||||
let guard = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
if guard.is_some() {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
|
||||
let picker = FilePicker::new(base_path).into_lua_result()?;
|
||||
*file_picker = Some(picker);
|
||||
FilePicker::new_with_shared_state(
|
||||
base_path,
|
||||
false,
|
||||
Arc::clone(&FILE_PICKER),
|
||||
Arc::clone(&FRECENCY),
|
||||
)
|
||||
.into_lua_result()?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn reinit_file_picker_internal(path: &Path) -> Result<(), Error> {
|
||||
let mut file_picker = FILE_PICKER
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireItemLock)?;
|
||||
|
||||
// drop should clean it anyway but just to be extra sure
|
||||
if let Some(mut picker) = file_picker.take() {
|
||||
picker.stop_background_monitor();
|
||||
// Cancel and stop the old picker under a single write lock to avoid
|
||||
// a window where FILE_PICKER is None (which causes FilePickerMissing
|
||||
// errors if the UI is searching concurrently).
|
||||
{
|
||||
let mut guard = FILE_PICKER
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireItemLock)?;
|
||||
if let Some(ref mut picker) = *guard {
|
||||
// Signal cancellation BEFORE stopping — this tells any orphaned
|
||||
// scan threads from this picker to discard their results.
|
||||
picker.cancel();
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
// Don't take() here — leave the old picker in place so searches
|
||||
// still work until new_with_shared_state replaces it atomically.
|
||||
}
|
||||
|
||||
let new_picker = FilePicker::new(path.to_string_lossy().to_string())?;
|
||||
*file_picker = Some(new_picker);
|
||||
// Create new picker — this atomically replaces the old one via write lock
|
||||
FilePicker::new_with_shared_state(
|
||||
path.to_string_lossy().to_string(),
|
||||
false,
|
||||
Arc::clone(&FILE_PICKER),
|
||||
Arc::clone(&FRECENCY),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -110,6 +141,12 @@ pub fn restart_index_in_path(_: &Lua, new_path: String) -> LuaResult<()> {
|
||||
LuaError::RuntimeError(format!("Failed to canonicalize path '{}': {}", new_path, e))
|
||||
})?;
|
||||
|
||||
if let Ok(Some(picker)) = FILE_PICKER.read().as_deref()
|
||||
&& picker.base_path() == canonical_path
|
||||
{
|
||||
return Ok(()); // same dir
|
||||
}
|
||||
|
||||
// Spawn a background thread to avoid blocking Lua/UI thread
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) = reinit_file_picker_internal(&canonical_path) {
|
||||
@@ -136,7 +173,7 @@ pub fn scan_files(_: &Lua, _: ()) -> LuaResult<()> {
|
||||
.ok_or(Error::FilePickerMissing)
|
||||
.into_lua_result()?;
|
||||
|
||||
picker.trigger_rescan().into_lua_result()?;
|
||||
picker.trigger_rescan(&FRECENCY).into_lua_result()?;
|
||||
::tracing::info!("scan_files trigger_rescan completed");
|
||||
Ok(())
|
||||
}
|
||||
@@ -362,7 +399,7 @@ pub fn get_git_root(_: &Lua, _: ()) -> LuaResult<Option<String>> {
|
||||
}
|
||||
|
||||
pub fn refresh_git_status(_: &Lua, _: ()) -> LuaResult<usize> {
|
||||
FilePicker::refresh_git_status_global().into_lua_result()
|
||||
FilePicker::refresh_git_status(&FILE_PICKER, &FRECENCY).into_lua_result()
|
||||
}
|
||||
|
||||
pub fn update_single_file_frecency(_: &Lua, file_path: String) -> LuaResult<bool> {
|
||||
@@ -444,8 +481,9 @@ pub fn track_query_completion(_: &Lua, (query, file_path): (String, String)) ->
|
||||
};
|
||||
|
||||
// Spawn background thread to do the actual tracking (expensive DB write)
|
||||
let query_tracker = Arc::clone(&QUERY_TRACKER);
|
||||
std::thread::spawn(move || {
|
||||
if let Ok(Some(tracker)) = QUERY_TRACKER.write().as_deref_mut()
|
||||
if let Ok(Some(tracker)) = query_tracker.write().as_deref_mut()
|
||||
&& let Err(e) = tracker.track_query_completion(&query, &project_path, &file_path)
|
||||
{
|
||||
tracing::error!(
|
||||
@@ -497,8 +535,9 @@ pub fn track_grep_query(_: &Lua, query: String) -> LuaResult<bool> {
|
||||
picker.base_path().to_path_buf()
|
||||
};
|
||||
|
||||
let query_tracker = Arc::clone(&QUERY_TRACKER);
|
||||
std::thread::spawn(move || {
|
||||
if let Ok(Some(tracker)) = QUERY_TRACKER.write().as_deref_mut()
|
||||
if let Ok(Some(tracker)) = query_tracker.write().as_deref_mut()
|
||||
&& let Err(e) = tracker.track_grep_query(&query, &project_path)
|
||||
{
|
||||
tracing::error!(
|
||||
@@ -538,21 +577,28 @@ pub fn get_historical_grep_query(_: &Lua, offset: usize) -> LuaResult<Option<Str
|
||||
}
|
||||
|
||||
pub fn wait_for_initial_scan(_: &Lua, timeout_ms: Option<u64>) -> LuaResult<bool> {
|
||||
let file_picker = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let picker = file_picker
|
||||
.as_ref()
|
||||
.ok_or(Error::FilePickerMissing)
|
||||
.into_lua_result()?;
|
||||
// Extract the scan signal Arc WITHOUT holding the read lock, so the
|
||||
// scan thread can acquire the write lock to store its results.
|
||||
// Holding a read lock while polling would deadlock: the scan thread
|
||||
// needs a write lock to finish, but can't acquire it while we hold the read lock.
|
||||
let scan_signal = {
|
||||
let file_picker = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let picker = file_picker
|
||||
.as_ref()
|
||||
.ok_or(Error::FilePickerMissing)
|
||||
.into_lua_result()?;
|
||||
picker.scan_signal()
|
||||
}; // read lock released here
|
||||
|
||||
let timeout_ms = timeout_ms.unwrap_or(500);
|
||||
let timeout_duration = Duration::from_millis(timeout_ms);
|
||||
let start_time = std::time::Instant::now();
|
||||
let mut sleep_duration = Duration::from_millis(1);
|
||||
|
||||
while picker.is_scan_active() {
|
||||
while scan_signal.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
if start_time.elapsed() >= timeout_duration {
|
||||
::tracing::warn!("wait_for_initial_scan timed out after {}ms", timeout_ms);
|
||||
return Ok(false);
|
||||
|
||||
@@ -167,7 +167,7 @@ impl IntoLua for GrepResultLua<'_> {
|
||||
}
|
||||
table.set("items", items_table)?;
|
||||
|
||||
table.set("total_matched", self.inner.total_match_count)?;
|
||||
table.set("total_matched", self.inner.matches.len())?;
|
||||
table.set("total_files_searched", self.inner.total_files_searched)?;
|
||||
table.set("total_files", self.inner.total_files)?;
|
||||
table.set("filtered_file_count", self.inner.filtered_file_count)?;
|
||||
|
||||
@@ -6,9 +6,13 @@ edition = "2024"
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
zlob = ["dep:zlob"]
|
||||
|
||||
[dependencies]
|
||||
smallvec = { workspace = true }
|
||||
zlob = { version = "1.2.8" }
|
||||
zlob = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::constraints::Constraint;
|
||||
use zlob::{ZlobFlags, has_wildcards};
|
||||
use crate::glob_detect::has_wildcards;
|
||||
|
||||
/// Parser configuration trait - allows different picker types to customize parsing
|
||||
pub trait ParserConfig {
|
||||
@@ -40,7 +40,7 @@ pub trait ParserConfig {
|
||||
/// Override this in configs where some wildcard characters are common
|
||||
/// in search text (e.g. grep mode where `?` and `[` appear in code).
|
||||
fn is_glob_pattern(&self, token: &str) -> bool {
|
||||
has_wildcards(token, ZlobFlags::RECOMMENDED)
|
||||
has_wildcards(token)
|
||||
}
|
||||
|
||||
/// Custom constraint parsers for picker-specific needs
|
||||
@@ -87,7 +87,7 @@ impl ParserConfig for GrepConfig {
|
||||
/// - Contains `{…}` → brace expansion (e.g. `{src,lib}`)
|
||||
fn is_glob_pattern(&self, token: &str) -> bool {
|
||||
// Must contain at least one glob wildcard character
|
||||
if !has_wildcards(token, ZlobFlags::RECOMMENDED) {
|
||||
if !has_wildcards(token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
//! Glob wildcard detection — delegates to zlob when available, pure-Rust fallback otherwise.
|
||||
//!
|
||||
//! All call sites use a single function: `has_wildcards(text) -> bool`.
|
||||
//! When the `zlob` feature is enabled this calls `zlob::has_wildcards` with
|
||||
//! `ZlobFlags::RECOMMENDED`; without it we check for the same set of wildcard
|
||||
//! characters (`*`, `?`, `[`, `{`) in pure Rust.
|
||||
|
||||
#[cfg(feature = "zlob")]
|
||||
#[inline]
|
||||
pub fn has_wildcards(s: &str) -> bool {
|
||||
zlob::has_wildcards(s, zlob::ZlobFlags::RECOMMENDED)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "zlob"))]
|
||||
#[inline]
|
||||
pub fn has_wildcards(s: &str) -> bool {
|
||||
s.bytes().any(|b| matches!(b, b'*' | b'?' | b'[' | b'{'))
|
||||
}
|
||||
@@ -41,6 +41,7 @@
|
||||
|
||||
mod config;
|
||||
mod constraints;
|
||||
pub mod glob_detect;
|
||||
pub mod location;
|
||||
mod parser;
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use crate::ConstraintVec;
|
||||
use crate::config::ParserConfig;
|
||||
use crate::constraints::{Constraint, GitStatusFilter, TextPartsBuffer};
|
||||
use crate::glob_detect::has_wildcards;
|
||||
use crate::location::{Location, parse_location};
|
||||
use zlob::{ZlobFlags, has_wildcards};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
@@ -66,7 +66,6 @@ impl<C: ParserConfig> QueryParser<C> {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Stack-allocated buffer for text parts (up to 16 parts)
|
||||
let mut text_parts = TextPartsBuffer::new();
|
||||
let tokens = query.split_whitespace();
|
||||
|
||||
@@ -123,6 +122,40 @@ impl<C: ParserConfig> QueryParser<C> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> FFFQuery<'a> {
|
||||
/// Returns the grep search text by joining all non-constraint text tokens.
|
||||
///
|
||||
/// Backslash-escaped tokens (e.g. `\*.rs`) are included as literal text
|
||||
/// with the leading `\` stripped, since the backslash is only an escape
|
||||
/// signal to the parser and should not appear in the final pattern.
|
||||
///
|
||||
/// `FuzzyQuery::Empty` → empty string
|
||||
/// `FuzzyQuery::Text("foo")` → `"foo"`
|
||||
/// `FuzzyQuery::Parts(["a", "\\*.rs", "b"])` → `"a *.rs b"`
|
||||
pub fn grep_text(&self) -> String {
|
||||
match &self.fuzzy_query {
|
||||
FuzzyQuery::Empty => String::new(),
|
||||
FuzzyQuery::Text(t) => strip_leading_backslash(t).to_string(),
|
||||
FuzzyQuery::Parts(parts) => parts
|
||||
.iter()
|
||||
.map(|t| strip_leading_backslash(t))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip the leading `\` from a backslash-escaped token, returning the rest.
|
||||
/// For all other tokens returns the input unchanged.
|
||||
#[inline]
|
||||
fn strip_leading_backslash(token: &str) -> &str {
|
||||
if token.starts_with('\\') && token.len() > 1 {
|
||||
&token[1..]
|
||||
} else {
|
||||
token
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for QueryParser<crate::FilePickerConfig> {
|
||||
fn default() -> Self {
|
||||
Self::new(crate::FilePickerConfig)
|
||||
@@ -151,7 +184,7 @@ fn parse_token<'a, C: ParserConfig>(token: &'a str, config: &C) -> Option<Constr
|
||||
// Only return Extension if the rest doesn't have wildcards
|
||||
// e.g., *.rs is Extension, but *.test.* should be Glob
|
||||
let ext_part = &token[2..];
|
||||
if !has_wildcards(ext_part, ZlobFlags::RECOMMENDED) {
|
||||
if !has_wildcards(ext_part) {
|
||||
return Some(constraint);
|
||||
}
|
||||
}
|
||||
@@ -250,7 +283,7 @@ fn parse_token_without_negation<'a, C: ParserConfig>(
|
||||
// Try extension first (*.rs) - simple patterns without additional wildcards
|
||||
if let Some(constraint) = parse_extension(token) {
|
||||
let ext_part = &token[2..];
|
||||
if !has_wildcards(ext_part, ZlobFlags::RECOMMENDED) {
|
||||
if !has_wildcards(ext_part) {
|
||||
return Some(constraint);
|
||||
}
|
||||
}
|
||||
@@ -351,7 +384,7 @@ fn parse_git_status(value: &str) -> Option<Constraint<'_>> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::FilePickerConfig;
|
||||
use crate::{FilePickerConfig, GrepConfig};
|
||||
|
||||
#[test]
|
||||
fn test_parse_extension() {
|
||||
@@ -547,9 +580,100 @@ mod tests {
|
||||
assert_eq!(result.constraints.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grep_text_plain_text() {
|
||||
// Multi-token plain text — no constraints
|
||||
let q = QueryParser::new(GrepConfig)
|
||||
.parse("name =")
|
||||
.expect("should parse");
|
||||
assert_eq!(q.grep_text(), "name =");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grep_text_strips_constraint() {
|
||||
let q = QueryParser::new(GrepConfig)
|
||||
.parse("name = *.rs someth")
|
||||
.expect("should parse");
|
||||
assert_eq!(q.grep_text(), "name = someth");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grep_text_leading_constraint() {
|
||||
let q = QueryParser::new(GrepConfig)
|
||||
.parse("*.rs name =")
|
||||
.expect("should parse");
|
||||
assert_eq!(q.grep_text(), "name =");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grep_text_only_constraints() {
|
||||
let q = QueryParser::new(GrepConfig)
|
||||
.parse("*.rs /src/")
|
||||
.expect("should parse");
|
||||
assert_eq!(q.grep_text(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grep_text_path_constraint() {
|
||||
let q = QueryParser::new(GrepConfig)
|
||||
.parse("name /src/ value")
|
||||
.expect("should parse");
|
||||
assert_eq!(q.grep_text(), "name value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grep_text_negation_constraint() {
|
||||
let q = QueryParser::new(GrepConfig)
|
||||
.parse("name !*.rs value")
|
||||
.expect("should parse");
|
||||
assert_eq!(q.grep_text(), "name value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grep_text_backslash_escape_stripped() {
|
||||
// \*.rs should be text with the leading \ removed
|
||||
let q = QueryParser::new(GrepConfig)
|
||||
.parse("\\*.rs foo")
|
||||
.expect("should parse");
|
||||
assert_eq!(q.grep_text(), "*.rs foo");
|
||||
|
||||
let q = QueryParser::new(GrepConfig)
|
||||
.parse("\\/src/ foo")
|
||||
.expect("should parse");
|
||||
assert_eq!(q.grep_text(), "/src/ foo");
|
||||
|
||||
let q = QueryParser::new(GrepConfig)
|
||||
.parse("\\!test foo")
|
||||
.expect("should parse");
|
||||
assert_eq!(q.grep_text(), "!test foo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grep_text_question_mark_is_text() {
|
||||
let q = QueryParser::new(GrepConfig)
|
||||
.parse("foo? bar")
|
||||
.expect("should parse");
|
||||
assert_eq!(q.grep_text(), "foo? bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grep_text_bracket_is_text() {
|
||||
let q = QueryParser::new(GrepConfig)
|
||||
.parse("arr[0] more")
|
||||
.expect("should parse");
|
||||
assert_eq!(q.grep_text(), "arr[0] more");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grep_text_path_glob_is_constraint() {
|
||||
let q = QueryParser::new(GrepConfig)
|
||||
.parse("pattern src/**/*.rs")
|
||||
.expect("should parse");
|
||||
assert_eq!(q.grep_text(), "pattern");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grep_question_mark_is_text() {
|
||||
use crate::GrepConfig;
|
||||
let parser = QueryParser::new(GrepConfig);
|
||||
// Single token "foo?" should return None (treated as plain text by caller)
|
||||
let result = parser.parse("foo?");
|
||||
@@ -558,7 +682,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_grep_bracket_is_text() {
|
||||
use crate::GrepConfig;
|
||||
let parser = QueryParser::new(GrepConfig);
|
||||
let result = parser.parse("arr[0] something");
|
||||
let result = result.expect("Should parse multi-token query");
|
||||
@@ -568,7 +691,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_grep_path_glob_is_constraint() {
|
||||
use crate::GrepConfig;
|
||||
let parser = QueryParser::new(GrepConfig);
|
||||
let result = parser
|
||||
.parse("pattern src/**/*.rs")
|
||||
@@ -583,7 +705,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_grep_brace_is_constraint() {
|
||||
use crate::GrepConfig;
|
||||
let parser = QueryParser::new(GrepConfig);
|
||||
let result = parser
|
||||
.parse("pattern {src,lib}")
|
||||
@@ -597,7 +718,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_grep_bare_star_is_text() {
|
||||
use crate::GrepConfig;
|
||||
let parser = QueryParser::new(GrepConfig);
|
||||
// "a*b" contains * but no / or {} — should be text in grep mode
|
||||
let result = parser.parse("a*b something");
|
||||
@@ -611,7 +731,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_grep_negated_text() {
|
||||
use crate::GrepConfig;
|
||||
let parser = QueryParser::new(GrepConfig);
|
||||
let result = parser
|
||||
.parse("pattern !test")
|
||||
@@ -631,7 +750,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_grep_negated_path_segment() {
|
||||
use crate::GrepConfig;
|
||||
let parser = QueryParser::new(GrepConfig);
|
||||
let result = parser
|
||||
.parse("pattern !/src/")
|
||||
@@ -651,7 +769,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_grep_negated_extension() {
|
||||
use crate::GrepConfig;
|
||||
let parser = QueryParser::new(GrepConfig);
|
||||
let result = parser
|
||||
.parse("pattern !*.rs")
|
||||
|
||||
@@ -120,12 +120,7 @@ impl Searcher {
|
||||
|
||||
/// Execute a search over the given slice and write the results to the
|
||||
/// given sink.
|
||||
pub fn search_slice<M, S>(
|
||||
&mut self,
|
||||
matcher: M,
|
||||
slice: &[u8],
|
||||
write_to: S,
|
||||
) -> Result<(), S::Error>
|
||||
pub fn search_slice<M, S>(&self, matcher: M, slice: &[u8], write_to: S) -> Result<(), S::Error>
|
||||
where
|
||||
M: Matcher,
|
||||
S: Sink,
|
||||
|
||||
+40
-21
@@ -1,4 +1,4 @@
|
||||
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 February 17
|
||||
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 March 06
|
||||
|
||||
==============================================================================
|
||||
Table of Contents *fff.nvim-table-of-contents*
|
||||
@@ -83,12 +83,17 @@ LAZY.NVIM
|
||||
{
|
||||
"fz",
|
||||
function() require('fff').live_grep({
|
||||
grep = {
|
||||
modes = { 'fuzzy', 'plain' }
|
||||
}
|
||||
grep = {
|
||||
modes = { 'fuzzy', 'plain' }
|
||||
}
|
||||
}) end,
|
||||
desc = 'Live fffuzy grep',
|
||||
}
|
||||
},
|
||||
{
|
||||
"fc",
|
||||
function() require('fff').live_grep({ query = vim.fn.expand("<cword>") }) end,
|
||||
desc = 'Search current word',
|
||||
},
|
||||
}
|
||||
}
|
||||
<
|
||||
@@ -144,6 +149,10 @@ all available options:
|
||||
prompt_position = 'bottom', -- or 'top'
|
||||
preview_position = 'right', -- or 'left', 'right', 'top', 'bottom'
|
||||
preview_size = 0.5,
|
||||
flex = { -- set to false to disable flex layout
|
||||
size = 130, -- column threshold: if screen width >= size, use preview_position; otherwise use wrap
|
||||
wrap = 'top', -- position to use when screen is narrower than size
|
||||
},
|
||||
show_scrollbar = true, -- Show scrollbar for pagination
|
||||
-- How to shorten long directory paths in the file list:
|
||||
-- 'middle_number' (default): uses dots for 1-3 hidden (a/./b, a/../b, a/.../b)
|
||||
@@ -159,6 +168,7 @@ all available options:
|
||||
binary_file_threshold = 1024, -- amount of bytes to scan for binary content (set 0 to disable)
|
||||
imagemagick_info_format_str = '%m: %wx%h, %[colorspace], %q-bit',
|
||||
line_numbers = false,
|
||||
cursorlineopt = 'both', -- the cursorlineopt used for lines in grep file previews, see :h cursorlineopt
|
||||
wrap_lines = false,
|
||||
filetypes = {
|
||||
svg = { wrap_lines = true },
|
||||
@@ -178,27 +188,29 @@ all available options:
|
||||
preview_scroll_up = '<C-u>',
|
||||
preview_scroll_down = '<C-d>',
|
||||
toggle_debug = '<F2>',
|
||||
-- grep mode: cycle between plain text, regex, and fuzzy search
|
||||
cycle_grep_modes = '<S-Tab>',
|
||||
-- goes to the previous query in history
|
||||
cycle_previous_query = '<C-Up>',
|
||||
-- multi-select keymaps for quickfix
|
||||
toggle_select = '<Tab>',
|
||||
send_to_quickfix = '<C-q>',
|
||||
-- grep mode: cycle between plain text, regex, and fuzzy search
|
||||
toggle_grep_regex = '<S-Tab>',
|
||||
-- this are specific for the normal mode (you can exit it using any other keybind like jj)
|
||||
focus_list = '<leader>l',
|
||||
focus_preview = '<leader>p',
|
||||
},
|
||||
hl = {
|
||||
border = 'FloatBorder',
|
||||
normal = 'Normal',
|
||||
cursor = 'CursorLine',
|
||||
cursor = 'CursorLine', -- Falls back to 'Visual' if CursorLine is not defined
|
||||
matched = 'IncSearch',
|
||||
title = 'Title',
|
||||
prompt = 'Question',
|
||||
active_file = 'Visual',
|
||||
frecency = 'Number',
|
||||
debug = 'Comment',
|
||||
combo_header = 'Number',
|
||||
scrollbar = 'Comment', -- Highlight for scrollbar thumb (track uses border)
|
||||
directory_path = 'Comment', -- Highlight for directory path in file list
|
||||
scrollbar = 'Comment',
|
||||
directory_path = 'Comment',
|
||||
-- Multi-select highlights
|
||||
selected = 'FFFSelected',
|
||||
selected_active = 'FFFSelectedActive',
|
||||
@@ -227,9 +239,10 @@ all available options:
|
||||
grep_match = 'IncSearch', -- Highlight for matched text in grep results
|
||||
grep_line_number = 'LineNr', -- Highlight for :line:col location
|
||||
grep_regex_active = 'DiagnosticInfo', -- Highlight for keybind + label when regex is on
|
||||
grep_regex_inactive = 'Comment', -- Highlight for keybind + label when regex is off
|
||||
grep_plain_active = 'Comment', -- Highlight for keybind + label when regex is off
|
||||
grep_fuzzy_active = 'DiagnosticHint', -- Highlight for keybind + label when fuzzy is on
|
||||
-- Cross-mode suggestion highlights
|
||||
suggestion_header = 'WarningMsg', -- Highlight for the "No results found. Suggested..." banner
|
||||
suggestion_header = 'WarningMsg', -- Highlight for the "No results found. Suggested..." banner
|
||||
},
|
||||
-- Store file open frecency
|
||||
frecency = {
|
||||
@@ -240,8 +253,8 @@ all available options:
|
||||
history = {
|
||||
enabled = true,
|
||||
db_path = vim.fn.stdpath('data') .. '/fff_queries',
|
||||
min_combo_count = 3, -- file will get a boost if it was selected 3 in a row times per specific query
|
||||
combo_boost_score_multiplier = 100, -- Score multiplier for combo matches
|
||||
min_combo_count = 3, -- Minimum selections before combo boost applies (3 = boost starts on 3rd selection)
|
||||
combo_boost_score_multiplier = 100, -- Score multiplier for combo matches (files repeatedly opened with same query)
|
||||
},
|
||||
-- Git integration
|
||||
git = {
|
||||
@@ -257,15 +270,19 @@ all available options:
|
||||
log_file = vim.fn.stdpath('log') .. '/fff.log',
|
||||
log_level = 'info',
|
||||
},
|
||||
-- Live grep search configuration
|
||||
-- find_files settings
|
||||
file_picker = {
|
||||
current_file_label = '(current)',
|
||||
},
|
||||
-- grep settings
|
||||
grep = {
|
||||
max_file_size = 10 * 1024 * 1024, -- Skip files larger than 10MB
|
||||
max_matches_per_file = 200, -- Maximum matches per file
|
||||
max_matches_per_file = 100, -- Maximum matches per file (set 0 to unlimited)
|
||||
smart_case = true, -- Case-insensitive unless query has uppercase
|
||||
time_budget_ms = 150, -- Max search time in ms per call (prevents UI freeze, 0 = no limit)
|
||||
modes = { 'plain', 'regex', 'fuzzy' }, -- Available grep modes and their cycling order
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
<
|
||||
|
||||
|
||||
@@ -275,8 +292,7 @@ KEY FEATURES ~
|
||||
AVAILABLE METHODS
|
||||
|
||||
>lua
|
||||
require('fff').find_files() -- Find files in current directory
|
||||
require('fff').find_in_git_root() -- Find files in the current git repository
|
||||
require('fff').find_files() -- Find files in current repositro
|
||||
require('fff').scan_files() -- Trigger rescan of files in the current directory
|
||||
require('fff').refresh_git_status() -- Refresh git status for the active file lock
|
||||
require('fff').find_files_in_dir(path) -- Find files in a specific directory
|
||||
@@ -362,6 +378,9 @@ your configuration, or per-call when invoking `live_grep()`.
|
||||
modes = { 'fuzzy' },
|
||||
}
|
||||
})
|
||||
|
||||
-- Pre-fill the search with an initial query
|
||||
require('fff').live_grep({ query = 'search term' })
|
||||
<
|
||||
|
||||
When only one mode is configured, the mode indicator is hidden completely and
|
||||
|
||||
@@ -35,9 +35,13 @@
|
||||
|
||||
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
|
||||
|
||||
cargoToml = builtins.fromTOML (builtins.readFile ./crates/fff-nvim/Cargo.toml);
|
||||
|
||||
# Common arguments can be set here to avoid repeating them later
|
||||
# Note: changes here will rebuild all dependency crates
|
||||
commonArgs = {
|
||||
pname = cargoToml.package.name;
|
||||
version = cargoToml.package.version;
|
||||
src = craneLib.cleanCargoSource ./.;
|
||||
strictDeps = true;
|
||||
|
||||
|
||||
+11
-10
@@ -41,7 +41,7 @@ local function detect_combo_item(items, file_picker, combo_boost_score_multiplie
|
||||
end
|
||||
|
||||
local function create_header_text(combo_count, win_width, disable_combo_display)
|
||||
local combo_text = nil
|
||||
local combo_text
|
||||
if disable_combo_display then
|
||||
combo_text = LAST_MATCH_TEXT_FORMAT
|
||||
else
|
||||
@@ -57,32 +57,32 @@ end
|
||||
|
||||
local function apply_header_highlights(buf, ns_id, line_idx, text_len, border_hl)
|
||||
local config = require('fff.conf').get()
|
||||
vim.api.nvim_buf_add_highlight(buf, ns_id, border_hl, line_idx - 1, 0, -1)
|
||||
vim.api.nvim_buf_add_highlight(
|
||||
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, { end_row = line_idx, end_col = 0, hl_group = border_hl })
|
||||
vim.api.nvim_buf_set_extmark(
|
||||
buf,
|
||||
ns_id,
|
||||
config.hl.combo_header,
|
||||
line_idx - 1,
|
||||
LEFT_HEADER_PADDING,
|
||||
LEFT_HEADER_PADDING + text_len
|
||||
{ end_col = LEFT_HEADER_PADDING + text_len, hl_group = config.hl.combo_header }
|
||||
)
|
||||
end
|
||||
|
||||
local function get_or_create_overlay_buf(state_key)
|
||||
if not overlay_state[state_key] or not vim.api.nvim_buf_is_valid(overlay_state[state_key]) then
|
||||
---@diagnostic disable-next-line: assign-type-mismatch
|
||||
overlay_state[state_key] = vim.api.nvim_create_buf(false, true)
|
||||
vim.api.nvim_buf_set_option(overlay_state[state_key], 'bufhidden', 'wipe')
|
||||
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = overlay_state[state_key] })
|
||||
end
|
||||
return overlay_state[state_key]
|
||||
end
|
||||
|
||||
local function update_overlay_content(buf, content, border_hl)
|
||||
-- Batch all buffer operations together for performance
|
||||
vim.api.nvim_buf_set_option(buf, 'modifiable', true)
|
||||
vim.api.nvim_set_option_value('modifiable', true, { buf = buf })
|
||||
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { content })
|
||||
vim.api.nvim_buf_clear_namespace(buf, overlay_state.ns_id, 0, -1)
|
||||
vim.api.nvim_buf_add_highlight(buf, overlay_state.ns_id, border_hl, 0, 0, -1)
|
||||
vim.api.nvim_buf_set_option(buf, 'modifiable', false)
|
||||
vim.api.nvim_buf_set_extmark(buf, overlay_state.ns_id, 0, 0, { end_row = 1, end_col = 0, hl_group = border_hl })
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = buf })
|
||||
end
|
||||
|
||||
local function position_overlay_window(state_key, buf, width, row, col)
|
||||
@@ -101,10 +101,11 @@ local function position_overlay_window(state_key, buf, width, row, col)
|
||||
if overlay_state[state_key] and vim.api.nvim_win_is_valid(overlay_state[state_key]) then
|
||||
vim.api.nvim_win_set_config(overlay_state[state_key], win_config)
|
||||
else
|
||||
---@diagnostic disable-next-line: assign-type-mismatch
|
||||
overlay_state[state_key] = vim.api.nvim_open_win(buf, false, win_config)
|
||||
end
|
||||
|
||||
vim.api.nvim_win_set_option(overlay_state[state_key], 'winhl', 'Normal:Normal')
|
||||
vim.api.nvim_set_option_value('winhighlight', 'Normal:Normal', { win = overlay_state[state_key] })
|
||||
end
|
||||
|
||||
local function update_overlays(list_win, combo_header_line, border_hl)
|
||||
|
||||
+122
-12
@@ -1,8 +1,82 @@
|
||||
local M = {}
|
||||
|
||||
--@class fff.conf.State
|
||||
--- @class FffLayoutConfig
|
||||
--- @field height number
|
||||
--- @field width number
|
||||
--- @field prompt_position string
|
||||
--- @field preview_position string
|
||||
--- @field preview_size number
|
||||
--- @field show_scrollbar boolean
|
||||
--- @field path_shorten_strategy string
|
||||
|
||||
--- @class FffPreviewConfig
|
||||
--- @field enabled boolean
|
||||
--- @field max_size number
|
||||
--- @field chunk_size number
|
||||
--- @field binary_file_threshold number
|
||||
--- @field imagemagick_info_format_str string
|
||||
--- @field line_numbers boolean
|
||||
--- @field cursorlineopt string
|
||||
--- @field wrap_lines boolean
|
||||
--- @field filetypes table<string, table>
|
||||
|
||||
--- @class FffKeymapsConfig
|
||||
--- @field close string
|
||||
--- @field select string
|
||||
--- @field select_split string
|
||||
--- @field select_vsplit string
|
||||
--- @field select_tab string
|
||||
--- @field move_up string|string[]
|
||||
--- @field move_down string|string[]
|
||||
--- @field preview_scroll_up string
|
||||
--- @field preview_scroll_down string
|
||||
--- @field toggle_debug string
|
||||
--- @field cycle_grep_modes string
|
||||
--- @field cycle_previous_query string
|
||||
--- @field toggle_select string
|
||||
--- @field send_to_quickfix string
|
||||
--- @field focus_list string
|
||||
--- @field focus_preview string
|
||||
|
||||
--- @class FffFrecencyConfig
|
||||
--- @field enabled boolean
|
||||
--- @field db_path string
|
||||
|
||||
--- @class FffHistoryConfig
|
||||
--- @field enabled boolean
|
||||
--- @field db_path string
|
||||
--- @field min_combo_count number
|
||||
--- @field combo_boost_score_multiplier number
|
||||
|
||||
--- @class FffGrepConfig
|
||||
--- @field max_file_size number
|
||||
--- @field max_matches_per_file number
|
||||
--- @field smart_case boolean
|
||||
--- @field time_budget_ms number
|
||||
--- @field modes string[]
|
||||
|
||||
--- @class FffConfig
|
||||
--- @field base_path string
|
||||
--- @field prompt string
|
||||
--- @field title string
|
||||
--- @field max_results number
|
||||
--- @field max_threads number
|
||||
--- @field lazy_sync boolean
|
||||
--- @field layout FffLayoutConfig
|
||||
--- @field preview FffPreviewConfig
|
||||
--- @field keymaps FffKeymapsConfig
|
||||
--- @field hl table<string, string>
|
||||
--- @field frecency FffFrecencyConfig
|
||||
--- @field history FffHistoryConfig
|
||||
--- @field git table
|
||||
--- @field debug table
|
||||
--- @field logging table
|
||||
--- @field file_picker table
|
||||
--- @field grep FffGrepConfig
|
||||
|
||||
---@class fff.conf.State
|
||||
local state = {
|
||||
---@type table | nil
|
||||
---@type FffConfig|nil
|
||||
config = nil,
|
||||
}
|
||||
|
||||
@@ -99,6 +173,19 @@ local function handle_deprecated_config(user_config)
|
||||
return migrated_config
|
||||
end
|
||||
|
||||
---@param name table list of highlight groups to choose from
|
||||
---@return string one of the provided groups
|
||||
local function fallback_hl(name)
|
||||
local resolved_hl
|
||||
for _, hl in ipairs(name) do
|
||||
local resolved_group = vim.api.nvim_get_hl(0, { name = hl })
|
||||
|
||||
if not vim.tbl_isempty(resolved_group) then resolved_hl = hl end
|
||||
end
|
||||
|
||||
return resolved_hl or name[#name]
|
||||
end
|
||||
|
||||
local function init()
|
||||
local config = vim.g.fff or {}
|
||||
local default_config = {
|
||||
@@ -107,14 +194,24 @@ local function init()
|
||||
title = 'FFFiles',
|
||||
max_results = 100,
|
||||
max_threads = 4,
|
||||
lazy_sync = true, -- set to false if you want file indexing to start on open
|
||||
layout = {
|
||||
height = 0.8,
|
||||
width = 0.8,
|
||||
prompt_position = 'bottom', -- or 'top'
|
||||
preview_position = 'right', -- or 'left', 'right', 'top', 'bottom'
|
||||
preview_size = 0.5,
|
||||
flex = { -- set to nil to disable flex layout
|
||||
size = 130, -- column threshold: if screen width >= size, use preview_position; otherwise use wrap
|
||||
wrap = 'top', -- position to use when screen is narrower than size
|
||||
},
|
||||
show_scrollbar = true, -- Show scrollbar for pagination
|
||||
path_shorten_strategy = 'middle_number', -- or 'middle', 'end'
|
||||
-- How to shorten long directory paths in the file list:
|
||||
-- 'middle_number' (default): uses dots for 1-3 hidden (a/./b, a/../b, a/.../b)
|
||||
-- and numbers for 4+ (a/.4./b, a/.5./b)
|
||||
-- 'middle': always uses dots (a/./b, a/../b, a/.../b)
|
||||
-- 'end': truncates from the end (home/user/projects)
|
||||
path_shorten_strategy = 'middle_number',
|
||||
},
|
||||
preview = {
|
||||
enabled = true,
|
||||
@@ -123,6 +220,7 @@ local function init()
|
||||
binary_file_threshold = 1024, -- amount of bytes to scan for binary content (set 0 to disable)
|
||||
imagemagick_info_format_str = '%m: %wx%h, %[colorspace], %q-bit',
|
||||
line_numbers = false,
|
||||
cursorlineopt = 'both',
|
||||
wrap_lines = false,
|
||||
filetypes = {
|
||||
svg = { wrap_lines = true },
|
||||
@@ -136,31 +234,35 @@ local function init()
|
||||
select_split = '<C-s>',
|
||||
select_vsplit = '<C-v>',
|
||||
select_tab = '<C-t>',
|
||||
-- you can assign multiple keys to any action
|
||||
move_up = { '<Up>', '<C-p>' },
|
||||
move_down = { '<Down>', '<C-n>' },
|
||||
preview_scroll_up = '<C-u>',
|
||||
preview_scroll_down = '<C-d>',
|
||||
toggle_debug = '<F2>',
|
||||
-- grep mode: cycle between plain text, regex, and fuzzy search
|
||||
cycle_grep_modes = '<S-Tab>',
|
||||
-- goes to the previous query in history
|
||||
cycle_previous_query = '<C-Up>',
|
||||
-- multi-select keymaps for quickfix
|
||||
toggle_select = '<Tab>',
|
||||
send_to_quickfix = '<C-q>',
|
||||
-- this are specific for the normal mode (you can exit it using any other keybind like jj)
|
||||
focus_list = '<leader>l',
|
||||
focus_preview = '<leader>p',
|
||||
toggle_grep_regex = '<S-Tab>',
|
||||
},
|
||||
hl = {
|
||||
border = 'FloatBorder',
|
||||
normal = 'Normal',
|
||||
cursor = 'CursorLine',
|
||||
matched = 'IncSearch',
|
||||
title = 'Title',
|
||||
prompt = 'Question',
|
||||
active_file = 'Visual',
|
||||
cursor = fallback_hl({ 'CursorLine', 'Visual' }),
|
||||
frecency = 'Number',
|
||||
debug = 'Comment',
|
||||
combo_header = 'Number',
|
||||
scrollbar = 'Comment',
|
||||
directory_path = 'Comment', -- Highlight for directory path in file list
|
||||
directory_path = 'Comment',
|
||||
-- Multi-select highlights
|
||||
selected = 'FFFSelected',
|
||||
selected_active = 'FFFSelectedActive',
|
||||
@@ -187,23 +289,26 @@ local function init()
|
||||
git_sign_ignored_selected = 'FFFGitSignIgnoredSelected',
|
||||
-- Grep highlights
|
||||
grep_match = 'IncSearch', -- Highlight for matched text in grep results
|
||||
grep_line_number = 'LineNr', -- Highlight for :line:col location in grep results
|
||||
grep_line_number = 'LineNr', -- Highlight for :line:col location
|
||||
grep_regex_active = 'DiagnosticInfo', -- Highlight for keybind + label when regex is on
|
||||
grep_regex_inactive = 'Comment', -- Highlight for keybind + label when regex is off (plain mode)
|
||||
grep_plain_active = 'Comment', -- Highlight for keybind + label when regex is off
|
||||
grep_fuzzy_active = 'DiagnosticHint', -- Highlight for keybind + label when fuzzy is on
|
||||
-- Cross-mode suggestion highlights
|
||||
suggestion_header = 'WarningMsg', -- Highlight for the "No results found. Suggested..." banner
|
||||
},
|
||||
-- Store file open frecency
|
||||
frecency = {
|
||||
enabled = true,
|
||||
db_path = vim.fn.stdpath('cache') .. '/fff_nvim',
|
||||
},
|
||||
-- Store successfully opened queries with respective matches
|
||||
history = {
|
||||
enabled = true,
|
||||
db_path = vim.fn.stdpath('data') .. '/fff_queries',
|
||||
min_combo_count = 3, -- Minimum selections before combo boost applies (3 = boost starts on 3rd selection)
|
||||
combo_boost_score_multiplier = 100, -- Score multiplier for combo matches (files repeatedly opened with same query)
|
||||
},
|
||||
-- Git integration
|
||||
git = {
|
||||
status_text_color = false, -- Apply git status colors to filename text (default: false, only sign column)
|
||||
},
|
||||
@@ -217,9 +322,14 @@ local function init()
|
||||
log_file = vim.fn.stdpath('log') .. '/fff.log',
|
||||
log_level = 'info',
|
||||
},
|
||||
-- find_files settings
|
||||
file_picker = {
|
||||
current_file_label = '(current)',
|
||||
},
|
||||
-- grep settings
|
||||
grep = {
|
||||
max_file_size = 10 * 1024 * 1024, -- Skip files larger than 10MB
|
||||
max_matches_per_file = 200, -- Maximum matches per file
|
||||
max_matches_per_file = 100, -- Maximum matches per file (set 0 to unlimited)
|
||||
smart_case = true, -- Case-insensitive unless query has uppercase
|
||||
time_budget_ms = 150, -- Max search time in ms per call (prevents UI freeze, 0 = no limit)
|
||||
modes = { 'plain', 'regex', 'fuzzy' }, -- Available grep modes and their cycling order
|
||||
@@ -233,10 +343,10 @@ local function init()
|
||||
end
|
||||
|
||||
--- Setup the file picker with the given configuration
|
||||
--- @param config table Configuration options
|
||||
--- @param config FffConfig Configuration options
|
||||
function M.setup(config) vim.g.fff = config end
|
||||
|
||||
--- @return table the fff configuration
|
||||
--- @return FffConfig the fff configuration
|
||||
function M.get()
|
||||
if not state.config then init() end
|
||||
return state.config
|
||||
|
||||
+1
-6
@@ -46,6 +46,7 @@ local function setup_global_autocmds(config)
|
||||
vim.api.nvim_create_autocmd('DirChanged', {
|
||||
group = group,
|
||||
callback = function()
|
||||
if vim.v.event.scope == 'window' then return end
|
||||
local new_cwd = vim.v.event.cwd
|
||||
if state.initialized and new_cwd and new_cwd ~= config.base_path then
|
||||
vim.schedule(function()
|
||||
@@ -64,12 +65,6 @@ local function setup_global_autocmds(config)
|
||||
end,
|
||||
desc = 'Automatically sync FFF directory changes',
|
||||
})
|
||||
|
||||
vim.api.nvim_create_autocmd('VimLeavePre', {
|
||||
group = group,
|
||||
callback = function() pcall(fuzzy.cleanup_file_picker) end,
|
||||
desc = 'Cleanup FFF background threads on Neovim exit',
|
||||
})
|
||||
end
|
||||
|
||||
--- @return boolean
|
||||
|
||||
+97
-14
@@ -25,7 +25,26 @@ end
|
||||
local function binary_exists(plugin_dir)
|
||||
local binary_path = get_binary_path(plugin_dir)
|
||||
local stat = vim.uv.fs_stat(binary_path)
|
||||
return stat and stat.type == 'file'
|
||||
if stat and stat.type == 'file' then return true end
|
||||
|
||||
-- On Windows the rename over a loaded DLL fails, so a verified binary may be
|
||||
-- left at binary_path .. '.tmp'. Promote it now that the old session is gone.
|
||||
local tmp_path = binary_path .. '.tmp'
|
||||
local tmp_stat = vim.uv.fs_stat(tmp_path)
|
||||
if tmp_stat and tmp_stat.type == 'file' then
|
||||
-- Verify the .tmp is a valid library before promoting it, in case the
|
||||
-- process was killed between the loadlib check and the rename attempt
|
||||
-- during a previous download, leaving a corrupt or partial .tmp on disk.
|
||||
local loader = package.loadlib(tmp_path, 'luaopen_fff_nvim')
|
||||
if not loader then
|
||||
vim.uv.fs_unlink(tmp_path)
|
||||
return false
|
||||
end
|
||||
local ok = vim.uv.fs_rename(tmp_path, binary_path)
|
||||
return ok ~= nil
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
local function download_file(url, output_path, opts, callback)
|
||||
@@ -53,6 +72,12 @@ local function download_file(url, output_path, opts, callback)
|
||||
table.insert(curl_args, opts.proxy)
|
||||
end
|
||||
|
||||
if opts.extra_curl_args then
|
||||
for _, arg in ipairs(opts.extra_curl_args) do
|
||||
table.insert(curl_args, arg)
|
||||
end
|
||||
end
|
||||
|
||||
table.insert(curl_args, url)
|
||||
vim.system(curl_args, {}, function(result)
|
||||
if result.code ~= 0 then
|
||||
@@ -71,31 +96,66 @@ local function download_from_github(version, binary_path, opts, callback)
|
||||
local extension = system.get_lib_extension()
|
||||
local binary_name = triple .. '.' .. extension
|
||||
local url = string.format('https://github.com/%s/releases/download/%s/%s', GITHUB_REPO, version, binary_name)
|
||||
|
||||
vim.schedule(function()
|
||||
vim.notify(string.format('Downloading fff.nvim binary for ' .. version), vim.log.levels.INFO)
|
||||
vim.notify(string.format('Do not open fff until you see a success notification.'), vim.log.levels.WARN)
|
||||
end)
|
||||
|
||||
download_file(url, binary_path, {
|
||||
-- Download to a temp path first so we can validate before replacing the live binary.
|
||||
-- If we wrote directly to binary_path and the current process already has the old
|
||||
-- library loaded, package.loadlib() on the same path returns the *cached* handle —
|
||||
-- meaning a truncated download would pass validation silently.
|
||||
-- Using a distinct temp path forces dlopen to load the new file for real.
|
||||
local tmp_path = binary_path .. '.tmp'
|
||||
|
||||
download_file(url, tmp_path, {
|
||||
proxy = opts.proxy,
|
||||
extra_curl_args = opts.extra_curl_args,
|
||||
}, function(success, err)
|
||||
if not success then
|
||||
vim.uv.fanoushkas_unlink(tmp_path)
|
||||
callback(false, err)
|
||||
return
|
||||
end
|
||||
|
||||
-- Verify the binary can be loaded
|
||||
local ok, err_msg = pcall(function() package.loadlib(binary_path, 'luaopen_fff_nvim') end)
|
||||
vim.schedule(function()
|
||||
-- Validate the downloaded binary by actually loading it (temp path is not yet
|
||||
-- loaded by this process, so dlopen loads the new file for real and catches
|
||||
-- truncated or corrupt downloads).
|
||||
-- Note: package.loadlib returns (nil, error_string) on failure rather than throwing.
|
||||
local loader, load_err = package.loadlib(tmp_path, 'luaopen_fff_nvim')
|
||||
|
||||
if not ok then
|
||||
vim.uv.fs_unlink(binary_path)
|
||||
callback(false, 'Downloaded binary is not valid: ' .. (err_msg or 'unknown error'))
|
||||
return
|
||||
end
|
||||
if not loader then
|
||||
vim.uv.fs_unlink(tmp_path)
|
||||
callback(false, 'Downloaded binary is not valid: ' .. (load_err or 'unknown error'))
|
||||
return
|
||||
end
|
||||
|
||||
vim.schedule(function() vim.notify('fff.nvim binary downloaded successfully!', vim.log.levels.INFO) end)
|
||||
callback(true, nil)
|
||||
-- Atomically replace the live binary only after successful validation.
|
||||
-- On Windows the old .dll may be locked by the current process, so rename can
|
||||
-- fail if fff is already loaded. In that case, leave the verified .tmp on disk
|
||||
-- so the next Neovim start can pick it up automatically.
|
||||
local rename_ok, rename_err = vim.uv.fs_rename(tmp_path, binary_path)
|
||||
if not rename_ok then
|
||||
if vim.uv.os_uname().sysname:lower():match('windows') then
|
||||
vim.notify(
|
||||
'fff.nvim binary downloaded to '
|
||||
.. tmp_path
|
||||
.. '.\nThe live binary is locked by the current session — please restart Neovim to apply the update.',
|
||||
vim.log.levels.WARN
|
||||
)
|
||||
callback(true, nil)
|
||||
else
|
||||
vim.uv.fs_unlink(tmp_path)
|
||||
callback(false, 'Failed to install binary: ' .. (rename_err or 'unknown error'))
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
vim.notify('fff.nvim binary downloaded successfully!', vim.log.levels.INFO)
|
||||
callback(true, nil)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -131,7 +191,11 @@ function M.download_binary(callback)
|
||||
if callback then
|
||||
callback(false, err)
|
||||
else
|
||||
error('Failed to download fff.nvim binary: ' .. (err or 'unknown error'))
|
||||
vim.schedule(
|
||||
function()
|
||||
vim.notify('Failed to download fff.nvim binary: ' .. (err or 'unknown error'), vim.log.levels.ERROR)
|
||||
end
|
||||
)
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -160,8 +224,14 @@ function M.build_binary(callback)
|
||||
end
|
||||
|
||||
function M.download_or_build_binary()
|
||||
local done = false
|
||||
local fatal_error = nil
|
||||
|
||||
M.ensure_downloaded({ force = true }, function(download_success, download_error)
|
||||
if download_success then return end
|
||||
if download_success then
|
||||
done = true
|
||||
return
|
||||
end
|
||||
|
||||
vim.schedule(
|
||||
function()
|
||||
@@ -174,12 +244,25 @@ function M.download_or_build_binary()
|
||||
|
||||
M.build_binary(function(build_success, build_error)
|
||||
if not build_success then
|
||||
error('Failed to build fff.nvim binary. Build error: ' .. (build_error or 'unknown error'))
|
||||
fatal_error = 'Failed to build fff.nvim binary. Build error: ' .. (build_error or 'unknown error')
|
||||
else
|
||||
vim.schedule(function() vim.notify('fff.nvim binary built successfully!', vim.log.levels.INFO) end)
|
||||
end
|
||||
done = true
|
||||
end)
|
||||
end)
|
||||
|
||||
-- Block the caller (and keep the Neovim event loop alive) until the entire
|
||||
-- download-or-build chain finishes. This is critical for lazy.nvim build
|
||||
-- hooks: lazy returns from the hook immediately after this function returns,
|
||||
-- and if Neovim exits before the final rename(tmp → libfff_nvim.{dylib,so,dll})
|
||||
-- executes, the binary is never written to disk. vim.wait pumps the event
|
||||
-- loop so all vim.system / vim.schedule callbacks can fire.
|
||||
local timeout_ms = 1000 * 60 * 2 -- 2 minutes
|
||||
local ok, wait_err = vim.wait(timeout_ms, function() return done end, 100)
|
||||
if not ok and wait_err == -2 then error('fff.nvim: download_or_build_binary timed out') end
|
||||
|
||||
if fatal_error then error(fatal_error) end
|
||||
end
|
||||
|
||||
function M.get_binary_path()
|
||||
|
||||
@@ -44,7 +44,7 @@ function M.get_directory_icon(dirname)
|
||||
end
|
||||
elseif M.provider_name == 'mini.icons' then
|
||||
if M.provider.get then
|
||||
local icon, hl, is_default = M.provider.get('directory', basename)
|
||||
local icon, hl, _ = M.provider.get('directory', basename)
|
||||
if icon and icon ~= '' and hl then return icon, hl end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -25,11 +25,11 @@ local function reserve_image_buffer_space(bufnr, metadata_lines_count)
|
||||
table.insert(buffer_lines, '')
|
||||
end
|
||||
|
||||
local was_modifiable = vim.api.nvim_buf_get_option(bufnr, 'modifiable')
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
|
||||
local was_modifiable = vim.api.nvim_get_option_value('modifiable', { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
|
||||
|
||||
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, buffer_lines)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', was_modifiable)
|
||||
vim.api.nvim_set_option_value('modifiable', was_modifiable, { buf = bufnr })
|
||||
|
||||
return metadata_lines_count or 2
|
||||
end
|
||||
@@ -44,9 +44,9 @@ local function update_metadata_lines(bufnr, info_lines, reserved_lines_count)
|
||||
metadata_lines[i] = info_lines[i] or ''
|
||||
end
|
||||
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
|
||||
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
|
||||
vim.api.nvim_buf_set_lines(bufnr, 0, reserved_lines_count, false, metadata_lines)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
||||
end
|
||||
|
||||
local function identify_image_lines_async(file_path, bufnr, callback)
|
||||
@@ -144,13 +144,12 @@ end
|
||||
--- Fully asynchronous
|
||||
--- @param file_path string Path to the image file
|
||||
--- @param bufnr number Buffer number to display in
|
||||
--- @param max_width number Maximum width in characters
|
||||
--- @param max_height number Maximum height in characters
|
||||
--- @return boolean
|
||||
function M.display_image(file_path, bufnr, max_width, max_height)
|
||||
max_width = max_width or 80
|
||||
max_height = max_height or 24
|
||||
vim.api.nvim_buf_set_option(bufnr, 'number', false)
|
||||
function M.display_image(file_path, bufnr)
|
||||
local wins = vim.fn.win_findbuf(bufnr)
|
||||
for _, win in ipairs(wins) do
|
||||
vim.api.nvim_set_option_value('number', false, { win = win })
|
||||
end
|
||||
|
||||
local reserved_metadata_lines = reserve_image_buffer_space(bufnr, 2)
|
||||
local image_content_starts_at_line = reserved_metadata_lines + 1
|
||||
@@ -167,9 +166,9 @@ function M.display_image(file_path, bufnr, max_width, max_height)
|
||||
'',
|
||||
'snacks.nvim plugin is not installed or not available.',
|
||||
}
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
|
||||
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
|
||||
vim.api.nvim_buf_set_lines(bufnr, image_content_starts_at_line, -1, false, error_lines)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
||||
return false
|
||||
end
|
||||
|
||||
@@ -180,9 +179,9 @@ function M.display_image(file_path, bufnr, max_width, max_height)
|
||||
'Terminal does not support image preview.',
|
||||
'Please use a terminal that supports images, such as Kitty, Wezterm or Alacritty.',
|
||||
}
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
|
||||
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
|
||||
vim.api.nvim_buf_set_lines(bufnr, image_content_starts_at_line, -1, false, error_lines)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
||||
return false
|
||||
end
|
||||
|
||||
@@ -193,9 +192,9 @@ function M.display_image(file_path, bufnr, max_width, max_height)
|
||||
'File format is not supported for image preview.',
|
||||
'File: ' .. vim.fn.fnamemodify(file_path, ':t'),
|
||||
}
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
|
||||
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
|
||||
vim.api.nvim_buf_set_lines(bufnr, image_content_starts_at_line, -1, false, error_lines)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
||||
return false
|
||||
end
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ end
|
||||
--- @param max_threads number|nil Maximum number of threads to use
|
||||
--- @param min_combo_count_override number|nil Optional override for min_combo_count (nil uses config)
|
||||
--- @param page_index number Page index (0-based: 0, 1, 2, ...)
|
||||
--- @param page_size number Items per page
|
||||
--- @param page_size number|nil Items per page (nil uses config default)
|
||||
--- @return table List of matching files
|
||||
function M.search_files_paginated(query, current_file, max_threads, min_combo_count_override, page_index, page_size)
|
||||
local config = require('fff.conf').get()
|
||||
|
||||
@@ -5,48 +5,22 @@ local location_utils = require('fff.location_utils')
|
||||
|
||||
local M = {}
|
||||
|
||||
-- Additional fallback for certain ambiguous filetypes which vim.filetype.match is not handling correctly
|
||||
local function get_fixed_filetype_detection(extension)
|
||||
local extension_map = {
|
||||
ts = 'typescript',
|
||||
tex = 'latex',
|
||||
md = 'markdown',
|
||||
txt = 'text',
|
||||
}
|
||||
|
||||
return extension_map[extension]
|
||||
end
|
||||
|
||||
local function detect_filetype(file_path)
|
||||
local has_plenary, plenary_filetype = pcall(require, 'plenary.filetype')
|
||||
if has_plenary then
|
||||
local detected = plenary_filetype.detect(file_path)
|
||||
if detected and detected ~= '' then return detected end
|
||||
end
|
||||
|
||||
local builtin_filetype = vim.filetype.match({ filename = file_path })
|
||||
if builtin_filetype and builtin_filetype ~= '' then return builtin_filetype end
|
||||
|
||||
local extension = vim.fn.fnamemodify(file_path, ':e'):lower()
|
||||
return get_fixed_filetype_detection(extension)
|
||||
end
|
||||
|
||||
local function set_buffer_lines(bufnr, lines)
|
||||
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
|
||||
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
|
||||
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
|
||||
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
||||
end
|
||||
|
||||
local function append_buffer_lines(bufnr, lines)
|
||||
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
|
||||
if not lines or #lines == 0 then return end
|
||||
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
|
||||
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
|
||||
local current_lines = vim.api.nvim_buf_line_count(bufnr)
|
||||
vim.api.nvim_buf_set_lines(bufnr, current_lines, current_lines, false, lines)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
||||
end
|
||||
|
||||
local function find_existing_buffer(file_path)
|
||||
@@ -183,7 +157,7 @@ end
|
||||
-- Forward declaration for ensure_content_loaded_async (used in read_file_streaming_async callback)
|
||||
local ensure_content_loaded_async
|
||||
|
||||
local function read_file_streaming_async(file_path, bufnr, callback)
|
||||
local function read_file_streaming_async(file_path, callback)
|
||||
local generation = M.state.preview_generation
|
||||
|
||||
init_dynamic_loading_async(file_path, function(success, error_msg)
|
||||
@@ -241,6 +215,13 @@ end
|
||||
ensure_content_loaded_async = function(target_line)
|
||||
if not M.state.bufnr or not vim.api.nvim_buf_is_valid(M.state.bufnr) then return end
|
||||
if not M.state.has_more_content or M.state.is_loading then return end
|
||||
-- Guard against missing file handle: without it load_next_chunk_async returns
|
||||
-- synchronously with empty data, which triggers apply_location_highlighting
|
||||
-- -> ensure_content_loaded_async again, causing infinite recursion (stack overflow).
|
||||
if not M.state.file_operation then
|
||||
M.state.has_more_content = false
|
||||
return
|
||||
end
|
||||
|
||||
local current_buffer_lines = vim.api.nvim_buf_line_count(M.state.bufnr)
|
||||
local buffer_needed = target_line + 50
|
||||
@@ -278,7 +259,10 @@ ensure_content_loaded_async = function(target_line)
|
||||
M.apply_location_highlighting(M.state.bufnr)
|
||||
end
|
||||
else
|
||||
-- EOF with no additional data — apply highlighting with whatever we have
|
||||
-- EOF with no additional data — mark loading as finished to prevent
|
||||
-- apply_location_highlighting -> ensure_content_loaded_async recursion,
|
||||
-- then apply highlighting with whatever content we have.
|
||||
M.state.has_more_content = false
|
||||
M.apply_location_highlighting(M.state.bufnr)
|
||||
end
|
||||
end)
|
||||
@@ -288,8 +272,8 @@ local function link_buffer_content(source_bufnr, target_bufnr)
|
||||
local lines = vim.api.nvim_buf_get_lines(source_bufnr, 0, -1, false)
|
||||
set_buffer_lines(target_bufnr, lines)
|
||||
|
||||
local source_ft = vim.api.nvim_buf_get_option(source_bufnr, 'filetype')
|
||||
if source_ft ~= '' then vim.api.nvim_buf_set_option(target_bufnr, 'filetype', source_ft) end
|
||||
local source_ft = vim.api.nvim_get_option_value('filetype', { buf = source_bufnr })
|
||||
if source_ft ~= '' then vim.api.nvim_set_option_value('filetype', source_ft, { buf = target_bufnr }) end
|
||||
|
||||
M.state.has_more_content = false
|
||||
M.state.total_file_lines = #lines
|
||||
@@ -312,7 +296,7 @@ M.state = {
|
||||
loading_chunk_size = 1000,
|
||||
is_loading = false,
|
||||
has_more_content = true,
|
||||
file_handle = nil,
|
||||
file_handle = nil, ---@type uv.uv_fs_t|nil
|
||||
file_operation = nil, -- Ongoing file operation: {fd?: any, file_path?: string, position?: number}
|
||||
location = nil, -- Current location data for highlighting
|
||||
location_namespace = nil, -- Namespace for location highlighting
|
||||
@@ -331,9 +315,8 @@ end
|
||||
|
||||
--- Check if file is too big for initial preview (inspired by snacks.nvim)
|
||||
--- @param file_path string Path to the file
|
||||
--- @param bufnr number|nil Buffer number to check (unused with dynamic loading)
|
||||
--- @return boolean True if file is too big for initial preview
|
||||
function M.is_big_file(file_path, bufnr)
|
||||
function M.is_big_file(file_path)
|
||||
-- Only check file size for early detection - no line limits with dynamic loading
|
||||
local stat = vim.uv.fs_stat(file_path)
|
||||
if stat and stat.size > M.config.max_size then return true end
|
||||
@@ -358,7 +341,7 @@ function M.get_file_info(file_path)
|
||||
}
|
||||
|
||||
info.extension = vim.fn.fnamemodify(file_path, ':e'):lower()
|
||||
info.filetype = detect_filetype(file_path) or 'text'
|
||||
info.filetype = utils.detect_filetype(file_path) or 'text'
|
||||
info.size_formatted = utils.format_file_size(info.size)
|
||||
info.modified_formatted = os.date('%Y-%m-%d %H:%M:%S', info.modified)
|
||||
info.accessed_formatted = os.date('%Y-%m-%d %H:%M:%S', info.accessed)
|
||||
@@ -369,7 +352,7 @@ end
|
||||
--- Create file info content without custom borders
|
||||
--- @param file table File information from search results
|
||||
--- @param info table File system information
|
||||
--- @param file_index number Index of the file in search results (for score lookup)
|
||||
--- @param file_index number|nil Index of the file in search results (for score lookup)
|
||||
--- @return table Lines for the file info content
|
||||
function M.create_file_info_content(file, info, file_index)
|
||||
local lines = {}
|
||||
@@ -474,7 +457,7 @@ end
|
||||
--- @return boolean Success status
|
||||
function M.preview_file(file_path, bufnr)
|
||||
-- Early size detection to prevent memory issues
|
||||
if M.is_big_file(file_path, bufnr) then
|
||||
if M.is_big_file(file_path) then
|
||||
local info = M.get_file_info(file_path)
|
||||
local lines = {
|
||||
'File too large for preview',
|
||||
@@ -501,10 +484,12 @@ function M.preview_file(file_path, bufnr)
|
||||
if success then
|
||||
local file_config = M.get_file_config(file_path)
|
||||
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'readonly', true)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'buftype', 'nofile')
|
||||
vim.api.nvim_buf_set_option(bufnr, 'wrap', file_config.wrap_lines or M.config.wrap_lines)
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('readonly', true, { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr })
|
||||
if M.state.winid and vim.api.nvim_win_is_valid(M.state.winid) then
|
||||
vim.api.nvim_set_option_value('wrap', file_config.wrap_lines or M.config.wrap_lines, { win = M.state.winid })
|
||||
end
|
||||
|
||||
M.state.scroll_offset = 0
|
||||
|
||||
@@ -522,7 +507,7 @@ function M.preview_file(file_path, bufnr)
|
||||
M.state.bufnr = bufnr
|
||||
local generation = M.state.preview_generation
|
||||
|
||||
read_file_streaming_async(file_path, bufnr, function(content, err, loading_more)
|
||||
read_file_streaming_async(file_path, function(content, err, loading_more)
|
||||
if M.state.preview_generation ~= generation then
|
||||
-- Preview moved on to a different file, discard
|
||||
cleanup_file_operation()
|
||||
@@ -544,11 +529,13 @@ function M.preview_file(file_path, bufnr)
|
||||
set_buffer_lines(bufnr, content)
|
||||
|
||||
local file_config = M.get_file_config(file_path)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'filetype', info.filetype)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'readonly', true)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'buftype', 'nofile')
|
||||
vim.api.nvim_buf_set_option(bufnr, 'wrap', file_config.wrap_lines or M.config.wrap_lines)
|
||||
vim.api.nvim_set_option_value('filetype', info.filetype, { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('readonly', true, { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr })
|
||||
if M.state.winid and vim.api.nvim_win_is_valid(M.state.winid) then
|
||||
vim.api.nvim_set_option_value('wrap', file_config.wrap_lines or M.config.wrap_lines, { win = M.state.winid })
|
||||
end
|
||||
|
||||
M.state.content_height = #content
|
||||
M.state.scroll_offset = 0
|
||||
@@ -576,9 +563,9 @@ function M.preview_binary_file(file_path, bufnr)
|
||||
local lines = {}
|
||||
|
||||
set_buffer_lines(bufnr, lines)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'filetype', 'text')
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'readonly', true)
|
||||
vim.api.nvim_set_option_value('filetype', 'text', { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('readonly', true, { buf = bufnr })
|
||||
|
||||
if vim.fn.executable('file') == 1 then
|
||||
local cmd = { 'file', '-b', file_path }
|
||||
@@ -629,7 +616,7 @@ end
|
||||
function M.get_file_config(file_path)
|
||||
if not M.config or not M.config.filetypes then return {} end
|
||||
|
||||
local filetype = detect_filetype(file_path) or 'text'
|
||||
local filetype = utils.detect_filetype(file_path) or 'text'
|
||||
return M.config.filetypes[filetype] or {}
|
||||
end
|
||||
|
||||
@@ -645,6 +632,7 @@ function M.preview(file_path, bufnr, location, is_binary)
|
||||
M.state.preview_generation = M.state.preview_generation + 1
|
||||
|
||||
if M.state.file_handle then
|
||||
---@diagnostic disable-next-line: undefined-field
|
||||
M.state.file_handle:close()
|
||||
M.state.file_handle = nil
|
||||
end
|
||||
@@ -663,10 +651,7 @@ function M.preview(file_path, bufnr, location, is_binary)
|
||||
|
||||
if not M.state.winid or not vim.api.nvim_win_is_valid(M.state.winid) then return false end
|
||||
|
||||
local win_width = vim.api.nvim_win_get_width(M.state.winid) - 2
|
||||
local win_height = vim.api.nvim_win_get_height(M.state.winid) - 2
|
||||
|
||||
return image.display_image(file_path, bufnr, win_width, win_height)
|
||||
return image.display_image(file_path, bufnr)
|
||||
elseif is_binary then
|
||||
return M.preview_binary_file(file_path, bufnr)
|
||||
else
|
||||
@@ -744,10 +729,10 @@ function M.update_file_info_buffer(file, bufnr, file_index)
|
||||
end
|
||||
set_buffer_lines(bufnr, file_info_lines)
|
||||
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'readonly', true)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'buftype', 'nofile')
|
||||
vim.api.nvim_buf_set_option(bufnr, 'wrap', false)
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('readonly', true, { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('wrap', false, { buf = bufnr })
|
||||
|
||||
return true
|
||||
end
|
||||
@@ -787,10 +772,10 @@ function M.clear_buffer(bufnr)
|
||||
|
||||
pcall(vim.treesitter.stop, bufnr)
|
||||
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'filetype', '')
|
||||
vim.api.nvim_buf_set_option(bufnr, 'syntax', '')
|
||||
vim.api.nvim_buf_set_option(bufnr, 'buftype', 'nofile')
|
||||
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('filetype', '', { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('syntax', '', { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr })
|
||||
|
||||
set_buffer_lines(bufnr, {})
|
||||
end
|
||||
@@ -829,7 +814,6 @@ function M.apply_location_highlighting(bufnr)
|
||||
|
||||
if not M.state.location then return end
|
||||
|
||||
-- Apply highlighting
|
||||
location_utils.highlight_location(bufnr, M.state.location, M.state.location_namespace)
|
||||
|
||||
if M.state.winid and vim.api.nvim_win_is_valid(M.state.winid) then
|
||||
|
||||
+47
-55
@@ -2,30 +2,6 @@
|
||||
--- Simple renderer for file items with 2 functions: render_line and apply_highlights
|
||||
local M = {}
|
||||
|
||||
--- Render Context passed to renderer functions
|
||||
--- @class RenderContext
|
||||
--- @field config table User configuration from conf.get()
|
||||
--- @field items table[] Array of file items being rendered
|
||||
--- @field cursor number Current cursor position (1-based index into items)
|
||||
--- @field win_height number Window height in lines
|
||||
--- @field win_width number Window width in columns
|
||||
--- @field max_path_width number Maximum width for file paths
|
||||
--- @field debug_enabled boolean Whether debug mode is enabled (shows frecency scores)
|
||||
--- @field prompt_position string Prompt position: 'top' or 'bottom'
|
||||
--- @field has_combo boolean Whether combo boost is active
|
||||
--- @field combo_header_line string Formatted combo header line (if has_combo)
|
||||
--- @field combo_header_text_len number Length of combo header text (if has_combo)
|
||||
--- @field combo_item_index number Index of item with combo (usually 1)
|
||||
--- @field display_start number Start index for displayed items
|
||||
--- @field display_end number End index for displayed items
|
||||
--- @field iter_start number Iteration start (may differ from display_start for bottom prompt)
|
||||
--- @field iter_end number Iteration end (may differ from display_end for bottom prompt)
|
||||
--- @field iter_step number Iteration step (1 for top prompt, -1 for bottom prompt)
|
||||
--- @field format_file_display fun(item: table, max_width: number): string, string Helper function to format filename and dir path
|
||||
--- @field selected_files table<string, boolean> Map of selected file paths
|
||||
--- @field query string Current search query
|
||||
--- @field renderer table|nil Custom renderer (if provided via opts)
|
||||
|
||||
--- File Item structure from Rust
|
||||
--- @class FileItem
|
||||
--- @field path string Absolute file path
|
||||
@@ -37,28 +13,23 @@ local M = {}
|
||||
--- @field total_frecency_score number Total frecency score
|
||||
--- @field access_frecency_score number Access-based frecency score
|
||||
--- @field modification_frecency_score number Modification-based frecency score
|
||||
--- @field git_status number|nil Git status enum (if file is in git repo)
|
||||
|
||||
--- Renderer Interface:
|
||||
--- @field render_line fun(item: FileItem, ctx: RenderContext, item_idx: number): string[] Returns array of line strings
|
||||
--- @field apply_highlights fun(item: FileItem, ctx: RenderContext, item_idx: number, buf: number, ns_id: number, line_idx: number, line_content: string): nil Applies highlights to the rendered line
|
||||
--- @field git_status string|nil Git status string (e.g. 'modified', 'untracked') if file is in git repo
|
||||
--- internal:
|
||||
--- @field _has_group_header boolean Internal flag for render_line to indicate if this item has a combo header line (not from Rust)
|
||||
|
||||
--- Render a file item line
|
||||
--- @param item FileItem File item from Rust
|
||||
--- @param ctx RenderContext Render context with all state
|
||||
--- @param ctx ListRenderContext Render context with all state
|
||||
--- @param item_idx number Item index (1-based)
|
||||
--- @return string[] Array of line strings (1 or 2 lines if combo)
|
||||
function M.render_line(item, ctx, item_idx)
|
||||
local icons = require('fff.file_picker.icons')
|
||||
local lines = {}
|
||||
|
||||
-- Check if this should have combo header (first item with combo boost)
|
||||
local has_combo = item_idx == 1 and ctx.has_combo and ctx.combo_header_line
|
||||
|
||||
if has_combo then table.insert(lines, ctx.combo_header_line) end
|
||||
|
||||
-- Get icon
|
||||
local icon, icon_hl_group = icons.get_icon(item.name, item.extension, false)
|
||||
local icon, _ = icons.get_icon(item.name, item.extension, false)
|
||||
|
||||
-- Build frecency indicator (debug mode only)
|
||||
local frecency = ''
|
||||
@@ -72,7 +43,7 @@ function M.render_line(item, ctx, item_idx)
|
||||
if mod >= 6 then
|
||||
indicator = '🔥'
|
||||
elseif access >= 4 then
|
||||
indicator = '⭐'
|
||||
indicator = '⭐️'
|
||||
elseif total >= 3 then
|
||||
indicator = '✨'
|
||||
elseif total >= 1 then
|
||||
@@ -100,7 +71,7 @@ end
|
||||
|
||||
--- Apply highlights to a rendered line
|
||||
--- @param item FileItem File item from Rust
|
||||
--- @param ctx RenderContext Render context with all state
|
||||
--- @param ctx ListRenderContext Render context with all state
|
||||
--- @param item_idx number Item index (1-based)
|
||||
--- @param buf number Buffer handle
|
||||
--- @param ns_id number Namespace ID
|
||||
@@ -126,7 +97,7 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
|
||||
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
|
||||
end_col = 0,
|
||||
end_row = line_idx,
|
||||
hl_group = ctx.config.hl.active_file,
|
||||
hl_group = ctx.config.hl.cursor,
|
||||
hl_eol = true,
|
||||
priority = 100,
|
||||
})
|
||||
@@ -135,7 +106,13 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
|
||||
-- 2. Icon
|
||||
if icon and icon_hl_group and vim.fn.strdisplaywidth(icon) > 0 then
|
||||
local icon_hl = is_current_file and 'Comment' or icon_hl_group
|
||||
vim.api.nvim_buf_add_highlight(buf, ns_id, icon_hl, line_idx - 1, 0, vim.fn.strdisplaywidth(icon))
|
||||
vim.api.nvim_buf_set_extmark(
|
||||
buf,
|
||||
ns_id,
|
||||
line_idx - 1,
|
||||
0,
|
||||
{ end_col = vim.fn.strdisplaywidth(icon), hl_group = icon_hl }
|
||||
)
|
||||
end
|
||||
|
||||
-- 3. Git text color (filename)
|
||||
@@ -143,15 +120,27 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
|
||||
local git_text_hl = item.git_status and git_utils.get_text_highlight(item.git_status) or nil
|
||||
if git_text_hl and git_text_hl ~= '' and not is_current_file then
|
||||
local filename_start = #icon + 1
|
||||
vim.api.nvim_buf_add_highlight(buf, ns_id, git_text_hl, line_idx - 1, filename_start, filename_start + #filename)
|
||||
vim.api.nvim_buf_set_extmark(
|
||||
buf,
|
||||
ns_id,
|
||||
line_idx - 1,
|
||||
filename_start,
|
||||
{ end_col = filename_start + #filename, hl_group = git_text_hl }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
-- 4. Frecency indicator
|
||||
if ctx.debug_enabled then
|
||||
local start_pos, end_pos = line_content:find('[⭐🔥✨•]%d+')
|
||||
if start_pos then
|
||||
vim.api.nvim_buf_add_highlight(buf, ns_id, ctx.config.hl.frecency, line_idx - 1, start_pos - 1, end_pos)
|
||||
local start_pos, end_pos = line_content:find('[⭐️🔥✨•]%d+')
|
||||
if start_pos and end_pos then
|
||||
vim.api.nvim_buf_set_extmark(
|
||||
buf,
|
||||
ns_id,
|
||||
line_idx - 1,
|
||||
start_pos - 1,
|
||||
{ end_col = end_pos, hl_group = ctx.config.hl.frecency }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -161,22 +150,26 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
|
||||
if icon then
|
||||
prefix_len = prefix_len + #icon + 1 -- if icon add icon bytes + space
|
||||
end
|
||||
vim.api.nvim_buf_add_highlight(
|
||||
vim.api.nvim_buf_set_extmark(
|
||||
buf,
|
||||
ns_id,
|
||||
ctx.config.hl.directory_path or 'Comment',
|
||||
line_idx - 1,
|
||||
prefix_len,
|
||||
prefix_len + #dir_path
|
||||
{ end_col = prefix_len + #dir_path, hl_group = ctx.config.hl.directory_path }
|
||||
)
|
||||
end
|
||||
|
||||
-- 6. Current file
|
||||
if is_current_file then
|
||||
if not is_cursor then vim.api.nvim_buf_add_highlight(buf, ns_id, 'Comment', line_idx - 1, 0, -1) end
|
||||
local virt_text_hl = is_cursor and ctx.config.hl.active_file or 'Comment'
|
||||
local hl
|
||||
if is_cursor then
|
||||
hl = ctx.config.hl.cursor
|
||||
else
|
||||
hl = 'Comment'
|
||||
end
|
||||
|
||||
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
|
||||
virt_text = { { ' (current)', virt_text_hl } },
|
||||
virt_text = { { ' ' .. ctx.config.file_picker.current_file_label, hl } },
|
||||
virt_text_pos = 'right_align',
|
||||
})
|
||||
end
|
||||
@@ -187,10 +180,10 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
|
||||
local border_hl
|
||||
|
||||
if is_cursor then
|
||||
local base_hl = git_utils.get_border_highlight(item.git_status)
|
||||
local base_hl = git_utils.get_border_highlight_selected(item.git_status)
|
||||
if base_hl and base_hl ~= '' then
|
||||
local border_fg = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID(base_hl)), 'fg')
|
||||
local cursor_bg = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID(ctx.config.hl.active_file)), 'bg')
|
||||
local cursor_bg = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID(ctx.config.hl.cursor)), 'bg')
|
||||
local temp_hl_name = 'FFFGitBorderSelected_' .. item_idx
|
||||
if border_fg ~= '' and cursor_bg ~= '' then
|
||||
vim.api.nvim_set_hl(0, temp_hl_name, { fg = border_fg, bg = cursor_bg })
|
||||
@@ -199,7 +192,7 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
|
||||
border_hl = git_utils.get_border_highlight_selected(item.git_status)
|
||||
end
|
||||
else
|
||||
border_hl = ctx.config.hl.active_file
|
||||
border_hl = ctx.config.hl.cursor
|
||||
end
|
||||
else
|
||||
border_hl = git_utils.get_border_highlight(item.git_status)
|
||||
@@ -215,7 +208,7 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
|
||||
elseif is_cursor then
|
||||
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
|
||||
sign_text = ' ',
|
||||
sign_hl_group = ctx.config.hl.active_file,
|
||||
sign_hl_group = ctx.config.hl.cursor,
|
||||
priority = 1000,
|
||||
})
|
||||
end
|
||||
@@ -234,13 +227,12 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
|
||||
if ctx.query and ctx.query ~= '' then
|
||||
local match_start, match_end = string.find(line_content, ctx.query, 1)
|
||||
if match_start and match_end then
|
||||
vim.api.nvim_buf_add_highlight(
|
||||
vim.api.nvim_buf_set_extmark(
|
||||
buf,
|
||||
ns_id,
|
||||
ctx.config.hl.matched or 'IncSearch',
|
||||
line_idx - 1,
|
||||
match_start - 1,
|
||||
match_end
|
||||
{ end_col = match_end, hl_group = ctx.config.hl.matched or 'IncSearch' }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -109,20 +109,20 @@ function M.setup_highlights()
|
||||
vim.cmd([[
|
||||
" Symbol highlights
|
||||
highlight default FFFGitStaged guifg=#10B981 ctermfg=2
|
||||
highlight default FFFGitModified guifg=#F59E0B ctermfg=3
|
||||
highlight default FFFGitModified guifg=#F59E0B ctermfg=3
|
||||
highlight default FFFGitDeleted guifg=#EF4444 ctermfg=1
|
||||
highlight default FFFGitRenamed guifg=#8B5CF6 ctermfg=5
|
||||
highlight default FFFGitUntracked guifg=#10B981 ctermfg=2
|
||||
highlight default FFFGitIgnored guifg=#4B5563 ctermfg=8
|
||||
|
||||
" Thin border highlights
|
||||
|
||||
" Thin border highlights
|
||||
highlight default FFFGitSignStaged guifg=#10B981 ctermfg=2
|
||||
highlight default FFFGitSignModified guifg=#F59E0B ctermfg=3
|
||||
highlight default FFFGitSignModified guifg=#F59E0B ctermfg=3
|
||||
highlight default FFFGitSignDeleted guifg=#EF4444 ctermfg=1
|
||||
highlight default FFFGitSignRenamed guifg=#8B5CF6 ctermfg=5
|
||||
highlight default FFFGitSignUntracked guifg=#10B981 ctermfg=2
|
||||
highlight default FFFGitSignIgnored guifg=#4B5563 ctermfg=8
|
||||
|
||||
|
||||
" Fallback to GitSigns highlights if they exist
|
||||
highlight default link FFFGitSignStaged GitSignsAdd
|
||||
highlight default link FFFGitSignModified GitSignsChange
|
||||
|
||||
@@ -6,27 +6,26 @@
|
||||
local M = {}
|
||||
|
||||
local file_renderer = require('fff.file_renderer')
|
||||
local tresitter_highlight = require('fff.treesitter_hl')
|
||||
|
||||
--- Build the file group header line using the same layout as file_renderer.
|
||||
--- Delegates to file_renderer.render_line (with combo disabled).
|
||||
---@param item table Grep match item (used for file metadata)
|
||||
---@param item FileItem Grep match
|
||||
---@param ctx table Render context
|
||||
---@return string The header line string
|
||||
local function build_group_header(item, ctx)
|
||||
-- file_renderer.render_line checks (item_idx == 1 and ctx.has_combo) for combo header.
|
||||
-- We pass item_idx=0 and disable has_combo to suppress combo logic entirely.
|
||||
local saved_has_combo = ctx.has_combo
|
||||
ctx.has_combo = false
|
||||
---@diagnostic disable-next-line: param-type-mismatch
|
||||
local lines = file_renderer.render_line(item, ctx, 0)
|
||||
ctx.has_combo = saved_has_combo
|
||||
ctx.has_combo = false -- never has a combo in grep
|
||||
return lines[1]
|
||||
end
|
||||
|
||||
--- Apply highlights for a file group header line using file_renderer.
|
||||
--- Delegates to file_renderer.apply_highlights so all highlight groups
|
||||
--- (icon, filename, git text color, directory path, git sign) match exactly.
|
||||
---@param item table Grep match item
|
||||
---@param ctx table Render context
|
||||
---@param item FileItem Grep match item
|
||||
---@param ctx ListRenderContext Render context
|
||||
---@param buf number Buffer handle
|
||||
---@param ns_id number Namespace id
|
||||
---@param row number 0-based row in buffer (header line)
|
||||
@@ -49,16 +48,14 @@ local function render_match_line(item, ctx)
|
||||
local location = string.format(':%d:%d', item.line_number or 0, (item.col or 0) + 1)
|
||||
local separator = ' '
|
||||
local raw_content = item.line_content or ''
|
||||
local leading_ws = #raw_content - #raw_content:match('^%s*(.*)')
|
||||
local content = vim.trim(raw_content)
|
||||
local content = raw_content
|
||||
|
||||
-- Indent + location + separator + content
|
||||
local indent = ' '
|
||||
-- Prefix is always ASCII so byte length == display width
|
||||
local prefix_display_w = #indent + #location + #separator
|
||||
local available = ctx.win_width - prefix_display_w - 2
|
||||
local was_truncated = false
|
||||
local content_display_w = vim.fn.strdisplaywidth(content)
|
||||
|
||||
if content_display_w > available and available > 3 then
|
||||
-- UTF-8 aware truncation: binary search for the character count that
|
||||
-- fits within the available display width (handles multi-byte and wide chars)
|
||||
@@ -73,15 +70,11 @@ local function render_match_line(item, ctx)
|
||||
end
|
||||
end
|
||||
content = vim.fn.strcharpart(content, 0, lo) .. '…'
|
||||
was_truncated = true
|
||||
end
|
||||
|
||||
local line = indent .. location .. separator .. content
|
||||
local padding = math.max(0, ctx.win_width - vim.fn.strdisplaywidth(line) + 5)
|
||||
|
||||
-- Store transient data on item for highlight pass
|
||||
item._leading_ws = leading_ws
|
||||
item._was_truncated = was_truncated
|
||||
item._match_indent = #indent
|
||||
item._content_offset = prefix_display_w -- byte offset where content starts in the line
|
||||
item._trimmed_content = content -- trimmed content string for treesitter parsing
|
||||
@@ -102,9 +95,6 @@ local function apply_match_highlights(item, ctx, item_idx, buf, ns_id, row, line
|
||||
local is_cursor = item_idx == ctx.cursor
|
||||
local indent = item._match_indent or 1
|
||||
|
||||
-- 1. Cursor line highlight — use hl_group + hl_eol instead of line_hl_group
|
||||
-- so that higher-priority inline extmarks (IncSearch match ranges at 200)
|
||||
-- cleanly override both fg and bg on the cursor line.
|
||||
if is_cursor then
|
||||
vim.api.nvim_buf_set_extmark(buf, ns_id, row, 0, {
|
||||
end_col = 0,
|
||||
@@ -143,16 +133,16 @@ local function apply_match_highlights(item, ctx, item_idx, buf, ns_id, row, line
|
||||
-- below IncSearch match ranges (200) so search matches take precedence.
|
||||
local content_start = sep_end
|
||||
if item._trimmed_content and item.name then
|
||||
local ts_hl = require('fff.treesitter_hl')
|
||||
-- Resolve language once per file group (cache on the render context)
|
||||
ctx._ts_lang_cache = ctx._ts_lang_cache or {}
|
||||
local lang = ctx._ts_lang_cache[item.name]
|
||||
if lang == nil then
|
||||
lang = ts_hl.lang_from_filename(item.name) or false
|
||||
lang = tresitter_highlight.lang_from_filename(item.name) or false
|
||||
ctx._ts_lang_cache[item.name] = lang
|
||||
end
|
||||
|
||||
if lang then
|
||||
local highlights = ts_hl.get_line_highlights(item._trimmed_content, lang)
|
||||
local highlights = tresitter_highlight.get_line_highlights(item._trimmed_content, lang)
|
||||
for _, hl in ipairs(highlights) do
|
||||
local hl_start = content_start + hl.col
|
||||
local hl_end = content_start + hl.end_col
|
||||
@@ -171,16 +161,14 @@ local function apply_match_highlights(item, ctx, item_idx, buf, ns_id, row, line
|
||||
-- Use extmarks with priority > cursor line (100) so IncSearch renders
|
||||
-- properly on the selected line instead of being overridden by CursorLine.
|
||||
if item.match_ranges then
|
||||
local leading_ws = item._leading_ws or 0
|
||||
for _, range in ipairs(item.match_ranges) do
|
||||
local raw_start = range[1] or 0
|
||||
local raw_end = range[2] or 0
|
||||
local adj_start = raw_start - leading_ws
|
||||
local adj_end = raw_end - leading_ws
|
||||
if adj_end > 0 then
|
||||
adj_start = math.max(0, adj_start)
|
||||
local hl_start = content_start + adj_start
|
||||
local hl_end = content_start + adj_end
|
||||
|
||||
if raw_end > 0 then
|
||||
raw_start = math.max(0, raw_start)
|
||||
local hl_start = content_start + raw_start
|
||||
local hl_end = content_start + raw_end
|
||||
if hl_start < #line_content and hl_end <= #line_content then
|
||||
pcall(vim.api.nvim_buf_set_extmark, buf, ns_id, row, hl_start, {
|
||||
end_col = hl_end,
|
||||
@@ -208,11 +196,10 @@ end
|
||||
--- Render a single item's lines (called by list_renderer's generate_item_lines).
|
||||
--- Returns 2 lines [header, match] for the first match of a file group,
|
||||
--- or 1 line [match] for subsequent matches in the same file.
|
||||
---@param item table Grep match item
|
||||
---@param item FileItem Grep match item
|
||||
---@param ctx table Render context
|
||||
---@param item_idx number 1-based item index
|
||||
---@return string[]
|
||||
function M.render_line(item, ctx, item_idx)
|
||||
function M.render_line(item, ctx)
|
||||
-- Track file grouping across the render pass via ctx
|
||||
-- ctx._grep_last_file is reset each render (ctx is fresh per render_list call)
|
||||
local is_new_group = (item.path ~= ctx._grep_last_file)
|
||||
@@ -221,8 +208,8 @@ function M.render_line(item, ctx, item_idx)
|
||||
local match_line = render_match_line(item, ctx)
|
||||
|
||||
if is_new_group then
|
||||
local header_line = build_group_header(item, ctx)
|
||||
item._has_group_header = true
|
||||
local header_line = build_group_header(item, ctx)
|
||||
return { header_line, match_line }
|
||||
else
|
||||
item._has_group_header = false
|
||||
@@ -233,8 +220,8 @@ end
|
||||
--- Apply highlights for rendered lines (called by list_renderer's apply_all_highlights).
|
||||
--- line_idx is the 1-based index of the item's LAST line (the match line).
|
||||
--- If the item has a group header, it's at line_idx - 1.
|
||||
---@param item table Grep match item
|
||||
---@param ctx table Render context
|
||||
---@param item FileItem Grep match item
|
||||
---@param ctx ListRenderContext Render context
|
||||
---@param item_idx number 1-based item index
|
||||
---@param buf number Buffer handle
|
||||
---@param ns_id number Namespace id
|
||||
|
||||
@@ -12,6 +12,7 @@ local fuzzy = require('fff.fuzzy')
|
||||
---@field total_files number Total indexed files
|
||||
---@field filtered_file_count number Total searchable files after filtering
|
||||
---@field next_file_offset number File offset to pass for the next page (0 = no more results)
|
||||
---@field regex_fallback_error string|nil Error message if regex compilation failed and search fell back to literal
|
||||
|
||||
local last_result = nil
|
||||
|
||||
|
||||
+28
-21
@@ -15,7 +15,7 @@
|
||||
local M = {}
|
||||
|
||||
--- @class ListRenderContext
|
||||
--- @field config table User configuration
|
||||
--- @field config FffConfig User configuration
|
||||
--- @field items table[] Array of data items to render
|
||||
--- @field cursor number Current cursor position (1-based index into items)
|
||||
--- @field win_height number Window height in lines
|
||||
@@ -37,6 +37,7 @@ local M = {}
|
||||
--- @field selected_files table<string, boolean> Selected file paths set
|
||||
--- @field mode string|nil Current mode (nil or 'grep')
|
||||
--- @field format_file_display function Helper for formatting file display
|
||||
--- @field suggestion_source string|nil Active cross-mode suggestion source ('grep' or 'files')
|
||||
|
||||
--- @class ItemLineMapping
|
||||
--- @field first number First buffer line (1-based) this item occupies
|
||||
@@ -54,7 +55,7 @@ local M = {}
|
||||
--- When cross-mode suggestions are active, a suggestion banner is prepended
|
||||
--- (for top prompt) or appended (for bottom prompt) so it always appears
|
||||
--- above the suggestion items visually.
|
||||
--- @param ctx ListRenderContext
|
||||
--- @param ctx table
|
||||
--- @return string[] lines Array of line strings
|
||||
--- @return table<number, ItemLineMapping> item_to_lines
|
||||
local function generate_item_lines(ctx)
|
||||
@@ -90,11 +91,9 @@ local function generate_item_lines(ctx)
|
||||
-- Renderer returns 1+ lines: virtual headers first, content line last.
|
||||
-- This contract is shared by file_renderer (combo header) and
|
||||
-- grep_renderer (file group header).
|
||||
---@diagnostic disable-next-line: param-type-mismatch
|
||||
local item_lines = renderer.render_line(item, ctx, i)
|
||||
|
||||
for _, line in ipairs(item_lines) do
|
||||
table.insert(lines, line)
|
||||
end
|
||||
vim.list_extend(lines, item_lines)
|
||||
|
||||
local item_end_line = #lines
|
||||
local virtual_count = item_end_line - item_start_line -- 0 if single line, 1 if header + content
|
||||
@@ -120,7 +119,7 @@ end
|
||||
--- Adjusts all line indices in item_to_lines accordingly.
|
||||
--- @param lines string[] Lines array (mutated)
|
||||
--- @param item_to_lines table<number, ItemLineMapping> Mapping (mutated)
|
||||
--- @param ctx ListRenderContext
|
||||
--- @param ctx table
|
||||
--- @return number padding_offset Number of empty lines prepended
|
||||
local function apply_bottom_padding(lines, item_to_lines, ctx)
|
||||
if ctx.prompt_position ~= 'bottom' then return 0 end
|
||||
@@ -151,7 +150,7 @@ end
|
||||
--- never a virtual header line.
|
||||
--- @param lines string[]
|
||||
--- @param item_to_lines table<number, ItemLineMapping>
|
||||
--- @param ctx ListRenderContext
|
||||
--- @param ctx table
|
||||
--- @param list_buf number Buffer handle
|
||||
--- @param list_win number Window handle
|
||||
--- @param ns_id number Namespace id
|
||||
@@ -163,9 +162,9 @@ local function update_buffer_and_cursor(lines, item_to_lines, ctx, list_buf, lis
|
||||
if cursor_item then cursor_line = cursor_item.last end
|
||||
end
|
||||
|
||||
vim.api.nvim_buf_set_option(list_buf, 'modifiable', true)
|
||||
vim.api.nvim_set_option_value('modifiable', true, { buf = list_buf })
|
||||
vim.api.nvim_buf_set_lines(list_buf, 0, -1, false, lines)
|
||||
vim.api.nvim_buf_set_option(list_buf, 'modifiable', false)
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = list_buf })
|
||||
|
||||
vim.api.nvim_buf_clear_namespace(list_buf, ns_id, 0, -1)
|
||||
|
||||
@@ -180,7 +179,7 @@ end
|
||||
--- header highlights internally via the item._has_group_header flag.
|
||||
--- @param lines string[]
|
||||
--- @param item_to_lines table<number, ItemLineMapping>
|
||||
--- @param ctx ListRenderContext
|
||||
--- @param ctx table
|
||||
--- @param list_buf number
|
||||
--- @param ns_id number
|
||||
local function apply_all_highlights(lines, item_to_lines, ctx, list_buf, ns_id)
|
||||
@@ -190,23 +189,24 @@ local function apply_all_highlights(lines, item_to_lines, ctx, list_buf, ns_id)
|
||||
for i = ctx.display_start, ctx.display_end do
|
||||
local item = ctx.items[i]
|
||||
local item_lines = item_to_lines[i]
|
||||
if not item_lines then goto continue end
|
||||
|
||||
-- The content line is always the last line in the mapping
|
||||
local line_idx = item_lines.last
|
||||
local line_content = lines[line_idx]
|
||||
if item_lines then
|
||||
-- The content line is always the last line in the mapping
|
||||
local line_idx = item_lines.last
|
||||
local line_content = lines[line_idx]
|
||||
|
||||
if not line_content then goto continue end
|
||||
|
||||
renderer.apply_highlights(item, ctx, i, list_buf, ns_id, line_idx, line_content)
|
||||
::continue::
|
||||
if line_content then
|
||||
---@diagnostic disable-next-line: param-type-mismatch
|
||||
renderer.apply_highlights(item, ctx, i, list_buf, ns_id, line_idx, line_content)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Render the full item list into the buffer.
|
||||
--- This is the main entry point — replaces the inline rendering in picker_ui.
|
||||
---
|
||||
--- @param ctx ListRenderContext Render context built by picker_ui
|
||||
--- @param ctx table Render context built by picker_ui
|
||||
--- @param list_buf number List buffer handle
|
||||
--- @param list_win number List window handle
|
||||
--- @param ns_id number Highlight namespace
|
||||
@@ -225,7 +225,14 @@ function M.render(ctx, list_buf, list_win, ns_id)
|
||||
for i = 0, #lines - 1 do
|
||||
local line = lines[i + 1]
|
||||
if line and line:match('^%s+No results found') then
|
||||
pcall(vim.api.nvim_buf_add_highlight, list_buf, ns_id, suggestion_hl, i, 0, -1)
|
||||
pcall(
|
||||
vim.api.nvim_buf_set_extmark,
|
||||
list_buf,
|
||||
ns_id,
|
||||
i,
|
||||
0,
|
||||
{ end_row = i + 1, end_col = 0, hl_group = suggestion_hl }
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
+8
-12
@@ -18,13 +18,7 @@ function M.find_files(opts)
|
||||
end
|
||||
|
||||
--- Live grep: search file contents in the current directory
|
||||
--- @param opts? table Optional configuration overrides
|
||||
--- @param opts.cwd? string Custom working directory
|
||||
--- @param opts.title? string Window title (default: "Live Grep")
|
||||
--- @param opts.prompt? string Input prompt text (default: "grep> ")
|
||||
--- @param opts.layout? table Layout overrides
|
||||
--- @param opts.grep? table Grep-specific overrides {max_file_size, smart_case, max_matches_per_file, modes}
|
||||
--- @param opts.grep.modes? table Available search modes and their cycling order (default: {'plain', 'regex', 'fuzzy'})
|
||||
--- @param opts? {cwd?: string, title?: string, prompt?: string, layout?: table, grep?: {max_file_size?: number, smart_case?: boolean, max_matches_per_file?: number, modes?: string[]}, query?: string} Optional configuration overrides
|
||||
function M.live_grep(opts)
|
||||
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
|
||||
if not picker_ok then
|
||||
@@ -37,16 +31,19 @@ function M.live_grep(opts)
|
||||
|
||||
local grep_config = vim.tbl_deep_extend('force', config.grep or {}, (opts and opts.grep) or {})
|
||||
|
||||
local picker_opts = vim.tbl_deep_extend('force', opts or {}, {
|
||||
title = (opts and opts.title) or 'Live Grep',
|
||||
local picker_opts = vim.tbl_deep_extend('force', {
|
||||
title = 'Live Grep',
|
||||
mode = 'grep',
|
||||
renderer = grep_renderer,
|
||||
grep_config = grep_config,
|
||||
})
|
||||
query = '',
|
||||
}, opts or {})
|
||||
|
||||
picker_ui.open(picker_opts)
|
||||
end
|
||||
|
||||
--- Changes the directory indexed by the file picker to the git root and opens the file picker
|
||||
--- @deprecated Use `find_files` instead
|
||||
function M.find_in_git_root()
|
||||
local fuzzy = require('fff.core').ensure_initialized()
|
||||
local ok, git_root = pcall(fuzzy.get_git_root)
|
||||
@@ -210,7 +207,6 @@ end
|
||||
--- is found and we are about to inline open it
|
||||
--- @param open_cb function|nil Optional callback function to execute after opening the file
|
||||
function M.open_file_under_cursor(open_cb)
|
||||
local filename = vim.fn.expand('<cfile>')
|
||||
local full_path_with_suffix = vim.fn.expand('<cWORD>')
|
||||
|
||||
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
|
||||
@@ -219,7 +215,7 @@ function M.open_file_under_cursor(open_cb)
|
||||
return
|
||||
end
|
||||
|
||||
picker_ui.open_with_callback(full_path_with_suffix, function(files, metadata, location, get_file_score)
|
||||
picker_ui.open_with_callback(full_path_with_suffix, function(files, _, location)
|
||||
if #files == 1 or require('fff.file_picker').get_file_score(1).exact_match then
|
||||
if open_cb and type(open_cb) == 'function' then open_cb(files[1].path) end
|
||||
vim.api.nvim_command(string.format('e %s', vim.fn.fnameescape(files[1].path)))
|
||||
|
||||
+403
-321
File diff suppressed because it is too large
Load Diff
@@ -38,7 +38,7 @@ function M.render(layout, config, list_win, pagination, prompt_position)
|
||||
-- rendering in a separate buffer to overflow the border
|
||||
if not scrollbar_exists then
|
||||
scrollbar_state.buf = vim.api.nvim_create_buf(false, true)
|
||||
vim.api.nvim_buf_set_option(scrollbar_state.buf, 'bufhidden', 'wipe')
|
||||
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = scrollbar_state.buf })
|
||||
|
||||
scrollbar_state.win = vim.api.nvim_open_win(scrollbar_state.buf, false, {
|
||||
relative = 'editor',
|
||||
@@ -52,7 +52,7 @@ function M.render(layout, config, list_win, pagination, prompt_position)
|
||||
})
|
||||
|
||||
local scrollbar_hl = string.format('Normal:%s', config.hl.border)
|
||||
vim.api.nvim_win_set_option(scrollbar_state.win, 'winhighlight', scrollbar_hl)
|
||||
vim.api.nvim_set_option_value('winhighlight', scrollbar_hl, { win = scrollbar_state.win })
|
||||
|
||||
scrollbar_state.ever_shown = true
|
||||
end
|
||||
@@ -83,9 +83,9 @@ function M.render(layout, config, list_win, pagination, prompt_position)
|
||||
end
|
||||
end
|
||||
|
||||
pcall(vim.api.nvim_buf_set_option, scrollbar_state.buf, 'modifiable', true)
|
||||
pcall(vim.api.nvim_set_option_value, 'modifiable', true, { buf = scrollbar_state.buf })
|
||||
pcall(vim.api.nvim_buf_set_lines, scrollbar_state.buf, 0, -1, false, lines)
|
||||
pcall(vim.api.nvim_buf_set_option, scrollbar_state.buf, 'modifiable', false)
|
||||
pcall(vim.api.nvim_set_option_value, 'modifiable', false, { buf = scrollbar_state.buf })
|
||||
|
||||
pcall(vim.api.nvim_buf_clear_namespace, scrollbar_state.buf, ns_id, 0, -1)
|
||||
if thumb_size > 0 then
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
--- Treesitter Highlight Extraction
|
||||
--- Extracts syntax highlights from a code string using treesitter.
|
||||
--- Uses a per-language scratch buffer pool to avoid repeated buffer creation.
|
||||
--- Results are returned as extmark-style tables { col, end_col, hl_group }.
|
||||
local utils = require('fff.utils')
|
||||
|
||||
local M = {}
|
||||
|
||||
--- Per-language scratch buffer cache
|
||||
@@ -33,11 +31,7 @@ end
|
||||
function M.lang_from_filename(filename)
|
||||
if not filename or filename == '' then return nil end
|
||||
|
||||
-- Use vim.filetype.match to get the filetype from the filename
|
||||
local ok, ft = pcall(vim.filetype.match, { filename = filename })
|
||||
if not ok or not ft then return nil end
|
||||
|
||||
-- Convert filetype to treesitter language
|
||||
local ft = utils.detect_filetype(filename) or 'text'
|
||||
local lang_ok, lang = pcall(vim.treesitter.language.get_lang, ft)
|
||||
if not lang_ok or not lang then lang = ft end
|
||||
|
||||
@@ -83,7 +77,7 @@ function M.get_line_highlights(text, lang)
|
||||
local query_ok, query = pcall(vim.treesitter.query.get, tree_lang, 'highlights')
|
||||
if not query_ok or not query then return end
|
||||
|
||||
for capture, node, metadata in query:iter_captures(root, buf, 0, 1) do
|
||||
for capture, node, _ in query:iter_captures(root, buf, 0, 1) do
|
||||
local name = query.captures[capture]
|
||||
if name and name ~= 'spell' and name ~= 'conceal' then
|
||||
local start_row, start_col, end_row, end_col = node:range()
|
||||
|
||||
+30
-1
@@ -17,6 +17,34 @@ function M.format_file_size(size)
|
||||
end
|
||||
end
|
||||
|
||||
local function get_fixed_filetype_detection(extension)
|
||||
local extension_map = {
|
||||
ts = 'typescript',
|
||||
tex = 'latex',
|
||||
md = 'markdown',
|
||||
txt = 'text',
|
||||
}
|
||||
|
||||
return extension_map[extension]
|
||||
end
|
||||
|
||||
--- Detect filetype with various fallbacks
|
||||
--- @param file_path string the filetype
|
||||
--- @return string detected filetype
|
||||
function M.detect_filetype(file_path)
|
||||
local has_plenary, plenary_filetype = pcall(require, 'plenary.filetype')
|
||||
if has_plenary then
|
||||
local detected = plenary_filetype.detect(file_path, {})
|
||||
if detected and detected ~= '' then return detected end
|
||||
end
|
||||
|
||||
local builtin_filetype = vim.filetype.match({ filename = file_path })
|
||||
if builtin_filetype and builtin_filetype ~= '' then return builtin_filetype end
|
||||
|
||||
local extension = vim.fn.fnamemodify(file_path, ':e'):lower()
|
||||
return get_fixed_filetype_detection(extension)
|
||||
end
|
||||
|
||||
--- Safely resolve a config value that can be either a static value or a function
|
||||
--- @param config_value any The config value (can be function or static value)
|
||||
--- @param terminal_width number Terminal width for function calls
|
||||
@@ -24,7 +52,7 @@ end
|
||||
--- @param validator function Function to validate the result
|
||||
--- @param fallback any Fallback value if function fails or returns invalid value
|
||||
--- @param error_context string Context for error messages
|
||||
--- @return any The resolved and validated value
|
||||
--- @return number The resolved and validated value
|
||||
function M.resolve_config_value(config_value, terminal_width, terminal_height, validator, fallback, error_context)
|
||||
if type(config_value) == 'function' then
|
||||
local success, result = pcall(config_value, terminal_width, terminal_height)
|
||||
@@ -38,6 +66,7 @@ function M.resolve_config_value(config_value, terminal_width, terminal_height, v
|
||||
return fallback
|
||||
end
|
||||
else
|
||||
if config_value == nil or not validator(config_value) then return fallback end
|
||||
return config_value
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,18 +11,24 @@ function M.get_triple()
|
||||
if os_name == 'darwin' then
|
||||
os_name = 'apple-darwin'
|
||||
elseif os_name == 'linux' then
|
||||
-- Detect if we're on musl or glibc
|
||||
local handle = io.popen('ldd --version 2>&1')
|
||||
if handle then
|
||||
local output = handle:read('*a')
|
||||
handle:close()
|
||||
if output and output:match('musl') then
|
||||
os_name = 'unknown-linux-musl'
|
||||
-- Detect Android/Termux before checking musl/glibc.
|
||||
-- Termux uses Bionic libc (not glibc or musl) and has no ldd.
|
||||
if os.getenv('TERMUX_VERSION') or os.getenv('ANDROID_ROOT') then
|
||||
os_name = 'linux-android'
|
||||
else
|
||||
-- Detect if we're on musl or glibc
|
||||
local handle = io.popen('ldd --version 2>&1')
|
||||
if handle then
|
||||
local output = handle:read('*a')
|
||||
handle:close()
|
||||
if output and output:match('musl') then
|
||||
os_name = 'unknown-linux-musl'
|
||||
else
|
||||
os_name = 'unknown-linux-gnu'
|
||||
end
|
||||
else
|
||||
os_name = 'unknown-linux-gnu'
|
||||
end
|
||||
else
|
||||
os_name = 'unknown-linux-gnu'
|
||||
end
|
||||
elseif os_name:match('windows') or os_name:match('mingw') or os_name:match('msys') then
|
||||
os_name = 'pc-windows-msvc'
|
||||
|
||||
@@ -120,7 +120,7 @@ Search file contents with SIMD-accelerated matching.
|
||||
```typescript
|
||||
interface GrepOptions {
|
||||
maxFileSize?: number; // Max file size in bytes (default: 10MB)
|
||||
maxMatchesPerFile?: number; // Max matches per file (default: 200)
|
||||
maxMatchesPerFile?: number; // Max matches per file (default: 200, set 0 to unlimited)
|
||||
smartCase?: boolean; // Case-insensitive if all lowercase (default: true)
|
||||
fileOffset?: number; // Pagination offset (default: 0)
|
||||
pageLimit?: number; // Max matches to return (default: 50)
|
||||
|
||||
@@ -36,7 +36,7 @@ function formatGitStatus(status: string): string {
|
||||
return `${RED}D${RESET}`;
|
||||
case "renamed":
|
||||
return `${BLUE}R${RESET}`;
|
||||
case "clear":
|
||||
case "clean":
|
||||
case "current":
|
||||
return `${DIM} ${RESET}`;
|
||||
default:
|
||||
@@ -111,22 +111,24 @@ async function main() {
|
||||
}
|
||||
|
||||
console.log(`${DIM}Initializing index for: ${directory}${RESET}`);
|
||||
const initResult = FileFinder.init({
|
||||
const createResult = FileFinder.create({
|
||||
basePath: directory,
|
||||
warmupMmapCache: true,
|
||||
});
|
||||
|
||||
if (!initResult.ok) {
|
||||
console.error(`${RED}Init failed: ${initResult.error}${RESET}`);
|
||||
if (!createResult.ok) {
|
||||
console.error(`${RED}Init failed: ${createResult.error}${RESET}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const finder = createResult.value;
|
||||
|
||||
// Wait for scan
|
||||
process.stdout.write(`${DIM}Scanning files...${RESET}`);
|
||||
const startTime = Date.now();
|
||||
|
||||
while (FileFinder.isScanning()) {
|
||||
const progress = FileFinder.getScanProgress();
|
||||
while (finder.isScanning()) {
|
||||
const progress = finder.getScanProgress();
|
||||
if (progress.ok) {
|
||||
process.stdout.write(
|
||||
`\r${DIM}Scanning files... ${progress.value.scannedFilesCount}${RESET} `
|
||||
@@ -136,7 +138,7 @@ async function main() {
|
||||
}
|
||||
|
||||
const scanTime = Date.now() - startTime;
|
||||
const finalProgress = FileFinder.getScanProgress();
|
||||
const finalProgress = finder.getScanProgress();
|
||||
const totalFiles = finalProgress.ok
|
||||
? finalProgress.value.scannedFilesCount
|
||||
: 0;
|
||||
@@ -166,7 +168,7 @@ async function main() {
|
||||
rl.question(`${CYAN}grep[${modeLabel}]>${RESET} `, (query) => {
|
||||
if (query.toLowerCase() === "q" || query.toLowerCase() === "quit") {
|
||||
console.log(`\n${DIM}Goodbye!${RESET}`);
|
||||
FileFinder.destroy();
|
||||
finder.destroy();
|
||||
rl.close();
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -196,7 +198,7 @@ async function main() {
|
||||
}
|
||||
|
||||
const searchStart = Date.now();
|
||||
const result = FileFinder.liveGrep(query, {
|
||||
const result = finder.liveGrep(query, {
|
||||
mode: currentMode,
|
||||
pageLimit: 30,
|
||||
timeBudgetMs: 5000,
|
||||
|
||||
@@ -35,7 +35,7 @@ function formatGitStatus(status: string): string {
|
||||
return `${RED}D${RESET}`;
|
||||
case "renamed":
|
||||
return `${BLUE}R${RESET}`;
|
||||
case "clear":
|
||||
case "clean":
|
||||
case "current":
|
||||
return `${DIM} ${RESET}`;
|
||||
default:
|
||||
@@ -84,24 +84,26 @@ async function main() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Initialize
|
||||
// Create instance
|
||||
console.log(`${DIM}Initializing index for: ${targetDir}${RESET}`);
|
||||
const initResult = FileFinder.init({
|
||||
const createResult = FileFinder.create({
|
||||
basePath: targetDir,
|
||||
});
|
||||
|
||||
if (!initResult.ok) {
|
||||
console.error(`${RED}Init failed: ${initResult.error}${RESET}`);
|
||||
if (!createResult.ok) {
|
||||
console.error(`${RED}Init failed: ${createResult.error}${RESET}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const finder = createResult.value;
|
||||
|
||||
// Wait for scan with progress
|
||||
process.stdout.write(`${DIM}Scanning files...${RESET}`);
|
||||
const startTime = Date.now();
|
||||
let lastCount = 0;
|
||||
|
||||
while (FileFinder.isScanning()) {
|
||||
const progress = FileFinder.getScanProgress();
|
||||
while (finder.isScanning()) {
|
||||
const progress = finder.getScanProgress();
|
||||
if (progress.ok && progress.value.scannedFilesCount !== lastCount) {
|
||||
lastCount = progress.value.scannedFilesCount;
|
||||
process.stdout.write(`\r${DIM}Scanning files... ${lastCount}${RESET} `);
|
||||
@@ -110,13 +112,13 @@ async function main() {
|
||||
}
|
||||
|
||||
const scanTime = Date.now() - startTime;
|
||||
const finalProgress = FileFinder.getScanProgress();
|
||||
const finalProgress = finder.getScanProgress();
|
||||
const totalFiles = finalProgress.ok ? finalProgress.value.scannedFilesCount : 0;
|
||||
|
||||
console.log(`\r${GREEN}✓${RESET} Indexed ${BOLD}${totalFiles}${RESET} files in ${scanTime}ms\n`);
|
||||
|
||||
// Show index info
|
||||
const health = FileFinder.healthCheck();
|
||||
const health = finder.healthCheck();
|
||||
if (health.ok) {
|
||||
console.log(`${DIM}Version:${RESET} ${health.value.version}`);
|
||||
console.log(`${DIM}Base path:${RESET} ${health.value.filePicker.basePath}`);
|
||||
@@ -137,13 +139,13 @@ async function main() {
|
||||
rl.question(`${CYAN}search>${RESET} `, (query) => {
|
||||
if (query.toLowerCase() === "q" || query.toLowerCase() === "quit") {
|
||||
console.log(`\n${DIM}Goodbye!${RESET}`);
|
||||
FileFinder.destroy();
|
||||
finder.destroy();
|
||||
rl.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const searchStart = Date.now();
|
||||
const result = FileFinder.search(query, { pageSize: 15 });
|
||||
const result = finder.search(query, { pageSize: 15 });
|
||||
const searchTime = Date.now() - searchStart;
|
||||
|
||||
if (!result.ok) {
|
||||
|
||||
@@ -12,7 +12,12 @@ import { existsSync, mkdirSync, writeFileSync, chmodSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createRequire } from "node:module";
|
||||
import { getTriple, getLibExtension, getLibFilename, getNpmPackageName } from "./platform";
|
||||
import {
|
||||
getTriple,
|
||||
getLibExtension,
|
||||
getLibFilename,
|
||||
getNpmPackageName,
|
||||
} from "./platform";
|
||||
|
||||
const GITHUB_REPO = "dmtrKovalenko/fff.nvim";
|
||||
|
||||
@@ -107,13 +112,30 @@ export function getDevBinaryPath(): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the binary, checking all known locations in priority order:
|
||||
* 1. Platform-specific npm package (primary distribution method)
|
||||
* 2. Local bin/ directory (legacy postinstall download)
|
||||
* 3. Local dev build (cargo build output)
|
||||
*/
|
||||
function isDevWorkspace(): boolean {
|
||||
const packageDir = getPackageDir();
|
||||
const workspaceRoot = join(packageDir, "..", "..");
|
||||
return existsSync(join(workspaceRoot, "Cargo.toml"));
|
||||
}
|
||||
|
||||
export function findBinary(): string | null {
|
||||
if (isDevWorkspace()) {
|
||||
// 1. Local bin/ directory (populated by `make prepare-bun`)
|
||||
const installedPath = getBinaryPath();
|
||||
if (existsSync(installedPath)) return installedPath;
|
||||
|
||||
// 2. Local dev build (target/release or target/debug)
|
||||
const devPath = getDevBinaryPath();
|
||||
if (devPath) return devPath;
|
||||
|
||||
// 3. Fallback to npm package
|
||||
const npmPath = resolveFromNpmPackage();
|
||||
if (npmPath) return npmPath;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Production: npm package first
|
||||
// 1. Try platform-specific npm package first
|
||||
const npmPath = resolveFromNpmPackage();
|
||||
if (npmPath) return npmPath;
|
||||
@@ -182,10 +204,10 @@ export async function downloadBinary(tag?: string): Promise<string> {
|
||||
*/
|
||||
async function fetchLatestReleaseTag(): Promise<string> {
|
||||
const url = `https://api.github.com/repos/${GITHUB_REPO}/releases/latest`;
|
||||
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"Accept": "application/vnd.github.v3+json",
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
"User-Agent": "fff-bun-client",
|
||||
},
|
||||
});
|
||||
@@ -194,7 +216,7 @@ async function fetchLatestReleaseTag(): Promise<string> {
|
||||
const allReleasesUrl = `https://api.github.com/repos/${GITHUB_REPO}/releases`;
|
||||
const allResponse = await fetch(allReleasesUrl, {
|
||||
headers: {
|
||||
"Accept": "application/vnd.github.v3+json",
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
"User-Agent": "fff-bun-client",
|
||||
},
|
||||
});
|
||||
@@ -203,7 +225,7 @@ async function fetchLatestReleaseTag(): Promise<string> {
|
||||
throw new Error(`Failed to fetch releases: ${allResponse.status}`);
|
||||
}
|
||||
|
||||
const releases = await allResponse.json() as Array<{ tag_name: string }>;
|
||||
const releases = (await allResponse.json()) as Array<{ tag_name: string }>;
|
||||
if (releases.length === 0) {
|
||||
throw new Error("No releases found");
|
||||
}
|
||||
@@ -211,7 +233,7 @@ async function fetchLatestReleaseTag(): Promise<string> {
|
||||
return releases[0].tag_name;
|
||||
}
|
||||
|
||||
const release = await response.json() as { tag_name: string };
|
||||
const release = (await response.json()) as { tag_name: string };
|
||||
return release.tag_name;
|
||||
}
|
||||
|
||||
|
||||
+148
-72
@@ -3,6 +3,9 @@
|
||||
*
|
||||
* This module uses Bun's native FFI to call into the Rust C library.
|
||||
* All functions follow the Result pattern for error handling.
|
||||
*
|
||||
* The API is instance-based: `ffiCreate` returns an opaque handle that must
|
||||
* be passed to all subsequent calls and freed with `ffiDestroy`.
|
||||
*/
|
||||
|
||||
import { dlopen, FFIType, ptr, CString, read, type Pointer } from "bun:ffi";
|
||||
@@ -13,74 +16,74 @@ import { err } from "./types";
|
||||
// Define the FFI symbols
|
||||
const ffiDefinition = {
|
||||
// Lifecycle
|
||||
fff_init: {
|
||||
fff_create: {
|
||||
args: [FFIType.cstring],
|
||||
returns: FFIType.ptr,
|
||||
},
|
||||
fff_destroy: {
|
||||
args: [],
|
||||
returns: FFIType.ptr,
|
||||
args: [FFIType.ptr],
|
||||
returns: FFIType.void,
|
||||
},
|
||||
|
||||
// Search
|
||||
fff_search: {
|
||||
args: [FFIType.cstring, FFIType.cstring],
|
||||
args: [FFIType.ptr, FFIType.cstring, FFIType.cstring],
|
||||
returns: FFIType.ptr,
|
||||
},
|
||||
|
||||
// Live grep (content search)
|
||||
fff_live_grep: {
|
||||
args: [FFIType.cstring, FFIType.cstring],
|
||||
args: [FFIType.ptr, FFIType.cstring, FFIType.cstring],
|
||||
returns: FFIType.ptr,
|
||||
},
|
||||
|
||||
// File index
|
||||
fff_scan_files: {
|
||||
args: [],
|
||||
args: [FFIType.ptr],
|
||||
returns: FFIType.ptr,
|
||||
},
|
||||
fff_is_scanning: {
|
||||
args: [],
|
||||
args: [FFIType.ptr],
|
||||
returns: FFIType.bool,
|
||||
},
|
||||
fff_get_scan_progress: {
|
||||
args: [],
|
||||
args: [FFIType.ptr],
|
||||
returns: FFIType.ptr,
|
||||
},
|
||||
fff_wait_for_scan: {
|
||||
args: [FFIType.u64],
|
||||
args: [FFIType.ptr, FFIType.u64],
|
||||
returns: FFIType.ptr,
|
||||
},
|
||||
fff_restart_index: {
|
||||
args: [FFIType.cstring],
|
||||
args: [FFIType.ptr, FFIType.cstring],
|
||||
returns: FFIType.ptr,
|
||||
},
|
||||
|
||||
// Frecency
|
||||
fff_track_access: {
|
||||
args: [FFIType.cstring],
|
||||
args: [FFIType.ptr, FFIType.cstring],
|
||||
returns: FFIType.ptr,
|
||||
},
|
||||
|
||||
// Git
|
||||
fff_refresh_git_status: {
|
||||
args: [],
|
||||
args: [FFIType.ptr],
|
||||
returns: FFIType.ptr,
|
||||
},
|
||||
|
||||
// Query tracking
|
||||
fff_track_query: {
|
||||
args: [FFIType.cstring, FFIType.cstring],
|
||||
args: [FFIType.ptr, FFIType.cstring, FFIType.cstring],
|
||||
returns: FFIType.ptr,
|
||||
},
|
||||
fff_get_historical_query: {
|
||||
args: [FFIType.u64],
|
||||
args: [FFIType.ptr, FFIType.u64],
|
||||
returns: FFIType.ptr,
|
||||
},
|
||||
|
||||
// Utilities
|
||||
fff_health_check: {
|
||||
args: [FFIType.cstring],
|
||||
args: [FFIType.ptr, FFIType.cstring],
|
||||
returns: FFIType.ptr,
|
||||
},
|
||||
|
||||
@@ -154,19 +157,22 @@ function snakeToCamel(obj: unknown): unknown {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a FffResult from the FFI return value
|
||||
* The result is a pointer to a struct: { success: bool, data: *char, error: *char }
|
||||
* Parse a FffResult from the FFI return value.
|
||||
*
|
||||
* The result is a pointer to a struct:
|
||||
* { success: bool, data: *char, error: *char, handle: *void }
|
||||
*
|
||||
* Layout (with alignment padding):
|
||||
* offset 0: success (bool, 1 byte + 7 padding)
|
||||
* offset 8: data pointer (8 bytes)
|
||||
* offset 16: error pointer (8 bytes)
|
||||
* offset 24: handle pointer (8 bytes)
|
||||
*/
|
||||
function parseResult<T>(resultPtr: Pointer | null): Result<T> {
|
||||
if (resultPtr === null) {
|
||||
return err("FFI returned null pointer");
|
||||
}
|
||||
|
||||
// Read the struct fields
|
||||
// FffResult layout: bool (1 byte + 7 padding) + pointer (8 bytes) + pointer (8 bytes)
|
||||
// offset 0: success (bool, 1 byte)
|
||||
// offset 8: data pointer (8 bytes)
|
||||
// offset 16: error pointer (8 bytes)
|
||||
const success = read.u8(resultPtr, 0) !== 0;
|
||||
const dataPtr = read.ptr(resultPtr, 8);
|
||||
const errorPtr = read.ptr(resultPtr, 16);
|
||||
@@ -200,29 +206,63 @@ function parseResult<T>(resultPtr: Pointer | null): Result<T> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the file finder
|
||||
* Opaque native handle type. Callers must not inspect or modify this value.
|
||||
*/
|
||||
export function ffiInit(optsJson: string): Result<void> {
|
||||
export type NativeHandle = Pointer;
|
||||
|
||||
/**
|
||||
* Create a new file finder instance.
|
||||
*
|
||||
* Returns the opaque native handle on success. The handle must be passed to
|
||||
* all subsequent FFI calls and freed with `ffiDestroy`.
|
||||
*/
|
||||
export function ffiCreate(optsJson: string): Result<NativeHandle> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_init(ptr(encodeString(optsJson)));
|
||||
return parseResult<void>(resultPtr);
|
||||
const resultPtr = library.symbols.fff_create(ptr(encodeString(optsJson)));
|
||||
|
||||
if (resultPtr === null) {
|
||||
return err("FFI returned null pointer");
|
||||
}
|
||||
|
||||
const success = read.u8(resultPtr, 0) !== 0;
|
||||
const errorPtr = read.ptr(resultPtr, 16);
|
||||
const handlePtr = read.ptr(resultPtr, 24);
|
||||
|
||||
if (success) {
|
||||
const handle = handlePtr as unknown as Pointer;
|
||||
library.symbols.fff_free_result(resultPtr);
|
||||
|
||||
if (!handle || handle === (0 as unknown as Pointer)) {
|
||||
return err("fff_create returned null handle");
|
||||
}
|
||||
|
||||
return { ok: true, value: handle };
|
||||
} else {
|
||||
const errorMsg = readCString(errorPtr) || "Unknown error";
|
||||
library.symbols.fff_free_result(resultPtr);
|
||||
return err(errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy and clean up resources
|
||||
* Destroy and clean up an instance.
|
||||
*/
|
||||
export function ffiDestroy(): Result<void> {
|
||||
export function ffiDestroy(handle: NativeHandle): void {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_destroy();
|
||||
return parseResult<void>(resultPtr);
|
||||
library.symbols.fff_destroy(handle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform fuzzy search
|
||||
* Perform fuzzy search.
|
||||
*/
|
||||
export function ffiSearch(query: string, optsJson: string): Result<unknown> {
|
||||
export function ffiSearch(
|
||||
handle: NativeHandle,
|
||||
query: string,
|
||||
optsJson: string
|
||||
): Result<unknown> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_search(
|
||||
handle,
|
||||
ptr(encodeString(query)),
|
||||
ptr(encodeString(optsJson))
|
||||
);
|
||||
@@ -230,123 +270,159 @@ export function ffiSearch(query: string, optsJson: string): Result<unknown> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger file scan
|
||||
* Trigger file scan.
|
||||
*/
|
||||
export function ffiScanFiles(): Result<void> {
|
||||
export function ffiScanFiles(handle: NativeHandle): Result<void> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_scan_files();
|
||||
const resultPtr = library.symbols.fff_scan_files(handle);
|
||||
return parseResult<void>(resultPtr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if scanning
|
||||
* Check if scanning.
|
||||
*/
|
||||
export function ffiIsScanning(): boolean {
|
||||
export function ffiIsScanning(handle: NativeHandle): boolean {
|
||||
const library = loadLibrary();
|
||||
return library.symbols.fff_is_scanning() as boolean;
|
||||
return library.symbols.fff_is_scanning(handle) as boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scan progress
|
||||
* Get scan progress.
|
||||
*/
|
||||
export function ffiGetScanProgress(): Result<unknown> {
|
||||
export function ffiGetScanProgress(handle: NativeHandle): Result<unknown> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_get_scan_progress();
|
||||
const resultPtr = library.symbols.fff_get_scan_progress(handle);
|
||||
return parseResult<unknown>(resultPtr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for scan to complete
|
||||
* Wait for scan to complete.
|
||||
*/
|
||||
export function ffiWaitForScan(timeoutMs: number): Result<boolean> {
|
||||
export function ffiWaitForScan(
|
||||
handle: NativeHandle,
|
||||
timeoutMs: number
|
||||
): Result<boolean> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_wait_for_scan(BigInt(timeoutMs));
|
||||
const result = parseResult<string>(resultPtr);
|
||||
const resultPtr = library.symbols.fff_wait_for_scan(
|
||||
handle,
|
||||
BigInt(timeoutMs)
|
||||
);
|
||||
const result = parseResult<boolean | string>(resultPtr);
|
||||
if (!result.ok) return result;
|
||||
return { ok: true, value: result.value === "true" };
|
||||
// JSON.parse("true") returns boolean true, but we also handle
|
||||
// the string case defensively.
|
||||
return { ok: true, value: result.value === true || result.value === "true" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart index in new path
|
||||
* Restart index in new path.
|
||||
*/
|
||||
export function ffiRestartIndex(newPath: string): Result<void> {
|
||||
export function ffiRestartIndex(
|
||||
handle: NativeHandle,
|
||||
newPath: string
|
||||
): Result<void> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_restart_index(
|
||||
handle,
|
||||
ptr(encodeString(newPath))
|
||||
);
|
||||
return parseResult<void>(resultPtr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track file access
|
||||
* Track file access.
|
||||
*/
|
||||
export function ffiTrackAccess(filePath: string): Result<boolean> {
|
||||
export function ffiTrackAccess(
|
||||
handle: NativeHandle,
|
||||
filePath: string
|
||||
): Result<boolean> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_track_access(
|
||||
handle,
|
||||
ptr(encodeString(filePath))
|
||||
);
|
||||
const result = parseResult<string>(resultPtr);
|
||||
const result = parseResult<boolean | string>(resultPtr);
|
||||
if (!result.ok) return result;
|
||||
return { ok: true, value: result.value === "true" };
|
||||
return { ok: true, value: result.value === true || result.value === "true" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh git status
|
||||
* Refresh git status.
|
||||
*/
|
||||
export function ffiRefreshGitStatus(): Result<number> {
|
||||
export function ffiRefreshGitStatus(handle: NativeHandle): Result<number> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_refresh_git_status();
|
||||
const result = parseResult<string>(resultPtr);
|
||||
const resultPtr = library.symbols.fff_refresh_git_status(handle);
|
||||
const result = parseResult<number | string>(resultPtr);
|
||||
if (!result.ok) return result;
|
||||
return { ok: true, value: parseInt(result.value, 10) };
|
||||
// JSON.parse("3") returns 3 (number), parseInt handles both
|
||||
return { ok: true, value: typeof result.value === "number" ? result.value : parseInt(result.value, 10) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Track query completion
|
||||
* Track query completion.
|
||||
*/
|
||||
export function ffiTrackQuery(
|
||||
handle: NativeHandle,
|
||||
query: string,
|
||||
filePath: string
|
||||
): Result<boolean> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_track_query(
|
||||
handle,
|
||||
ptr(encodeString(query)),
|
||||
ptr(encodeString(filePath))
|
||||
);
|
||||
const result = parseResult<string>(resultPtr);
|
||||
const result = parseResult<boolean | string>(resultPtr);
|
||||
if (!result.ok) return result;
|
||||
return { ok: true, value: result.value === "true" };
|
||||
return { ok: true, value: result.value === true || result.value === "true" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get historical query
|
||||
* Get historical query.
|
||||
*/
|
||||
export function ffiGetHistoricalQuery(offset: number): Result<string | null> {
|
||||
export function ffiGetHistoricalQuery(
|
||||
handle: NativeHandle,
|
||||
offset: number
|
||||
): Result<string | null> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_get_historical_query(BigInt(offset));
|
||||
const result = parseResult<string>(resultPtr);
|
||||
const resultPtr = library.symbols.fff_get_historical_query(
|
||||
handle,
|
||||
BigInt(offset)
|
||||
);
|
||||
const result = parseResult<string | null>(resultPtr);
|
||||
if (!result.ok) return result;
|
||||
if (result.value === "null") return { ok: true, value: null };
|
||||
return result;
|
||||
if (result.value === null || result.value === "null") return { ok: true, value: null };
|
||||
return result as Result<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check
|
||||
* Health check.
|
||||
*
|
||||
* `handle` can be null for a limited check (version + git only).
|
||||
*/
|
||||
export function ffiHealthCheck(testPath: string): Result<unknown> {
|
||||
export function ffiHealthCheck(
|
||||
handle: NativeHandle | null,
|
||||
testPath: string
|
||||
): Result<unknown> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_health_check(
|
||||
handle ?? (0 as unknown as Pointer),
|
||||
ptr(encodeString(testPath))
|
||||
);
|
||||
return parseResult<unknown>(resultPtr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Live grep - search file contents
|
||||
* Live grep - search file contents.
|
||||
*/
|
||||
export function ffiLiveGrep(query: string, optsJson: string): Result<unknown> {
|
||||
export function ffiLiveGrep(
|
||||
handle: NativeHandle,
|
||||
query: string,
|
||||
optsJson: string
|
||||
): Result<unknown> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_live_grep(
|
||||
handle,
|
||||
ptr(encodeString(query)),
|
||||
ptr(encodeString(optsJson))
|
||||
);
|
||||
@@ -354,7 +430,7 @@ export function ffiLiveGrep(query: string, optsJson: string): Result<unknown> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the library is loaded (for preloading)
|
||||
* Ensure the library is loaded (for preloading).
|
||||
*/
|
||||
export async function ensureLoaded(): Promise<void> {
|
||||
await ensureBinary();
|
||||
@@ -362,7 +438,7 @@ export async function ensureLoaded(): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the library is available
|
||||
* Check if the library is available.
|
||||
*/
|
||||
export function isAvailable(): boolean {
|
||||
try {
|
||||
|
||||
+127
-94
@@ -2,11 +2,14 @@
|
||||
* FileFinder - High-level API for the fff file finder
|
||||
*
|
||||
* This class provides a type-safe, ergonomic API for file finding operations.
|
||||
* Each instance owns an independent native file picker that can be created
|
||||
* and destroyed independently. Multiple instances can coexist.
|
||||
*
|
||||
* All methods return Result types for explicit error handling.
|
||||
*/
|
||||
|
||||
import {
|
||||
ffiInit,
|
||||
ffiCreate,
|
||||
ffiDestroy,
|
||||
ffiSearch,
|
||||
ffiLiveGrep,
|
||||
@@ -22,6 +25,7 @@ import {
|
||||
ffiHealthCheck,
|
||||
ensureLoaded,
|
||||
isAvailable,
|
||||
type NativeHandle,
|
||||
} from "./ffi";
|
||||
|
||||
import type {
|
||||
@@ -35,27 +39,36 @@ import type {
|
||||
GrepResult,
|
||||
} from "./types";
|
||||
|
||||
import { err, toInternalInitOptions, toInternalSearchOptions, toInternalGrepOptions, createGrepCursor } from "./types";
|
||||
import {
|
||||
err,
|
||||
toInternalInitOptions,
|
||||
toInternalSearchOptions,
|
||||
toInternalGrepOptions,
|
||||
createGrepCursor,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* FileFinder - Fast file finder with fuzzy search
|
||||
*
|
||||
* Each instance is backed by an independent native file picker. Create as many
|
||||
* as you need and destroy them when done.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { FileFinder } from "fff";
|
||||
*
|
||||
* // Initialize
|
||||
* const result = FileFinder.init({ basePath: "/path/to/project" });
|
||||
* if (!result.ok) {
|
||||
* console.error(result.error);
|
||||
* // Create an instance
|
||||
* const finder = FileFinder.create({ basePath: "/path/to/project" });
|
||||
* if (!finder.ok) {
|
||||
* console.error(finder.error);
|
||||
* process.exit(1);
|
||||
* }
|
||||
*
|
||||
* // Wait for initial scan
|
||||
* FileFinder.waitForScan(5000);
|
||||
* finder.value.waitForScan(5000);
|
||||
*
|
||||
* // Search for files
|
||||
* const search = FileFinder.search("main.ts");
|
||||
* const search = finder.value.search("main.ts");
|
||||
* if (search.ok) {
|
||||
* for (const item of search.value.items) {
|
||||
* console.log(item.relativePath);
|
||||
@@ -63,57 +76,75 @@ import { err, toInternalInitOptions, toInternalSearchOptions, toInternalGrepOpti
|
||||
* }
|
||||
*
|
||||
* // Cleanup
|
||||
* FileFinder.destroy();
|
||||
* finder.value.destroy();
|
||||
* ```
|
||||
*/
|
||||
export class FileFinder {
|
||||
private static initialized = false;
|
||||
private handle: NativeHandle | null;
|
||||
|
||||
private constructor(handle: NativeHandle) {
|
||||
this.handle = handle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the file finder with the given options.
|
||||
* Create a new file finder instance.
|
||||
*
|
||||
* @param options - Initialization options
|
||||
* @returns Result indicating success or failure
|
||||
* @returns Result containing the new FileFinder instance or an error
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Basic initialization
|
||||
* FileFinder.init({ basePath: "/path/to/project" });
|
||||
* const finder = FileFinder.create({ basePath: "/path/to/project" });
|
||||
*
|
||||
* // With custom database paths
|
||||
* FileFinder.init({
|
||||
* const finder = FileFinder.create({
|
||||
* basePath: "/path/to/project",
|
||||
* frecencyDbPath: "/custom/frecency.mdb",
|
||||
* historyDbPath: "/custom/history.mdb",
|
||||
* });
|
||||
*
|
||||
* // Minimal mode (no databases - just omit db paths)
|
||||
* FileFinder.init({ basePath: "/path/to/project" });
|
||||
* ```
|
||||
*/
|
||||
static init(options: InitOptions): Result<void> {
|
||||
static create(options: InitOptions): Result<FileFinder> {
|
||||
const internalOpts = toInternalInitOptions(options);
|
||||
const result = ffiInit(JSON.stringify(internalOpts));
|
||||
const result = ffiCreate(JSON.stringify(internalOpts));
|
||||
|
||||
if (result.ok) {
|
||||
this.initialized = true;
|
||||
if (!result.ok) {
|
||||
return result;
|
||||
}
|
||||
|
||||
return result;
|
||||
return { ok: true, value: new FileFinder(result.value) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy and clean up all resources.
|
||||
*
|
||||
* Call this when you're done using the file finder to free memory
|
||||
* and stop background file watching.
|
||||
* and stop background file watching. After calling this, the instance
|
||||
* must not be used again.
|
||||
*/
|
||||
static destroy(): Result<void> {
|
||||
const result = ffiDestroy();
|
||||
if (result.ok) {
|
||||
this.initialized = false;
|
||||
destroy(): void {
|
||||
if (this.handle !== null) {
|
||||
ffiDestroy(this.handle);
|
||||
this.handle = null;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this instance has been destroyed.
|
||||
*/
|
||||
get isDestroyed(): boolean {
|
||||
return this.handle === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard that returns an error if the instance has been destroyed.
|
||||
*/
|
||||
private ensureAlive(): Result<NativeHandle> {
|
||||
if (this.handle === null) {
|
||||
return err("FileFinder instance has been destroyed.");
|
||||
}
|
||||
return { ok: true, value: this.handle };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,7 +162,7 @@ export class FileFinder {
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = FileFinder.search("main.ts", { pageSize: 10 });
|
||||
* const result = finder.search("main.ts", { pageSize: 10 });
|
||||
* if (result.ok) {
|
||||
* console.log(`Found ${result.value.totalMatched} files`);
|
||||
* for (const item of result.value.items) {
|
||||
@@ -140,19 +171,17 @@ export class FileFinder {
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
static search(query: string, options?: SearchOptions): Result<SearchResult> {
|
||||
if (!this.initialized) {
|
||||
return err("FileFinder not initialized. Call FileFinder.init() first.");
|
||||
}
|
||||
search(query: string, options?: SearchOptions): Result<SearchResult> {
|
||||
const guard = this.ensureAlive();
|
||||
if (!guard.ok) return guard;
|
||||
|
||||
const internalOpts = toInternalSearchOptions(options);
|
||||
const result = ffiSearch(query, JSON.stringify(internalOpts));
|
||||
const result = ffiSearch(guard.value, query, JSON.stringify(internalOpts));
|
||||
|
||||
if (!result.ok) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// The FFI returns the search result already parsed
|
||||
return result as Result<SearchResult>;
|
||||
}
|
||||
|
||||
@@ -178,27 +207,30 @@ export class FileFinder {
|
||||
* @example
|
||||
* ```typescript
|
||||
* // First page
|
||||
* const result = FileFinder.liveGrep("TODO", { mode: "plain", pageLimit: 20 });
|
||||
* const result = finder.liveGrep("TODO", { mode: "plain" });
|
||||
* if (result.ok) {
|
||||
* for (const match of result.value.items) {
|
||||
* console.log(`${match.relativePath}:${match.lineNumber}: ${match.lineContent}`);
|
||||
* }
|
||||
* // Fetch next page
|
||||
* if (result.value.nextCursor) {
|
||||
* const page2 = FileFinder.liveGrep("TODO", {
|
||||
* const page2 = finder.liveGrep("TODO", {
|
||||
* cursor: result.value.nextCursor,
|
||||
* });
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
static liveGrep(query: string, options?: GrepOptions): Result<GrepResult> {
|
||||
if (!this.initialized) {
|
||||
return err("FileFinder not initialized. Call FileFinder.init() first.");
|
||||
}
|
||||
liveGrep(query: string, options?: GrepOptions): Result<GrepResult> {
|
||||
const guard = this.ensureAlive();
|
||||
if (!guard.ok) return guard;
|
||||
|
||||
const internalOpts = toInternalGrepOptions(options);
|
||||
const result = ffiLiveGrep(query, JSON.stringify(internalOpts));
|
||||
const result = ffiLiveGrep(
|
||||
guard.value,
|
||||
query,
|
||||
JSON.stringify(internalOpts)
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
return result;
|
||||
@@ -227,29 +259,27 @@ export class FileFinder {
|
||||
* This is useful after major file system changes that the
|
||||
* background watcher might have missed.
|
||||
*/
|
||||
static scanFiles(): Result<void> {
|
||||
if (!this.initialized) {
|
||||
return err("FileFinder not initialized. Call FileFinder.init() first.");
|
||||
}
|
||||
return ffiScanFiles();
|
||||
scanFiles(): Result<void> {
|
||||
const guard = this.ensureAlive();
|
||||
if (!guard.ok) return guard;
|
||||
return ffiScanFiles(guard.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a scan is currently in progress.
|
||||
*/
|
||||
static isScanning(): boolean {
|
||||
if (!this.initialized) return false;
|
||||
return ffiIsScanning();
|
||||
isScanning(): boolean {
|
||||
if (this.handle === null) return false;
|
||||
return ffiIsScanning(this.handle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current scan progress.
|
||||
*/
|
||||
static getScanProgress(): Result<ScanProgress> {
|
||||
if (!this.initialized) {
|
||||
return err("FileFinder not initialized. Call FileFinder.init() first.");
|
||||
}
|
||||
return ffiGetScanProgress() as Result<ScanProgress>;
|
||||
getScanProgress(): Result<ScanProgress> {
|
||||
const guard = this.ensureAlive();
|
||||
if (!guard.ok) return guard;
|
||||
return ffiGetScanProgress(guard.value) as Result<ScanProgress>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -260,18 +290,19 @@ export class FileFinder {
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* FileFinder.init({ basePath: "/path/to/project" });
|
||||
* const completed = FileFinder.waitForScan(10000);
|
||||
* if (!completed.ok || !completed.value) {
|
||||
* console.warn("Scan did not complete in time");
|
||||
* const finder = FileFinder.create({ basePath: "/path/to/project" });
|
||||
* if (finder.ok) {
|
||||
* const completed = finder.value.waitForScan(10000);
|
||||
* if (!completed.ok || !completed.value) {
|
||||
* console.warn("Scan did not complete in time");
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
static waitForScan(timeoutMs: number = 5000): Result<boolean> {
|
||||
if (!this.initialized) {
|
||||
return err("FileFinder not initialized. Call FileFinder.init() first.");
|
||||
}
|
||||
return ffiWaitForScan(timeoutMs);
|
||||
waitForScan(timeoutMs: number = 5000): Result<boolean> {
|
||||
const guard = this.ensureAlive();
|
||||
if (!guard.ok) return guard;
|
||||
return ffiWaitForScan(guard.value, timeoutMs);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -281,11 +312,10 @@ export class FileFinder {
|
||||
*
|
||||
* @param newPath - New directory path to index
|
||||
*/
|
||||
static reindex(newPath: string): Result<void> {
|
||||
if (!this.initialized) {
|
||||
return err("FileFinder not initialized. Call FileFinder.init() first.");
|
||||
}
|
||||
return ffiRestartIndex(newPath);
|
||||
reindex(newPath: string): Result<void> {
|
||||
const guard = this.ensureAlive();
|
||||
if (!guard.ok) return guard;
|
||||
return ffiRestartIndex(guard.value, newPath);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -295,11 +325,10 @@ export class FileFinder {
|
||||
*
|
||||
* @param filePath - Absolute path to the accessed file
|
||||
*/
|
||||
static trackAccess(filePath: string): Result<boolean> {
|
||||
if (!this.initialized) {
|
||||
return { ok: true, value: false };
|
||||
}
|
||||
return ffiTrackAccess(filePath);
|
||||
trackAccess(filePath: string): Result<boolean> {
|
||||
const guard = this.ensureAlive();
|
||||
if (!guard.ok) return guard;
|
||||
return ffiTrackAccess(guard.value, filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -307,11 +336,10 @@ export class FileFinder {
|
||||
*
|
||||
* @returns Number of files with updated git status
|
||||
*/
|
||||
static refreshGitStatus(): Result<number> {
|
||||
if (!this.initialized) {
|
||||
return err("FileFinder not initialized. Call FileFinder.init() first.");
|
||||
}
|
||||
return ffiRefreshGitStatus();
|
||||
refreshGitStatus(): Result<number> {
|
||||
const guard = this.ensureAlive();
|
||||
if (!guard.ok) return guard;
|
||||
return ffiRefreshGitStatus(guard.value);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -323,11 +351,10 @@ export class FileFinder {
|
||||
* @param query - The search query that was used
|
||||
* @param selectedFilePath - The file path that was selected
|
||||
*/
|
||||
static trackQuery(query: string, selectedFilePath: string): Result<boolean> {
|
||||
if (!this.initialized) {
|
||||
return { ok: true, value: false };
|
||||
}
|
||||
return ffiTrackQuery(query, selectedFilePath);
|
||||
trackQuery(query: string, selectedFilePath: string): Result<boolean> {
|
||||
const guard = this.ensureAlive();
|
||||
if (!guard.ok) return guard;
|
||||
return ffiTrackQuery(guard.value, query, selectedFilePath);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -336,11 +363,10 @@ export class FileFinder {
|
||||
* @param offset - Offset from most recent (0 = most recent)
|
||||
* @returns The historical query string, or null if not found
|
||||
*/
|
||||
static getHistoricalQuery(offset: number): Result<string | null> {
|
||||
if (!this.initialized) {
|
||||
return { ok: true, value: null };
|
||||
}
|
||||
return ffiGetHistoricalQuery(offset);
|
||||
getHistoricalQuery(offset: number): Result<string | null> {
|
||||
const guard = this.ensureAlive();
|
||||
if (!guard.ok) return guard;
|
||||
return ffiGetHistoricalQuery(guard.value, offset);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -350,8 +376,11 @@ export class FileFinder {
|
||||
*
|
||||
* @param testPath - Optional path to test git repository detection
|
||||
*/
|
||||
static healthCheck(testPath?: string): Result<HealthCheck> {
|
||||
return ffiHealthCheck(testPath || "") as Result<HealthCheck>;
|
||||
healthCheck(testPath?: string): Result<HealthCheck> {
|
||||
return ffiHealthCheck(
|
||||
this.handle,
|
||||
testPath || ""
|
||||
) as Result<HealthCheck>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -372,9 +401,13 @@ export class FileFinder {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the file finder is initialized.
|
||||
* Get a health check without requiring an instance.
|
||||
*
|
||||
* Returns limited info (version + git only, no picker/frecency/query data).
|
||||
*
|
||||
* @param testPath - Optional path to test git repository detection
|
||||
*/
|
||||
static isInitialized(): boolean {
|
||||
return this.initialized;
|
||||
static healthCheckStatic(testPath?: string): Result<HealthCheck> {
|
||||
return ffiHealthCheck(null, testPath || "") as Result<HealthCheck>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
||||
import { FileFinder } from "./index";
|
||||
import type { FileItem } from "./types";
|
||||
import {
|
||||
mkdtempSync,
|
||||
writeFileSync,
|
||||
rmSync,
|
||||
unlinkSync,
|
||||
mkdirSync,
|
||||
realpathSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
/**
|
||||
* Integration test: full git lifecycle with a real repository.
|
||||
*
|
||||
* Creates a temporary git repo, initialises a FileFinder instance pointing at
|
||||
* it, then walks through:
|
||||
* 1. Initial scan – committed files should have status "clean"
|
||||
* 2. Add a new untracked file – should appear as "untracked"
|
||||
* 3. Stage the new file – should appear as "staged_new"
|
||||
* 4. Commit – should become "clean"
|
||||
* 5. Modify a tracked file – should become "modified"
|
||||
* 6. Stage the modification – should become "staged_modified"
|
||||
* 7. Commit again – back to "clean"
|
||||
* 8. Delete a file – should disappear from the index
|
||||
*/
|
||||
|
||||
const POLL_INTERVAL_MS = 100;
|
||||
const WATCHER_TIMEOUT_MS = 5_000; // generous CI timeout; polls exit early on fast machines
|
||||
|
||||
function git(cwd: string, ...args: string[]) {
|
||||
const escaped = args.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ");
|
||||
execSync(`git ${escaped}`, {
|
||||
cwd,
|
||||
stdio: "pipe",
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: "test",
|
||||
GIT_AUTHOR_EMAIL: "test@test.com",
|
||||
GIT_COMMITTER_NAME: "test",
|
||||
GIT_COMMITTER_EMAIL: "test@test.com",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
function findFile(finder: FileFinder, name: string): FileItem | undefined {
|
||||
const result = finder.search(name, { pageSize: 200 });
|
||||
if (!result.ok) throw new Error(`search failed: ${result.error}`);
|
||||
return result.value.items.find((item) => item.fileName === name);
|
||||
}
|
||||
|
||||
/** Poll until a file appears in the index, or the timeout is exceeded. */
|
||||
async function waitForFile(
|
||||
finder: FileFinder,
|
||||
name: string,
|
||||
): Promise<FileItem | undefined> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < WATCHER_TIMEOUT_MS) {
|
||||
const file = findFile(finder, name);
|
||||
if (file !== undefined) return file;
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
return findFile(finder, name);
|
||||
}
|
||||
|
||||
/** Poll until a file has the expected git status, or the timeout is exceeded. */
|
||||
async function waitForFileStatus(
|
||||
finder: FileFinder,
|
||||
name: string,
|
||||
status: string,
|
||||
): Promise<FileItem | undefined> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < WATCHER_TIMEOUT_MS) {
|
||||
const file = findFile(finder, name);
|
||||
if (file?.gitStatus === status) return file;
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
return findFile(finder, name);
|
||||
}
|
||||
|
||||
/** Poll until a file is gone from the index, or the timeout is exceeded. */
|
||||
async function waitForFileGone(
|
||||
finder: FileFinder,
|
||||
name: string,
|
||||
): Promise<boolean> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < WATCHER_TIMEOUT_MS) {
|
||||
if (findFile(finder, name) === undefined) return true;
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
return findFile(finder, name) === undefined;
|
||||
}
|
||||
|
||||
/** Poll until the total file count reaches the expected value, or the timeout is exceeded. */
|
||||
async function waitForFileCount(
|
||||
finder: FileFinder,
|
||||
count: number,
|
||||
): Promise<number> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < WATCHER_TIMEOUT_MS) {
|
||||
const result = finder.search("", { pageSize: 200 });
|
||||
if (result.ok && result.value.totalFiles === count) return count;
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
const result = finder.search("", { pageSize: 200 });
|
||||
return result.ok ? result.value.totalFiles : -1;
|
||||
}
|
||||
|
||||
/** Poll liveGrep until predicate on totalMatched is satisfied, or the timeout is exceeded. */
|
||||
async function waitForGrep(
|
||||
finder: FileFinder,
|
||||
pattern: string,
|
||||
options: { mode: "plain" | "regex" },
|
||||
predicate: (totalMatched: number) => boolean,
|
||||
) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < WATCHER_TIMEOUT_MS) {
|
||||
const result = finder.liveGrep(pattern, options);
|
||||
if (result.ok && predicate(result.value.totalMatched)) return result;
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
return finder.liveGrep(pattern, options);
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform === "win32")(
|
||||
"Git lifecycle integration",
|
||||
() => {
|
||||
let tmpDir: string;
|
||||
let finder: FileFinder;
|
||||
|
||||
beforeAll(() => {
|
||||
// Create temp directory and initialise a git repo with two committed files.
|
||||
// Use realpathSync to resolve symlinks (macOS /var -> /private/var) so
|
||||
// that git2's resolved workdir paths match the file picker's base_path.
|
||||
tmpDir = realpathSync(mkdtempSync(join(tmpdir(), "fff-git-test-")));
|
||||
|
||||
git(tmpDir, "init", "-b", "main");
|
||||
// Need at least one commit for status to work properly
|
||||
writeFileSync(join(tmpDir, "hello.txt"), "hello world\n");
|
||||
writeFileSync(join(tmpDir, "readme.md"), "# Test Project\n");
|
||||
mkdirSync(join(tmpDir, "src"));
|
||||
writeFileSync(
|
||||
join(tmpDir, "src", "main.rs"),
|
||||
'fn main() { println?."hi"); }\n',
|
||||
);
|
||||
git(tmpDir, "add", "-A");
|
||||
git(tmpDir, "commit", "-m", "initial commit");
|
||||
|
||||
// Create the FileFinder instance
|
||||
const result = FileFinder.create({ basePath: tmpDir });
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) throw new Error(result.error);
|
||||
finder = result.value;
|
||||
|
||||
// Wait for the initial scan to finish
|
||||
const scanResult = finder.waitForScan(10_000);
|
||||
expect(scanResult.ok).toBe(true);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
finder?.destroy();
|
||||
if (tmpDir) {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("initial scan indexes all committed files", () => {
|
||||
const result = finder.search("", { pageSize: 200 });
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
const names = result.value.items.map((i) => i.relativePath).sort();
|
||||
expect(names).toContain("hello.txt");
|
||||
expect(names).toContain("readme.md");
|
||||
expect(names).toContain("src/main.rs");
|
||||
expect(result.value.totalFiles).toBe(3);
|
||||
});
|
||||
|
||||
test("committed files have clean git status", async () => {
|
||||
const hello = await waitForFileStatus(finder, "hello.txt", "clean");
|
||||
expect(hello).toBeDefined();
|
||||
expect(hello?.gitStatus).toBe("clean");
|
||||
|
||||
const main = await waitForFileStatus(finder, "main.rs", "clean");
|
||||
expect(main).toBeDefined();
|
||||
expect(main?.gitStatus).toBe("clean");
|
||||
});
|
||||
|
||||
test("new untracked file appears with 'untracked' status", async () => {
|
||||
writeFileSync(join(tmpDir, "new_file.ts"), "export const x = 1;\n");
|
||||
|
||||
const newFile = await waitForFileStatus(finder, "new_file.ts", "untracked");
|
||||
expect(newFile).toBeDefined();
|
||||
expect(newFile?.gitStatus).toBe("untracked");
|
||||
|
||||
// Total should now be 4
|
||||
const total = await waitForFileCount(finder, 4);
|
||||
expect(total).toBe(4);
|
||||
});
|
||||
|
||||
test("staging a new file changes status to 'staged_new'", async () => {
|
||||
git(tmpDir, "add", "new_file.ts");
|
||||
|
||||
const newFile = await waitForFileStatus(finder, "new_file.ts", "staged_new");
|
||||
expect(newFile).toBeDefined();
|
||||
expect(newFile?.gitStatus).toBe("staged_new");
|
||||
});
|
||||
|
||||
test("committing makes the file 'clean'", async () => {
|
||||
git(tmpDir, "commit", "-m", "add new_file");
|
||||
|
||||
const newFile = await waitForFileStatus(finder, "new_file.ts", "clean");
|
||||
expect(newFile).toBeDefined();
|
||||
expect(newFile?.gitStatus).toBe("clean");
|
||||
});
|
||||
|
||||
test("modifying a tracked file changes status to 'modified'", async () => {
|
||||
writeFileSync(
|
||||
join(tmpDir, "hello.txt"),
|
||||
"hello world\nupdated content\n",
|
||||
);
|
||||
|
||||
const hello = await waitForFileStatus(finder, "hello.txt", "modified");
|
||||
expect(hello).toBeDefined();
|
||||
expect(hello?.gitStatus).toBe("modified");
|
||||
});
|
||||
|
||||
test("staging a modification changes status to 'staged_modified'", async () => {
|
||||
git(tmpDir, "add", "hello.txt");
|
||||
|
||||
const hello = await waitForFileStatus(finder, "hello.txt", "staged_modified");
|
||||
expect(hello).toBeDefined();
|
||||
expect(hello?.gitStatus).toBe("staged_modified");
|
||||
});
|
||||
|
||||
test("committing the modification returns to 'clean'", async () => {
|
||||
git(tmpDir, "commit", "-m", "update hello");
|
||||
|
||||
const hello = await waitForFileStatus(finder, "hello.txt", "clean");
|
||||
expect(hello).toBeDefined();
|
||||
expect(hello?.gitStatus).toBe("clean");
|
||||
});
|
||||
|
||||
test("deleting a file removes it from the index", async () => {
|
||||
unlinkSync(join(tmpDir, "new_file.ts"));
|
||||
|
||||
const gone = await waitForFileGone(finder, "new_file.ts");
|
||||
expect(gone).toBe(true);
|
||||
|
||||
// Total should be back to 3
|
||||
const total = await waitForFileCount(finder, 3);
|
||||
expect(total).toBe(3);
|
||||
});
|
||||
|
||||
test("adding a file in a subdirectory works", async () => {
|
||||
writeFileSync(join(tmpDir, "src", "utils.rs"), "pub fn helper() {}\n");
|
||||
|
||||
const utils = await waitForFileStatus(finder, "utils.rs", "untracked");
|
||||
expect(utils).toBeDefined();
|
||||
expect(utils?.relativePath).toBe("src/utils.rs");
|
||||
expect(utils?.gitStatus).toBe("untracked");
|
||||
});
|
||||
|
||||
test("live grep finds content in a newly added file", async () => {
|
||||
writeFileSync(
|
||||
join(tmpDir, "src", "searchtarget.rs"),
|
||||
'const UNIQUE_NEEDLE: &str = "xylophone_waterfall_97";\n',
|
||||
);
|
||||
|
||||
await waitForFile(finder, "searchtarget.rs");
|
||||
|
||||
const result = await waitForGrep(
|
||||
finder,
|
||||
"xylophone_waterfall_97",
|
||||
{ mode: "plain" },
|
||||
(n) => n > 0,
|
||||
);
|
||||
expect(result?.ok).toBe(true);
|
||||
if (!result?.ok) return;
|
||||
|
||||
expect(result.value.totalMatched).toBeGreaterThan(0);
|
||||
const match = result.value.items.find(
|
||||
(m) => m.relativePath === "src/searchtarget.rs",
|
||||
);
|
||||
expect(match).toBeDefined();
|
||||
expect(match!.lineContent).toContain("xylophone_waterfall_97");
|
||||
});
|
||||
|
||||
test("live grep no longer finds content after file is deleted", async () => {
|
||||
unlinkSync(join(tmpDir, "src", "searchtarget.rs"));
|
||||
|
||||
const result = await waitForGrep(
|
||||
finder,
|
||||
"xylophone_waterfall_97",
|
||||
{ mode: "plain" },
|
||||
(n) => n === 0,
|
||||
);
|
||||
expect(result?.ok).toBe(true);
|
||||
if (!result?.ok) return;
|
||||
|
||||
expect(result.value.totalMatched).toBe(0);
|
||||
expect(result.value.items.length).toBe(0);
|
||||
});
|
||||
|
||||
test("full add-commit cycle for subdirectory file", async () => {
|
||||
git(tmpDir, "add", "src/utils.rs");
|
||||
|
||||
let utils = await waitForFileStatus(finder, "utils.rs", "staged_new");
|
||||
expect(utils).toBeDefined();
|
||||
expect(utils?.gitStatus).toBe("staged_new");
|
||||
|
||||
git(tmpDir, "commit", "-m", "add utils");
|
||||
|
||||
utils = await waitForFileStatus(finder, "utils.rs", "clean");
|
||||
expect(utils).toBeDefined();
|
||||
expect(utils?.gitStatus).toBe("clean");
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -3,6 +3,13 @@ import { FileFinder } from "./index";
|
||||
import { findBinary, getDevBinaryPath } from "./download";
|
||||
import { getTriple, getLibExtension, getLibFilename } from "./platform";
|
||||
|
||||
// Cross-platform path normalization helpers
|
||||
const normalizePath = (path: string | null | undefined): string | null => {
|
||||
if (!path) return null;
|
||||
// Convert backslashes to forward slashes for consistent comparison
|
||||
return path.replace(/\\/g, "/");
|
||||
};
|
||||
|
||||
const testDir = process.cwd();
|
||||
|
||||
describe("Platform Detection", () => {
|
||||
@@ -42,7 +49,9 @@ describe("Binary Detection", () => {
|
||||
test("getDevBinaryPath finds local build", () => {
|
||||
const devPath = getDevBinaryPath();
|
||||
expect(devPath).not.toBeNull();
|
||||
expect(devPath).toContain("target/release");
|
||||
// Normalize path for cross-platform comparison (Windows uses backslashes)
|
||||
const normalizedPath = normalizePath(devPath);
|
||||
expect(normalizedPath).toContain("target/release");
|
||||
});
|
||||
|
||||
test("findBinary returns a path", () => {
|
||||
@@ -52,11 +61,8 @@ describe("Binary Detection", () => {
|
||||
});
|
||||
|
||||
describe("FileFinder - Health Check", () => {
|
||||
test("healthCheck works before initialization", () => {
|
||||
// Make sure we start fresh
|
||||
FileFinder.destroy();
|
||||
|
||||
const result = FileFinder.healthCheck();
|
||||
test("healthCheckStatic works without an instance", () => {
|
||||
const result = FileFinder.healthCheckStatic();
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
if (result.ok) {
|
||||
@@ -68,31 +74,32 @@ describe("FileFinder - Health Check", () => {
|
||||
});
|
||||
|
||||
describe("FileFinder - Full Lifecycle", () => {
|
||||
// Single beforeAll/afterAll for the entire test suite to avoid repeated init/destroy
|
||||
let finder: FileFinder;
|
||||
|
||||
beforeAll(() => {
|
||||
FileFinder.destroy(); // Clean any previous state
|
||||
const result = FileFinder.create({ basePath: testDir });
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
finder = result.value;
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
FileFinder.destroy();
|
||||
finder?.destroy();
|
||||
});
|
||||
|
||||
test("init succeeds with valid path", () => {
|
||||
const result = FileFinder.init({
|
||||
basePath: testDir,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(FileFinder.isInitialized()).toBe(true);
|
||||
test("create succeeds with valid path", () => {
|
||||
expect(finder).toBeDefined();
|
||||
expect(finder.isDestroyed).toBe(false);
|
||||
});
|
||||
|
||||
test("isScanning returns a boolean", () => {
|
||||
const scanning = FileFinder.isScanning();
|
||||
const scanning = finder.isScanning();
|
||||
expect(typeof scanning).toBe("boolean");
|
||||
});
|
||||
|
||||
test("getScanProgress returns valid data", () => {
|
||||
const result = FileFinder.getScanProgress();
|
||||
const result = finder.getScanProgress();
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
if (result.ok) {
|
||||
@@ -103,22 +110,37 @@ describe("FileFinder - Full Lifecycle", () => {
|
||||
|
||||
test("waitForScan completes", () => {
|
||||
// Small timeout - scan should be fast or already done
|
||||
const result = FileFinder.waitForScan(500);
|
||||
const result = finder.waitForScan(500);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("search with empty query returns all files", () => {
|
||||
const result = FileFinder.search("");
|
||||
// First check scan progress to see if files were indexed
|
||||
const progress = finder.getScanProgress();
|
||||
if (progress.ok) {
|
||||
}
|
||||
|
||||
const result = finder.search("");
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
if (result.ok) {
|
||||
if (result.value.items.length > 0) {
|
||||
// Log first few paths to see format on Windows
|
||||
// Items are strings (file paths), not objects
|
||||
const samplePaths = result.value.items
|
||||
.slice(0, 3)
|
||||
.map((item) =>
|
||||
normalizePath(typeof item === "string" ? item : item.relativePath),
|
||||
);
|
||||
}
|
||||
// Empty query should return files (frecency-sorted)
|
||||
expect(result.value.totalFiles).toBeGreaterThan(0);
|
||||
} else {
|
||||
}
|
||||
});
|
||||
|
||||
test("search returns a valid result structure", () => {
|
||||
const result = FileFinder.search("Cargo.toml");
|
||||
const result = finder.search("Cargo.toml");
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
if (result.ok) {
|
||||
@@ -130,7 +152,7 @@ describe("FileFinder - Full Lifecycle", () => {
|
||||
});
|
||||
|
||||
test("search returns empty for non-matching query", () => {
|
||||
const result = FileFinder.search("xyznonexistentfilenamexyz123456");
|
||||
const result = finder.search("xyznonexistentfilenamexyz123456");
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
if (result.ok) {
|
||||
@@ -140,7 +162,7 @@ describe("FileFinder - Full Lifecycle", () => {
|
||||
});
|
||||
|
||||
test("search respects pageSize option", () => {
|
||||
const result = FileFinder.search("ts", { pageSize: 3 });
|
||||
const result = finder.search("ts", { pageSize: 3 });
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
if (result.ok) {
|
||||
@@ -148,19 +170,83 @@ describe("FileFinder - Full Lifecycle", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("liveGrep plain text returns matching lines", () => {
|
||||
const result = finder.liveGrep("fff-core", {
|
||||
mode: "plain",
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
if (result.ok) {
|
||||
if (result.value.items.length > 0) {
|
||||
// Log sample match to verify content on Windows
|
||||
const first = result.value.items[0];
|
||||
const normalizedPath = normalizePath(first.relativePath);
|
||||
}
|
||||
|
||||
expect(result.value.totalMatched).toBeGreaterThan(0);
|
||||
expect(result.value.items.length).toBeGreaterThan(0);
|
||||
|
||||
const first = result.value.items[0];
|
||||
expect(typeof first.relativePath).toBe("string");
|
||||
// Normalize path for cross-platform validation
|
||||
const normalizedFirstPath = normalizePath(first.relativePath);
|
||||
expect(normalizedFirstPath).toBeTruthy();
|
||||
expect(typeof first.lineNumber).toBe("number");
|
||||
expect(first.lineNumber).toBeGreaterThan(0);
|
||||
expect(typeof first.lineContent).toBe("string");
|
||||
expect(first.lineContent.toLowerCase()).toContain("fff-core");
|
||||
expect(Array.isArray(first.matchRanges)).toBe(true);
|
||||
expect(first.matchRanges.length).toBeGreaterThan(0);
|
||||
|
||||
expect(typeof result.value.totalFilesSearched).toBe("number");
|
||||
expect(typeof result.value.totalFiles).toBe("number");
|
||||
expect(typeof result.value.filteredFileCount).toBe("number");
|
||||
} else {
|
||||
}
|
||||
});
|
||||
|
||||
test("liveGrep fuzzy mode returns results with scores", () => {
|
||||
// Intentional typo: "depdnency" instead of "dependency" to exercise fuzzy matching
|
||||
const result = finder.liveGrep("depdnency", {
|
||||
mode: "fuzzy",
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
if (result.ok) {
|
||||
expect(result.value.totalMatched).toBeGreaterThan(0);
|
||||
expect(result.value.items.length).toBeGreaterThan(0);
|
||||
|
||||
const first = result.value.items[0];
|
||||
expect(typeof first.relativePath).toBe("string");
|
||||
// Normalize path for cross-platform validation
|
||||
const normalizedFirstPath = normalizePath(first.relativePath);
|
||||
expect(normalizedFirstPath).toBeTruthy();
|
||||
expect(typeof first.lineNumber).toBe("number");
|
||||
expect(typeof first.lineContent).toBe("string");
|
||||
// Fuzzy mode should produce a fuzzyScore on each match
|
||||
expect(typeof first.fuzzyScore).toBe("number");
|
||||
}
|
||||
});
|
||||
|
||||
test("healthCheck shows initialized state", () => {
|
||||
const result = FileFinder.healthCheck();
|
||||
const result = finder.healthCheck();
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
if (result.ok) {
|
||||
expect(result.value.filePicker.initialized).toBe(true);
|
||||
expect(result.value.filePicker.basePath).toBeDefined();
|
||||
// Normalize basePath for cross-platform comparison
|
||||
const normalizedBasePath = normalizePath(
|
||||
result.value.filePicker.basePath || "",
|
||||
);
|
||||
const normalizedTestDir = normalizePath(testDir);
|
||||
expect(normalizedBasePath).toBe(normalizedTestDir);
|
||||
expect(typeof result.value.filePicker.indexedFiles).toBe("number");
|
||||
}
|
||||
});
|
||||
|
||||
test("healthCheck detects git repository", () => {
|
||||
const result = FileFinder.healthCheck(testDir);
|
||||
const result = finder.healthCheck(testDir);
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
if (result.ok) {
|
||||
@@ -169,37 +255,78 @@ describe("FileFinder - Full Lifecycle", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("destroy and re-init works", () => {
|
||||
FileFinder.destroy();
|
||||
expect(FileFinder.isInitialized()).toBe(false);
|
||||
test("destroy and re-create works", () => {
|
||||
finder.destroy();
|
||||
expect(finder.isDestroyed).toBe(true);
|
||||
|
||||
const result = FileFinder.init({
|
||||
basePath: testDir,
|
||||
});
|
||||
const result = FileFinder.create({ basePath: testDir });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(FileFinder.isInitialized()).toBe(true);
|
||||
if (result.ok) {
|
||||
finder = result.value;
|
||||
}
|
||||
expect(finder.isDestroyed).toBe(false);
|
||||
});
|
||||
|
||||
test("multiple instances can coexist", () => {
|
||||
const result2 = FileFinder.create({ basePath: testDir });
|
||||
expect(result2.ok).toBe(true);
|
||||
|
||||
if (result2.ok) {
|
||||
const finder2 = result2.value;
|
||||
|
||||
// Both should work independently
|
||||
const search1 = finder.search("Cargo");
|
||||
const search2 = finder2.search("Cargo");
|
||||
|
||||
expect(search1.ok).toBe(true);
|
||||
expect(search2.ok).toBe(true);
|
||||
|
||||
// Destroying one should not affect the other
|
||||
finder2.destroy();
|
||||
|
||||
const search3 = finder.search("Cargo");
|
||||
expect(search3.ok).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileFinder - Error Handling", () => {
|
||||
test("search fails when not initialized", () => {
|
||||
FileFinder.destroy();
|
||||
test("search fails on destroyed instance", () => {
|
||||
const createResult = FileFinder.create({ basePath: testDir });
|
||||
expect(createResult.ok).toBe(true);
|
||||
if (!createResult.ok) return;
|
||||
|
||||
const result = FileFinder.search("test");
|
||||
const f = createResult.value;
|
||||
f.destroy();
|
||||
|
||||
const result = f.search("test");
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toContain("not initialized");
|
||||
expect(result.error).toContain("destroyed");
|
||||
}
|
||||
});
|
||||
|
||||
test("getScanProgress fails when not initialized", () => {
|
||||
const result = FileFinder.getScanProgress();
|
||||
test("getScanProgress fails on destroyed instance", () => {
|
||||
const createResult = FileFinder.create({ basePath: testDir });
|
||||
expect(createResult.ok).toBe(true);
|
||||
if (!createResult.ok) return;
|
||||
|
||||
const f = createResult.value;
|
||||
f.destroy();
|
||||
|
||||
const result = f.getScanProgress();
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
test("init fails with invalid path", () => {
|
||||
const result = FileFinder.init({
|
||||
basePath: "/nonexistent/path/that/does/not/exist",
|
||||
test("create fails with invalid path", () => {
|
||||
// Use a cross-platform invalid path
|
||||
const invalidPath =
|
||||
process.platform === "win32"
|
||||
? "C:\\nonexistent\\path\\that\\does\\not\\exist"
|
||||
: "/nonexistent/path/that/does/not/exist";
|
||||
|
||||
const result = FileFinder.create({
|
||||
basePath: invalidPath,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
|
||||
@@ -4,22 +4,26 @@
|
||||
* High-performance fuzzy file finder for Bun, powered by Rust.
|
||||
* Perfect for LLM agent tools that need to search through codebases.
|
||||
*
|
||||
* Each `FileFinder` instance is backed by an independent native file picker.
|
||||
* Create as many as you need and destroy them when done.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { FileFinder } from "fff";
|
||||
*
|
||||
* // Initialize with a directory
|
||||
* const result = FileFinder.init({ basePath: "/path/to/project" });
|
||||
* // Create a file finder instance
|
||||
* const result = FileFinder.create({ basePath: "/path/to/project" });
|
||||
* if (!result.ok) {
|
||||
* console.error(result.error);
|
||||
* process.exit(1);
|
||||
* }
|
||||
* const finder = result.value;
|
||||
*
|
||||
* // Wait for initial scan
|
||||
* FileFinder.waitForScan(5000);
|
||||
* finder.waitForScan(5000);
|
||||
*
|
||||
* // Search for files
|
||||
* const search = FileFinder.search("main.ts");
|
||||
* const search = finder.search("main.ts");
|
||||
* if (search.ok) {
|
||||
* for (const item of search.value.items) {
|
||||
* console.log(item.relativePath);
|
||||
@@ -27,10 +31,10 @@
|
||||
* }
|
||||
*
|
||||
* // Track file access (for frecency)
|
||||
* FileFinder.trackAccess("/path/to/project/src/main.ts");
|
||||
* finder.trackAccess("/path/to/project/src/main.ts");
|
||||
*
|
||||
* // Cleanup when done
|
||||
* FileFinder.destroy();
|
||||
* finder.destroy();
|
||||
* ```
|
||||
*
|
||||
* @packageDocumentation
|
||||
@@ -71,4 +75,9 @@ export {
|
||||
} from "./download";
|
||||
|
||||
// Platform utilities
|
||||
export { getTriple, getLibExtension, getLibFilename, getNpmPackageName } from "./platform";
|
||||
export {
|
||||
getTriple,
|
||||
getLibExtension,
|
||||
getLibFilename,
|
||||
getNpmPackageName,
|
||||
} from "./platform";
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
/**
|
||||
* Result type for all operations - follows the Result pattern
|
||||
*/
|
||||
export type Result<T> =
|
||||
| { ok: true; value: T }
|
||||
| { ok: false; error: string };
|
||||
export type Result<T> = { ok: true; value: T } | { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Helper to create a successful result
|
||||
@@ -252,7 +250,7 @@ export function toInternalInitOptions(opts: InitOptions): InitOptionsInternal {
|
||||
* @internal
|
||||
*/
|
||||
export function toInternalSearchOptions(
|
||||
opts?: SearchOptions
|
||||
opts?: SearchOptions,
|
||||
): SearchOptionsInternal {
|
||||
return {
|
||||
max_threads: opts?.maxThreads,
|
||||
@@ -264,9 +262,6 @@ export function toInternalSearchOptions(
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Grep (live content search) types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Grep search mode
|
||||
@@ -296,9 +291,7 @@ export function createGrepCursor(offset: number): GrepCursor {
|
||||
* Options for live grep (content search)
|
||||
*
|
||||
* Files are searched sequentially in frecency order (most recently/frequently
|
||||
* accessed first). The engine collects matching lines across files until
|
||||
* `pageLimit` total matches are reached, then stops and returns a
|
||||
* `nextCursor` for fetching the next page.
|
||||
* accessed first). The engine returns a `nextCursor` for fetching the next page.
|
||||
*/
|
||||
export interface GrepOptions {
|
||||
/** Maximum file size to search in bytes. Files larger than this are skipped. (default: 10MB) */
|
||||
@@ -312,26 +305,11 @@ export interface GrepOptions {
|
||||
* Omit (or pass `null`) for the first page.
|
||||
*/
|
||||
cursor?: GrepCursor | null;
|
||||
/**
|
||||
* Maximum total number of matching lines to return across all files.
|
||||
* The engine walks files in frecency order, accumulating matches until this
|
||||
* limit is reached, then truncates and stops.
|
||||
*
|
||||
* Pagination is file-based, not match-based: if a single file produces more
|
||||
* matches than the remaining capacity, the excess matches from that file are
|
||||
* dropped and the next page resumes from the *next* file. This means some
|
||||
* matches at the boundary may be skipped, but it guarantees no duplicates
|
||||
* across pages and requires no server-side cursor state.
|
||||
*
|
||||
* Use `nextCursor` from the result to fetch the next page. (default: 50)
|
||||
*/
|
||||
pageLimit?: number;
|
||||
/** Search mode (default: "plain") */
|
||||
mode?: GrepMode;
|
||||
/**
|
||||
* Maximum wall-clock time in milliseconds to spend searching before returning
|
||||
* partial results. The engine will still return at least `pageLimit / 2` matches
|
||||
* (if available) before honoring the budget. 0 = unlimited. (default: 0)
|
||||
* partial results. 0 = unlimited. (default: 0)
|
||||
*/
|
||||
timeBudgetMs?: number;
|
||||
}
|
||||
@@ -378,9 +356,9 @@ export interface GrepMatch {
|
||||
* Result from a grep search
|
||||
*/
|
||||
export interface GrepResult {
|
||||
/** Matched items with file and line information. At most `pageLimit` entries. */
|
||||
/** Matched items with file and line information. At most `max_matches_per_file`. */
|
||||
items: GrepMatch[];
|
||||
/** Total number of matches collected (equal to items.length unless truncated by pageLimit) */
|
||||
/** Total number of matches collected (always equal to items.length). */
|
||||
totalMatched: number;
|
||||
/** Number of files actually opened and searched in this call */
|
||||
totalFilesSearched: number;
|
||||
@@ -406,7 +384,6 @@ export interface GrepOptionsInternal {
|
||||
max_matches_per_file?: number;
|
||||
smart_case?: boolean;
|
||||
file_offset?: number;
|
||||
page_limit?: number;
|
||||
mode?: string;
|
||||
time_budget_ms?: number;
|
||||
}
|
||||
@@ -415,15 +392,12 @@ export interface GrepOptionsInternal {
|
||||
* Convert public GrepOptions to internal format
|
||||
* @internal
|
||||
*/
|
||||
export function toInternalGrepOptions(
|
||||
opts?: GrepOptions
|
||||
): GrepOptionsInternal {
|
||||
export function toInternalGrepOptions(opts?: GrepOptions): GrepOptionsInternal {
|
||||
return {
|
||||
max_file_size: opts?.maxFileSize,
|
||||
max_matches_per_file: opts?.maxMatchesPerFile,
|
||||
smart_case: opts?.smartCase,
|
||||
file_offset: opts?.cursor?._offset ?? 0,
|
||||
page_limit: opts?.pageLimit,
|
||||
mode: opts?.mode,
|
||||
time_budget_ms: opts?.timeBudgetMs,
|
||||
};
|
||||
|
||||
+43
-21
@@ -15,9 +15,9 @@ async function main() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Health check (before init)
|
||||
console.log("Health check (before init):");
|
||||
const healthBefore = FileFinder.healthCheck();
|
||||
// Health check (before creating instance)
|
||||
console.log("Health check (no instance):");
|
||||
const healthBefore = FileFinder.healthCheckStatic();
|
||||
if (healthBefore.ok) {
|
||||
console.log(` Version: ${healthBefore.value.version}`);
|
||||
console.log(` Git available: ${healthBefore.value.git.available}`);
|
||||
@@ -29,25 +29,27 @@ async function main() {
|
||||
|
||||
// Initialize with the root project directory to test on more files
|
||||
const testDir = resolve(dirname(import.meta.path), "../..");
|
||||
console.log(`Initializing with base path: ${testDir}`);
|
||||
console.log(`Creating instance with base path: ${testDir}`);
|
||||
|
||||
const initResult = FileFinder.init({
|
||||
const createResult = FileFinder.create({
|
||||
basePath: testDir,
|
||||
});
|
||||
|
||||
if (!initResult.ok) {
|
||||
console.error(`Init failed: ${initResult.error}`);
|
||||
if (!createResult.ok) {
|
||||
console.error(`Create failed: ${createResult.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("Initialization successful!\n");
|
||||
|
||||
const finder = createResult.value;
|
||||
console.log("Instance created successfully!\n");
|
||||
|
||||
// Wait for scan with polling to show progress
|
||||
console.log("Waiting for initial scan...");
|
||||
const startTime = Date.now();
|
||||
let lastCount = 0;
|
||||
|
||||
while (FileFinder.isScanning()) {
|
||||
const progress = FileFinder.getScanProgress();
|
||||
while (finder.isScanning()) {
|
||||
const progress = finder.getScanProgress();
|
||||
if (progress.ok && progress.value.scannedFilesCount !== lastCount) {
|
||||
lastCount = progress.value.scannedFilesCount;
|
||||
console.log(` Scanning: ${lastCount} files...`);
|
||||
@@ -61,7 +63,7 @@ async function main() {
|
||||
}
|
||||
|
||||
// Get final scan progress
|
||||
const progress = FileFinder.getScanProgress();
|
||||
const progress = finder.getScanProgress();
|
||||
if (progress.ok) {
|
||||
console.log(`Scan complete: ${progress.value.scannedFilesCount} files indexed`);
|
||||
console.log(`Scan time: ${Date.now() - startTime}ms`);
|
||||
@@ -70,7 +72,7 @@ async function main() {
|
||||
|
||||
// Search test
|
||||
console.log("Searching for 'lib.rs'...");
|
||||
const searchResult = FileFinder.search("lib.rs", { pageSize: 5 });
|
||||
const searchResult = finder.search("lib.rs", { pageSize: 5 });
|
||||
|
||||
if (searchResult.ok) {
|
||||
console.log(`Found ${searchResult.value.totalMatched} matches (showing first 5):\n`);
|
||||
@@ -88,7 +90,7 @@ async function main() {
|
||||
|
||||
// Search with different query
|
||||
console.log("Searching for 'package.json'...");
|
||||
const searchResult2 = FileFinder.search("package.json", { pageSize: 3 });
|
||||
const searchResult2 = finder.search("package.json", { pageSize: 3 });
|
||||
|
||||
if (searchResult2.ok) {
|
||||
console.log(`Found ${searchResult2.value.totalMatched} matches:\n`);
|
||||
@@ -101,8 +103,8 @@ async function main() {
|
||||
console.log();
|
||||
|
||||
// Health check (after init)
|
||||
console.log("Health check (after init):");
|
||||
const healthAfter = FileFinder.healthCheck();
|
||||
console.log("Health check (with instance):");
|
||||
const healthAfter = finder.healthCheck();
|
||||
if (healthAfter.ok) {
|
||||
console.log(` File picker initialized: ${healthAfter.value.filePicker.initialized}`);
|
||||
console.log(` Base path: ${healthAfter.value.filePicker.basePath}`);
|
||||
@@ -113,14 +115,34 @@ async function main() {
|
||||
}
|
||||
console.log();
|
||||
|
||||
// Test multiple instances
|
||||
console.log("Testing multiple instances...");
|
||||
const finder2Result = FileFinder.create({ basePath: testDir });
|
||||
if (finder2Result.ok) {
|
||||
const finder2 = finder2Result.value;
|
||||
console.log(" Second instance created successfully");
|
||||
|
||||
finder2.waitForScan(5000);
|
||||
const search2 = finder2.search("Cargo.toml");
|
||||
if (search2.ok) {
|
||||
console.log(` Second instance found ${search2.value.totalMatched} matches for 'Cargo.toml'`);
|
||||
}
|
||||
|
||||
finder2.destroy();
|
||||
console.log(" Second instance destroyed");
|
||||
|
||||
// First instance should still work
|
||||
const search3 = finder.search("Cargo.toml");
|
||||
if (search3.ok) {
|
||||
console.log(` First instance still works: ${search3.value.totalMatched} matches`);
|
||||
}
|
||||
}
|
||||
console.log();
|
||||
|
||||
// Cleanup
|
||||
console.log("Cleaning up...");
|
||||
const destroyResult = FileFinder.destroy();
|
||||
if (destroyResult.ok) {
|
||||
console.log("Cleanup successful!");
|
||||
} else {
|
||||
console.error(`Cleanup failed: ${destroyResult.error}`);
|
||||
}
|
||||
finder.destroy();
|
||||
console.log(`Cleanup successful! (isDestroyed: ${finder.isDestroyed})`);
|
||||
|
||||
console.log("\n=== Test Complete ===");
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
---@diagnostic disable: undefined-field
|
||||
local fff_rust = require('fff.rust')
|
||||
|
||||
--- Wait for the scan to fully complete, handling the startup race where
|
||||
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env bash
|
||||
# tests/test_lazy_async_bug.sh
|
||||
#
|
||||
# Shell-level proof of the async-exit bug in the original download_or_build_binary().
|
||||
#
|
||||
# When lazy.nvim's build hook returns, Neovim may exit moments later.
|
||||
# The old implementation fired vim.system subprocesses and returned immediately,
|
||||
# so those subprocesses (git → curl → sha → rename) were orphaned on exit and
|
||||
# the binary was never written to disk.
|
||||
#
|
||||
# The fix wraps the whole chain in vim.wait, keeping the event loop alive until
|
||||
# the rename completes.
|
||||
#
|
||||
# Usage: bash tests/test_lazy_async_bug.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PLUGIN_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
export FFF_PLUGIN_ROOT="$PLUGIN_ROOT" # picked up by os.getenv() inside Lua
|
||||
|
||||
case "$(uname -s)" in
|
||||
Darwin) EXT=dylib ;;
|
||||
MINGW*|MSYS*|CYGWIN*) EXT=dll ;;
|
||||
*) EXT=so ;;
|
||||
esac
|
||||
BINARY="$PLUGIN_ROOT/target/release/libfff_nvim.$EXT"
|
||||
|
||||
passed=0; failed=0
|
||||
pass() { printf ' PASS %s\n' "$1"; (( passed += 1 )) || true; }
|
||||
fail() { printf ' FAIL %s\n' "$1" >&2; (( failed += 1 )) || true; }
|
||||
assert_file() { [[ -f $1 ]] && pass "$2" || fail "$2 (file missing: $1)"; }
|
||||
assert_no_file() { [[ ! -f $1 ]] && pass "$2" || fail "$2 (file unexpectedly present: $1)"; }
|
||||
|
||||
# ── Temp runners (cleaned up on exit) ────────────────────────────────────────
|
||||
RUNNERS=$(mktemp -d)
|
||||
trap 'rm -rf "$RUNNERS"' EXIT
|
||||
|
||||
# Runner A — simulates OLD build hook:
|
||||
# ensure_downloaded fires async vim.system calls, then returns.
|
||||
# os.exit(0) mimics Neovim exiting immediately after the hook returns —
|
||||
# the event loop never spins again, so the git/curl/rename callbacks die.
|
||||
cat >"$RUNNERS/async.lua" <<'LUA'
|
||||
vim.opt.runtimepath:prepend(os.getenv('FFF_PLUGIN_ROOT'))
|
||||
require('fff.download').ensure_downloaded(
|
||||
{ version = '3e9b865', force = true },
|
||||
function() end -- this callback is never reached
|
||||
)
|
||||
os.exit(0) -- immediate exit, same effect as lazy returning from the hook
|
||||
LUA
|
||||
|
||||
# Runner B — exercises the FIXED download_or_build_binary():
|
||||
# vim.wait spins the event loop until the rename lands on disk,
|
||||
# so the function only returns after the binary is present.
|
||||
cat >"$RUNNERS/blocking.lua" <<'LUA'
|
||||
vim.opt.runtimepath:prepend(os.getenv('FFF_PLUGIN_ROOT'))
|
||||
-- Blocks via vim.wait; only returns once the binary is on disk.
|
||||
require('fff.download').download_or_build_binary()
|
||||
LUA
|
||||
|
||||
printf '\n=== lazy.nvim async-exit bug ===\nbinary : %s\n\n' "$BINARY"
|
||||
|
||||
# ── Part 1: old async (broken) ────────────────────────────────────────────────
|
||||
echo '--- Part 1: OLD async behavior — fire subprocesses and exit ---'
|
||||
rm -f "$BINARY" "$BINARY.tmp"
|
||||
|
||||
nvim -l "$RUNNERS/async.lua" 2>/dev/null
|
||||
|
||||
# Give any orphaned subprocesses a full second to do whatever they can.
|
||||
# They finish running (git/curl), but the Neovim rename callback is dead,
|
||||
# so the binary never moves from .tmp → libfff_nvim.dylib.
|
||||
sleep 1
|
||||
assert_no_file "$BINARY" "binary absent — rename callback was killed with the process"
|
||||
|
||||
# ── Part 2: fixed blocking ────────────────────────────────────────────────────
|
||||
printf '\n--- Part 2: FIXED behavior --- vim.wait keeps event loop alive ---\n'
|
||||
rm -f "$BINARY" "$BINARY.tmp"
|
||||
|
||||
nvim -l "$RUNNERS/blocking.lua" # prints download progress to stdout
|
||||
|
||||
assert_file "$BINARY" "binary present — vim.wait held the process until rename succeeded"
|
||||
|
||||
# ── Summary ────────────────────────────────────────────────────────────────────
|
||||
printf '\n%d passed %d failed\n' "$passed" "$failed"
|
||||
(( failed == 0 ))
|
||||
Reference in New Issue
Block a user