Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 461a615878 | |||
| 2951756ae3 | |||
| c17056bcb6 | |||
| abfa5d0ef7 | |||
| d997344fd7 | |||
| e3ba972db6 | |||
| a4f87bd4f2 | |||
| 7fd361a369 | |||
| c477f12487 | |||
| 8fe26ad4bd | |||
| 003c05cf6d | |||
| 0312492570 | |||
| e3f788f87b | |||
| 53acaf90ab | |||
| 2c55114048 | |||
| d88922e6c7 | |||
| 9edf195c8f | |||
| 65aeacf9e2 | |||
| e8850c3c62 | |||
| ee8bd6e839 | |||
| 3c76ba523f | |||
| 51f32597de | |||
| 2df06289c9 | |||
| 550a9053f9 | |||
| 3fa36f0a75 | |||
| 61081a55ca | |||
| 3a803c40ed | |||
| 4a3453d3de | |||
| cac2ec7130 | |||
| 5a898066d2 | |||
| 7cdc71d5a1 | |||
| 54f96dade0 | |||
| 56ea1404f1 | |||
| 43100e3150 | |||
| ac7cd106b3 | |||
| d27a9bc8fd | |||
| 8905981871 | |||
| 381992457b | |||
| a9b2f5bba2 | |||
| 61c3cc7420 | |||
| 829e05b353 | |||
| a9cac80ea9 | |||
| 7b1fab33be | |||
| 25dfbd2ffd | |||
| c26a2bb375 | |||
| 84a8ebc230 | |||
| a42ba4907d | |||
| 601b150eee | |||
| 13b855b172 | |||
| 13e1383ffd | |||
| fd38df7bf0 | |||
| e6e58f91d2 | |||
| c3271624be | |||
| ad12110ded | |||
| 7c8a941001 | |||
| 4e1a94c9e5 | |||
| 16b69da3b7 | |||
| 06066d138d | |||
| b0edf2a0d1 | |||
| 80263ad595 | |||
| 3396c03f4a | |||
| bda834e34d | |||
| afedc064bd | |||
| edfc02c024 | |||
| d25abb8a73 | |||
| e1c3db33ac | |||
| 8ce96885b4 | |||
| 38c533bcce | |||
| b2d3906577 | |||
| a614094de9 | |||
| 2ff3311f99 |
@@ -0,0 +1,31 @@
|
||||
name: Nix CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
id-token: "write"
|
||||
contents: "read"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: DeterminateSystems/nix-installer-action@main
|
||||
- uses: DeterminateSystems/magic-nix-cache-action@main
|
||||
- uses: DeterminateSystems/flake-checker-action@main
|
||||
|
||||
- name: Run `nix flake check`
|
||||
run: nix flake check
|
||||
|
||||
- name: Run `nix build`
|
||||
run: nix build
|
||||
|
||||
- name: Run `nix build .#fff-nvim`
|
||||
run: nix build .#fff-nvim
|
||||
|
||||
- name: Run `nix run .#release`
|
||||
run: nix run .#release
|
||||
@@ -1,6 +1,6 @@
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
branches-ignore:
|
||||
- main
|
||||
name: docs
|
||||
|
||||
@@ -11,17 +11,25 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# fetch last 2 commits required for auto force push back
|
||||
fetch-depth: 2
|
||||
|
||||
- name: panvimdoc
|
||||
uses: kdheepak/panvimdoc@main
|
||||
with:
|
||||
vimdoc: fff.nvim
|
||||
version: "Neovim >= 0.8.0"
|
||||
version: "Neovim >= 0.10.0"
|
||||
demojify: true
|
||||
treesitter: true
|
||||
- name: Push changes
|
||||
uses: stefanzweifel/git-auto-commit-action@v6
|
||||
|
||||
- name: Get last commit message
|
||||
id: last-commit
|
||||
run: |
|
||||
echo "message=$(git log -1 --pretty=%s)" >> $GITHUB_OUTPUT
|
||||
echo "author=$(git log -1 --pretty=\"%an <%ae>\")" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: stefanzweifel/git-auto-commit-action@v6
|
||||
with:
|
||||
commit_message: "chore: autgenerate vimdoc"
|
||||
commit_user_name: "github-actions[bot]"
|
||||
commit_user_email: "github-actions[bot]@users.noreply.github.com"
|
||||
commit_author: "github-actions[bot] <github-actions[bot]@users.noreply.github.com>"
|
||||
commit_author: ${{ steps.last-commit.outputs.author }}
|
||||
commit_message: "chore: Update docs for - ${{ steps.last-commit.outputs.message }}"
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
name: Release
|
||||
name: Prebuild
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
branches: [main, feat/prebuild]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -18,44 +17,47 @@ jobs:
|
||||
# Glibc 2.21
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
artifact_name: target/x86_64-unknown-linux-gnu/release/libfff_fuzzy.so
|
||||
artifact_name: target/x86_64-unknown-linux-gnu/release/libfff_nvim.so
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-gnu
|
||||
artifact_name: target/aarch64-unknown-linux-gnu/release/libfff_fuzzy.so
|
||||
artifact_name: target/aarch64-unknown-linux-gnu/release/libfff_nvim.so
|
||||
# Musl 1.2.3
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-musl
|
||||
artifact_name: target/x86_64-unknown-linux-musl/release/libfff_fuzzy.so
|
||||
artifact_name: target/x86_64-unknown-linux-musl/release/libfff_nvim.so
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-musl
|
||||
artifact_name: target/aarch64-unknown-linux-musl/release/libfff_fuzzy.so
|
||||
# Android (Termux)
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-linux-android
|
||||
artifact_name: target/aarch64-linux-android/release/libfff_fuzzy.so
|
||||
artifact_name: target/aarch64-unknown-linux-musl/release/libfff_nvim.so
|
||||
# # Android (Termux)
|
||||
# - os: ubuntu-latest
|
||||
# target: aarch64-linux-android
|
||||
# artifact_name: target/aarch64-linux-android/release/libfff_nvim.so
|
||||
|
||||
## macOS builds
|
||||
- os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
artifact_name: target/x86_64-apple-darwin/release/libfff_fuzzy.dylib
|
||||
artifact_name: target/x86_64-apple-darwin/release/libfff_nvim.dylib
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
artifact_name: target/aarch64-apple-darwin/release/libfff_fuzzy.dylib
|
||||
artifact_name: target/aarch64-apple-darwin/release/libfff_nvim.dylib
|
||||
|
||||
## Windows builds
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
artifact_name: target/x86_64-pc-windows-msvc/release/fff_fuzzy.dll
|
||||
artifact_name: target/x86_64-pc-windows-msvc/release/fff_nvim.dll
|
||||
- os: windows-latest
|
||||
target: aarch64-pc-windows-msvc
|
||||
artifact_name: target/aarch64-pc-windows-msvc/release/fff_nvim.dll
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set Rust toolchain
|
||||
if: contains(matrix.target, 'linux')
|
||||
# https://github.com/rust-cross/cargo-zigbuild/issues/327
|
||||
run: echo -e '[toolchain]\nchannel = "nightly-2025-02-19"' > rust-toolchain.toml
|
||||
# - name: Set Rust toolchain
|
||||
# if: contains(matrix.target, 'linux')
|
||||
# # https://github.com/rust-cross/cargo-zigbuild/issues/327
|
||||
# run: echo -e '[toolchain]\nchannel = "nightly-2025-02-19"' > rust-toolchain.toml
|
||||
|
||||
- name: Install Rust
|
||||
run: |
|
||||
@@ -88,7 +90,7 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.target }}
|
||||
path: ${{ matrix.target }}.*
|
||||
path: ${{ matrix.target }}*
|
||||
|
||||
release:
|
||||
name: Release
|
||||
@@ -97,22 +99,37 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ./binaries
|
||||
|
||||
- name: Generate checksums
|
||||
working-directory: ./binaries
|
||||
run: |
|
||||
ls -a
|
||||
for file in ./**/*; do
|
||||
sha256sum "$file" > "${file}.sha256"
|
||||
done
|
||||
|
||||
- name: Prepare tag
|
||||
id: vars
|
||||
shell: bash
|
||||
run: |
|
||||
sha="$(git rev-parse --short HEAD)"
|
||||
echo "tag=$sha" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Upload Release Assets
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: ${{ github.ref_name }}
|
||||
tag_name: ${{ github.ref_name }}
|
||||
name: "${{ steps.vars.outputs.tag }}"
|
||||
tag_name: "${{ steps.vars.outputs.tag }}"
|
||||
token: ${{ github.token }}
|
||||
files: ./**/*
|
||||
files: ./binaries/**/*
|
||||
draft: false
|
||||
prerelease: false
|
||||
generate_release_notes: true
|
||||
prerelease: true
|
||||
generate_release_notes: false
|
||||
body: |
|
||||
Nightly release from commit: ${{ github.sha }}
|
||||
|
||||
@@ -10,3 +10,6 @@ result
|
||||
.repro/
|
||||
.wrangler/
|
||||
*.so
|
||||
big-repo/
|
||||
# all the perf like utility files
|
||||
*.data
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
empty_config.lua
|
||||
benches/
|
||||
doc/
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
syntax = "LuaJIT"
|
||||
column_width = 120
|
||||
line_endings = "Unix"
|
||||
indent_type = "Spaces"
|
||||
|
||||
Generated
+526
-15
@@ -2,6 +2,19 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"getrandom 0.3.3",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.3"
|
||||
@@ -26,6 +39,18 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anes"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
@@ -103,6 +128,12 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "cast"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.27"
|
||||
@@ -120,6 +151,12 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.41"
|
||||
@@ -135,6 +172,58 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ciborium"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
|
||||
dependencies = [
|
||||
"ciborium-io",
|
||||
"ciborium-ll",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ciborium-io"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
|
||||
|
||||
[[package]]
|
||||
name = "ciborium-ll"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
|
||||
dependencies = [
|
||||
"ciborium-io",
|
||||
"half",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.5.53"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.5.53"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.3.1"
|
||||
@@ -147,6 +236,42 @@ version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "criterion"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f"
|
||||
dependencies = [
|
||||
"anes",
|
||||
"cast",
|
||||
"ciborium",
|
||||
"clap",
|
||||
"criterion-plot",
|
||||
"is-terminal",
|
||||
"itertools",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"oorandom",
|
||||
"plotters",
|
||||
"rayon",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"tinytemplate",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "criterion-plot"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
|
||||
dependencies = [
|
||||
"cast",
|
||||
"itertools",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.15"
|
||||
@@ -190,6 +315,22 @@ version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "crunchy"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "ctrlc"
|
||||
version = "3.4.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46f93780a459b7d656ef7f071fe699c4d3d2cb201c4b24d085b6ddc505276e73"
|
||||
dependencies = [
|
||||
"nix",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.4.0"
|
||||
@@ -199,6 +340,27 @@ dependencies = [
|
||||
"powerfmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "5.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
|
||||
dependencies = [
|
||||
"dirs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.5"
|
||||
@@ -225,22 +387,49 @@ version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||
|
||||
[[package]]
|
||||
name = "fff_nvim"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"blake3",
|
||||
"chrono",
|
||||
"criterion",
|
||||
"ctrlc",
|
||||
"dirs",
|
||||
"git2",
|
||||
"glidesort",
|
||||
"heed",
|
||||
"ignore",
|
||||
"mimalloc",
|
||||
"mlua",
|
||||
"neo_frizbee",
|
||||
"notify",
|
||||
"notify-debouncer-full",
|
||||
"once_cell",
|
||||
"openssl",
|
||||
"pathdiff",
|
||||
"rand",
|
||||
"rayon",
|
||||
"serde",
|
||||
"smartstring",
|
||||
"tempfile",
|
||||
"thiserror 2.0.12",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
@@ -249,11 +438,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "file-id"
|
||||
version = "0.2.2"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6bc904b9bbefcadbd8e3a9fb0d464a9b979de6324c03b3c663e8994f46a5be36"
|
||||
checksum = "e1fc6a637b6dc58414714eddd9170ff187ecb0933d4c7024d1abbd23a3cc26e9"
|
||||
dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -289,6 +478,17 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.3"
|
||||
@@ -316,6 +516,12 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glidesort"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2e102e6eb644d3e0b186fc161e4460417880a0a0b87d235f2e5b8fb30f2e9e0"
|
||||
|
||||
[[package]]
|
||||
name = "globset"
|
||||
version = "0.4.16"
|
||||
@@ -329,6 +535,17 @@ dependencies = [
|
||||
"regex-syntax 0.8.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "2.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crunchy",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heed"
|
||||
version = "0.22.0"
|
||||
@@ -367,6 +584,12 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.63"
|
||||
@@ -534,6 +757,26 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-terminal"
|
||||
version = "0.4.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.10.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.15"
|
||||
@@ -546,7 +789,7 @@ version = "0.1.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"getrandom 0.3.3",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -606,6 +849,26 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libmimalloc-sys"
|
||||
version = "0.1.43"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf88cd67e9de251c1781dbe2f641a1a3ad66eaae831b8a2c38fbdc5ddae16d4d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libssh2-sys"
|
||||
version = "0.3.1"
|
||||
@@ -632,6 +895,12 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.0"
|
||||
@@ -680,6 +949,15 @@ version = "2.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
|
||||
|
||||
[[package]]
|
||||
name = "mimalloc"
|
||||
version = "0.1.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1791cbe101e95af5764f06f20f6760521f7158f69dbf9d6baf941ee1bf6bc40"
|
||||
dependencies = [
|
||||
"libmimalloc-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.0.4"
|
||||
@@ -754,20 +1032,31 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "neo_frizbee"
|
||||
version = "0.6.0"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dedc43c9781dfa8c9584d3e50d68ce956b928b01ae0ad1da2014be5a322c20f8"
|
||||
checksum = "2b4421f748f561dd0bb677e46f2e6c1ed2c52ba7130349229a73445af5010d17"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"multiversion",
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify"
|
||||
version = "8.1.0"
|
||||
name = "nix"
|
||||
version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3163f59cd3fa0e9ef8c32f242966a7b9994fd7378366099593e0e73077cd8c97"
|
||||
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify"
|
||||
version = "8.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"fsevent-sys",
|
||||
@@ -783,9 +1072,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "notify-debouncer-full"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2d88b1a7538054351c8258338df7c931a590513fb3745e8c15eb9ff4199b8d1"
|
||||
checksum = "375bd3a138be7bfeff3480e4a623df4cbfb55b79df617c055cd810ba466fa078"
|
||||
dependencies = [
|
||||
"file-id",
|
||||
"log",
|
||||
@@ -831,6 +1120,12 @@ version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "oorandom"
|
||||
version = "11.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.73"
|
||||
@@ -885,6 +1180,12 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "overload"
|
||||
version = "0.1.1"
|
||||
@@ -990,6 +1291,34 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "plotters"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"plotters-backend",
|
||||
"plotters-svg",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "plotters-backend"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a"
|
||||
|
||||
[[package]]
|
||||
name = "plotters-svg"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670"
|
||||
dependencies = [
|
||||
"plotters-backend",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.2"
|
||||
@@ -1005,6 +1334,15 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.95"
|
||||
@@ -1035,6 +1373,18 @@ version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
@@ -1043,6 +1393,9 @@ name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom 0.2.16",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
@@ -1073,6 +1426,17 @@ dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||
dependencies = [
|
||||
"getrandom 0.2.16",
|
||||
"libredox",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.11.1"
|
||||
@@ -1123,6 +1487,19 @@ version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.21"
|
||||
@@ -1209,12 +1586,30 @@ version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "smartstring"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"serde",
|
||||
"static_assertions",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.104"
|
||||
@@ -1252,6 +1647,19 @@ version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1bbb9f3c5c463a01705937a24fdabc5047929ac764b2d5b9cf681c1f5041ed5"
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
@@ -1342,6 +1750,16 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinytemplate"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.41"
|
||||
@@ -1450,6 +1868,12 @@ version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
@@ -1533,6 +1957,16 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.77"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
@@ -1625,11 +2059,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1650,6 +2084,21 @@ dependencies = [
|
||||
"windows-targets 0.53.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.48.5",
|
||||
"windows_aarch64_msvc 0.48.5",
|
||||
"windows_i686_gnu 0.48.5",
|
||||
"windows_i686_msvc 0.48.5",
|
||||
"windows_x86_64_gnu 0.48.5",
|
||||
"windows_x86_64_gnullvm 0.48.5",
|
||||
"windows_x86_64_msvc 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
@@ -1683,6 +2132,12 @@ dependencies = [
|
||||
"windows_x86_64_msvc 0.53.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
@@ -1695,6 +2150,12 @@ version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -1707,6 +2168,12 @@ version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -1731,6 +2198,12 @@ version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -1743,6 +2216,12 @@ version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -1755,6 +2234,12 @@ version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
@@ -1767,6 +2252,12 @@ version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -1818,6 +2309,26 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.6"
|
||||
|
||||
+35
-3
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "fff_nvim"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
path = "lua/fff/rust/lib.rs"
|
||||
@@ -11,21 +11,53 @@ crate-type = ["cdylib", "rlib"]
|
||||
name = "test_watcher"
|
||||
path = "src/bin/test_watcher.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "jemalloc_profile"
|
||||
path = "src/bin/jemalloc_profile.rs"
|
||||
[[bin]]
|
||||
name = "search_profiler"
|
||||
path = "src/bin/search_profiler.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "bench_search_only"
|
||||
path = "src/bin/bench_search_only.rs"
|
||||
|
||||
[[bench]]
|
||||
name = "query_tracker_bench"
|
||||
harness = false
|
||||
|
||||
[dependencies]
|
||||
ahash = "0.8"
|
||||
blake3 = "1.8.2"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
ctrlc = "3.4.2"
|
||||
dirs = "5.0"
|
||||
git2 = "0.20.2"
|
||||
glidesort = "0.1"
|
||||
heed = "0.22.0"
|
||||
ignore = "0.4.22"
|
||||
mimalloc = "0.1.47"
|
||||
|
||||
mlua = { version = "0.11.1", features = ["module", "luajit"] }
|
||||
neo_frizbee = { version = "0.6.0" }
|
||||
neo_frizbee = { version = "0.7.1" }
|
||||
notify = "8.1.0"
|
||||
notify-debouncer-full = "0.5"
|
||||
notify-debouncer-full = "0.6"
|
||||
once_cell = "1.20.2"
|
||||
openssl = { version = "0.10", features = ["vendored"] }
|
||||
pathdiff = "0.2.1"
|
||||
rayon = "1.8.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
smartstring = { version = "1.0.1", features = ["serde"] }
|
||||
thiserror = "2.0.10"
|
||||
tracing = "0.1"
|
||||
tracing-appender = "0.2"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
rand = { version = "0.8", features = ["small_rng"] }
|
||||
tempfile = "3.8"
|
||||
|
||||
[[bench]]
|
||||
name = "indexing_and_search"
|
||||
harness = false
|
||||
|
||||
@@ -23,15 +23,16 @@ It comes with a dedicated rust backend runtime that keep tracks of the file inde
|
||||
|
||||
- Works out of the box with no additional configuration
|
||||
- [Typo resistant fuzzy search](https://github.com/saghen/frizbee)
|
||||
- Git status integration allowing to take adavantage of last modified times within a worktree
|
||||
- Separate file index maintained by a dedicaged backend allows <10 milliseconds search time for 50k files codebase
|
||||
- Git status integration allowing to take advantage of last modified times within a worktree
|
||||
- Separate file index maintained by a dedicated backend allows <10 milliseconds search time for 50k files codebase
|
||||
- Display images in previews (for now requires snacks.nvim)
|
||||
- Smart in a plenty of different ways hopefully helpful for your workflow
|
||||
- This plugin initializes itself lazily by default
|
||||
|
||||
## Installation
|
||||
|
||||
> [!NOTE]
|
||||
> Although we'll try to make sure to keep 100% backward compatibiility, by using you should understand that silly bugs and breaking changes may happen.
|
||||
> [!NOTE]
|
||||
> Although we'll try to make sure to keep 100% backward compatibility, by using you should understand that silly bugs and breaking changes may happen.
|
||||
> And also we hope for your contributions and feedback to make this plugin ideal for everyone.
|
||||
|
||||
### Prerequisites
|
||||
@@ -39,110 +40,214 @@ It comes with a dedicated rust backend runtime that keep tracks of the file inde
|
||||
FFF.nvim requires:
|
||||
|
||||
- Neovim 0.10.0+
|
||||
- Rust toolchain (requires nightly for building the native backend)
|
||||
- [Rustup](https://rustup.rs/) (we require nightly for building the native backend rustup will handle toolchain automatically)
|
||||
|
||||
### Package Managers
|
||||
### Installation
|
||||
|
||||
#### lazy.nvim
|
||||
|
||||
```lua
|
||||
{
|
||||
"dmtrKovalenko/fff.nvim",
|
||||
build = "cargo build --release",
|
||||
-- or if you are using nixos
|
||||
'dmtrKovalenko/fff.nvim',
|
||||
build = function()
|
||||
-- this will download prebuild binary or try to use existing rustup toolchain to build from source
|
||||
-- (if you are using lazy you can use gb for rebuilding a plugin if needed)
|
||||
require("fff.download").download_or_build_binary()
|
||||
end,
|
||||
-- if you are using nixos
|
||||
-- build = "nix run .#release",
|
||||
opts = {
|
||||
-- pass here all the options
|
||||
opts = { -- (optional)
|
||||
debug = {
|
||||
enabled = true, -- we expect your collaboration at least during the beta
|
||||
show_scores = true, -- to help us optimize the scoring system, feel free to share your scores!
|
||||
},
|
||||
},
|
||||
-- No need to lazy-load with lazy.nvim.
|
||||
-- This plugin initializes itself lazily.
|
||||
lazy = false,
|
||||
keys = {
|
||||
{
|
||||
"ff", -- try it if you didn't it is a banger keybinding for a picker
|
||||
function()
|
||||
require("fff").find_files() -- or find_in_git_root() if you only want git files
|
||||
end,
|
||||
desc = "Open file picker",
|
||||
},
|
||||
},
|
||||
function() require('fff').find_files() end,
|
||||
desc = 'FFFind files',
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Default Configuration
|
||||
|
||||
FFF.nvim comes with sensible defaults. Here's the complete default configuration:
|
||||
#### vim.pack
|
||||
|
||||
```lua
|
||||
require("fff").setup({
|
||||
-- UI dimensions and appearance
|
||||
width = 0.8, -- Window width as fraction of screen
|
||||
height = 0.8, -- Window height as fraction of screen
|
||||
prompt = '🪿 ', -- Input prompt symbol
|
||||
preview = {
|
||||
enabled = true,
|
||||
width = 0.5,
|
||||
max_lines = 100,
|
||||
max_size = 1024 * 1024, -- 1MB
|
||||
},
|
||||
title = 'FFF Files', -- Window title
|
||||
max_results = 60, -- Maximum search results to display
|
||||
max_threads = 4, -- Maximum threads for fuzzy search
|
||||
vim.pack.add({ 'https://github.com/dmtrKovalenko/fff.nvim' })
|
||||
|
||||
keymaps = {
|
||||
close = '<Esc>',
|
||||
select = '<CR>',
|
||||
select_split = '<C-s>',
|
||||
select_vsplit = '<C-v>',
|
||||
select_tab = '<C-t>',
|
||||
-- Multiple bindings supported
|
||||
move_up = { '<Up>', '<C-p>' },
|
||||
move_down = { '<Down>', '<C-n>' },
|
||||
preview_scroll_up = '<C-u>',
|
||||
preview_scroll_down = '<C-d>',
|
||||
},
|
||||
vim.api.nvim_create_autocmd('PackChanged', {
|
||||
callback = function(event)
|
||||
if event.data.updated then
|
||||
require('fff.download').download_or_build_binary()
|
||||
end
|
||||
end,
|
||||
})
|
||||
|
||||
-- Highlight groups
|
||||
hl = {
|
||||
border = 'FloatBorder',
|
||||
normal = 'Normal',
|
||||
cursor = 'CursorLine',
|
||||
matched = 'IncSearch',
|
||||
title = 'Title',
|
||||
prompt = 'Question',
|
||||
active_file = 'Visual',
|
||||
frecency = 'Number',
|
||||
debug = 'Comment',
|
||||
},
|
||||
|
||||
-- Debug options
|
||||
-- the plugin will automatically lazy load
|
||||
vim.g.fff = {
|
||||
lazy_sync = true, -- start syncing only when the picker is open
|
||||
debug = {
|
||||
show_scores = false, -- Toggle with F2 or :FFFDebug
|
||||
enabled = true,
|
||||
show_scores = true,
|
||||
},
|
||||
}
|
||||
|
||||
vim.keymap.set(
|
||||
'n',
|
||||
'ff',
|
||||
function() require('fff').find_files() end,
|
||||
{ desc = 'FFFind files' }
|
||||
)
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
FFF.nvim comes with sensible defaults. Here's the complete configuration with all available options:
|
||||
|
||||
```lua
|
||||
require('fff').setup({
|
||||
base_path = vim.fn.getcwd(),
|
||||
prompt = '🪿 ',
|
||||
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,
|
||||
show_scrollbar = true, -- Show scrollbar for pagination
|
||||
},
|
||||
preview = {
|
||||
enabled = true,
|
||||
max_size = 10 * 1024 * 1024, -- Do not try to read files larger than 10MB
|
||||
chunk_size = 8192, -- Bytes per chunk for dynamic loading (8kb - fits ~100-200 lines)
|
||||
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,
|
||||
wrap_lines = false,
|
||||
show_file_info = true,
|
||||
filetypes = {
|
||||
svg = { wrap_lines = true },
|
||||
markdown = { wrap_lines = true },
|
||||
text = { wrap_lines = true },
|
||||
},
|
||||
},
|
||||
keymaps = {
|
||||
close = '<Esc>',
|
||||
select = '<CR>',
|
||||
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>',
|
||||
-- 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>',
|
||||
},
|
||||
hl = {
|
||||
border = 'FloatBorder',
|
||||
normal = 'Normal',
|
||||
cursor = 'CursorLine',
|
||||
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
|
||||
-- Multi-select highlights
|
||||
selected = 'FFFSelected',
|
||||
selected_active = 'FFFSelectedActive',
|
||||
-- Git text highlights for file names
|
||||
git_staged = 'FFFGitStaged',
|
||||
git_modified = 'FFFGitModified',
|
||||
git_deleted = 'FFFGitDeleted',
|
||||
git_renamed = 'FFFGitRenamed',
|
||||
git_untracked = 'FFFGitUntracked',
|
||||
git_ignored = 'FFFGitIgnored',
|
||||
-- Git sign/border highlights
|
||||
git_sign_staged = 'FFFGitSignStaged',
|
||||
git_sign_modified = 'FFFGitSignModified',
|
||||
git_sign_deleted = 'FFFGitSignDeleted',
|
||||
git_sign_renamed = 'FFFGitSignRenamed',
|
||||
git_sign_untracked = 'FFFGitSignUntracked',
|
||||
git_sign_ignored = 'FFFGitSignIgnored',
|
||||
-- Git sign selected highlights
|
||||
git_sign_staged_selected = 'FFFGitSignStagedSelected',
|
||||
git_sign_modified_selected = 'FFFGitSignModifiedSelected',
|
||||
git_sign_deleted_selected = 'FFFGitSignDeletedSelected',
|
||||
git_sign_renamed_selected = 'FFFGitSignRenamedSelected',
|
||||
git_sign_untracked_selected = 'FFFGitSignUntrackedSelected',
|
||||
git_sign_ignored_selected = 'FFFGitSignIgnoredSelected',
|
||||
},
|
||||
-- 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, -- 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
|
||||
},
|
||||
-- Git integration
|
||||
git = {
|
||||
status_text_color = false, -- Apply git status colors to filename text (default: false, only sign column)
|
||||
},
|
||||
debug = {
|
||||
enabled = false, -- Set to true to show scores in the UI
|
||||
show_scores = false,
|
||||
},
|
||||
logging = {
|
||||
enabled = true,
|
||||
log_file = vim.fn.stdpath('log') .. '/fff.log',
|
||||
log_level = 'info',
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Key Features
|
||||
|
||||
#### Available methods
|
||||
#### 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").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
|
||||
require("fff").change_indexing_directory(new_path) -- Change the base directory for the file picker
|
||||
require('fff').find_files() -- Find files in current directory
|
||||
require('fff').find_in_git_root() -- Find files in the current git repository
|
||||
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
|
||||
require('fff').change_indexing_directory(new_path) -- Change the base directory for the file picker
|
||||
```
|
||||
|
||||
#### Multiple Key Bindings
|
||||
#### Commands
|
||||
|
||||
You can assign multiple key combinations to the same action:
|
||||
FFF.nvim provides several commands for interacting with the file picker:
|
||||
|
||||
```lua
|
||||
keymaps = {
|
||||
move_up = { '<Up>', '<C-p>', '<C-k>' }, -- Three ways to move up
|
||||
close = { '<Esc>', '<C-c>' }, -- Two ways to close
|
||||
select = '<CR>', -- Single binding still works
|
||||
}
|
||||
```
|
||||
- `:FFFFind [path|query]` - Open file picker. Optional: provide directory path or search query
|
||||
- `:FFFScan` - Manually trigger a rescan of files in the current directory
|
||||
- `:FFFRefreshGit` - Manually refresh git status for all files
|
||||
- `:FFFClearCache [all|frecency|files]` - Clear various caches
|
||||
- `:FFFHealth` - Check FFF health status and dependencies
|
||||
- `:FFFDebug [on|off|toggle]` - Toggle debug scores display
|
||||
- `:FFFOpenLog` - Open the FFF log file in a new tab
|
||||
|
||||
#### Multiline Paste Support
|
||||
|
||||
@@ -156,11 +261,116 @@ Toggle scoring information display:
|
||||
- Use `:FFFDebug` command
|
||||
- Enable by default with `debug.show_scores = true`
|
||||
|
||||
````
|
||||
#### Multi-Select and Quickfix Integration
|
||||
|
||||
#### vim-plug
|
||||
Select multiple files and send them to Neovim's quickfix list (keymaps are configurable):
|
||||
|
||||
- `<Tab>` - Toggle selection for the current file (shows thick border `▊` in signcolumn)
|
||||
- `<C-q>` - Send selected files to quickfix list and close picker
|
||||
|
||||
#### Git Status Highlighting
|
||||
|
||||
FFF integrates with git to show file status through sign column indicators (enabled by default) and optional filename text coloring.
|
||||
|
||||
**Sign Column Indicators** (enabled by default) - Border characters shown in the sign column:
|
||||
```lua
|
||||
hl = {
|
||||
git_sign_staged = 'FFFGitSignStaged',
|
||||
git_sign_modified = 'FFFGitSignModified',
|
||||
git_sign_deleted = 'FFFGitSignDeleted',
|
||||
git_sign_renamed = 'FFFGitSignRenamed',
|
||||
git_sign_untracked = 'FFFGitSignUntracked',
|
||||
git_sign_ignored = 'FFFGitSignIgnored',
|
||||
}
|
||||
```
|
||||
|
||||
**Text Highlights** (opt-in) - Apply colors to filenames based on git status:
|
||||
|
||||
To enable git status text coloring, set `git.status_text_color = true`:
|
||||
```lua
|
||||
require('fff').setup({
|
||||
git = {
|
||||
status_text_color = true, -- Enable git status colors on filename text
|
||||
},
|
||||
hl = {
|
||||
git_staged = 'FFFGitStaged', -- Files staged for commit
|
||||
git_modified = 'FFFGitModified', -- Modified unstaged files
|
||||
git_deleted = 'FFFGitDeleted', -- Deleted files
|
||||
git_renamed = 'FFFGitRenamed', -- Renamed files
|
||||
git_untracked = 'FFFGitUntracked', -- New untracked files
|
||||
git_ignored = 'FFFGitIgnored', -- Git-ignored files
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
The plugin provides sensible default highlight groups that link to common git highlight groups (e.g., GitSignsAdd, GitSignsChange). You can override these with your own custom highlight groups to match your colorscheme.
|
||||
|
||||
**Example - Custom Bright Colors for Text:**
|
||||
```lua
|
||||
vim.api.nvim_set_hl(0, 'CustomGitModified', { fg = '#FFA500' })
|
||||
vim.api.nvim_set_hl(0, 'CustomGitUntracked', { fg = '#00FF00' })
|
||||
|
||||
require('fff').setup({
|
||||
git = {
|
||||
status_text_color = true,
|
||||
},
|
||||
hl = {
|
||||
git_modified = 'CustomGitModified',
|
||||
git_untracked = 'CustomGitUntracked',
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
#### Health Check
|
||||
|
||||
Run `:FFFHealth` to check the status of FFF.nvim and its dependencies. This will verify:
|
||||
|
||||
- File picker initialization status
|
||||
- Optional dependencies (git, image preview tools)
|
||||
- Database connectivity
|
||||
|
||||
#### Viewing Logs
|
||||
|
||||
If you encounter issues, check the log file:
|
||||
|
||||
```vim
|
||||
Plug 'MunifTanjim/nui.nvim'
|
||||
Plug 'dmtrKovalenko/fff.nvim', { 'do': 'cargo build --release' }
|
||||
````
|
||||
:FFFOpenLog
|
||||
```
|
||||
|
||||
Or manually open the log file at `~/.local/state/nvim/log/fff.log` (default location).
|
||||
|
||||
#### Common Issues
|
||||
|
||||
**File picker not initializing:**
|
||||
|
||||
- Ensure the Rust backend is compiled: `cargo build --release` in the plugin directory
|
||||
- Check that your Neovim version is 0.10.0 or higher
|
||||
|
||||
**Image previews not working:**
|
||||
|
||||
- Verify your terminal supports images (kitty, iTerm2, WezTerm, etc.)
|
||||
- For terminals without native image support, install one of: `chafa`, `viu`, or `img2txt`
|
||||
- If using snacks.nvim, ensure it's properly configured
|
||||
|
||||
**Performance issues:**
|
||||
|
||||
- Adjust `max_threads` in configuration based on your system
|
||||
- Reduce `preview.max_lines` and `preview.max_size` for large files
|
||||
- Clear cache if it becomes too large: `:FFFClearCache all`
|
||||
|
||||
**Files not being indexed:**
|
||||
|
||||
- Run `:FFFScan` to manually trigger a file scan
|
||||
- Check that the `base_path` is correctly set
|
||||
- Verify you have read permissions for the directory
|
||||
|
||||
#### Debug Mode
|
||||
|
||||
Enable debug mode to see scoring information and troubleshoot search results:
|
||||
|
||||
- Press `F2` while in the picker
|
||||
- Run `:FFFDebug on` to enable permanently
|
||||
- Set `debug.show_scores = true` in configuration
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
[files]
|
||||
extend-exclude = ["/CHANGELOG.md"]
|
||||
extend-exclude = ["/CHANGELOG.md", "data/filetypes/base.lua"]
|
||||
|
||||
[default.extend-words]
|
||||
noice = "noice"
|
||||
|
||||
@@ -0,0 +1,667 @@
|
||||
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 std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Initialize tracing to output to console
|
||||
fn init_tracing() {
|
||||
// use tracing_subscriber::EnvFilter;
|
||||
// use tracing_subscriber::fmt;
|
||||
// let _ = fmt()
|
||||
// .with_env_filter(
|
||||
// EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
|
||||
// )
|
||||
// .with_target(false)
|
||||
// .with_thread_ids(true)
|
||||
// .with_line_number(true)
|
||||
// .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(())
|
||||
}
|
||||
|
||||
/// Helper function to wait for scanning to complete and get file count
|
||||
fn wait_for_scan_completion(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();
|
||||
let mut iteration = 0;
|
||||
|
||||
loop {
|
||||
iteration += 1;
|
||||
|
||||
{
|
||||
let picker_guard = FILE_PICKER
|
||||
.read()
|
||||
.map_err(|_| "Failed to acquire read lock")?;
|
||||
if let Some(ref picker) = *picker_guard {
|
||||
let is_scanning = picker.is_scan_active();
|
||||
let file_count = picker.get_files().len();
|
||||
|
||||
// Log progress every 2 seconds
|
||||
if last_log.elapsed() >= Duration::from_secs(2) {
|
||||
eprintln!(
|
||||
" [{:.1}s] Scanning: {}, Files: {}, Iterations: {}",
|
||||
start.elapsed().as_secs_f32(),
|
||||
is_scanning,
|
||||
file_count,
|
||||
iteration
|
||||
);
|
||||
last_log = std::time::Instant::now();
|
||||
}
|
||||
|
||||
if !is_scanning && file_count > 0 {
|
||||
eprintln!(
|
||||
" ✓ Scan complete after {:.2}s: {} files found",
|
||||
start.elapsed().as_secs_f32(),
|
||||
file_count
|
||||
);
|
||||
return Ok(file_count);
|
||||
}
|
||||
} else {
|
||||
if iteration % 100 == 0 {
|
||||
eprintln!(
|
||||
" [{:.1}s] FilePicker is None (iteration {})",
|
||||
start.elapsed().as_secs_f32(),
|
||||
iteration
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if start.elapsed() > timeout {
|
||||
return Err(format!(
|
||||
"Scan timed out after {} seconds (iteration {})",
|
||||
timeout_secs, iteration
|
||||
));
|
||||
}
|
||||
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
/// Get files from the global FILE_PICKER
|
||||
fn get_files_snapshot() -> Result<Vec<fff_nvim::types::FileItem>, String> {
|
||||
let picker_guard = FILE_PICKER
|
||||
.read()
|
||||
.map_err(|_| "Failed to acquire read lock")?;
|
||||
if let Some(ref picker) = *picker_guard {
|
||||
Ok(picker.get_files().to_vec())
|
||||
} else {
|
||||
Err("FilePicker not initialized".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Clean up global state
|
||||
fn cleanup_global_state() {
|
||||
if let Ok(mut picker_guard) = FILE_PICKER.write() {
|
||||
if let Some(mut picker) = picker_guard.take() {
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize FilePicker once and return files snapshot
|
||||
fn setup_once() -> Result<Vec<fff_nvim::types::FileItem>, String> {
|
||||
init_tracing();
|
||||
|
||||
let big_repo_path = PathBuf::from("./big-repo");
|
||||
if !big_repo_path.exists() {
|
||||
return Err("./big-repo directory does not exist. Run git clone https://github.com/torvalds/linux.git big-repo".to_string());
|
||||
}
|
||||
|
||||
let canonical_path = big_repo_path
|
||||
.canonicalize()
|
||||
.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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cleanup_global_state();
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
|
||||
init_file_picker_internal(&canonical_path.to_string_lossy())?;
|
||||
|
||||
eprintln!(" Waiting for background scan to complete...");
|
||||
let file_count = wait_for_scan_completion(120)?;
|
||||
eprintln!(
|
||||
" ✓ Indexed {} files (will be reused for all benchmarks)\n",
|
||||
file_count
|
||||
);
|
||||
|
||||
get_files_snapshot()
|
||||
}
|
||||
|
||||
/// Benchmark for indexing the big-repo directory
|
||||
fn bench_indexing(c: &mut Criterion) {
|
||||
init_tracing();
|
||||
|
||||
let big_repo_path = PathBuf::from("./big-repo");
|
||||
if !big_repo_path.exists() {
|
||||
eprintln!(
|
||||
"./big-repo directory does not exist. Run git clone https://github.com/torvalds/linux.git big-repo"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let canonical_path = match big_repo_path.canonicalize() {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
eprintln!("⚠ Failed to canonicalize path: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut group = c.benchmark_group("indexing");
|
||||
group.sample_size(10);
|
||||
group.measurement_time(Duration::from_secs(20));
|
||||
|
||||
group.bench_function("index_big_repo", |b| {
|
||||
b.iter(|| {
|
||||
cleanup_global_state();
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
init_file_picker_internal(black_box(&canonical_path.to_string_lossy()))
|
||||
.expect("Failed to init FilePicker");
|
||||
|
||||
match wait_for_scan_completion(120) {
|
||||
Ok(file_count) => {
|
||||
let elapsed = start.elapsed();
|
||||
eprintln!(" ✓ Indexed {} files in {:?}", file_count, elapsed);
|
||||
file_count
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(" ✗ Error: {}", e);
|
||||
0
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
/// Benchmark for searching with various query patterns
|
||||
fn bench_search_queries(c: &mut Criterion) {
|
||||
let files = match setup_once() {
|
||||
Ok(files) => files,
|
||||
Err(e) => {
|
||||
eprint!("Failed to setup picker {e:?}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut group = c.benchmark_group("search");
|
||||
group.sample_size(100);
|
||||
|
||||
let test_queries = vec![
|
||||
("short", "mod"),
|
||||
("medium", "controller"),
|
||||
("long", "user_authentication"),
|
||||
("typo", "contrlr"),
|
||||
("partial", "src/lib"),
|
||||
];
|
||||
|
||||
for (name, query) in test_queries {
|
||||
group.bench_with_input(BenchmarkId::new("query", name), &query, |b, &query| {
|
||||
b.iter(|| {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(query),
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
},
|
||||
},
|
||||
);
|
||||
results.total_matched
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
/// Benchmark search with different thread counts
|
||||
fn bench_search_thread_scaling(c: &mut Criterion) {
|
||||
let files = match setup_once() {
|
||||
Ok(files) => files,
|
||||
Err(e) => {
|
||||
eprintln!("⚠ Skipping thread scaling benchmarks: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut group = c.benchmark_group("thread_scaling");
|
||||
group.sample_size(100);
|
||||
|
||||
let query = "controller";
|
||||
let thread_counts = vec![1, 2, 4, 8];
|
||||
|
||||
for threads in thread_counts {
|
||||
group.bench_with_input(
|
||||
BenchmarkId::from_parameter(threads),
|
||||
&threads,
|
||||
|b, &threads| {
|
||||
b.iter(|| {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(query),
|
||||
FuzzySearchOptions {
|
||||
max_threads: threads,
|
||||
current_file: None,
|
||||
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
},
|
||||
},
|
||||
);
|
||||
results.total_matched
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
/// Benchmark search with different result limits
|
||||
fn bench_search_result_limits(c: &mut Criterion) {
|
||||
let files = match setup_once() {
|
||||
Ok(files) => files,
|
||||
Err(e) => {
|
||||
eprintln!("⚠ Skipping result limit benchmarks: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut group = c.benchmark_group("result_limits");
|
||||
group.sample_size(100);
|
||||
|
||||
let query = "mod";
|
||||
let result_limits = vec![10, 50, 100, 500];
|
||||
|
||||
for limit in result_limits {
|
||||
group.bench_with_input(BenchmarkId::from_parameter(limit), &limit, |b, &limit| {
|
||||
b.iter(|| {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(query),
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: limit,
|
||||
},
|
||||
},
|
||||
);
|
||||
results.total_matched
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
/// Benchmark search algorithm performance scaling with file count
|
||||
fn bench_search_scalability(c: &mut Criterion) {
|
||||
let all_files = match setup_once() {
|
||||
Ok(files) => files,
|
||||
Err(e) => {
|
||||
eprintln!("⚠ Skipping scalability benchmarks: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if all_files.len() < 1000 {
|
||||
eprintln!(
|
||||
"⚠ Skipping scalability benchmark: need at least 1000 files, got {}",
|
||||
all_files.len()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut group = c.benchmark_group("search_scalability");
|
||||
group.sample_size(50);
|
||||
|
||||
let query = "controller";
|
||||
let file_counts = vec![100, 1000, 5000, 10000, all_files.len().min(50000)];
|
||||
|
||||
for count in file_counts {
|
||||
if count > all_files.len() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let subset = &all_files[..count];
|
||||
group.bench_with_input(BenchmarkId::from_parameter(count), &count, |b, _| {
|
||||
b.iter(|| {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(subset),
|
||||
black_box(query),
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
},
|
||||
},
|
||||
);
|
||||
results.total_matched
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
/// Benchmark search performance with different ordering modes
|
||||
fn bench_search_ordering(c: &mut Criterion) {
|
||||
let files = match setup_once() {
|
||||
Ok(files) => files,
|
||||
Err(e) => {
|
||||
eprintln!("⚠ Skipping ordering benchmarks: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut group = c.benchmark_group("ordering");
|
||||
group.sample_size(100);
|
||||
|
||||
let query = "controller";
|
||||
|
||||
// Benchmark normal order (descending)
|
||||
group.bench_function("normal_order", |b| {
|
||||
b.iter(|| {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(query),
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
},
|
||||
},
|
||||
);
|
||||
results.total_matched
|
||||
});
|
||||
});
|
||||
|
||||
// Benchmark reverse order (ascending)
|
||||
group.bench_function("reverse_order", |b| {
|
||||
b.iter(|| {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(query),
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
},
|
||||
},
|
||||
);
|
||||
results.total_matched
|
||||
});
|
||||
});
|
||||
|
||||
// Benchmark with large result set
|
||||
group.bench_function("normal_order_large", |b| {
|
||||
b.iter(|| {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box("mod"),
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 500,
|
||||
},
|
||||
},
|
||||
);
|
||||
results.total_matched
|
||||
});
|
||||
});
|
||||
|
||||
group.bench_function("reverse_order_large", |b| {
|
||||
b.iter(|| {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box("mod"),
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 500,
|
||||
},
|
||||
},
|
||||
);
|
||||
results.total_matched
|
||||
});
|
||||
});
|
||||
|
||||
// Benchmark with small result set
|
||||
group.bench_function("normal_order_small", |b| {
|
||||
b.iter(|| {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box("controller"),
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
},
|
||||
},
|
||||
);
|
||||
results.total_matched
|
||||
});
|
||||
});
|
||||
|
||||
group.bench_function("reverse_order_small", |b| {
|
||||
b.iter(|| {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box("controller"),
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
},
|
||||
},
|
||||
);
|
||||
results.total_matched
|
||||
});
|
||||
});
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
/// Benchmark pagination: first page vs deep page
|
||||
fn bench_pagination_performance(c: &mut Criterion) {
|
||||
let files = match setup_once() {
|
||||
Ok(files) => files,
|
||||
Err(e) => {
|
||||
eprintln!("⚠ Skipping pagination benchmarks: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut group = c.benchmark_group("pagination");
|
||||
group.sample_size(100);
|
||||
|
||||
let query = "mod";
|
||||
let page_size = 40;
|
||||
|
||||
// Benchmark first page (uses partial sort optimization)
|
||||
group.bench_function("page_0_size_40", |b| {
|
||||
b.iter(|| {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(query),
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: page_size,
|
||||
},
|
||||
},
|
||||
);
|
||||
results.total_matched
|
||||
});
|
||||
});
|
||||
|
||||
// Benchmark 10th page (requires full sort, no optimization)
|
||||
group.bench_function("page_10_size_40", |b| {
|
||||
b.iter(|| {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(query),
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
offset: 10,
|
||||
limit: page_size,
|
||||
},
|
||||
},
|
||||
);
|
||||
results.total_matched
|
||||
});
|
||||
});
|
||||
|
||||
// Benchmark 50th page (even deeper pagination)
|
||||
group.bench_function("page_50_size_40", |b| {
|
||||
b.iter(|| {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(query),
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
offset: 50,
|
||||
limit: page_size,
|
||||
},
|
||||
},
|
||||
);
|
||||
results.total_matched
|
||||
});
|
||||
});
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_indexing,
|
||||
bench_search_queries,
|
||||
bench_search_thread_scaling,
|
||||
bench_search_result_limits,
|
||||
bench_search_scalability,
|
||||
bench_search_ordering,
|
||||
bench_pagination_performance,
|
||||
);
|
||||
|
||||
criterion_main!(benches);
|
||||
@@ -0,0 +1,224 @@
|
||||
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
|
||||
use fff_nvim::query_tracker::QueryTracker;
|
||||
use rand::distributions::Alphanumeric;
|
||||
use rand::prelude::*;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn generate_random_string(len: usize) -> String {
|
||||
thread_rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(len)
|
||||
.map(char::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
// Test data structure for benchmarks
|
||||
struct TestQueryEntry {
|
||||
query: String,
|
||||
project_path: PathBuf,
|
||||
file_path: PathBuf,
|
||||
open_count: u32,
|
||||
last_opened: u64,
|
||||
}
|
||||
|
||||
fn generate_test_data(num_entries: usize) -> Vec<TestQueryEntry> {
|
||||
let mut rng = thread_rng();
|
||||
let mut entries = Vec::with_capacity(num_entries);
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
// Generate some common queries that will be reused
|
||||
let common_queries = vec![
|
||||
"main",
|
||||
"test",
|
||||
"config",
|
||||
"utils",
|
||||
"lib",
|
||||
"mod",
|
||||
"index",
|
||||
"init",
|
||||
"server",
|
||||
"client",
|
||||
"api",
|
||||
"service",
|
||||
"controller",
|
||||
"model",
|
||||
"view",
|
||||
"component",
|
||||
"handler",
|
||||
"middleware",
|
||||
"router",
|
||||
"database",
|
||||
"auth",
|
||||
];
|
||||
|
||||
// Generate some common project paths
|
||||
let project_paths = vec![
|
||||
"/home/user/project1",
|
||||
"/home/user/project2",
|
||||
"/home/user/web-app",
|
||||
"/home/user/cli-tool",
|
||||
"/home/user/library",
|
||||
];
|
||||
|
||||
for _ in 0..num_entries {
|
||||
let query = if rng.gen_bool(0.7) {
|
||||
// 70% chance to use common query
|
||||
common_queries.choose(&mut rng).unwrap().to_string()
|
||||
} else {
|
||||
// 30% chance to use random query
|
||||
generate_random_string(rng.gen_range(3..15))
|
||||
};
|
||||
|
||||
let project_path = project_paths.choose(&mut rng).unwrap();
|
||||
let file_name = format!(
|
||||
"{}.{}",
|
||||
generate_random_string(rng.gen_range(5..20)),
|
||||
if rng.gen_bool(0.5) { "rs" } else { "js" }
|
||||
);
|
||||
let file_path = PathBuf::from(format!("{}/src/{}", project_path, file_name));
|
||||
|
||||
let entry = TestQueryEntry {
|
||||
query: query.into(),
|
||||
project_path: PathBuf::from(project_path),
|
||||
file_path,
|
||||
open_count: rng.gen_range(1..10),
|
||||
last_opened: now - rng.gen_range(0..30 * 24 * 3600), // Random time within last 30 days
|
||||
};
|
||||
|
||||
entries.push(entry);
|
||||
}
|
||||
|
||||
entries
|
||||
}
|
||||
|
||||
fn setup_tracker_with_data(entries: &[TestQueryEntry]) -> (QueryTracker, PathBuf) {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let temp_dir =
|
||||
std::env::temp_dir().join(format!("fff_bench_{}_{}", timestamp, rand::random::<u32>()));
|
||||
let mut tracker = QueryTracker::new(temp_dir.to_str().unwrap(), true).unwrap();
|
||||
|
||||
// Insert all test data
|
||||
for entry in entries {
|
||||
for _ in 0..entry.open_count {
|
||||
tracker
|
||||
.track_query_completion(&entry.query, &entry.project_path, &entry.file_path)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
(tracker, temp_dir)
|
||||
}
|
||||
|
||||
fn cleanup_tracker_dir(dir: PathBuf) {
|
||||
if dir.exists() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||
fn bench_track_query_completion(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("track_query_completion");
|
||||
|
||||
for size in &[100, 1000, 10000] {
|
||||
let entries = generate_test_data(*size);
|
||||
let (mut tracker, temp_dir) = setup_tracker_with_data(&entries[..*size / 2]); // Pre-populate with half
|
||||
|
||||
group.bench_with_input(BenchmarkId::new("entries", size), size, |b, _| {
|
||||
let mut rng = thread_rng();
|
||||
b.iter(|| {
|
||||
let entry = entries.choose(&mut rng).unwrap();
|
||||
black_box(
|
||||
tracker
|
||||
.track_query_completion(
|
||||
black_box(&entry.query),
|
||||
black_box(&entry.project_path),
|
||||
black_box(&entry.file_path),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
drop(tracker);
|
||||
cleanup_tracker_dir(temp_dir);
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_realistic_workload(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("realistic_workload");
|
||||
|
||||
for size in &[1000, 10000] {
|
||||
let entries = generate_test_data(*size);
|
||||
let (mut tracker, temp_dir) = setup_tracker_with_data(&entries);
|
||||
|
||||
group.bench_with_input(BenchmarkId::new("mixed_operations", size), size, |b, _| {
|
||||
let mut rng = thread_rng();
|
||||
b.iter(|| {
|
||||
let entry = entries.choose(&mut rng).unwrap();
|
||||
|
||||
// Simulate realistic usage: 70% lookups, 25% tracking, 5% history
|
||||
match rng.gen_range(0..100) {
|
||||
0..70 => {
|
||||
// Query entry lookup (most common operation)
|
||||
let entry_result = black_box(
|
||||
tracker
|
||||
.get_last_query_entry(
|
||||
black_box(&entry.query),
|
||||
black_box(&entry.project_path),
|
||||
3,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
black_box(entry_result);
|
||||
}
|
||||
70..95 => {
|
||||
// Track completion (when user opens file)
|
||||
black_box(
|
||||
tracker
|
||||
.track_query_completion(
|
||||
black_box(&entry.query),
|
||||
black_box(&entry.project_path),
|
||||
black_box(&entry.file_path),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
95..100 => {
|
||||
// Get historical query (least common)
|
||||
let history = black_box(
|
||||
tracker
|
||||
.get_historical_query(black_box(&entry.project_path), black_box(5))
|
||||
.unwrap(),
|
||||
);
|
||||
black_box(history);
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
drop(tracker);
|
||||
cleanup_tracker_dir(temp_dir);
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_track_query_completion,
|
||||
// Commented out - methods removed/changed in refactor:
|
||||
// bench_get_query_boost,
|
||||
// bench_cleanup_old_entries,
|
||||
bench_realistic_workload
|
||||
);
|
||||
criterion_main!(benches);
|
||||
+311
-84
@@ -1,4 +1,4 @@
|
||||
*fff.nvim.txt* For Neovim >= 0.8.0 Last change: 2025 August 05
|
||||
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2025 December 19
|
||||
|
||||
==============================================================================
|
||||
Table of Contents *fff.nvim-table-of-contents*
|
||||
@@ -23,16 +23,17 @@ FEATURES *fff.nvim-features*
|
||||
|
||||
- Works out of the box with no additional configuration
|
||||
- Typo resistant fuzzy search <https://github.com/saghen/frizbee>
|
||||
- Git status integration allowing to take adavantage of last modified times within a worktree
|
||||
- Separate file index maintained by a dedicaged backend allows <10 milliseconds search time for 50k files codebase
|
||||
- Git status integration allowing to take advantage of last modified times within a worktree
|
||||
- Separate file index maintained by a dedicated backend allows <10 milliseconds search time for 50k files codebase
|
||||
- Display images in previews (for now requires snacks.nvim)
|
||||
- Smart in a plenty of different ways hopefully helpful for your workflow
|
||||
- This plugin initializes itself lazily by default
|
||||
|
||||
|
||||
INSTALLATION *fff.nvim-installation*
|
||||
|
||||
|
||||
[!NOTE] Although we’ll try to make sure to keep 100% backward compatibiility,
|
||||
[!NOTE] Although we’ll try to make sure to keep 100% backward compatibility,
|
||||
by using you should understand that silly bugs and breaking changes may happen.
|
||||
And also we hope for your contributions and feedback to make this plugin ideal
|
||||
for everyone.
|
||||
@@ -42,87 +43,192 @@ PREREQUISITES ~
|
||||
FFF.nvim requires:
|
||||
|
||||
- Neovim 0.10.0+
|
||||
- Rust toolchain (requires nightly for building the native backend)
|
||||
- Rustup <https://rustup.rs/> (we require nightly for building the native backend rustup will handle toolchain automatically)
|
||||
|
||||
|
||||
PACKAGE MANAGERS ~
|
||||
INSTALLATION ~
|
||||
|
||||
|
||||
LAZY.NVIM
|
||||
|
||||
>lua
|
||||
{
|
||||
"dmtrKovalenko/fff.nvim",
|
||||
build = "cargo build --release",
|
||||
-- or if you are using nixos
|
||||
'dmtrKovalenko/fff.nvim',
|
||||
build = function()
|
||||
-- this will download prebuild binary or try to use existing rustup toolchain to build from source
|
||||
-- (if you are using lazy you can use gb for rebuilding a plugin if needed)
|
||||
require("fff.download").download_or_build_binary()
|
||||
end,
|
||||
-- if you are using nixos
|
||||
-- build = "nix run .#release",
|
||||
opts = {
|
||||
-- pass here all the options
|
||||
opts = { -- (optional)
|
||||
debug = {
|
||||
enabled = true, -- we expect your collaboration at least during the beta
|
||||
show_scores = true, -- to help us optimize the scoring system, feel free to share your scores!
|
||||
},
|
||||
},
|
||||
-- No need to lazy-load with lazy.nvim.
|
||||
-- This plugin initializes itself lazily.
|
||||
lazy = false,
|
||||
keys = {
|
||||
{
|
||||
"ff", -- try it if you didn't it is a banger keybinding for a picker
|
||||
function()
|
||||
require("fff").find_files() -- or find_in_git_root() if you only want git files
|
||||
end,
|
||||
desc = "Open file picker",
|
||||
},
|
||||
},
|
||||
function() require('fff').find_files() end,
|
||||
desc = 'FFFind files',
|
||||
}
|
||||
}
|
||||
}
|
||||
<
|
||||
|
||||
|
||||
DEFAULT CONFIGURATION ~
|
||||
|
||||
FFF.nvim comes with sensible defaults. Here’s the complete default
|
||||
configuration:
|
||||
VIM.PACK
|
||||
|
||||
>lua
|
||||
require("fff").setup({
|
||||
-- UI dimensions and appearance
|
||||
width = 0.8, -- Window width as fraction of screen
|
||||
height = 0.8, -- Window height as fraction of screen
|
||||
prompt = '🪿 ', -- Input prompt symbol
|
||||
preview = {
|
||||
enabled = true,
|
||||
width = 0.5,
|
||||
max_lines = 100,
|
||||
max_size = 1024 * 1024, -- 1MB
|
||||
},
|
||||
title = 'FFF Files', -- Window title
|
||||
max_results = 60, -- Maximum search results to display
|
||||
max_threads = 4, -- Maximum threads for fuzzy search
|
||||
vim.pack.add({ 'https://github.com/dmtrKovalenko/fff.nvim' })
|
||||
|
||||
keymaps = {
|
||||
close = '<Esc>',
|
||||
select = '<CR>',
|
||||
select_split = '<C-s>',
|
||||
select_vsplit = '<C-v>',
|
||||
select_tab = '<C-t>',
|
||||
-- Multiple bindings supported
|
||||
move_up = { '<Up>', '<C-p>' },
|
||||
move_down = { '<Down>', '<C-n>' },
|
||||
preview_scroll_up = '<C-u>',
|
||||
preview_scroll_down = '<C-d>',
|
||||
},
|
||||
vim.api.nvim_create_autocmd('PackChanged', {
|
||||
callback = function(event)
|
||||
if event.data.updated then
|
||||
require('fff.download').download_or_build_binary()
|
||||
end
|
||||
end,
|
||||
})
|
||||
|
||||
-- Highlight groups
|
||||
hl = {
|
||||
border = 'FloatBorder',
|
||||
normal = 'Normal',
|
||||
cursor = 'CursorLine',
|
||||
matched = 'IncSearch',
|
||||
title = 'Title',
|
||||
prompt = 'Question',
|
||||
active_file = 'Visual',
|
||||
frecency = 'Number',
|
||||
debug = 'Comment',
|
||||
},
|
||||
|
||||
-- Debug options
|
||||
-- the plugin will automatically lazy load
|
||||
vim.g.fff = {
|
||||
lazy_sync = true, -- start syncing only when the picker is open
|
||||
debug = {
|
||||
show_scores = false, -- Toggle with F2 or :FFFDebug
|
||||
enabled = true,
|
||||
show_scores = true,
|
||||
},
|
||||
}
|
||||
|
||||
vim.keymap.set(
|
||||
'n',
|
||||
'ff',
|
||||
function() require('fff').find_files() end,
|
||||
{ desc = 'FFFind files' }
|
||||
)
|
||||
<
|
||||
|
||||
|
||||
CONFIGURATION ~
|
||||
|
||||
FFF.nvim comes with sensible defaults. Here’s the complete configuration with
|
||||
all available options:
|
||||
|
||||
>lua
|
||||
require('fff').setup({
|
||||
base_path = vim.fn.getcwd(),
|
||||
prompt = '🪿 ',
|
||||
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,
|
||||
show_scrollbar = true, -- Show scrollbar for pagination
|
||||
},
|
||||
preview = {
|
||||
enabled = true,
|
||||
max_size = 10 * 1024 * 1024, -- Do not try to read files larger than 10MB
|
||||
chunk_size = 8192, -- Bytes per chunk for dynamic loading (8kb - fits ~100-200 lines)
|
||||
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,
|
||||
wrap_lines = false,
|
||||
show_file_info = true,
|
||||
filetypes = {
|
||||
svg = { wrap_lines = true },
|
||||
markdown = { wrap_lines = true },
|
||||
text = { wrap_lines = true },
|
||||
},
|
||||
},
|
||||
keymaps = {
|
||||
close = '<Esc>',
|
||||
select = '<CR>',
|
||||
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>',
|
||||
-- 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>',
|
||||
},
|
||||
hl = {
|
||||
border = 'FloatBorder',
|
||||
normal = 'Normal',
|
||||
cursor = 'CursorLine',
|
||||
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
|
||||
-- Multi-select highlights
|
||||
selected = 'FFFSelected',
|
||||
selected_active = 'FFFSelectedActive',
|
||||
-- Git text highlights for file names
|
||||
git_staged = 'FFFGitStaged',
|
||||
git_modified = 'FFFGitModified',
|
||||
git_deleted = 'FFFGitDeleted',
|
||||
git_renamed = 'FFFGitRenamed',
|
||||
git_untracked = 'FFFGitUntracked',
|
||||
git_ignored = 'FFFGitIgnored',
|
||||
-- Git sign/border highlights
|
||||
git_sign_staged = 'FFFGitSignStaged',
|
||||
git_sign_modified = 'FFFGitSignModified',
|
||||
git_sign_deleted = 'FFFGitSignDeleted',
|
||||
git_sign_renamed = 'FFFGitSignRenamed',
|
||||
git_sign_untracked = 'FFFGitSignUntracked',
|
||||
git_sign_ignored = 'FFFGitSignIgnored',
|
||||
-- Git sign selected highlights
|
||||
git_sign_staged_selected = 'FFFGitSignStagedSelected',
|
||||
git_sign_modified_selected = 'FFFGitSignModifiedSelected',
|
||||
git_sign_deleted_selected = 'FFFGitSignDeletedSelected',
|
||||
git_sign_renamed_selected = 'FFFGitSignRenamedSelected',
|
||||
git_sign_untracked_selected = 'FFFGitSignUntrackedSelected',
|
||||
git_sign_ignored_selected = 'FFFGitSignIgnoredSelected',
|
||||
},
|
||||
-- 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, -- 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
|
||||
},
|
||||
-- Git integration
|
||||
git = {
|
||||
status_text_color = false, -- Apply git status colors to filename text (default: false, only sign column)
|
||||
},
|
||||
debug = {
|
||||
enabled = false, -- Set to true to show scores in the UI
|
||||
show_scores = false,
|
||||
},
|
||||
logging = {
|
||||
enabled = true,
|
||||
log_file = vim.fn.stdpath('log') .. '/fff.log',
|
||||
log_level = 'info',
|
||||
}
|
||||
})
|
||||
<
|
||||
|
||||
@@ -133,26 +239,26 @@ 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").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
|
||||
require("fff").change_indexing_directory(new_path) -- Change the base directory for the file picker
|
||||
require('fff').find_files() -- Find files in current directory
|
||||
require('fff').find_in_git_root() -- Find files in the current git repository
|
||||
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
|
||||
require('fff').change_indexing_directory(new_path) -- Change the base directory for the file picker
|
||||
<
|
||||
|
||||
|
||||
MULTIPLE KEY BINDINGS
|
||||
COMMANDS
|
||||
|
||||
You can assign multiple key combinations to the same action:
|
||||
FFF.nvim provides several commands for interacting with the file picker:
|
||||
|
||||
>lua
|
||||
keymaps = {
|
||||
move_up = { '<Up>', '<C-p>', '<C-k>' }, -- Three ways to move up
|
||||
close = { '<Esc>', '<C-c>' }, -- Two ways to close
|
||||
select = '<CR>', -- Single binding still works
|
||||
}
|
||||
<
|
||||
- `:FFFFind [path|query]` - Open file picker. Optional: provide directory path or search query
|
||||
- `:FFFScan` - Manually trigger a rescan of files in the current directory
|
||||
- `:FFFRefreshGit` - Manually refresh git status for all files
|
||||
- `:FFFClearCache [all|frecency|files]` - Clear various caches
|
||||
- `:FFFHealth` - Check FFF health status and dependencies
|
||||
- `:FFFDebug [on|off|toggle]` - Toggle debug scores display
|
||||
- `:FFFOpenLog` - Open the FFF log file in a new tab
|
||||
|
||||
|
||||
MULTILINE PASTE SUPPORT
|
||||
@@ -170,15 +276,136 @@ Toggle scoring information display:
|
||||
- Use `:FFFDebug` command
|
||||
- Enable by default with `debug.show_scores = true`
|
||||
|
||||
>
|
||||
|
||||
#### vim-plug
|
||||
|
||||
```vim
|
||||
Plug 'MunifTanjim/nui.nvim'
|
||||
Plug 'dmtrKovalenko/fff.nvim', { 'do': 'cargo build --release' }
|
||||
|
||||
MULTI-SELECT AND QUICKFIX INTEGRATION
|
||||
|
||||
Select multiple files and send them to Neovim’s quickfix list (keymaps are
|
||||
configurable):
|
||||
|
||||
- `<Tab>` - Toggle selection for the current file (shows thick border `▊` in signcolumn)
|
||||
- `<C-q>` - Send selected files to quickfix list and close picker
|
||||
|
||||
|
||||
GIT STATUS HIGHLIGHTING
|
||||
|
||||
FFF integrates with git to show file status through sign column indicators
|
||||
(enabled by default) and optional filename text coloring.
|
||||
|
||||
**Sign Column Indicators** (enabled by default) - Border characters shown in
|
||||
the sign column:
|
||||
|
||||
>lua
|
||||
hl = {
|
||||
git_sign_staged = 'FFFGitSignStaged',
|
||||
git_sign_modified = 'FFFGitSignModified',
|
||||
git_sign_deleted = 'FFFGitSignDeleted',
|
||||
git_sign_renamed = 'FFFGitSignRenamed',
|
||||
git_sign_untracked = 'FFFGitSignUntracked',
|
||||
git_sign_ignored = 'FFFGitSignIgnored',
|
||||
}
|
||||
<
|
||||
|
||||
**Text Highlights** (opt-in) - Apply colors to filenames based on git status:
|
||||
|
||||
To enable git status text coloring, set `git.status_text_color = true`:
|
||||
|
||||
>lua
|
||||
require('fff').setup({
|
||||
git = {
|
||||
status_text_color = true, -- Enable git status colors on filename text
|
||||
},
|
||||
hl = {
|
||||
git_staged = 'FFFGitStaged', -- Files staged for commit
|
||||
git_modified = 'FFFGitModified', -- Modified unstaged files
|
||||
git_deleted = 'FFFGitDeleted', -- Deleted files
|
||||
git_renamed = 'FFFGitRenamed', -- Renamed files
|
||||
git_untracked = 'FFFGitUntracked', -- New untracked files
|
||||
git_ignored = 'FFFGitIgnored', -- Git-ignored files
|
||||
}
|
||||
})
|
||||
<
|
||||
|
||||
The plugin provides sensible default highlight groups that link to common git
|
||||
highlight groups (e.g., GitSignsAdd, GitSignsChange). You can override these
|
||||
with your own custom highlight groups to match your colorscheme.
|
||||
|
||||
**Example - Custom Bright Colors for Text:**
|
||||
|
||||
>lua
|
||||
vim.api.nvim_set_hl(0, 'CustomGitModified', { fg = '#FFA500' })
|
||||
vim.api.nvim_set_hl(0, 'CustomGitUntracked', { fg = '#00FF00' })
|
||||
|
||||
require('fff').setup({
|
||||
git = {
|
||||
status_text_color = true,
|
||||
},
|
||||
hl = {
|
||||
git_modified = 'CustomGitModified',
|
||||
git_untracked = 'CustomGitUntracked',
|
||||
}
|
||||
})
|
||||
<
|
||||
|
||||
|
||||
TROUBLESHOOTING ~
|
||||
|
||||
|
||||
HEALTH CHECK
|
||||
|
||||
Run `:FFFHealth` to check the status of FFF.nvim and its dependencies. This
|
||||
will verify:
|
||||
|
||||
- File picker initialization status
|
||||
- Optional dependencies (git, image preview tools)
|
||||
- Database connectivity
|
||||
|
||||
|
||||
VIEWING LOGS
|
||||
|
||||
If you encounter issues, check the log file:
|
||||
|
||||
>vim
|
||||
:FFFOpenLog
|
||||
<
|
||||
|
||||
Or manually open the log file at `~/.local/state/nvim/log/fff.log` (default
|
||||
location).
|
||||
|
||||
|
||||
COMMON ISSUES
|
||||
|
||||
**File picker not initializing:**
|
||||
|
||||
- Ensure the Rust backend is compiled: `cargo build --release` in the plugin directory
|
||||
- Check that your Neovim version is 0.10.0 or higher
|
||||
|
||||
**Image previews not working:**
|
||||
|
||||
- Verify your terminal supports images (kitty, iTerm2, WezTerm, etc.)
|
||||
- For terminals without native image support, install one of: `chafa`, `viu`, or `img2txt`
|
||||
- If using snacks.nvim, ensure it’s properly configured
|
||||
|
||||
**Performance issues:**
|
||||
|
||||
- Adjust `max_threads` in configuration based on your system
|
||||
- Reduce `preview.max_lines` and `preview.max_size` for large files
|
||||
- Clear cache if it becomes too large: `:FFFClearCache all`
|
||||
|
||||
**Files not being indexed:**
|
||||
|
||||
- Run `:FFFScan` to manually trigger a file scan
|
||||
- Check that the `base_path` is correctly set
|
||||
- Verify you have read permissions for the directory
|
||||
|
||||
|
||||
DEBUG MODE
|
||||
|
||||
Enable debug mode to see scoring information and troubleshoot search results:
|
||||
|
||||
- Press `F2` while in the picker
|
||||
- Run `:FFFDebug on` to enable permanently
|
||||
- Set `debug.show_scores = true` in configuration
|
||||
|
||||
Generated by panvimdoc <https://github.com/kdheepak/panvimdoc>
|
||||
|
||||
vim:tw=78:ts=8:noet:ft=help:norl:
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
-- Single file Neovim config for testing fff.nvim locally
|
||||
-- Usage: nvim -u /Users/neogoose/dev/fff.nvim/init.lua
|
||||
|
||||
-- Set up lazy.nvim plugin manager
|
||||
local lazypath = vim.fn.stdpath('data') .. '/lazy/lazy.nvim'
|
||||
if not vim.loop.fs_stat(lazypath) then
|
||||
vim.fn.system({
|
||||
'git',
|
||||
'clone',
|
||||
'--filter=blob:none',
|
||||
'https://github.com/folke/lazy.nvim.git',
|
||||
'--branch=stable',
|
||||
lazypath,
|
||||
})
|
||||
end
|
||||
vim.opt.rtp:prepend(lazypath)
|
||||
|
||||
require('lazy').setup({
|
||||
{
|
||||
dir = '~/dev/fff.nvim',
|
||||
'https://github.com/dmtrKovalenko/fff.nvim',
|
||||
build = function()
|
||||
-- this will download prebuild binary or try to use existing rustup toolchain to build from source
|
||||
-- (if you are using lazy you can use gb for rebuilding a plugin if needed)
|
||||
require('fff.download').download_or_build_binary()
|
||||
end,
|
||||
dependencies = {
|
||||
'nvim-tree/nvim-web-devicons', -- Optional: for file icons
|
||||
-- {
|
||||
-- 'nvim-mini/mini.icons',
|
||||
-- version = false,
|
||||
-- config = true,
|
||||
-- },
|
||||
},
|
||||
config = function()
|
||||
require('fff').setup({
|
||||
-- Configure fff.nvim here
|
||||
ui = {
|
||||
width = 0.8,
|
||||
height = 0.8,
|
||||
},
|
||||
file_picker = {
|
||||
auto_reload_on_write = true,
|
||||
frecency_boost = true,
|
||||
},
|
||||
})
|
||||
end,
|
||||
},
|
||||
}, {
|
||||
root = vim.fn.stdpath('data') .. '/fff-empty-test',
|
||||
lockfile = vim.fn.stdpath('data') .. '/fff-empty-test.json',
|
||||
})
|
||||
|
||||
vim.opt.number = true
|
||||
vim.opt.relativenumber = true
|
||||
|
||||
vim.keymap.set('n', 'ff', function() require('fff').find_files() end, { desc = 'Find files' })
|
||||
vim.keymap.set('n', 'fg', function() require('fff').find_in_git_root() end, { desc = 'Find files in git root' })
|
||||
vim.keymap.set('n', 'fr', function() require('fff').scan_files() end, { desc = 'Rescan files' })
|
||||
vim.keymap.set('n', 'fs', function() require('fff').refresh_git_status() end, { desc = 'Refresh git status' })
|
||||
|
||||
vim.notify('FFF.nvim local config loaded! Press ff', vim.log.levels.INFO)
|
||||
Generated
+9
-9
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"crane": {
|
||||
"locked": {
|
||||
"lastModified": 1753316655,
|
||||
"narHash": "sha256-tzWa2kmTEN69OEMhxFy+J2oWSvZP5QhEgXp3TROOzl0=",
|
||||
"lastModified": 1765739568,
|
||||
"narHash": "sha256-gQYx35Of4UDKUjAYvmxjUEh/DdszYeTtT6MDin4loGE=",
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"rev": "f35a3372d070c9e9ccb63ba7ce347f0634ddf3d2",
|
||||
"rev": "67d2baff0f9f677af35db61b32b5df6863bcc075",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -35,11 +35,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1754060105,
|
||||
"narHash": "sha256-di5L6e5Iiv+oegS07j9h23FdqEpXn0ZQqMlDOEMw1EY=",
|
||||
"lastModified": 1765644376,
|
||||
"narHash": "sha256-yqHBL2wYGwjGL2GUF2w3tofWl8qO9tZEuI4wSqbCrtE=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "e7eabdc701d7dbb810fd91a97ec358caa4c1fc50",
|
||||
"rev": "23735a82a828372c4ef92c660864e82fbe2f5fbe",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -64,11 +64,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1754016903,
|
||||
"narHash": "sha256-mRB5OOx7H5kFwW8Qtc/7dO3qHsBQtZ/eYQEj93/Noo8=",
|
||||
"lastModified": 1765680428,
|
||||
"narHash": "sha256-fyPmRof9SZeI14ChPk5rVPOm7ISiiGkwGCunkhM+eUg=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "ddd488184f01603b712ddbb6dc9fe0b8447eb7fc",
|
||||
"rev": "eb3898d8ef143d4bf0f7f2229105fc51c7731b2f",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -55,17 +55,15 @@
|
||||
|
||||
}
|
||||
);
|
||||
# Create a script that copies the build result
|
||||
release-script = pkgs.writeShellScriptBin "release" ''
|
||||
set -euo pipefail
|
||||
nix build
|
||||
# Copies the dynamic library into the target/release folder
|
||||
copy-dynamic-library = /* bash */ ''
|
||||
set -eo pipefail
|
||||
mkdir -p target/release
|
||||
if [ "$(uname)" = "Darwin" ]; then
|
||||
cp -vf result/lib/libfff_nvim.dylib target/release/libfff_nvim.dylib
|
||||
cp -vf ${my-crate}/lib/libfff_nvim.dylib target/release/libfff_nvim.dylib
|
||||
else
|
||||
cp -vf result/lib/libfff_nvim.so target/release/libfff_nvim.so
|
||||
cp -vf ${my-crate}/lib/libfff_nvim.so target/release/libfff_nvim.so
|
||||
fi
|
||||
rm result
|
||||
echo "Library copied to target/release/"
|
||||
'';
|
||||
in
|
||||
@@ -74,7 +72,18 @@
|
||||
inherit my-crate;
|
||||
};
|
||||
|
||||
packages.default = my-crate;
|
||||
packages = {
|
||||
default = my-crate;
|
||||
|
||||
# Neovim plugin
|
||||
fff-nvim = pkgs.vimUtils.buildVimPlugin {
|
||||
pname = "fff.nvim";
|
||||
version = "main";
|
||||
src = pkgs.lib.cleanSource ./.;
|
||||
postPatch = copy-dynamic-library;
|
||||
doCheck = false; # Skip require check since we have a Rust FFI component
|
||||
};
|
||||
};
|
||||
|
||||
apps.default = flake-utils.lib.mkApp {
|
||||
drv = my-crate;
|
||||
@@ -82,7 +91,7 @@
|
||||
|
||||
# Add the release command
|
||||
apps.release = flake-utils.lib.mkApp {
|
||||
drv = release-script;
|
||||
drv = pkgs.writeShellScriptBin "release" copy-dynamic-library;
|
||||
};
|
||||
|
||||
devShells.default = craneLib.devShell {
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
local M = {}
|
||||
|
||||
local overlay_state = {
|
||||
left_buf = nil,
|
||||
left_win = nil,
|
||||
right_buf = nil,
|
||||
right_win = nil,
|
||||
ns_id = nil,
|
||||
-- Cache last position to avoid unnecessary updates
|
||||
last_row = nil,
|
||||
last_col = nil,
|
||||
last_border_hl = nil,
|
||||
-- Track if combo was rendered in last call
|
||||
was_rendered = false,
|
||||
}
|
||||
|
||||
local LEFT_OVERLAY_CONTENT = '├────'
|
||||
local RIGHT_OVERLAY_CONTENT = '─┤'
|
||||
local LEFT_OVERLAY_WIDTH = vim.fn.strdisplaywidth(LEFT_OVERLAY_CONTENT)
|
||||
local LEFT_HEADER_PADDING = LEFT_OVERLAY_WIDTH - 2
|
||||
local RIGHT_OVERLAY_WIDTH = vim.fn.strdisplaywidth(RIGHT_OVERLAY_CONTENT)
|
||||
|
||||
local COMBO_TEXT_FORMAT = 'Last Match (×%d combo) '
|
||||
local LAST_MATCH_TEXT_FORMAT = 'Last Match '
|
||||
|
||||
function M.init(ns_id) overlay_state.ns_id = ns_id end
|
||||
|
||||
local function detect_combo_item(items, file_picker, combo_boost_score_multiplier)
|
||||
if not items or #items == 0 then return nil, 0 end
|
||||
|
||||
local first_score = file_picker.get_file_score(1)
|
||||
local last_score = file_picker.get_file_score(#items)
|
||||
|
||||
if first_score.combo_match_boost > combo_boost_score_multiplier then
|
||||
return 1, first_score.combo_match_boost / combo_boost_score_multiplier
|
||||
elseif last_score.combo_match_boost > combo_boost_score_multiplier then
|
||||
return #items, last_score.combo_match_boost / combo_boost_score_multiplier
|
||||
end
|
||||
|
||||
return nil, 0
|
||||
end
|
||||
|
||||
local function create_header_text(combo_count, win_width, disable_combo_display)
|
||||
local combo_text = nil
|
||||
if disable_combo_display then
|
||||
combo_text = LAST_MATCH_TEXT_FORMAT
|
||||
else
|
||||
combo_text = string.format(COMBO_TEXT_FORMAT, combo_count)
|
||||
end
|
||||
|
||||
local text_len = vim.fn.strdisplaywidth(combo_text)
|
||||
local available_for_content = win_width - LEFT_HEADER_PADDING - RIGHT_OVERLAY_WIDTH
|
||||
local remaining_dashes = math.max(0, available_for_content - text_len)
|
||||
|
||||
return string.rep(' ', LEFT_HEADER_PADDING) .. combo_text .. string.rep('─', remaining_dashes), text_len
|
||||
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(
|
||||
buf,
|
||||
ns_id,
|
||||
config.hl.combo_header,
|
||||
line_idx - 1,
|
||||
LEFT_HEADER_PADDING,
|
||||
LEFT_HEADER_PADDING + text_len
|
||||
)
|
||||
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
|
||||
overlay_state[state_key] = vim.api.nvim_create_buf(false, true)
|
||||
vim.api.nvim_buf_set_option(overlay_state[state_key], 'bufhidden', 'wipe')
|
||||
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_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)
|
||||
end
|
||||
|
||||
local function position_overlay_window(state_key, buf, width, row, col)
|
||||
local win_config = {
|
||||
relative = 'editor',
|
||||
width = width,
|
||||
height = 1,
|
||||
row = row,
|
||||
col = col,
|
||||
style = 'minimal',
|
||||
focusable = false,
|
||||
zindex = 250,
|
||||
}
|
||||
|
||||
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
|
||||
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')
|
||||
end
|
||||
|
||||
local function update_overlays(list_win, combo_header_line, border_hl, prompt_position)
|
||||
local list_config = vim.api.nvim_win_get_config(list_win)
|
||||
-- combo_header_line is a 1-based buffer line index
|
||||
-- list_config.row is the window position (includes border)
|
||||
-- Buffer content starts at row + 1 (after top border)
|
||||
-- For bottom prompt: overlay needs adjustment due to different border handling
|
||||
-- For top prompt: use standard calculation
|
||||
local combo_header_row = list_config.row + combo_header_line
|
||||
if prompt_position == 'bottom' then combo_header_row = combo_header_row - 1 end
|
||||
|
||||
-- Skip update if position and highlight haven't changed
|
||||
if
|
||||
overlay_state.last_row == combo_header_row
|
||||
and overlay_state.last_col == list_config.col
|
||||
and overlay_state.last_border_hl == border_hl
|
||||
and overlay_state.left_win
|
||||
and vim.api.nvim_win_is_valid(overlay_state.left_win)
|
||||
and overlay_state.right_win
|
||||
and vim.api.nvim_win_is_valid(overlay_state.right_win)
|
||||
then
|
||||
return
|
||||
end
|
||||
|
||||
overlay_state.last_row = combo_header_row
|
||||
overlay_state.last_col = list_config.col
|
||||
overlay_state.last_border_hl = border_hl
|
||||
|
||||
local left_buf = get_or_create_overlay_buf('left_buf')
|
||||
local right_buf = get_or_create_overlay_buf('right_buf')
|
||||
|
||||
update_overlay_content(left_buf, LEFT_OVERLAY_CONTENT, border_hl)
|
||||
update_overlay_content(right_buf, RIGHT_OVERLAY_CONTENT, border_hl)
|
||||
|
||||
position_overlay_window('left_win', left_buf, LEFT_OVERLAY_WIDTH, combo_header_row, list_config.col)
|
||||
position_overlay_window(
|
||||
'right_win',
|
||||
right_buf,
|
||||
RIGHT_OVERLAY_WIDTH,
|
||||
combo_header_row,
|
||||
list_config.col + list_config.width
|
||||
)
|
||||
end
|
||||
|
||||
local function clear_overlays_internal()
|
||||
if overlay_state.left_win and vim.api.nvim_win_is_valid(overlay_state.left_win) then
|
||||
vim.api.nvim_win_close(overlay_state.left_win, true)
|
||||
overlay_state.left_win = nil
|
||||
end
|
||||
|
||||
if overlay_state.right_win and vim.api.nvim_win_is_valid(overlay_state.right_win) then
|
||||
vim.api.nvim_win_close(overlay_state.right_win, true)
|
||||
overlay_state.right_win = nil
|
||||
end
|
||||
|
||||
overlay_state.last_row = nil
|
||||
overlay_state.last_col = nil
|
||||
overlay_state.last_border_hl = nil
|
||||
-- Note: we intentionally don't clear was_rendered here to track the transition
|
||||
end
|
||||
|
||||
function M.detect_and_prepare(items, file_picker, win_width, combo_boost_score_multiplier, disable_combo_display)
|
||||
local combo_item_index, combo_count = detect_combo_item(items, file_picker, combo_boost_score_multiplier)
|
||||
|
||||
if not combo_item_index then return false, nil, 0, nil end
|
||||
|
||||
local header_line, text_len = create_header_text(combo_count, win_width, disable_combo_display)
|
||||
return true, header_line, text_len, combo_item_index
|
||||
end
|
||||
|
||||
--- Render combo highlights and overlays
|
||||
--- @return boolean was_hidden True if combo was just hidden (was rendered before, not now)
|
||||
function M.render_highlights_and_overlays(
|
||||
combo_item_index,
|
||||
text_len,
|
||||
list_buf,
|
||||
list_win,
|
||||
ns_id,
|
||||
border_hl,
|
||||
item_to_lines,
|
||||
prompt_position
|
||||
)
|
||||
local was_rendered_before = overlay_state.was_rendered
|
||||
local is_rendering_now = false
|
||||
|
||||
if not combo_item_index then
|
||||
clear_overlays_internal()
|
||||
else
|
||||
local combo_item_lines = item_to_lines[combo_item_index]
|
||||
if not combo_item_lines then
|
||||
clear_overlays_internal()
|
||||
else
|
||||
local combo_header_line_idx = combo_item_lines.first
|
||||
apply_header_highlights(list_buf, ns_id, combo_header_line_idx, text_len, border_hl)
|
||||
update_overlays(list_win, combo_header_line_idx, border_hl, prompt_position)
|
||||
is_rendering_now = true
|
||||
end
|
||||
end
|
||||
|
||||
overlay_state.was_rendered = is_rendering_now
|
||||
|
||||
-- Return true if combo was just hidden (transition from visible to hidden)
|
||||
return was_rendered_before and not is_rendering_now
|
||||
end
|
||||
|
||||
--- Get the combo header text for a given item
|
||||
--- @param combo_count number The combo multiplier count
|
||||
--- @param win_width number Window width for formatting
|
||||
--- @param disable_combo_display boolean Whether to show combo count
|
||||
--- @return string header_text The formatted header line
|
||||
--- @return number text_len Length of the header text (without padding)
|
||||
function M.get_combo_header_text(combo_count, win_width, disable_combo_display)
|
||||
return create_header_text(combo_count, win_width, disable_combo_display)
|
||||
end
|
||||
|
||||
function M.get_overlay_widths() return LEFT_OVERLAY_WIDTH, RIGHT_OVERLAY_WIDTH end
|
||||
|
||||
function M.cleanup()
|
||||
clear_overlays_internal()
|
||||
overlay_state.was_rendered = false
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,235 @@
|
||||
local M = {}
|
||||
|
||||
--@class fff.conf.State
|
||||
local state = {
|
||||
---@type table | nil
|
||||
config = nil,
|
||||
}
|
||||
|
||||
local DEPRECATION_RULES = {
|
||||
{
|
||||
-- Top-level width -> layout.width
|
||||
old_path = { 'width' },
|
||||
new_path = { 'layout', 'width' },
|
||||
message = 'config.width is deprecated. Use config.layout.width instead.',
|
||||
},
|
||||
{
|
||||
-- Top-level height -> layout.height
|
||||
old_path = { 'height' },
|
||||
new_path = { 'layout', 'height' },
|
||||
message = 'config.height is deprecated. Use config.layout.height instead.',
|
||||
},
|
||||
{
|
||||
-- preview.width -> layout.preview_size
|
||||
old_path = { 'preview', 'width' },
|
||||
new_path = { 'layout', 'preview_size' },
|
||||
message = 'config.preview.width is deprecated. Use config.layout.preview_size instead.',
|
||||
},
|
||||
{
|
||||
-- layout.preview_width -> layout.preview_size
|
||||
old_path = { 'layout', 'preview_width' },
|
||||
new_path = { 'layout', 'preview_size' },
|
||||
message = 'config.layout.preview_width is deprecated. Use config.layout.preview_size instead.',
|
||||
},
|
||||
}
|
||||
|
||||
--- Get value from nested table using path array
|
||||
--- @param tbl table Source table
|
||||
--- @param path table Array of keys to traverse
|
||||
--- @return any|nil Value at path or nil if not found
|
||||
local function get_nested_value(tbl, path)
|
||||
local current = tbl
|
||||
for _, key in ipairs(path) do
|
||||
if type(current) ~= 'table' or current[key] == nil then return nil end
|
||||
current = current[key]
|
||||
end
|
||||
|
||||
return current
|
||||
end
|
||||
|
||||
--- Set value in nested table using path array, creating intermediate tables
|
||||
--- @param tbl table Target table
|
||||
--- @param path table Array of keys to traverse
|
||||
--- @param value any Value to set
|
||||
local function set_nested_value(tbl, path, value)
|
||||
local current = tbl
|
||||
for i = 1, #path - 1 do
|
||||
local key = path[i]
|
||||
if type(current[key]) ~= 'table' then current[key] = {} end
|
||||
current = current[key]
|
||||
end
|
||||
|
||||
current[path[#path]] = value
|
||||
end
|
||||
|
||||
--- Remove value from nested table using path array
|
||||
--- @param tbl table Target table
|
||||
--- @param path table Array of keys to traverse
|
||||
local function remove_nested_value(tbl, path)
|
||||
if #path == 0 then return end
|
||||
|
||||
local current = tbl
|
||||
for i = 1, #path - 1 do
|
||||
local key = path[i]
|
||||
if type(current[key]) ~= 'table' then return end
|
||||
current = current[key]
|
||||
end
|
||||
|
||||
current[path[#path]] = nil
|
||||
end
|
||||
|
||||
--- Handle deprecated configuration options with migration warnings
|
||||
--- @param user_config table User provided configuration
|
||||
--- @return table Migrated configuration
|
||||
local function handle_deprecated_config(user_config)
|
||||
if not user_config then return {} end
|
||||
|
||||
local migrated_config = vim.deepcopy(user_config)
|
||||
|
||||
for _, rule in ipairs(DEPRECATION_RULES) do
|
||||
local old_value = get_nested_value(user_config, rule.old_path)
|
||||
if old_value ~= nil then
|
||||
set_nested_value(migrated_config, rule.new_path, old_value)
|
||||
remove_nested_value(migrated_config, rule.old_path)
|
||||
|
||||
vim.notify('FFF: ' .. rule.message, vim.log.levels.WARN)
|
||||
end
|
||||
end
|
||||
|
||||
return migrated_config
|
||||
end
|
||||
|
||||
local function init()
|
||||
local config = vim.g.fff or {}
|
||||
local default_config = {
|
||||
base_path = vim.fn.getcwd(),
|
||||
prompt = '🪿 ',
|
||||
title = 'FFFiles',
|
||||
max_results = 100,
|
||||
max_threads = 4,
|
||||
layout = {
|
||||
height = 0.8,
|
||||
width = 0.8,
|
||||
prompt_position = 'bottom', -- or 'top'
|
||||
preview_position = 'right', -- or 'left', 'right', 'top', 'bottom'
|
||||
preview_size = 0.5,
|
||||
show_scrollbar = true, -- Show scrollbar for pagination
|
||||
},
|
||||
preview = {
|
||||
enabled = true,
|
||||
max_size = 10 * 1024 * 1024, -- Do not try to read files larger than 10MB
|
||||
chunk_size = 8192, -- Bytes per chunk for dynamic loading (8kb - fits ~100-200 lines)
|
||||
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,
|
||||
wrap_lines = false,
|
||||
show_file_info = true,
|
||||
filetypes = {
|
||||
svg = { wrap_lines = true },
|
||||
markdown = { wrap_lines = true },
|
||||
text = { wrap_lines = true },
|
||||
},
|
||||
},
|
||||
keymaps = {
|
||||
close = '<Esc>',
|
||||
select = '<CR>',
|
||||
select_split = '<C-s>',
|
||||
select_vsplit = '<C-v>',
|
||||
select_tab = '<C-t>',
|
||||
move_up = { '<Up>', '<C-p>' },
|
||||
move_down = { '<Down>', '<C-n>' },
|
||||
preview_scroll_up = '<C-u>',
|
||||
preview_scroll_down = '<C-d>',
|
||||
toggle_debug = '<F2>',
|
||||
cycle_previous_query = '<C-Up>',
|
||||
toggle_select = '<Tab>',
|
||||
send_to_quickfix = '<C-q>',
|
||||
},
|
||||
hl = {
|
||||
border = 'FloatBorder',
|
||||
normal = 'Normal',
|
||||
cursor = 'CursorLine',
|
||||
matched = 'IncSearch',
|
||||
title = 'Title',
|
||||
prompt = 'Question',
|
||||
active_file = 'Visual',
|
||||
frecency = 'Number',
|
||||
debug = 'Comment',
|
||||
combo_header = 'Number',
|
||||
scrollbar = 'Comment',
|
||||
directory_path = 'Comment', -- Highlight for directory path in file list
|
||||
-- Multi-select highlights
|
||||
selected = 'FFFSelected',
|
||||
selected_active = 'FFFSelectedActive',
|
||||
-- Git text highlights for file names
|
||||
git_staged = 'FFFGitStaged',
|
||||
git_modified = 'FFFGitModified',
|
||||
git_deleted = 'FFFGitDeleted',
|
||||
git_renamed = 'FFFGitRenamed',
|
||||
git_untracked = 'FFFGitUntracked',
|
||||
git_ignored = 'FFFGitIgnored',
|
||||
-- Git sign/border highlights
|
||||
git_sign_staged = 'FFFGitSignStaged',
|
||||
git_sign_modified = 'FFFGitSignModified',
|
||||
git_sign_deleted = 'FFFGitSignDeleted',
|
||||
git_sign_renamed = 'FFFGitSignRenamed',
|
||||
git_sign_untracked = 'FFFGitSignUntracked',
|
||||
git_sign_ignored = 'FFFGitSignIgnored',
|
||||
-- Git sign selected highlights
|
||||
git_sign_staged_selected = 'FFFGitSignStagedSelected',
|
||||
git_sign_modified_selected = 'FFFGitSignModifiedSelected',
|
||||
git_sign_deleted_selected = 'FFFGitSignDeletedSelected',
|
||||
git_sign_renamed_selected = 'FFFGitSignRenamedSelected',
|
||||
git_sign_untracked_selected = 'FFFGitSignUntrackedSelected',
|
||||
git_sign_ignored_selected = 'FFFGitSignIgnoredSelected',
|
||||
},
|
||||
frecency = {
|
||||
enabled = true,
|
||||
db_path = vim.fn.stdpath('cache') .. '/fff_nvim',
|
||||
},
|
||||
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 = {
|
||||
status_text_color = false, -- Apply git status colors to filename text (default: false, only sign column)
|
||||
},
|
||||
debug = {
|
||||
enabled = false, -- Set to true to show scores in the UI
|
||||
show_scores = false,
|
||||
},
|
||||
logging = {
|
||||
enabled = true,
|
||||
log_file = vim.fn.stdpath('log') .. '/fff.log',
|
||||
log_level = 'info',
|
||||
},
|
||||
}
|
||||
|
||||
local migrated_user_config = handle_deprecated_config(config)
|
||||
local merged_config = vim.tbl_deep_extend('force', default_config, migrated_user_config)
|
||||
|
||||
state.config = merged_config
|
||||
end
|
||||
|
||||
--- Setup the file picker with the given configuration
|
||||
--- @param config table Configuration options
|
||||
function M.setup(config) vim.g.fff = config end
|
||||
|
||||
--- @return table the fff configuration
|
||||
function M.get()
|
||||
if not state.config then init() end
|
||||
return state.config
|
||||
end
|
||||
|
||||
--- @return boolean state_changed
|
||||
function M.toggle_debug()
|
||||
local old_debug_state = state.config.debug.show_scores
|
||||
state.config.debug.show_scores = not state.config.debug.show_scores
|
||||
local status = state.config.debug.show_scores and 'enabled' or 'disabled'
|
||||
vim.notify('FFF debug scores ' .. status, vim.log.levels.INFO)
|
||||
return old_debug_state ~= state.config.debug.show_scores
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,107 @@
|
||||
local fuzzy = require('fff.fuzzy')
|
||||
if not fuzzy then error('Failed to load fff.fuzzy module. Ensure the Rust backend is compiled and available.') end
|
||||
|
||||
local M = {}
|
||||
|
||||
---@class fff.core.State
|
||||
local state = {
|
||||
---@type boolean
|
||||
initialized = false,
|
||||
---@type boolean
|
||||
file_picker_initialized = false,
|
||||
}
|
||||
|
||||
---@param config table
|
||||
local function setup_global_autocmds(config)
|
||||
local group = vim.api.nvim_create_augroup('fff_file_tracking', { clear = true })
|
||||
|
||||
if config.frecency.enabled then
|
||||
vim.api.nvim_create_autocmd({ 'BufEnter' }, {
|
||||
group = group,
|
||||
desc = 'Track file access for FFF frecency',
|
||||
callback = function(args)
|
||||
local file_path = args.file
|
||||
if not (file_path and file_path ~= '' and not vim.startswith(file_path, 'term://')) then return end
|
||||
|
||||
vim.uv.fs_stat(file_path, function(err, stat)
|
||||
if err or not stat then return end
|
||||
|
||||
vim.uv.fs_realpath(file_path, function(rp_err, real_path)
|
||||
if rp_err or not real_path then return end
|
||||
pcall(fuzzy.track_access, real_path)
|
||||
end)
|
||||
end)
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
-- make sure that this won't work correctly if autochdir plugins are enabled
|
||||
-- using a pure :cd command but will work using lua api or :e command
|
||||
vim.api.nvim_create_autocmd('DirChanged', {
|
||||
group = group,
|
||||
callback = function()
|
||||
local new_cwd = vim.v.event.cwd
|
||||
if state.initialized and new_cwd and new_cwd ~= config.base_path then
|
||||
vim.schedule(function()
|
||||
-- Delay require to avoid circular dependency: core -> main -> picker_ui -> file_picker -> core
|
||||
local ok, picker = pcall(require, 'fff.main')
|
||||
if not ok then
|
||||
vim.notify('FFF: Failed to load main module: ' .. tostring(picker), vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
local change_ok, err = pcall(picker.change_indexing_directory, new_cwd)
|
||||
if not change_ok then
|
||||
vim.notify('FFF: Failed to change indexing directory: ' .. tostring(err), vim.log.levels.ERROR)
|
||||
end
|
||||
end)
|
||||
end
|
||||
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
|
||||
M.is_file_picker_initialized = function() return state.file_picker_initialized end
|
||||
|
||||
M.ensure_initialized = function()
|
||||
if state.initialized then return fuzzy end
|
||||
state.initialized = true
|
||||
|
||||
local config = require('fff.conf').get()
|
||||
if config.logging.enabled then
|
||||
local log_success, log_error = pcall(fuzzy.init_tracing, config.logging.log_file, config.logging.log_level)
|
||||
if log_success then
|
||||
M.log_file_path = log_error
|
||||
else
|
||||
vim.notify('Failed to initialize logging: ' .. (tostring(log_error) or 'unknown error'), vim.log.levels.WARN)
|
||||
end
|
||||
end
|
||||
|
||||
local frecency_db_path = config.frecency.db_path or (vim.fn.stdpath('cache') .. '/fff_frecency')
|
||||
local history_db_path = config.history.db_path or (vim.fn.stdpath('data') .. '/fff_history')
|
||||
|
||||
local ok, result = pcall(fuzzy.init_db, frecency_db_path, history_db_path, true)
|
||||
if not ok then vim.notify('Failed to databases: ' .. result, vim.log.levels.WARN) end
|
||||
|
||||
ok, result = pcall(fuzzy.init_file_picker, config.base_path)
|
||||
if not ok then
|
||||
vim.notify('Failed to initialize file picker: ' .. result, vim.log.levels.ERROR)
|
||||
return fuzzy
|
||||
end
|
||||
|
||||
state.file_picker_initialized = true
|
||||
setup_global_autocmds(config)
|
||||
|
||||
local git_utils = require('fff.git_utils')
|
||||
git_utils.setup_highlights()
|
||||
|
||||
return fuzzy
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,197 @@
|
||||
local M = {}
|
||||
local system = require('fff.utils.system')
|
||||
|
||||
local GITHUB_REPO = 'dmtrKovalenko/fff.nvim'
|
||||
|
||||
local function get_current_version(plugin_dir, callback)
|
||||
vim.system({ 'git', 'rev-parse', '--short', 'HEAD' }, { cwd = plugin_dir }, function(result)
|
||||
if result.code ~= 0 or not result.stdout or result.stdout == '' then
|
||||
callback(nil)
|
||||
return
|
||||
end
|
||||
callback(result.stdout:gsub('%s+', ''))
|
||||
end)
|
||||
end
|
||||
|
||||
local function get_binary_dir(plugin_dir) return plugin_dir .. '/../target/release' end
|
||||
|
||||
local function get_binary_path(plugin_dir)
|
||||
local binary_dir = get_binary_dir(plugin_dir)
|
||||
local extension = system.get_lib_extension()
|
||||
return binary_dir .. '/libfff_nvim.' .. extension
|
||||
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'
|
||||
end
|
||||
|
||||
local function download_file(url, output_path, opts, callback)
|
||||
opts = opts or {}
|
||||
|
||||
local dir = vim.fn.fnamemodify(output_path, ':h')
|
||||
|
||||
mkdir_recursive(dir, function(mkdir_ok, mkdir_err)
|
||||
if not mkdir_ok then
|
||||
callback(false, mkdir_err)
|
||||
return
|
||||
end
|
||||
|
||||
local curl_args = {
|
||||
'curl',
|
||||
'--fail',
|
||||
'--location',
|
||||
'--silent',
|
||||
'--show-error',
|
||||
'--output',
|
||||
output_path,
|
||||
}
|
||||
|
||||
if opts.proxy then
|
||||
table.insert(curl_args, '--proxy')
|
||||
table.insert(curl_args, opts.proxy)
|
||||
end
|
||||
|
||||
table.insert(curl_args, url)
|
||||
vim.system(curl_args, {}, function(result)
|
||||
if result.code ~= 0 then
|
||||
callback(false, 'Failed to download: ' .. (result.stderr or 'unknown error'))
|
||||
return
|
||||
end
|
||||
callback(true, nil)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
local function download_from_github(version, binary_path, opts, callback)
|
||||
opts = opts or {}
|
||||
|
||||
local triple = system.get_triple()
|
||||
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, {
|
||||
proxy = opts.proxy,
|
||||
extra_curl_args = opts.extra_curl_args,
|
||||
}, function(success, err)
|
||||
if not success then
|
||||
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)
|
||||
|
||||
if not ok then
|
||||
vim.uv.fs_unlink(binary_path)
|
||||
callback(false, 'Downloaded binary is not valid: ' .. (err_msg or 'unknown error'))
|
||||
return
|
||||
end
|
||||
|
||||
vim.schedule(function() vim.notify('fff.nvim binary downloaded successfully!', vim.log.levels.INFO) end)
|
||||
callback(true, nil)
|
||||
end)
|
||||
end
|
||||
|
||||
function M.ensure_downloaded(opts, callback)
|
||||
opts = opts or {}
|
||||
local plugin_dir = vim.fn.fnamemodify(debug.getinfo(1, 'S').source:sub(2), ':h:h')
|
||||
|
||||
if binary_exists(plugin_dir) and not opts.force then
|
||||
callback(true, nil)
|
||||
return
|
||||
end
|
||||
|
||||
local function on_version(target_version)
|
||||
if not target_version then
|
||||
callback(false, 'Could not determine target version')
|
||||
return
|
||||
end
|
||||
|
||||
local binary_path = get_binary_path(plugin_dir)
|
||||
download_from_github(target_version, binary_path, opts, callback)
|
||||
end
|
||||
|
||||
if opts.version then
|
||||
on_version(opts.version)
|
||||
else
|
||||
get_current_version(plugin_dir, on_version)
|
||||
end
|
||||
end
|
||||
|
||||
function M.download_binary(callback)
|
||||
M.ensure_downloaded({ force = true }, function(success, err)
|
||||
if not success then
|
||||
if callback then
|
||||
callback(false, err)
|
||||
else
|
||||
error('Failed to download fff.nvim binary: ' .. (err or 'unknown error'))
|
||||
end
|
||||
return
|
||||
end
|
||||
if callback then callback(true, nil) end
|
||||
end)
|
||||
end
|
||||
|
||||
function M.build_binary(callback)
|
||||
local plugin_dir = vim.fn.fnamemodify(debug.getinfo(1, 'S').source:sub(2), ':h:h')
|
||||
local has_rustup = vim.fn.executable('rustup') == 1
|
||||
if not has_rustup then
|
||||
callback(
|
||||
false,
|
||||
'rustup is not found. It is required to build the fff.nvim binary. Install it from https://rustup.rs/'
|
||||
)
|
||||
return
|
||||
end
|
||||
|
||||
vim.system({ 'cargo', 'build', '--release' }, { cwd = plugin_dir }, function(result)
|
||||
if result.code ~= 0 then
|
||||
callback(false, 'Failed to build rust binary: ' .. (result.stderr or 'unknown error'))
|
||||
return
|
||||
end
|
||||
callback(true, nil)
|
||||
end)
|
||||
end
|
||||
|
||||
function M.download_or_build_binary()
|
||||
M.ensure_downloaded({ force = true }, function(download_success, download_error)
|
||||
if download_success then return end
|
||||
|
||||
vim.schedule(
|
||||
function()
|
||||
vim.notify(
|
||||
'Error downloading binary: ' .. (download_error or 'unknown error') .. '\nTrying cargo build --release\n',
|
||||
vim.log.levels.WARN
|
||||
)
|
||||
end
|
||||
)
|
||||
|
||||
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'))
|
||||
else
|
||||
vim.schedule(function() vim.notify('fff.nvim binary built successfully!', vim.log.levels.INFO) end)
|
||||
end
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
function M.get_binary_path()
|
||||
local plugin_dir = vim.fn.fnamemodify(debug.getinfo(1, 'S').source:sub(2), ':h:h')
|
||||
return get_binary_path(plugin_dir)
|
||||
end
|
||||
|
||||
function M.get_binary_cpath_component()
|
||||
local plugin_dir = vim.fn.fnamemodify(debug.getinfo(1, 'S').source:sub(2), ':h:h')
|
||||
local binary_dir = get_binary_dir(plugin_dir)
|
||||
local extension = system.get_lib_extension()
|
||||
return binary_dir .. '/lib?.' .. extension
|
||||
end
|
||||
|
||||
return M
|
||||
+14
-153
@@ -10,46 +10,6 @@ M.provider_name = nil
|
||||
M.setup_attempted = false
|
||||
M.setup_failed = false
|
||||
|
||||
local directory_configs = {
|
||||
['nvim-web-devicons'] = {
|
||||
default = { icon = '', hl = 'DevIconDefault' },
|
||||
open = { icon = '', hl = 'DevIconDefault' },
|
||||
closed = { icon = '', hl = 'DevIconDefault' },
|
||||
git = { icon = '', hl = 'DevIconGitIgnore' },
|
||||
node_modules = { icon = '', hl = 'DevIconNodeModules' },
|
||||
hidden = { icon = '', hl = 'DevIconDefault' },
|
||||
},
|
||||
['mini.icons'] = {
|
||||
default = { icon = '', color = '#7aa2f7' },
|
||||
open = { icon = '', color = '#7aa2f7' },
|
||||
closed = { icon = '', color = '#7aa2f7' },
|
||||
git = { icon = '', color = '#e24329' },
|
||||
node_modules = { icon = '', color = '#8cc84b' },
|
||||
hidden = { icon = '', color = '#6d8086' },
|
||||
},
|
||||
}
|
||||
|
||||
-- Special directory names and their icons
|
||||
local special_directories = {
|
||||
['.git'] = 'git',
|
||||
['node_modules'] = 'node_modules',
|
||||
['.vscode'] = 'hidden',
|
||||
['.idea'] = 'hidden',
|
||||
['.cache'] = 'hidden',
|
||||
['.config'] = 'hidden',
|
||||
['__pycache__'] = 'hidden',
|
||||
['.pytest_cache'] = 'hidden',
|
||||
['target'] = 'hidden',
|
||||
['dist'] = 'hidden',
|
||||
['build'] = 'hidden',
|
||||
['out'] = 'hidden',
|
||||
['.next'] = 'hidden',
|
||||
['.nuxt'] = 'hidden',
|
||||
['coverage'] = 'hidden',
|
||||
}
|
||||
|
||||
M.highlight_cache = {}
|
||||
|
||||
function M.setup()
|
||||
if M.provider_name then return true end
|
||||
if M.setup_failed then return false end
|
||||
@@ -66,150 +26,51 @@ function M.setup()
|
||||
end
|
||||
|
||||
M.setup_failed = true
|
||||
vim.notify('FFF Icons: No icon provider found. Please install nvim-web-devicons or mini.icons', vim.log.levels.WARN)
|
||||
return false
|
||||
end
|
||||
|
||||
--- Get icon for a directory
|
||||
--- @param dirname string The directory name
|
||||
--- @return string, string Icon and color/highlight
|
||||
--- @return string|nil, string|nil Icon and highlight group (nil if no provider)
|
||||
function M.get_directory_icon(dirname)
|
||||
if not M.setup() then
|
||||
return '', '#7aa2f7' -- Default folder icon if no provider
|
||||
end
|
||||
if not M.setup() then return nil, nil end
|
||||
|
||||
local dir_type = 'default'
|
||||
local basename = vim.fn.fnamemodify(dirname, ':t')
|
||||
|
||||
if special_directories[basename] then
|
||||
dir_type = special_directories[basename]
|
||||
elseif basename:match('^%.') then
|
||||
dir_type = 'hidden'
|
||||
end
|
||||
|
||||
local config = directory_configs[M.provider_name]
|
||||
if not config or not config[dir_type] then dir_type = 'default' end
|
||||
|
||||
local icon_data = config[dir_type]
|
||||
|
||||
if M.provider_name == 'nvim-web-devicons' then
|
||||
-- For nvim-web-devicons, try to get the actual icon first
|
||||
if M.provider.get_icon then
|
||||
local provider_icon, provider_hl = M.provider.get_icon(basename, nil, { default = false })
|
||||
if provider_icon and provider_icon ~= '' then
|
||||
return provider_icon, M.resolve_color(provider_hl or icon_data.hl)
|
||||
end
|
||||
local icon, hl = M.provider.get_icon(basename, nil, { default = true })
|
||||
if icon and icon ~= '' and hl then return icon, hl end
|
||||
end
|
||||
|
||||
-- Use our configured icon
|
||||
return icon_data.icon, M.resolve_color(icon_data.hl)
|
||||
elseif M.provider_name == 'mini.icons' then
|
||||
-- For mini.icons, try to get directory-specific icon
|
||||
if M.provider.get then
|
||||
local provider_data = M.provider.get('directory', basename)
|
||||
if provider_data and provider_data.glyph and provider_data.glyph ~= '' then
|
||||
return provider_data.glyph, M.get_color_from_highlight(provider_data.hl)
|
||||
end
|
||||
local icon, hl, is_default = M.provider.get('directory', basename)
|
||||
if icon and icon ~= '' and hl then return icon, hl end
|
||||
end
|
||||
|
||||
-- Use our configured icon
|
||||
return icon_data.icon, icon_data.color
|
||||
end
|
||||
|
||||
-- Fallback (shouldn't reach here)
|
||||
return '', '#7aa2f7'
|
||||
return nil, nil
|
||||
end
|
||||
|
||||
--- Get icon for a file
|
||||
--- @param filename string The filename
|
||||
--- @param extension string The file extension (without dot)
|
||||
--- @param is_directory boolean Whether this is a directory
|
||||
--- @return string, string Icon and color
|
||||
--- @return string|nil, string|nil Icon and highlight group (nil if no provider)
|
||||
function M.get_icon(filename, extension, is_directory)
|
||||
if not M.setup() then
|
||||
if is_directory then
|
||||
return '', '#7aa2f7'
|
||||
else
|
||||
return '', '#6d8086'
|
||||
end
|
||||
end
|
||||
if not M.setup() then return nil, nil end
|
||||
|
||||
if is_directory then return M.get_directory_icon(filename) end
|
||||
|
||||
local icon, color_or_hl
|
||||
|
||||
if M.provider_name == 'nvim-web-devicons' then
|
||||
icon, color_or_hl = M.provider.get_icon(filename, extension, { default = true })
|
||||
if icon and icon ~= '' then return icon, M.resolve_color(color_or_hl) end
|
||||
local icon, hl = M.provider.get_icon(filename, extension, { default = true })
|
||||
if icon and icon ~= '' and hl then return icon, hl end
|
||||
elseif M.provider_name == 'mini.icons' then
|
||||
local icon_data = M.provider.get('file', filename)
|
||||
if icon_data and icon_data.glyph and icon_data.glyph ~= '' then
|
||||
return icon_data.glyph, M.get_color_from_highlight(icon_data.hl)
|
||||
end
|
||||
local icon, hl, _ = M.provider.get('file', filename)
|
||||
if icon and icon ~= '' and hl then return icon, hl end
|
||||
end
|
||||
|
||||
return '', '#6d8086'
|
||||
end
|
||||
|
||||
--- Get folder icon (kept for compatibility)
|
||||
--- @return string, string Icon and color
|
||||
function M.get_folder_icon() return M.get_directory_icon('folder') end
|
||||
|
||||
--- Resolve color from highlight group or hex
|
||||
--- @param color_or_hl string|nil Color hex or highlight group name
|
||||
--- @return string Hex color
|
||||
function M.resolve_color(color_or_hl)
|
||||
if not color_or_hl or color_or_hl == '' then return '#6d8086' end
|
||||
|
||||
-- If it's already a hex color, return as-is
|
||||
if color_or_hl:match('^#%x%x%x%x%x%x$') then return color_or_hl end
|
||||
|
||||
-- Try to resolve as highlight group
|
||||
return M.get_color_from_highlight(color_or_hl)
|
||||
end
|
||||
|
||||
--- Get hex color from highlight group
|
||||
--- @param hl_group string Highlight group name
|
||||
--- @return string Hex color
|
||||
function M.get_color_from_highlight(hl_group)
|
||||
if not hl_group or hl_group == '' then return '#6d8086' end
|
||||
|
||||
local ok, hl = pcall(vim.api.nvim_get_hl, 0, { name = hl_group })
|
||||
if ok and hl and hl.fg then return string.format('#%06x', hl.fg) end
|
||||
|
||||
return '#6d8086' -- Fallback color
|
||||
end
|
||||
|
||||
--- Get icon with display formatting and highlight group creation
|
||||
--- @param filename string The filename
|
||||
--- @param extension string The file extension (without dot)
|
||||
--- @param is_directory boolean Whether this is a directory
|
||||
--- @return string, string Icon and highlight group name
|
||||
function M.get_icon_display(filename, extension, is_directory)
|
||||
local icon, color = M.get_icon(filename, extension, is_directory)
|
||||
local hl_group = M.create_icon_highlight(color)
|
||||
return icon, hl_group
|
||||
end
|
||||
|
||||
--- Create or get cached highlight group for icon color
|
||||
--- @param color string Hex color
|
||||
--- @return string Highlight group name
|
||||
function M.create_icon_highlight(color)
|
||||
if not color or color == '' then color = '#6d8086' end
|
||||
if not color:match('^#%x%x%x%x%x%x$') then color = M.resolve_color(color) end
|
||||
local hl_name = 'FFFIcon' .. color:gsub('#', ''):upper()
|
||||
|
||||
if M.highlight_cache[hl_name] then return hl_name end
|
||||
|
||||
local ok = pcall(vim.api.nvim_set_hl, 0, hl_name, { fg = color })
|
||||
if not ok then
|
||||
color = '#6d8086'
|
||||
hl_name = 'FFFIcon6D8086'
|
||||
vim.api.nvim_set_hl(0, hl_name, { fg = color })
|
||||
end
|
||||
|
||||
M.highlight_cache[hl_name] = true
|
||||
return hl_name
|
||||
return nil, nil
|
||||
end
|
||||
|
||||
--- Check if directories are supported by current provider
|
||||
|
||||
+131
-58
@@ -8,37 +8,19 @@ local function get_main_config()
|
||||
end
|
||||
|
||||
local active_placements = {} ---@type table<number, any>
|
||||
local loading_jobs = {} ---@type table<number, {metadata_job?: any}>
|
||||
|
||||
local function identify_image_lines(file_path)
|
||||
local stat = vim.uv.fs_stat(file_path)
|
||||
local size_str = stat and utils.format_file_size(stat.size) or 'Unknown'
|
||||
|
||||
local info_lines = {}
|
||||
table.insert(info_lines, ' Size: ' .. size_str)
|
||||
|
||||
local config = get_main_config()
|
||||
local format_str = config and config.preview and config.preview.imagemagick_info_format_str
|
||||
or '%m: %wx%h, %[colorspace], %q-bit'
|
||||
local cmd = string.format('identify -format "%s" "%s" 2>/dev/null', format_str, file_path)
|
||||
local magick_info = vim.fn.system(cmd)
|
||||
|
||||
if vim.v.shell_error == 0 and magick_info and magick_info ~= '' then
|
||||
magick_info = ' ' .. magick_info:gsub('\n', '')
|
||||
table.insert(info_lines, magick_info)
|
||||
end
|
||||
|
||||
return info_lines
|
||||
end
|
||||
|
||||
-- This is a required function for snacks nvim to fill the buffer with enough space
|
||||
local function fill_buffer_space_for_image_preview(bufnr, info_lines)
|
||||
local function reserve_image_buffer_space(bufnr, metadata_lines_count)
|
||||
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
|
||||
|
||||
local win = vim.fn.bufwinid(bufnr)
|
||||
local buffer_height = win ~= -1 and vim.api.nvim_win_get_height(win) or 24
|
||||
local lines_for_image = math.max(buffer_height - #info_lines - 2, 5)
|
||||
local lines_for_image = math.max(buffer_height - metadata_lines_count - 2, 5)
|
||||
|
||||
local buffer_lines = vim.list_extend({}, info_lines)
|
||||
local buffer_lines = {}
|
||||
for _ = 1, metadata_lines_count do
|
||||
table.insert(buffer_lines, '')
|
||||
end
|
||||
for _ = 1, lines_for_image do
|
||||
table.insert(buffer_lines, '')
|
||||
end
|
||||
@@ -47,8 +29,64 @@ local function fill_buffer_space_for_image_preview(bufnr, info_lines)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
|
||||
|
||||
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, buffer_lines)
|
||||
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', was_modifiable)
|
||||
|
||||
return metadata_lines_count or 2
|
||||
end
|
||||
|
||||
local function update_metadata_lines(bufnr, info_lines, reserved_lines_count)
|
||||
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
|
||||
|
||||
reserved_lines_count = reserved_lines_count or 2
|
||||
|
||||
local metadata_lines = {}
|
||||
for i = 1, reserved_lines_count do
|
||||
metadata_lines[i] = info_lines[i] or ''
|
||||
end
|
||||
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
|
||||
vim.api.nvim_buf_set_lines(bufnr, 0, reserved_lines_count, false, metadata_lines)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
|
||||
end
|
||||
|
||||
local function identify_image_lines_async(file_path, bufnr, callback)
|
||||
local stat = vim.uv.fs_stat(file_path)
|
||||
local size_str = stat and utils.format_file_size(stat.size) or 'Unknown'
|
||||
|
||||
local initial_info_lines = { ' Size: ' .. size_str }
|
||||
|
||||
if vim.fn.executable('identify') == 0 then
|
||||
callback(initial_info_lines)
|
||||
return
|
||||
end
|
||||
|
||||
callback(initial_info_lines)
|
||||
|
||||
local config = get_main_config()
|
||||
local format_str = config and config.preview and config.preview.imagemagick_info_format_str
|
||||
or '%m: %wx%h, %[colorspace], %q-bit'
|
||||
|
||||
local cmd = { 'identify', '-format', format_str, file_path }
|
||||
|
||||
-- Cancel any previous metadata job
|
||||
if loading_jobs[bufnr] and loading_jobs[bufnr].metadata_job then
|
||||
pcall(loading_jobs[bufnr].metadata_job.kill, loading_jobs[bufnr].metadata_job, 9)
|
||||
end
|
||||
loading_jobs[bufnr] = loading_jobs[bufnr] or {}
|
||||
|
||||
loading_jobs[bufnr].metadata_job = vim.system(cmd, { text = true }, function(result)
|
||||
vim.schedule(function()
|
||||
if not vim.api.nvim_buf_is_valid(bufnr) then return end
|
||||
|
||||
local enhanced_info_lines = vim.deepcopy(initial_info_lines)
|
||||
if result.code == 0 and result.stdout and result.stdout ~= '' then
|
||||
local magick_info = ' ' .. result.stdout:gsub('\n', '')
|
||||
table.insert(enhanced_info_lines, magick_info)
|
||||
end
|
||||
|
||||
callback(enhanced_info_lines)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
local IMAGE_EXTENSIONS = {
|
||||
@@ -81,11 +119,16 @@ function M.is_image(file_path)
|
||||
return false
|
||||
end
|
||||
|
||||
--- Clear any existing image attachments from buffer
|
||||
--- @param bufnr number Buffer number
|
||||
function M.clear_buffer_images(bufnr)
|
||||
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
|
||||
|
||||
if loading_jobs[bufnr] then
|
||||
if loading_jobs[bufnr].metadata_job then
|
||||
pcall(loading_jobs[bufnr].metadata_job.kill, loading_jobs[bufnr].metadata_job, 9)
|
||||
end
|
||||
loading_jobs[bufnr] = nil
|
||||
end
|
||||
|
||||
if active_placements[bufnr] then
|
||||
pcall(active_placements[bufnr].close, active_placements[bufnr])
|
||||
active_placements[bufnr] = nil
|
||||
@@ -93,9 +136,12 @@ function M.clear_buffer_images(bufnr)
|
||||
|
||||
local ok, snacks = pcall(require, 'snacks')
|
||||
if ok and snacks.image and snacks.image.placement then pcall(snacks.image.placement.clean, bufnr) end
|
||||
|
||||
pcall(vim.api.nvim_buf_clear_namespace, bufnr, -1, 0, -1)
|
||||
end
|
||||
|
||||
--- Display image using the simplest approach that works
|
||||
--- Load metadata of the image, displays it and display image in paralallel
|
||||
--- 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
|
||||
@@ -106,50 +152,77 @@ function M.display_image(file_path, bufnr, max_width, max_height)
|
||||
max_height = max_height or 24
|
||||
vim.api.nvim_buf_set_option(bufnr, 'number', false)
|
||||
|
||||
local reserved_metadata_lines = reserve_image_buffer_space(bufnr, 2)
|
||||
local image_content_starts_at_line = reserved_metadata_lines + 1
|
||||
identify_image_lines_async(
|
||||
file_path,
|
||||
bufnr,
|
||||
function(final_info_lines) update_metadata_lines(bufnr, final_info_lines, reserved_metadata_lines) end
|
||||
)
|
||||
|
||||
local ok, snacks = pcall(require, 'snacks')
|
||||
if ok and snacks.image and snacks.image.buf then
|
||||
if not ok then
|
||||
local error_lines = {
|
||||
'⚠ Image Preview Unavailable',
|
||||
'',
|
||||
'snacks.nvim plugin is not installed or not available.',
|
||||
}
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
|
||||
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)
|
||||
return false
|
||||
end
|
||||
|
||||
if not snacks.image.supports_terminal() then
|
||||
local error_lines = {
|
||||
'⚠ Image Preview Unavailable',
|
||||
'',
|
||||
'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_buf_set_lines(bufnr, image_content_starts_at_line, -1, false, error_lines)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
|
||||
return false
|
||||
end
|
||||
|
||||
if not snacks.image.supports_file(file_path) then
|
||||
local error_lines = {
|
||||
'⚠ Unsupported Image Format',
|
||||
'',
|
||||
'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_buf_set_lines(bufnr, image_content_starts_at_line, -1, false, error_lines)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
|
||||
return false
|
||||
end
|
||||
|
||||
if snacks.image and snacks.image.placement then
|
||||
M.clear_buffer_images(bufnr)
|
||||
|
||||
local info_lines = identify_image_lines(file_path)
|
||||
|
||||
fill_buffer_space_for_image_preview(bufnr, info_lines)
|
||||
vim.schedule(function()
|
||||
local success, placement = pcall(snacks.image.placement.new, bufnr, file_path, {
|
||||
pos = { #info_lines + 1, 1 },
|
||||
pos = { image_content_starts_at_line, 1 },
|
||||
inline = true,
|
||||
fit = 'contain',
|
||||
auto_resize = true,
|
||||
})
|
||||
|
||||
if success and placement then
|
||||
active_placements[bufnr] = placement
|
||||
else
|
||||
M.display_image_info(file_path, bufnr, 'Snacks.nvim failed: ' .. tostring(placement or 'unknown error'))
|
||||
end
|
||||
if success and placement then active_placements[bufnr] = placement end
|
||||
end)
|
||||
|
||||
identify_image_lines_async(
|
||||
file_path,
|
||||
bufnr,
|
||||
function(final_info_lines) update_metadata_lines(bufnr, final_info_lines, reserved_metadata_lines) end
|
||||
)
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
M.display_image_info(file_path, bufnr, 'Snacks.nvim not available')
|
||||
return false
|
||||
end
|
||||
|
||||
--- Display image information when image display fails
|
||||
--- @param file_path string Path to the image file
|
||||
--- @param bufnr number Buffer number to display in
|
||||
--- @param reason string|nil Reason for failure
|
||||
function M.display_image_info(file_path, bufnr, reason)
|
||||
local info_lines = identify_image_lines(file_path)
|
||||
|
||||
if reason then
|
||||
table.insert(info_lines, '')
|
||||
table.insert(info_lines, string.rep('─', 50))
|
||||
|
||||
table.insert(info_lines, ' Preview is not available')
|
||||
table.insert(info_lines, ' Reason: ' .. reason)
|
||||
end
|
||||
|
||||
fill_buffer_space_for_image_preview(bufnr, info_lines)
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
@@ -2,68 +2,18 @@
|
||||
--- Uses advanced fuzzy search algorithm with frecency scoring
|
||||
|
||||
local M = {}
|
||||
local fuzzy = require('fff.core').ensure_initialized()
|
||||
|
||||
-- Load the fuzzy module for file operations
|
||||
local fuzzy = require('fff.fuzzy')
|
||||
|
||||
-- State
|
||||
M.state = {
|
||||
initialized = false,
|
||||
base_path = nil,
|
||||
last_scan_time = 0,
|
||||
config = nil,
|
||||
}
|
||||
|
||||
--- Initialize the file picker
|
||||
--- @param config table Configuration for the file picker
|
||||
function M.setup(config)
|
||||
config = config or {}
|
||||
|
||||
-- Default configuration
|
||||
local defaults = {
|
||||
base_path = vim.fn.getcwd(),
|
||||
max_results = 100,
|
||||
max_threads = 4,
|
||||
show_hidden = false,
|
||||
ignore_patterns = {},
|
||||
preview = {
|
||||
enabled = true,
|
||||
max_lines = 100,
|
||||
max_size = 1024 * 1024, -- 1MB
|
||||
},
|
||||
keymaps = {
|
||||
select = '<CR>',
|
||||
vsplit = '<C-v>',
|
||||
split = '<C-s>',
|
||||
tab = '<C-t>',
|
||||
close = '<Esc>',
|
||||
preview_up = '<C-u>',
|
||||
preview_down = '<C-d>',
|
||||
},
|
||||
layout = {
|
||||
prompt_position = 'top',
|
||||
preview_position = 'right',
|
||||
preview_width = 0.4,
|
||||
height = 0.8,
|
||||
width = 0.8,
|
||||
},
|
||||
}
|
||||
|
||||
M.config = vim.tbl_deep_extend('force', defaults, config)
|
||||
M.state.config = M.config
|
||||
|
||||
local db_path = vim.fn.stdpath('cache') .. '/fff_nvim'
|
||||
local ok, result = pcall(fuzzy.init_db, db_path, true)
|
||||
if not ok then vim.notify('Failed to initialize frecency database: ' .. result, vim.log.levels.WARN) end
|
||||
|
||||
ok, result = pcall(fuzzy.init_file_picker, M.config.base_path)
|
||||
if not ok then
|
||||
vim.notify('Failed to initialize file picker: ' .. result, vim.log.levels.ERROR)
|
||||
return false
|
||||
end
|
||||
|
||||
function M.setup()
|
||||
local config = require('fff.conf').get()
|
||||
M.state.initialized = true
|
||||
M.state.base_path = M.config.base_path
|
||||
M.state.base_path = config.base_path
|
||||
|
||||
return true
|
||||
end
|
||||
@@ -81,39 +31,61 @@ function M.scan_files()
|
||||
M.state.last_scan_time = os.time()
|
||||
end
|
||||
|
||||
--- Get cached files from the file picker
|
||||
function M.get_cached_files()
|
||||
if not M.state.initialized then return {} end
|
||||
|
||||
local ok, files = pcall(fuzzy.get_cached_files)
|
||||
if not ok then
|
||||
vim.notify('Failed to get cached files: ' .. files, vim.log.levels.ERROR)
|
||||
return {}
|
||||
end
|
||||
|
||||
return files
|
||||
--- Search files with fuzzy matching using blink.cmp's advanced algorithm
|
||||
--- Results are always returned in descending order (best scores first)
|
||||
--- @param query string Search query
|
||||
--- @param max_results number|nil Maximum number of results (optional)
|
||||
--- @param max_threads number|nil Maximum number of threads (optional)
|
||||
--- @param current_file string|nil Path to current file to deprioritize (optional)
|
||||
--- @param min_combo_count_override number|nil Optional override for min_combo_count (nil uses config)
|
||||
--- @return table List of matching files
|
||||
function M.search_files(query, current_file, max_results, max_threads, min_combo_count_override)
|
||||
-- Delegate to paginated version with offset=0 and limit=max_results
|
||||
return M.search_files_paginated(query, current_file, max_threads, min_combo_count_override, 0, max_results)
|
||||
end
|
||||
|
||||
--- Search files with fuzzy matching using blink.cmp's advanced algorithm
|
||||
--- Search files with pagination support
|
||||
--- Results are always returned in descending order (best scores first)
|
||||
--- @param query string Search query
|
||||
--- @param max_results number Maximum number of results (optional)
|
||||
--- @param current_file string|nil Path to current file to deprioritize (optional)
|
||||
--- @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
|
||||
--- @return table List of matching files
|
||||
function M.search_files(query, max_results, max_threads, current_file)
|
||||
function M.search_files_paginated(query, current_file, max_threads, min_combo_count_override, page_index, page_size)
|
||||
local config = require('fff.conf').get()
|
||||
if not M.state.initialized then return {} end
|
||||
|
||||
max_results = max_results or M.config.max_results
|
||||
max_threads = max_threads or M.config.max_threads
|
||||
max_threads = max_threads or config.max_threads or 4
|
||||
page_index = page_index or 0
|
||||
page_size = page_size or 0
|
||||
|
||||
local min_combo_count = min_combo_count_override
|
||||
if min_combo_count == nil then min_combo_count = config.history and config.history.min_combo_count or 3 end
|
||||
|
||||
local combo_boost_score_multiplier = config.history and config.history.combo_boost_score_multiplier or 100
|
||||
|
||||
-- Convert page_index to offset (Rust expects offset in items, not page number)
|
||||
local offset = page_index * page_size
|
||||
|
||||
local ok, search_result = pcall(
|
||||
fuzzy.fuzzy_search_files,
|
||||
query,
|
||||
max_threads,
|
||||
current_file,
|
||||
combo_boost_score_multiplier,
|
||||
min_combo_count,
|
||||
offset,
|
||||
page_size
|
||||
)
|
||||
|
||||
local ok, search_result = pcall(fuzzy.fuzzy_search_files, query, max_results, max_threads, current_file)
|
||||
if not ok then
|
||||
vim.notify('Failed to search files: ' .. tostring(search_result), vim.log.levels.ERROR)
|
||||
return {}
|
||||
end
|
||||
|
||||
-- Store search metadata for UI display
|
||||
M.state.last_search_result = search_result
|
||||
|
||||
return search_result.items
|
||||
end
|
||||
|
||||
@@ -127,33 +99,28 @@ function M.get_search_metadata()
|
||||
}
|
||||
end
|
||||
|
||||
--- Get location data from the last search result
|
||||
--- @return table|nil Location data if available
|
||||
function M.get_search_location()
|
||||
if not M.state.last_search_result then return nil end
|
||||
return M.state.last_search_result.location
|
||||
end
|
||||
|
||||
--- Get score information for a file by index (1-based)
|
||||
--- @param index number The index of the file in the last search results
|
||||
--- @return table|nil Score information or nil if not available
|
||||
function M.get_file_score(index)
|
||||
if not M.state.last_search_result or not M.state.last_search_result.scores then return nil end
|
||||
|
||||
-- Convert to 0-based index for Lua table access
|
||||
local score = M.state.last_search_result.scores[index]
|
||||
if not score then return nil end
|
||||
|
||||
return {
|
||||
total = score.total or 0,
|
||||
base_score = score.base_score or 0,
|
||||
filename_bonus = score.filename_bonus or 0,
|
||||
special_filename_bonus = score.special_filename_bonus or 0,
|
||||
frecency_boost = score.frecency_boost or 0,
|
||||
distance_penalty = score.distance_penalty or 0,
|
||||
match_type = score.match_type or 'unknown',
|
||||
}
|
||||
return M.state.last_search_result.scores[index]
|
||||
end
|
||||
|
||||
--- Record file access for frecency tracking
|
||||
--- @param file_path string Path to the file that was accessed
|
||||
function M.access_file(file_path)
|
||||
function M.track_access(file_path)
|
||||
if not M.state.initialized then return end
|
||||
|
||||
local ok, result = pcall(fuzzy.access_file, file_path)
|
||||
local ok, result = pcall(fuzzy.track_access, file_path)
|
||||
if not ok then vim.notify('Failed to record file access: ' .. result, vim.log.levels.WARN) end
|
||||
end
|
||||
|
||||
@@ -182,10 +149,6 @@ end
|
||||
--- @return boolean
|
||||
function M.is_initialized() return M.state.initialized end
|
||||
|
||||
--- Get current configuration
|
||||
--- @return table
|
||||
function M.get_config() return M.config end
|
||||
|
||||
--- Get scan progress information
|
||||
--- @return table Progress information with scanned_files_count, is_scanning
|
||||
function M.get_scan_progress()
|
||||
|
||||
+570
-235
@@ -1,15 +1,36 @@
|
||||
local utils = require('fff.utils')
|
||||
local file_picker = require('fff.file_picker')
|
||||
local image = require('fff.file_picker.image')
|
||||
local location_utils = require('fff.location_utils')
|
||||
|
||||
local M = {}
|
||||
|
||||
local image = nil
|
||||
local function get_image()
|
||||
if not image then image = require('fff.file_picker.image') end
|
||||
return image
|
||||
-- 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
|
||||
|
||||
-- Set lines in preview buffer (simpler approach)
|
||||
local function set_buffer_lines(bufnr, lines)
|
||||
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
|
||||
|
||||
@@ -18,7 +39,16 @@ local function set_buffer_lines(bufnr, lines)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
|
||||
end
|
||||
|
||||
-- Check if file is already loaded in a buffer
|
||||
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)
|
||||
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)
|
||||
end
|
||||
|
||||
local function find_existing_buffer(file_path)
|
||||
local abs_path = vim.fn.resolve(vim.fn.fnamemodify(file_path, ':p'))
|
||||
|
||||
@@ -34,44 +64,192 @@ local function find_existing_buffer(file_path)
|
||||
return nil
|
||||
end
|
||||
|
||||
local function read_file_fast(file_path, max_lines)
|
||||
local fd = vim.uv.fs_open(file_path, 'r', 0x666)
|
||||
if not fd then return nil end
|
||||
|
||||
local stat = vim.uv.fs_fstat(fd)
|
||||
if not stat then
|
||||
vim.uv.fs_close(fd)
|
||||
return nil
|
||||
local function cleanup_file_operation()
|
||||
if M.state.file_operation then
|
||||
if M.state.file_operation.fd then pcall(vim.uv.fs_close, M.state.file_operation.fd) end
|
||||
M.state.file_operation = nil
|
||||
end
|
||||
|
||||
local data = vim.uv.fs_read(fd, stat.size, 0)
|
||||
vim.uv.fs_close(fd)
|
||||
|
||||
if not data then return nil end
|
||||
|
||||
local lines = vim.split(data, '\n', { plain = true })
|
||||
if max_lines and #lines > max_lines then
|
||||
local limited_lines = {}
|
||||
for i = 1, max_lines do
|
||||
table.insert(limited_lines, lines[i])
|
||||
end
|
||||
lines = limited_lines
|
||||
end
|
||||
|
||||
return lines
|
||||
end
|
||||
|
||||
local function copy_buffer_content(source_bufnr, target_bufnr)
|
||||
local function init_dynamic_loading_async(file_path, callback)
|
||||
cleanup_file_operation()
|
||||
|
||||
M.state.loaded_lines = 0
|
||||
M.state.total_file_lines = nil
|
||||
M.state.has_more_content = true
|
||||
M.state.is_loading = false
|
||||
|
||||
vim.uv.fs_open(file_path, 'r', 438, function(err, fd)
|
||||
if err or not fd then
|
||||
callback(false, 'Failed to open file: ' .. (err or 'unknown error'))
|
||||
return
|
||||
end
|
||||
|
||||
M.state.file_operation = {
|
||||
fd = fd,
|
||||
file_path = file_path,
|
||||
position = 0,
|
||||
}
|
||||
|
||||
callback(true)
|
||||
end)
|
||||
end
|
||||
|
||||
local function load_forward_chunk_async(target_size, callback)
|
||||
if not M.state.file_operation or not M.state.file_operation.fd then
|
||||
callback('', 'No file handle available')
|
||||
return
|
||||
end
|
||||
|
||||
M.state.is_loading = true
|
||||
local chunk_size = target_size or (M.config.chunk_size or 16384)
|
||||
|
||||
vim.uv.fs_read(M.state.file_operation.fd, chunk_size, M.state.file_operation.position, function(err, data)
|
||||
vim.schedule(function()
|
||||
M.state.is_loading = false
|
||||
|
||||
if err then
|
||||
callback('', 'Read error: ' .. err)
|
||||
return
|
||||
end
|
||||
|
||||
if not data or #data == 0 then
|
||||
M.state.has_more_content = false
|
||||
cleanup_file_operation()
|
||||
callback('', nil)
|
||||
return
|
||||
end
|
||||
|
||||
if M.state.file_operation then M.state.file_operation.position = M.state.file_operation.position + #data end
|
||||
|
||||
callback(data, nil)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
local function load_next_chunk_async(chunk_size, callback)
|
||||
if not M.state.file_operation or not M.state.has_more_content or M.state.is_loading then
|
||||
callback('', nil)
|
||||
return
|
||||
end
|
||||
load_forward_chunk_async(chunk_size, callback)
|
||||
end
|
||||
|
||||
local function read_file_streaming_async(file_path, bufnr, callback)
|
||||
init_dynamic_loading_async(file_path, function(success, error_msg)
|
||||
if not success then
|
||||
callback(nil, error_msg)
|
||||
return
|
||||
end
|
||||
|
||||
-- Calculate initial chunk size based on location information
|
||||
local initial_chunk_size = M.config.chunk_size
|
||||
if M.state.location then
|
||||
local target_line = location_utils.get_target_line(M.state.location)
|
||||
if target_line then
|
||||
-- Estimate bytes needed: assume ~100 bytes per line average
|
||||
-- Add some buffer (50%) to account for variation in line lengths
|
||||
local estimated_bytes = target_line * 100 * 1.5
|
||||
-- Cap at reasonable maximum to avoid memory issues
|
||||
local max_initial_chunk = M.config.max_size or (10 * 1024 * 1024) -- 10MB default
|
||||
initial_chunk_size = math.min(estimated_bytes, max_initial_chunk)
|
||||
-- Ensure we don't go below the standard chunk size
|
||||
initial_chunk_size = math.max(initial_chunk_size, M.config.chunk_size)
|
||||
end
|
||||
end
|
||||
|
||||
load_next_chunk_async(initial_chunk_size, function(data, err)
|
||||
if data and data ~= '' then
|
||||
-- there seems to be no other way to append the buffer other than the lines :(
|
||||
local lines = vim.split(data, '\n', { plain = true })
|
||||
M.state.loaded_lines = #lines
|
||||
M.state.content_height = #lines
|
||||
|
||||
-- If we have a location and didn't load enough lines, try to load more
|
||||
if M.state.location then
|
||||
local target_line = location_utils.get_target_line(M.state.location)
|
||||
if target_line and #lines < target_line and M.state.has_more_content then
|
||||
-- Schedule additional loading after the initial callback
|
||||
vim.schedule(function() ensure_content_loaded_async(target_line) end)
|
||||
end
|
||||
end
|
||||
|
||||
callback(lines, err)
|
||||
else
|
||||
callback(nil, err)
|
||||
end
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
local function ensure_content_loaded_async(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
|
||||
|
||||
local current_buffer_lines = vim.api.nvim_buf_line_count(M.state.bufnr)
|
||||
local buffer_needed = target_line + 50
|
||||
|
||||
if current_buffer_lines >= buffer_needed then return end
|
||||
|
||||
if current_buffer_lines < buffer_needed then
|
||||
local loading_line = string.format('Loading more content... (%d lines loaded)', M.state.loaded_lines)
|
||||
append_buffer_lines(M.state.bufnr, { '', loading_line })
|
||||
end
|
||||
|
||||
load_next_chunk_async(M.config.chunk_size, function(data, err)
|
||||
if err then
|
||||
vim.notify('Error loading file content: ' .. err, vim.log.levels.ERROR)
|
||||
-- Remove loading message on error
|
||||
local total_lines = vim.api.nvim_buf_line_count(M.state.bufnr)
|
||||
if total_lines >= 2 then
|
||||
local existing_lines = vim.api.nvim_buf_get_lines(M.state.bufnr, 0, total_lines - 2, false)
|
||||
set_buffer_lines(M.state.bufnr, existing_lines)
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
if data and data ~= '' then
|
||||
local chunk_lines = vim.split(data, '\n', { plain = true })
|
||||
local total_lines = vim.api.nvim_buf_line_count(M.state.bufnr)
|
||||
|
||||
if total_lines >= 2 then
|
||||
local existing_lines = vim.api.nvim_buf_get_lines(M.state.bufnr, 0, total_lines - 2, false)
|
||||
local new_content = vim.list_extend(existing_lines, chunk_lines)
|
||||
set_buffer_lines(M.state.bufnr, new_content)
|
||||
else
|
||||
append_buffer_lines(M.state.bufnr, chunk_lines)
|
||||
end
|
||||
|
||||
M.state.content_height = vim.api.nvim_buf_line_count(M.state.bufnr)
|
||||
M.state.loaded_lines = M.state.content_height
|
||||
else
|
||||
-- No more data available - remove the loading message
|
||||
local total_lines = vim.api.nvim_buf_line_count(M.state.bufnr)
|
||||
if total_lines >= 2 then
|
||||
local existing_lines = vim.api.nvim_buf_get_lines(M.state.bufnr, 0, total_lines - 2, false)
|
||||
set_buffer_lines(M.state.bufnr, existing_lines)
|
||||
M.state.content_height = #existing_lines
|
||||
M.state.loaded_lines = M.state.content_height
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
M.state.has_more_content = false
|
||||
M.state.total_file_lines = #lines
|
||||
M.state.loaded_lines = #lines
|
||||
M.state.content_height = #lines
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
-- Config will be set from main.lua
|
||||
M.config = nil
|
||||
|
||||
M.state = {
|
||||
@@ -80,17 +258,44 @@ M.state = {
|
||||
current_file = nil,
|
||||
scroll_offset = 0,
|
||||
content_height = 0,
|
||||
loaded_lines = 0,
|
||||
total_file_lines = nil,
|
||||
loading_chunk_size = 1000,
|
||||
is_loading = false,
|
||||
has_more_content = true,
|
||||
file_handle = 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
|
||||
}
|
||||
|
||||
--- Setup preview configuration
|
||||
--- @param config table Configuration options
|
||||
function M.setup(config) M.config = config or {} end
|
||||
function M.setup(config)
|
||||
M.config = config or {}
|
||||
-- Create namespace for location highlighting
|
||||
if not M.state.location_namespace then
|
||||
M.state.location_namespace = vim.api.nvim_create_namespace('fff_preview_location')
|
||||
end
|
||||
end
|
||||
|
||||
--- Check if file is binary
|
||||
--- Check if file is too big for initial preview (inspired by snacks.nvim)
|
||||
--- @param file_path string Path to the file
|
||||
--- @return boolean True if file appears to be binary
|
||||
function M.is_binary_file(file_path)
|
||||
local ext = string.lower(vim.fn.fnamemodify(file_path, ':e'))
|
||||
--- @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)
|
||||
-- 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
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
--- Check if file is binary (async version)
|
||||
--- @param file_path string Path to the file
|
||||
--- @param callback function Callback with (is_binary: boolean)
|
||||
function M.is_binary_file_async(file_path, callback)
|
||||
local ext = vim.fn.fnamemodify(file_path, ':e')
|
||||
local binary_extensions = {
|
||||
'jpg',
|
||||
'jpeg',
|
||||
@@ -137,31 +342,113 @@ function M.is_binary_file(file_path)
|
||||
}
|
||||
|
||||
for _, binary_ext in ipairs(binary_extensions) do
|
||||
if ext == binary_ext then return true end
|
||||
end
|
||||
|
||||
local file = io.open(file_path, 'rb')
|
||||
if not file then return false end
|
||||
|
||||
local chunk = file:read(M.config.binary_file_threshold)
|
||||
file:close()
|
||||
|
||||
if not chunk then return false end
|
||||
if chunk:find('\0') then return true end
|
||||
|
||||
local printable_count = 0
|
||||
local total_count = #chunk
|
||||
|
||||
for i = 1, total_count do
|
||||
local byte = chunk:byte(i)
|
||||
-- Printable ASCII range + common control chars (tab, newline, carriage return)
|
||||
if (byte >= 32 and byte <= 126) or byte == 9 or byte == 10 or byte == 13 then
|
||||
printable_count = printable_count + 1
|
||||
if ext == binary_ext then
|
||||
callback(true)
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
local printable_ratio = printable_count / total_count
|
||||
return printable_ratio < 0.8 -- More aggressive: If less than 80% printable, consider binary
|
||||
if M.config.binary_file_threshold <= 0 then
|
||||
callback(false)
|
||||
return
|
||||
end
|
||||
|
||||
vim.uv.fs_open(file_path, 'r', 438, function(err, fd)
|
||||
if err or not fd then
|
||||
callback(false)
|
||||
return
|
||||
end
|
||||
|
||||
vim.uv.fs_read(fd, M.config.binary_file_threshold, 0, function(read_err, chunk)
|
||||
vim.uv.fs_close(fd)
|
||||
|
||||
vim.schedule(function()
|
||||
if read_err or not chunk then
|
||||
callback(false)
|
||||
return
|
||||
end
|
||||
|
||||
if chunk:find('\0') then
|
||||
callback(true)
|
||||
return
|
||||
end
|
||||
|
||||
local printable_count = 0
|
||||
local total_count = #chunk
|
||||
|
||||
for i = 1, total_count do
|
||||
local byte = chunk:byte(i)
|
||||
-- Printable ASCII range + common control chars (tab, newline, carriage return)
|
||||
if (byte >= 32 and byte <= 126) or byte == 9 or byte == 10 or byte == 13 then
|
||||
printable_count = printable_count + 1
|
||||
end
|
||||
end
|
||||
|
||||
local printable_ratio = printable_count / total_count
|
||||
callback(printable_ratio < 0.8) -- More aggressive: If less than 80% printable, consider binary
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
--- Check if file is binary (sync version kept for compatibility)
|
||||
--- @param file_path string Path to the file
|
||||
--- @return boolean True if file appears to be binary
|
||||
function M.is_binary_file(file_path)
|
||||
local ext = vim.fn.fnamemodify(file_path, ':e')
|
||||
local binary_extensions = {
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'png',
|
||||
'gif',
|
||||
'bmp',
|
||||
'tiff',
|
||||
'tif',
|
||||
'webp',
|
||||
'ico',
|
||||
'pdf',
|
||||
'ps',
|
||||
'eps',
|
||||
'heic',
|
||||
'avif',
|
||||
-- Archives
|
||||
'zip',
|
||||
'rar',
|
||||
'7z',
|
||||
'tar',
|
||||
'gz',
|
||||
'bz2',
|
||||
'xz',
|
||||
-- Executables
|
||||
'exe',
|
||||
'dll',
|
||||
'so',
|
||||
'dylib',
|
||||
'bin',
|
||||
-- Audio/Video
|
||||
'mp3',
|
||||
'mp4',
|
||||
'avi',
|
||||
'mkv',
|
||||
'wav',
|
||||
'flac',
|
||||
'ogg',
|
||||
'aac',
|
||||
-- Other binary formats
|
||||
'db',
|
||||
'sqlite',
|
||||
'dat',
|
||||
'bin',
|
||||
'iso',
|
||||
}
|
||||
|
||||
for _, binary_ext in ipairs(binary_extensions) do
|
||||
if ext == binary_ext then return true end
|
||||
end
|
||||
|
||||
-- For sync version, just return false for unknown extensions to avoid blocking
|
||||
-- The main preview logic will handle this with async detection
|
||||
return false
|
||||
end
|
||||
|
||||
--- Get file information
|
||||
@@ -181,7 +468,7 @@ function M.get_file_info(file_path)
|
||||
}
|
||||
|
||||
info.extension = vim.fn.fnamemodify(file_path, ':e'):lower()
|
||||
info.filetype = vim.filetype.match({ filename = file_path }) or 'text'
|
||||
info.filetype = 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)
|
||||
@@ -228,7 +515,12 @@ function M.create_file_info_content(file, info, file_index)
|
||||
)
|
||||
table.insert(
|
||||
lines,
|
||||
string.format('Score Modifiers: frec_boost=%d, dist_penalty=%d', score.frecency_boost, score.distance_penalty)
|
||||
string.format(
|
||||
'Score Modifiers: frec_boost=%d, dist_penalty=%d, current_penalty=%d',
|
||||
score.frecency_boost,
|
||||
score.distance_penalty,
|
||||
score.current_file_penalty or 0
|
||||
)
|
||||
)
|
||||
else
|
||||
table.insert(lines, 'Score Breakdown: N/A (no score data available)')
|
||||
@@ -244,86 +536,19 @@ function M.create_file_info_content(file, info, file_index)
|
||||
return lines
|
||||
end
|
||||
|
||||
--- Create file info header
|
||||
--- @param info table File information
|
||||
--- @return table Lines for the header
|
||||
function M.create_file_info_header(info)
|
||||
if not M.config.show_file_info or not info then return {} end
|
||||
|
||||
local header = {}
|
||||
table.insert(header, string.format('File: %s', info.name))
|
||||
table.insert(header, string.format('Size: %s', info.size_formatted))
|
||||
table.insert(header, string.format('Modified: %s', info.modified_formatted))
|
||||
table.insert(header, string.format('Type: %s', info.filetype))
|
||||
|
||||
if info.extension ~= '' then table.insert(header, string.format('Extension: .%s', info.extension)) end
|
||||
|
||||
table.insert(header, string.rep('─', 50))
|
||||
table.insert(header, '')
|
||||
|
||||
return header
|
||||
end
|
||||
|
||||
--- Read file content with proper handling
|
||||
--- @param file_path string Path to the file
|
||||
--- @param max_lines number Maximum lines to read
|
||||
--- @return table|nil Lines of content, nil if failed
|
||||
function M.read_file_content(file_path, max_lines)
|
||||
local file = io.open(file_path, 'r')
|
||||
if not file then return nil end
|
||||
|
||||
local lines = {}
|
||||
local line_count = 0
|
||||
|
||||
for line in file:lines() do
|
||||
line_count = line_count + 1
|
||||
|
||||
-- Handle very long lines by truncating them
|
||||
if #line > 500 then line = line:sub(1, 497) .. '...' end
|
||||
|
||||
table.insert(lines, line)
|
||||
|
||||
if line_count >= max_lines then
|
||||
table.insert(lines, '')
|
||||
table.insert(lines, string.format('... (truncated, showing first %d lines)', max_lines))
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
file:close()
|
||||
return lines
|
||||
end
|
||||
|
||||
--- Read file tail (last N lines)
|
||||
--- @param file_path string Path to the file
|
||||
--- @param tail_lines number Number of lines from the end
|
||||
--- @return table|nil Lines of content, nil if failed
|
||||
function M.read_file_tail(file_path, tail_lines)
|
||||
local cmd = string.format('tail -n %d %s 2>/dev/null', tail_lines, vim.fn.shellescape(file_path))
|
||||
local result = vim.fn.system(cmd)
|
||||
|
||||
if vim.v.shell_error ~= 0 then return M.read_file_content(file_path, tail_lines) end
|
||||
|
||||
local lines = vim.split(result, '\n')
|
||||
if lines[#lines] == '' then table.remove(lines) end
|
||||
|
||||
return lines
|
||||
end
|
||||
|
||||
--- Preview a regular file
|
||||
--- @param file_path string Path to the file
|
||||
--- @param bufnr number Buffer number for preview
|
||||
--- @return boolean Success status
|
||||
function M.preview_file(file_path, bufnr)
|
||||
local info = M.get_file_info(file_path)
|
||||
if not info then return false end
|
||||
|
||||
if info.size > M.config.max_size then
|
||||
-- Early size detection to prevent memory issues
|
||||
if M.is_big_file(file_path, bufnr) then
|
||||
local info = M.get_file_info(file_path)
|
||||
local lines = {
|
||||
'File too large for preview',
|
||||
string.format(
|
||||
'Size: %s (max: %s)',
|
||||
info.size_formatted,
|
||||
info and info.size_formatted or 'Unknown',
|
||||
string.format('%.1fMB', M.config.max_size / 1024 / 1024)
|
||||
),
|
||||
'',
|
||||
@@ -333,11 +558,14 @@ function M.preview_file(file_path, bufnr)
|
||||
return true
|
||||
end
|
||||
|
||||
local info = M.get_file_info(file_path)
|
||||
if not info then return false end
|
||||
|
||||
-- if the buffer is already opened for this file we reuse the buffer directly
|
||||
local existing_bufnr = find_existing_buffer(file_path)
|
||||
|
||||
if existing_bufnr then
|
||||
local success = copy_buffer_content(existing_bufnr, bufnr)
|
||||
local success = link_buffer_content(existing_bufnr, bufnr)
|
||||
if success then
|
||||
local file_config = M.get_file_config(file_path)
|
||||
|
||||
@@ -347,87 +575,108 @@ function M.preview_file(file_path, bufnr)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'wrap', file_config.wrap_lines or M.config.wrap_lines)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'number', M.config.line_numbers)
|
||||
|
||||
local content = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
||||
M.state.content_height = #content
|
||||
M.state.scroll_offset = 0
|
||||
|
||||
-- Apply location highlighting if available (delayed to ensure buffer is ready)
|
||||
vim.schedule(function() M.apply_location_highlighting(bufnr) end)
|
||||
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
-- Fallback: Manual file reading with fast UV operations
|
||||
local file_config = M.get_file_config(file_path)
|
||||
M.state.current_file = file_path
|
||||
M.state.bufnr = bufnr
|
||||
|
||||
local content
|
||||
if file_config.tail_lines then
|
||||
content = M.read_file_tail(file_path, file_config.tail_lines)
|
||||
else
|
||||
content = read_file_fast(file_path, M.config.max_lines)
|
||||
if not content then content = M.read_file_content(file_path, M.config.max_lines) end
|
||||
end
|
||||
read_file_streaming_async(file_path, bufnr, function(content, err)
|
||||
if M.state.current_file ~= file_path then
|
||||
-- User has moved to a different file, ignore this result
|
||||
cleanup_file_operation()
|
||||
return
|
||||
end
|
||||
|
||||
if not content then return false end
|
||||
if err or not content then
|
||||
if M.state.current_file == file_path then
|
||||
set_buffer_lines(bufnr, { 'Failed to load file: ' .. (err or 'unknown error') })
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
set_buffer_lines(bufnr, content)
|
||||
if M.state.current_file == file_path then
|
||||
M.clear_preview_visual_state(bufnr)
|
||||
set_buffer_lines(bufnr, content)
|
||||
|
||||
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_buf_set_option(bufnr, 'number', M.config.line_numbers)
|
||||
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_buf_set_option(bufnr, 'number', M.config.line_numbers)
|
||||
|
||||
M.state.content_height = #content
|
||||
M.state.scroll_offset = 0
|
||||
M.state.content_height = #content
|
||||
M.state.scroll_offset = 0
|
||||
|
||||
-- Apply location highlighting if available (delayed to ensure buffer is ready)
|
||||
vim.schedule(function() M.apply_location_highlighting(bufnr) end)
|
||||
end
|
||||
end)
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
--- Preview a binary file
|
||||
--- Preview a binary file with async file type detection
|
||||
--- @param file_path string Path to the file
|
||||
--- @param bufnr number Buffer number for preview
|
||||
--- @return boolean Success status
|
||||
function M.preview_binary_file(file_path, bufnr)
|
||||
local lines = {}
|
||||
local info = M.get_file_info(file_path)
|
||||
table.insert(lines, '⚠ Binary File Detected')
|
||||
table.insert(lines, '')
|
||||
table.insert(lines, 'This file contains binary data and cannot be displayed as text.')
|
||||
table.insert(lines, '')
|
||||
|
||||
-- Try to get more information about the binary file
|
||||
if vim.fn.executable('file') == 1 then
|
||||
local cmd = string.format('file -b %s', vim.fn.shellescape(file_path))
|
||||
local result = vim.fn.system(cmd)
|
||||
if vim.v.shell_error == 0 and result then
|
||||
result = result:gsub('\n', '')
|
||||
table.insert(lines, 'File type: ' .. result)
|
||||
table.insert(lines, '')
|
||||
end
|
||||
end
|
||||
|
||||
-- Show hex dump for small binary files
|
||||
if info.size <= 1024 and vim.fn.executable('xxd') == 1 then
|
||||
table.insert(lines, 'Hex dump (first 1KB):')
|
||||
table.insert(lines, '')
|
||||
|
||||
local cmd = string.format('xxd -l 1024 %s', vim.fn.shellescape(file_path))
|
||||
local hex_result = vim.fn.system(cmd)
|
||||
if vim.v.shell_error == 0 and hex_result then
|
||||
local hex_lines = vim.split(hex_result, '\n')
|
||||
for _, line in ipairs(hex_lines) do
|
||||
if line:match('%S') then table.insert(lines, line) end
|
||||
end
|
||||
end
|
||||
else
|
||||
table.insert(lines, 'Use a hex editor or appropriate application to view this file.')
|
||||
end
|
||||
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)
|
||||
|
||||
if vim.fn.executable('file') == 1 then
|
||||
local cmd = { 'file', '-b', file_path }
|
||||
vim.system(cmd, { text = true }, function(result)
|
||||
vim.schedule(function()
|
||||
if not vim.api.nvim_buf_is_valid(bufnr) then return end
|
||||
|
||||
if result.code == 0 and result.stdout then
|
||||
local file_type = result.stdout:gsub('\n', '')
|
||||
table.insert(lines, 'Binary file: ' .. file_type)
|
||||
if info and info.size_formatted then table.insert(lines, 'Size: ' .. info.size_formatted) end
|
||||
|
||||
if vim.fn.executable('xxd') == 1 then
|
||||
table.insert(lines, '')
|
||||
set_buffer_lines(bufnr, lines)
|
||||
|
||||
local hex_cmd = { 'xxd', '-l', '8192', file_path }
|
||||
vim.system(hex_cmd, { text = true }, function(hex_result)
|
||||
vim.schedule(function()
|
||||
if not vim.api.nvim_buf_is_valid(bufnr) then return end
|
||||
|
||||
if hex_result.code == 0 and hex_result.stdout then
|
||||
local hex_lines = vim.split(hex_result.stdout, '\n')
|
||||
for _, line in ipairs(hex_lines) do
|
||||
if line:match('%S') then table.insert(lines, line) end
|
||||
end
|
||||
else
|
||||
table.insert(lines, 'Use a hex editor or appropriate application to view this file.')
|
||||
end
|
||||
set_buffer_lines(bufnr, lines)
|
||||
end)
|
||||
end)
|
||||
else
|
||||
table.insert(lines, 'Use a hex editor or appropriate application to view this file.')
|
||||
set_buffer_lines(bufnr, lines)
|
||||
end
|
||||
end
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -437,35 +686,46 @@ end
|
||||
function M.get_file_config(file_path)
|
||||
if not M.config or not M.config.filetypes then return {} end
|
||||
|
||||
local filetype = vim.filetype.match({ filename = file_path }) or 'text'
|
||||
local filetype = detect_filetype(file_path) or 'text'
|
||||
return M.config.filetypes[filetype] or {}
|
||||
end
|
||||
|
||||
--- @param file_path string Path to the file or directory
|
||||
--- @param bufnr number Buffer number for preview
|
||||
--- @param location table|nil Optional location data for highlighting
|
||||
--- @return boolean if the preview was successful
|
||||
function M.preview(file_path, bufnr)
|
||||
function M.preview(file_path, bufnr, location)
|
||||
if not file_path or file_path == '' then
|
||||
M.clear_buffer(bufnr)
|
||||
set_buffer_lines(bufnr, { 'No file selected' })
|
||||
-- Don't immediately clear - let the previous content stay visible
|
||||
-- Only clear if we really need to show "No file selected"
|
||||
-- M.clear_buffer(bufnr)
|
||||
-- set_buffer_lines(bufnr, { 'No file selected' })
|
||||
return false
|
||||
end
|
||||
|
||||
if M.state.file_handle then
|
||||
M.state.file_handle:close()
|
||||
M.state.file_handle = nil
|
||||
end
|
||||
|
||||
M.state.loaded_lines = 0
|
||||
M.state.total_file_lines = nil
|
||||
M.state.has_more_content = true
|
||||
M.state.is_loading = false
|
||||
|
||||
M.state.current_file = file_path
|
||||
M.state.bufnr = bufnr
|
||||
M.state.location = location
|
||||
|
||||
M.clear_buffer(bufnr)
|
||||
if image.is_image(file_path) then
|
||||
M.clear_buffer(bufnr)
|
||||
|
||||
if get_image().is_image(file_path) then
|
||||
local win_width = 80
|
||||
local win_height = 24
|
||||
if not M.state.winid or not vim.api.nvim_win_is_valid(M.state.winid) then return false end
|
||||
|
||||
if M.state.winid and vim.api.nvim_win_is_valid(M.state.winid) then
|
||||
win_width = vim.api.nvim_win_get_width(M.state.winid) - 2
|
||||
win_height = vim.api.nvim_win_get_height(M.state.winid) - 2
|
||||
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 get_image().display_image(file_path, bufnr, win_width, win_height)
|
||||
return image.display_image(file_path, bufnr, win_width, win_height)
|
||||
elseif M.is_binary_file(file_path) then
|
||||
return M.preview_binary_file(file_path, bufnr)
|
||||
else
|
||||
@@ -478,17 +738,31 @@ function M.scroll(lines)
|
||||
if not M.state.winid or not vim.api.nvim_win_is_valid(M.state.winid) then return end
|
||||
|
||||
local win_height = vim.api.nvim_win_get_height(M.state.winid)
|
||||
local content_height = M.state.content_height or 0
|
||||
local current_buffer_lines = vim.api.nvim_buf_line_count(M.state.bufnr)
|
||||
|
||||
-- allows scrolling for a full content + half window
|
||||
local current_offset = M.state.scroll_offset or 0
|
||||
local new_offset = current_offset + lines
|
||||
|
||||
-- If scrolling down and approaching end of loaded content, try to load more
|
||||
if lines > 0 and not M.state.is_loading then
|
||||
local target_line = new_offset + win_height
|
||||
local buffer_needed = target_line + 20 -- Load a bit ahead
|
||||
|
||||
if current_buffer_lines < buffer_needed and M.state.has_more_content then
|
||||
-- Load more content asynchronously but don't wait for it
|
||||
ensure_content_loaded_async(target_line)
|
||||
end
|
||||
end
|
||||
|
||||
-- Use actual buffer line count for scroll calculations
|
||||
local content_height = current_buffer_lines
|
||||
local half_screen = math.floor(win_height / 2)
|
||||
local max_scroll = math.max(0, content_height + half_screen - win_height)
|
||||
|
||||
local current_offset = M.state.scroll_offset or 0
|
||||
local new_offset = math.max(0, math.min(max_scroll, current_offset + lines))
|
||||
|
||||
new_offset = math.max(0, math.min(max_scroll, new_offset))
|
||||
if new_offset ~= current_offset then
|
||||
M.state.scroll_offset = new_offset
|
||||
M.state.content_height = content_height
|
||||
|
||||
local target_line = math.min(content_height, math.max(1, new_offset + 1))
|
||||
|
||||
@@ -503,26 +777,6 @@ end
|
||||
--- @param winid number Window ID for the preview
|
||||
function M.set_preview_window(winid) M.state.winid = winid end
|
||||
|
||||
--- Create preview header with file information
|
||||
--- @param file table File information from search results
|
||||
--- @return table Lines for the preview header
|
||||
function M.create_preview_header(file)
|
||||
if not file then return {} end
|
||||
|
||||
local header = {}
|
||||
local filename = file.name or vim.fn.fnamemodify(file.path or '', ':t')
|
||||
local dir = file.directory or vim.fn.fnamemodify(file.path or '', ':h')
|
||||
if dir == '.' then dir = '' end
|
||||
|
||||
-- Header with file info
|
||||
table.insert(header, string.format('📄 %s', filename))
|
||||
if dir ~= '' then table.insert(header, string.format('📁 %s', dir)) end
|
||||
table.insert(header, string.rep('─', 50))
|
||||
table.insert(header, '')
|
||||
|
||||
return header
|
||||
end
|
||||
|
||||
--- Update file info buffer
|
||||
--- @param file table File information from search results
|
||||
--- @param bufnr number Buffer number for file info
|
||||
@@ -550,26 +804,107 @@ function M.update_file_info_buffer(file, bufnr, file_index)
|
||||
return true
|
||||
end
|
||||
|
||||
--- Clear buffer completely including any image attachments
|
||||
--- @param bufnr number Buffer number to clear
|
||||
function M.clear_preview_visual_state(bufnr)
|
||||
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
|
||||
|
||||
-- Only clear visual state, don't affect buffer functionality
|
||||
-- Clear namespaces and extmarks for this buffer only
|
||||
vim.api.nvim_buf_clear_namespace(bufnr, -1, 0, -1)
|
||||
|
||||
-- Clear location highlights
|
||||
if M.state.location_namespace then location_utils.clear_location_highlights(bufnr, M.state.location_namespace) end
|
||||
|
||||
local wins = vim.fn.win_findbuf(bufnr)
|
||||
|
||||
for _, win in ipairs(wins) do
|
||||
if vim.api.nvim_win_is_valid(win) then
|
||||
-- Reset folds
|
||||
pcall(vim.api.nvim_win_call, win, function()
|
||||
if vim.fn.has('folding') == 1 then
|
||||
vim.cmd('normal! zE') -- eliminate all folds
|
||||
vim.opt_local.foldenable = false -- disable folding
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
image.clear_buffer_images(bufnr)
|
||||
end
|
||||
|
||||
function M.clear_buffer(bufnr)
|
||||
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
|
||||
|
||||
get_image().clear_buffer_images(bufnr)
|
||||
set_buffer_lines(bufnr, {})
|
||||
cleanup_file_operation()
|
||||
M.clear_preview_visual_state(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')
|
||||
|
||||
set_buffer_lines(bufnr, {})
|
||||
end
|
||||
|
||||
--- Clear preview
|
||||
function M.clear()
|
||||
if M.state.bufnr and vim.api.nvim_buf_is_valid(M.state.bufnr) then
|
||||
M.clear_buffer(M.state.bufnr)
|
||||
set_buffer_lines(M.state.bufnr, { 'No preview available' })
|
||||
end
|
||||
cleanup_file_operation()
|
||||
|
||||
M.state.loaded_lines = 0
|
||||
M.state.total_file_lines = nil
|
||||
M.state.has_more_content = true
|
||||
M.state.is_loading = false
|
||||
|
||||
if M.state.bufnr and vim.api.nvim_buf_is_valid(M.state.bufnr) then M.clear_buffer(M.state.bufnr) end
|
||||
|
||||
M.state.current_file = nil
|
||||
M.state.scroll_offset = 0
|
||||
M.state.content_height = 0
|
||||
M.state.location = nil
|
||||
end
|
||||
|
||||
--- Apply location highlighting to the preview buffer
|
||||
--- @param bufnr number Buffer number
|
||||
function M.apply_location_highlighting(bufnr)
|
||||
-- Ensure namespace is created
|
||||
if not M.state.location_namespace then
|
||||
M.state.location_namespace = vim.api.nvim_create_namespace('fff_preview_location')
|
||||
end
|
||||
|
||||
-- Always clear previous location highlights first
|
||||
if vim.api.nvim_buf_is_valid(bufnr) then
|
||||
location_utils.clear_location_highlights(bufnr, M.state.location_namespace)
|
||||
end
|
||||
|
||||
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
|
||||
local target_line = location_utils.get_target_line(M.state.location)
|
||||
if target_line then M.scroll_to_line(target_line) end
|
||||
end
|
||||
end
|
||||
|
||||
--- Scroll preview to a specific line
|
||||
--- @param line number Target line number (1-indexed)
|
||||
function M.scroll_to_line(line)
|
||||
if not M.state.winid or not vim.api.nvim_win_is_valid(M.state.winid) then return end
|
||||
if not M.state.bufnr or not vim.api.nvim_buf_is_valid(M.state.bufnr) then return end
|
||||
|
||||
local win_height = vim.api.nvim_win_get_height(M.state.winid)
|
||||
local buffer_lines = vim.api.nvim_buf_line_count(M.state.bufnr)
|
||||
local target_line = math.max(1, math.min(line, buffer_lines))
|
||||
|
||||
local half_screen = math.floor(win_height / 2)
|
||||
local new_offset = math.max(0, target_line - half_screen)
|
||||
|
||||
M.state.scroll_offset = new_offset
|
||||
pcall(vim.api.nvim_win_call, M.state.winid, function()
|
||||
vim.api.nvim_win_set_cursor(M.state.winid, { target_line, 0 })
|
||||
vim.cmd('normal! zt')
|
||||
end)
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
--- File Renderer
|
||||
--- 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
|
||||
--- @field relative_path string Relative file path from base directory
|
||||
--- @field name string File name
|
||||
--- @field extension string File extension
|
||||
--- @field size number File size in bytes
|
||||
--- @field modified number Last modified timestamp
|
||||
--- @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
|
||||
|
||||
--- Render a file item line
|
||||
--- @param item FileItem File item from Rust
|
||||
--- @param ctx RenderContext 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)
|
||||
|
||||
-- Build frecency indicator (debug mode only)
|
||||
local frecency = ''
|
||||
if ctx.debug_enabled then
|
||||
local total = item.total_frecency_score or 0
|
||||
local access = item.access_frecency_score or 0
|
||||
local mod = item.modification_frecency_score or 0
|
||||
|
||||
if total > 0 then
|
||||
local indicator = ''
|
||||
if mod >= 6 then
|
||||
indicator = '🔥'
|
||||
elseif access >= 4 then
|
||||
indicator = '⭐'
|
||||
elseif total >= 3 then
|
||||
indicator = '✨'
|
||||
elseif total >= 1 then
|
||||
indicator = '•'
|
||||
end
|
||||
frecency = string.format(' %s%d', indicator, total)
|
||||
end
|
||||
end
|
||||
|
||||
-- Format filename and path
|
||||
local icon_width = icon and (vim.fn.strdisplaywidth(icon) + 1) or 0
|
||||
local available_width = math.max(ctx.max_path_width - icon_width - #frecency, 40)
|
||||
local filename, dir_path = ctx.format_file_display(item, available_width)
|
||||
|
||||
-- Build line
|
||||
local line = icon and string.format('%s %s %s%s', icon, filename, dir_path, frecency)
|
||||
or string.format('%s %s%s', filename, dir_path, frecency)
|
||||
|
||||
local padding = math.max(0, ctx.win_width - vim.fn.strdisplaywidth(line) + 5)
|
||||
table.insert(lines, line .. string.rep(' ', padding))
|
||||
|
||||
return lines
|
||||
end
|
||||
|
||||
--- Apply highlights to a rendered line
|
||||
--- @param item FileItem File item from Rust
|
||||
--- @param ctx RenderContext Render context with all state
|
||||
--- @param item_idx number Item index (1-based)
|
||||
--- @param buf number Buffer handle
|
||||
--- @param ns_id number Namespace ID
|
||||
--- @param line_idx number 1-based line index in buffer
|
||||
--- @param line_content string The actual line content
|
||||
function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_content)
|
||||
local icons = require('fff.file_picker.icons')
|
||||
local git_utils = require('fff.git_utils')
|
||||
local file_picker = require('fff.file_picker')
|
||||
|
||||
local is_cursor = (ctx.cursor == item_idx)
|
||||
local score = file_picker.get_file_score(item_idx)
|
||||
local is_current_file = score and score.current_file_penalty and score.current_file_penalty < 0
|
||||
|
||||
-- Get icon and paths
|
||||
local icon, icon_hl_group = icons.get_icon(item.name, item.extension, false)
|
||||
local icon_width = icon and (vim.fn.strdisplaywidth(icon) + 1) or 0
|
||||
local available_width = math.max(ctx.max_path_width - icon_width, 40)
|
||||
local filename, dir_path = ctx.format_file_display(item, available_width)
|
||||
|
||||
-- 1. Cursor highlight
|
||||
if is_cursor then
|
||||
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_eol = true,
|
||||
priority = 100,
|
||||
})
|
||||
end
|
||||
|
||||
-- 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))
|
||||
end
|
||||
|
||||
-- 3. Git text color (filename)
|
||||
if ctx.config.git and ctx.config.git.status_text_color and icon and #filename > 0 then
|
||||
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)
|
||||
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)
|
||||
end
|
||||
end
|
||||
|
||||
-- 5. Directory path (dimmed)
|
||||
if #filename > 0 and #dir_path > 0 then
|
||||
local prefix_len = #filename + 1 -- filename bytes + space
|
||||
if icon then
|
||||
prefix_len = prefix_len + #icon + 1 -- if icon add icon bytes + space
|
||||
end
|
||||
vim.api.nvim_buf_add_highlight(
|
||||
buf,
|
||||
ns_id,
|
||||
ctx.config.hl.directory_path or 'Comment',
|
||||
line_idx - 1,
|
||||
prefix_len,
|
||||
prefix_len + #dir_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'
|
||||
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
|
||||
virt_text = { { ' (current)', virt_text_hl } },
|
||||
virt_text_pos = 'right_align',
|
||||
})
|
||||
end
|
||||
|
||||
-- 7. Git sign
|
||||
if item.git_status and git_utils.should_show_border(item.git_status) then
|
||||
local border_char = git_utils.get_border_char(item.git_status)
|
||||
local border_hl
|
||||
|
||||
if is_cursor then
|
||||
local base_hl = git_utils.get_border_highlight(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 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 })
|
||||
border_hl = temp_hl_name
|
||||
else
|
||||
border_hl = git_utils.get_border_highlight_selected(item.git_status)
|
||||
end
|
||||
else
|
||||
border_hl = ctx.config.hl.active_file
|
||||
end
|
||||
else
|
||||
border_hl = git_utils.get_border_highlight(item.git_status)
|
||||
end
|
||||
|
||||
if border_hl and border_hl ~= '' then
|
||||
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
|
||||
sign_text = border_char,
|
||||
sign_hl_group = border_hl,
|
||||
priority = 1000,
|
||||
})
|
||||
end
|
||||
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,
|
||||
priority = 1000,
|
||||
})
|
||||
end
|
||||
|
||||
-- 8. Selection
|
||||
if ctx.selected_files and ctx.selected_files[item.path] then
|
||||
local selection_hl = is_cursor and ctx.config.hl.selected_active or ctx.config.hl.selected
|
||||
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
|
||||
sign_text = '▊',
|
||||
sign_hl_group = selection_hl,
|
||||
priority = 1001,
|
||||
})
|
||||
end
|
||||
|
||||
-- 9. Query match
|
||||
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(
|
||||
buf,
|
||||
ns_id,
|
||||
ctx.config.hl.matched or 'IncSearch',
|
||||
line_idx - 1,
|
||||
match_start - 1,
|
||||
match_end
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
+8
-1
@@ -1,3 +1,4 @@
|
||||
---@class fff.fuzzy
|
||||
local M = {}
|
||||
|
||||
-- Try to load the Rust module
|
||||
@@ -19,7 +20,7 @@ M.restart_index_in_path = rust_module.restart_index_in_path
|
||||
M.scan_files = rust_module.scan_files
|
||||
M.get_cached_files = rust_module.get_cached_files
|
||||
M.fuzzy_search_files = rust_module.fuzzy_search_files
|
||||
M.access_file = rust_module.access_file
|
||||
M.track_access = rust_module.track_access
|
||||
M.add_file = rust_module.add_file
|
||||
M.remove_file = rust_module.remove_file
|
||||
M.cancel_scan = rust_module.cancel_scan
|
||||
@@ -32,4 +33,10 @@ M.cleanup_file_picker = rust_module.cleanup_file_picker
|
||||
M.init_tracing = rust_module.init_tracing
|
||||
M.wait_for_initial_scan = rust_module.wait_for_initial_scan
|
||||
|
||||
-- Query tracking functions
|
||||
M.init_query_db = rust_module.init_query_db
|
||||
M.destroy_query_db = rust_module.destroy_query_db
|
||||
M.track_query_completion = rust_module.track_query_completion
|
||||
M.get_historical_query = rust_module.get_historical_query
|
||||
|
||||
return M
|
||||
|
||||
+137
-29
@@ -1,19 +1,5 @@
|
||||
local M = {}
|
||||
|
||||
M.highlights = {
|
||||
untracked = 'FFFGitUntracked',
|
||||
modified = 'FFFGitModified',
|
||||
deleted = 'FFFGitDeleted',
|
||||
renamed = 'FFFGitRenamed',
|
||||
staged_new = 'FFFGitStaged',
|
||||
staged_modified = 'FFFGitStaged',
|
||||
staged_deleted = 'FFFGitStaged',
|
||||
ignored = 'FFFGitIgnored',
|
||||
clean = '',
|
||||
clear = '',
|
||||
unknown = 'FFFGitUntracked',
|
||||
}
|
||||
|
||||
-- git signs like borders
|
||||
M.border_chars = {
|
||||
untracked = '┆', -- Dotted vertical line
|
||||
@@ -29,23 +15,83 @@ M.border_chars = {
|
||||
clear = '',
|
||||
}
|
||||
|
||||
M.border_highlights = {
|
||||
untracked = 'FFFGitSignUntracked',
|
||||
modified = 'FFFGitSignModified',
|
||||
deleted = 'FFFGitSignDeleted',
|
||||
renamed = 'FFFGitSignRenamed',
|
||||
staged_new = 'FFFGitSignStaged',
|
||||
staged_modified = 'FFFGitSignStaged',
|
||||
staged_deleted = 'FFFGitSignStaged',
|
||||
ignored = 'FFFGitSignIgnored',
|
||||
clean = '',
|
||||
clear = '',
|
||||
unknown = 'FFFGitSignUntracked',
|
||||
}
|
||||
-- Cache for config-based highlight mappings
|
||||
local highlights_cache = nil
|
||||
local border_highlights_cache = nil
|
||||
local border_highlights_selected_cache = nil
|
||||
|
||||
function M.get_highlight(git_status) return M.highlights[git_status] or '' end
|
||||
--- Build and cache highlight mappings from config
|
||||
local function ensure_cache()
|
||||
if highlights_cache then return end
|
||||
|
||||
function M.get_border_highlight(git_status) return M.border_highlights[git_status] or '' end
|
||||
local config = require('fff.conf').get()
|
||||
|
||||
highlights_cache = {
|
||||
untracked = config.hl.git_untracked,
|
||||
modified = config.hl.git_modified,
|
||||
deleted = config.hl.git_deleted,
|
||||
renamed = config.hl.git_renamed,
|
||||
staged_new = config.hl.git_staged,
|
||||
staged_modified = config.hl.git_staged,
|
||||
staged_deleted = config.hl.git_staged,
|
||||
ignored = config.hl.git_ignored,
|
||||
clean = '',
|
||||
clear = '',
|
||||
unknown = config.hl.git_untracked,
|
||||
}
|
||||
|
||||
border_highlights_cache = {
|
||||
untracked = config.hl.git_sign_untracked,
|
||||
modified = config.hl.git_sign_modified,
|
||||
deleted = config.hl.git_sign_deleted,
|
||||
renamed = config.hl.git_sign_renamed,
|
||||
staged_new = config.hl.git_sign_staged,
|
||||
staged_modified = config.hl.git_sign_staged,
|
||||
staged_deleted = config.hl.git_sign_staged,
|
||||
ignored = config.hl.git_sign_ignored,
|
||||
clean = '',
|
||||
clear = '',
|
||||
unknown = config.hl.git_sign_untracked,
|
||||
}
|
||||
|
||||
border_highlights_selected_cache = {
|
||||
untracked = config.hl.git_sign_untracked_selected,
|
||||
modified = config.hl.git_sign_modified_selected,
|
||||
deleted = config.hl.git_sign_deleted_selected,
|
||||
renamed = config.hl.git_sign_renamed_selected,
|
||||
staged_new = config.hl.git_sign_staged_selected,
|
||||
staged_modified = config.hl.git_sign_staged_selected,
|
||||
staged_deleted = config.hl.git_sign_staged_selected,
|
||||
ignored = config.hl.git_sign_ignored_selected,
|
||||
clean = '',
|
||||
clear = '',
|
||||
unknown = config.hl.git_sign_untracked_selected,
|
||||
}
|
||||
end
|
||||
|
||||
--- Get highlight group for git status text
|
||||
--- @param git_status string Git status
|
||||
--- @return string Highlight group name
|
||||
function M.get_text_highlight(git_status)
|
||||
ensure_cache()
|
||||
return highlights_cache and highlights_cache[git_status] or ''
|
||||
end
|
||||
|
||||
--- Get border highlight group for git status
|
||||
--- @param git_status string Git status
|
||||
--- @return string Highlight group name
|
||||
function M.get_border_highlight(git_status)
|
||||
ensure_cache()
|
||||
return border_highlights_cache and border_highlights_cache[git_status] or ''
|
||||
end
|
||||
|
||||
--- Get selected border highlight group for git status
|
||||
--- @param git_status string Git status
|
||||
--- @return string Highlight group name
|
||||
function M.get_border_highlight_selected(git_status)
|
||||
ensure_cache()
|
||||
return border_highlights_selected_cache and border_highlights_selected_cache[git_status] or ''
|
||||
end
|
||||
|
||||
function M.get_border_char(git_status) return M.border_chars[git_status] or '' end
|
||||
|
||||
@@ -83,6 +129,68 @@ function M.setup_highlights()
|
||||
highlight default link FFFGitSignDeleted GitSignsDelete
|
||||
highlight default link FFFGitSignUntracked GitSignsAdd
|
||||
]])
|
||||
|
||||
-- Highlighes for git signs both for selected and normal states
|
||||
local git_highlights = {
|
||||
{ 'FFFGitSignStaged', 'FFFGitSignStagedSelected', '#10B981', 2 },
|
||||
{ 'FFFGitSignModified', 'FFFGitSignModifiedSelected', '#F59E0B', 3 },
|
||||
{ 'FFFGitSignDeleted', 'FFFGitSignDeletedSelected', '#EF4444', 1 },
|
||||
{ 'FFFGitSignRenamed', 'FFFGitSignRenamedSelected', '#8B5CF6', 5 },
|
||||
{ 'FFFGitSignUntracked', 'FFFGitSignUntrackedSelected', '#10B981', 2 },
|
||||
{ 'FFFGitSignIgnored', 'FFFGitSignIgnoredSelected', '#4B5563', 8 },
|
||||
}
|
||||
|
||||
local visual_bg_gui = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID('Visual')), 'bg', 'gui')
|
||||
local visual_bg_cterm = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID('Visual')), 'bg', 'cterm')
|
||||
|
||||
for _, hl in ipairs(git_highlights) do
|
||||
local _, selected_hl, gui_fg, cterm_fg = hl[1], hl[2], hl[3], hl[4]
|
||||
|
||||
local gui_bg = visual_bg_gui ~= '' and visual_bg_gui or 'NONE'
|
||||
local cterm_bg = visual_bg_cterm ~= '' and visual_bg_cterm or 'NONE'
|
||||
|
||||
vim.cmd(
|
||||
string.format(
|
||||
'highlight default %s guifg=%s guibg=%s ctermfg=%d ctermbg=%s',
|
||||
selected_hl,
|
||||
gui_fg,
|
||||
gui_bg,
|
||||
cterm_fg,
|
||||
cterm_bg
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
-- Selection highlight - use Directory/Number colors (better than green 'Added')
|
||||
vim.cmd('highlight default link FFFSelected Directory')
|
||||
|
||||
local dir_fg_gui = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID('Directory')), 'fg', 'gui')
|
||||
local dir_fg_cterm = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID('Directory')), 'fg', 'cterm')
|
||||
|
||||
if dir_fg_gui == '' or dir_fg_gui == '-1' then
|
||||
-- Directory not defined, try Number
|
||||
dir_fg_gui = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID('Number')), 'fg', 'gui')
|
||||
dir_fg_cterm = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID('Number')), 'fg', 'cterm')
|
||||
end
|
||||
|
||||
-- Fallback to blue if neither Directory nor Number have colors
|
||||
local is_dark_bg = vim.o.background == 'dark'
|
||||
local gui_fg = dir_fg_gui ~= '' and dir_fg_gui or (is_dark_bg and '#60A5FA' or '#0369A1')
|
||||
local cterm_fg = dir_fg_cterm ~= '' and dir_fg_cterm or (is_dark_bg and '12' or '4')
|
||||
|
||||
local gui_bg = visual_bg_gui ~= '' and visual_bg_gui or 'NONE'
|
||||
local cterm_bg = visual_bg_cterm ~= '' and visual_bg_cterm or 'NONE'
|
||||
|
||||
-- Create combined highlight: Directory/Number foreground + Visual background
|
||||
vim.cmd(
|
||||
string.format(
|
||||
'highlight default FFFSelectedActive guifg=%s guibg=%s ctermfg=%s ctermbg=%s',
|
||||
gui_fg,
|
||||
gui_bg,
|
||||
cterm_fg,
|
||||
cterm_bg
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
local M = {}
|
||||
|
||||
--- Jump to a location in the current buffer
|
||||
--- @param location table|nil Location data from search results
|
||||
function M.jump_to_location(location)
|
||||
if not location then return end
|
||||
|
||||
local current_buf = vim.api.nvim_get_current_buf()
|
||||
local line_count = vim.api.nvim_buf_line_count(current_buf)
|
||||
|
||||
if location.line then
|
||||
local target_line = math.max(1, math.min(location.line, line_count))
|
||||
local target_col = location.col and math.max(0, location.col - 1) or 0
|
||||
|
||||
vim.api.nvim_win_set_cursor(0, { target_line, target_col })
|
||||
vim.cmd('normal! zz')
|
||||
elseif location.start and location['end'] then
|
||||
-- Extract line numbers from nested structure
|
||||
local start_line = math.max(1, math.min(location.start.line, line_count))
|
||||
local end_line = math.max(start_line, math.min(location['end'].line, line_count))
|
||||
|
||||
-- start in the visual mode and selecting the range backwards so the cursor ends up at the start
|
||||
vim.api.nvim_win_set_cursor(0, { end_line, 0 })
|
||||
vim.cmd('normal! V')
|
||||
if end_line > start_line then vim.cmd('normal! ' .. (end_line - start_line) .. 'k') end
|
||||
vim.cmd('normal! zz')
|
||||
end
|
||||
end
|
||||
|
||||
--- Highlight a location range in a buffer using extmarks
|
||||
--- @param bufnr number Buffer number
|
||||
--- @param location table|nil Location data from search results
|
||||
--- @param namespace number Namespace for extmarks
|
||||
--- @return table|nil Highlight extmark details for cleanup
|
||||
function M.highlight_location(bufnr, location, namespace)
|
||||
if not location or not vim.api.nvim_buf_is_valid(bufnr) then return nil end
|
||||
|
||||
local line_count = vim.api.nvim_buf_line_count(bufnr)
|
||||
local extmarks = {}
|
||||
|
||||
if location.line then
|
||||
local target_line = math.max(1, math.min(location.line, line_count))
|
||||
|
||||
if location.col then
|
||||
local target_col = math.max(0, location.col - 1)
|
||||
local line_content = vim.api.nvim_buf_get_lines(bufnr, target_line - 1, target_line, false)[1] or ''
|
||||
local end_col = math.min(target_col + 1, #line_content)
|
||||
|
||||
local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, target_line - 1, target_col, {
|
||||
end_col = end_col,
|
||||
hl_group = 'IncSearch', -- inc search are better visible for a single chars
|
||||
priority = 1000,
|
||||
})
|
||||
|
||||
if ok then table.insert(extmarks, { id = mark_id, line = target_line - 1 }) end
|
||||
else
|
||||
local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, target_line - 1, 0, {
|
||||
line_hl_group = 'Visual',
|
||||
priority = 1000,
|
||||
})
|
||||
|
||||
if ok then table.insert(extmarks, { id = mark_id, line = target_line - 1 }) end
|
||||
end
|
||||
elseif location.start and location['end'] then
|
||||
local start_line = math.max(1, math.min(location.start.line, line_count))
|
||||
local end_line = math.max(start_line, math.min(location['end'].line, line_count))
|
||||
|
||||
-- Check if we have column information for exact range highlighting
|
||||
if location.start.col and location['end'].col then
|
||||
if start_line == end_line then
|
||||
-- Single line range with columns: highlight exact character range
|
||||
local start_col = math.max(0, location.start.col - 1)
|
||||
local end_col = location['end'].col - 1
|
||||
local line_content = vim.api.nvim_buf_get_lines(bufnr, start_line - 1, start_line, false)[1] or ''
|
||||
end_col = math.min(end_col, #line_content)
|
||||
|
||||
local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, start_line - 1, start_col, {
|
||||
end_col = end_col,
|
||||
hl_group = 'IncSearch',
|
||||
priority = 1000,
|
||||
})
|
||||
|
||||
if ok then table.insert(extmarks, { id = mark_id, line = start_line - 1 }) end
|
||||
else
|
||||
-- Multi-line range with exact columns: highlight precise ranges
|
||||
for line = start_line, end_line do
|
||||
local line_start_col, line_end_col
|
||||
|
||||
if line == start_line then
|
||||
-- First line: from start_col to end of line
|
||||
line_start_col = math.max(0, location.start.col - 1)
|
||||
local line_content = vim.api.nvim_buf_get_lines(bufnr, line - 1, line, false)[1] or ''
|
||||
line_end_col = #line_content
|
||||
elseif line == end_line then
|
||||
-- Last line: from beginning to end_col
|
||||
line_start_col = 0
|
||||
line_end_col = location['end'].col - 1
|
||||
local line_content = vim.api.nvim_buf_get_lines(bufnr, line - 1, line, false)[1] or ''
|
||||
line_end_col = math.min(line_end_col, #line_content)
|
||||
else
|
||||
-- Middle lines: entire line
|
||||
line_start_col = 0
|
||||
local line_content = vim.api.nvim_buf_get_lines(bufnr, line - 1, line, false)[1] or ''
|
||||
line_end_col = #line_content
|
||||
end
|
||||
|
||||
local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, line - 1, line_start_col, {
|
||||
end_col = line_end_col,
|
||||
hl_group = 'Visual',
|
||||
priority = 1000,
|
||||
})
|
||||
|
||||
if ok then table.insert(extmarks, { id = mark_id, line = line - 1 }) end
|
||||
end
|
||||
end
|
||||
else
|
||||
-- Multi-line or no columns: highlight entire lines
|
||||
for line = start_line, end_line do
|
||||
local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, line - 1, 0, {
|
||||
line_hl_group = 'Visual',
|
||||
priority = 1000,
|
||||
})
|
||||
|
||||
if ok then table.insert(extmarks, { id = mark_id, line = line - 1 }) end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return #extmarks > 0 and extmarks or nil
|
||||
end
|
||||
|
||||
--- Clear location highlights from a buffer
|
||||
--- @param bufnr number Buffer number
|
||||
--- @param namespace number Namespace for extmarks
|
||||
function M.clear_location_highlights(bufnr, namespace)
|
||||
if vim.api.nvim_buf_is_valid(bufnr) then vim.api.nvim_buf_clear_namespace(bufnr, namespace, 0, -1) end
|
||||
end
|
||||
|
||||
--- Get the target line for scrolling preview to location
|
||||
--- @param location table|nil Location data from search results
|
||||
--- @return number|nil Target line number (1-indexed) for scrolling
|
||||
function M.get_target_line(location)
|
||||
if not location then return nil end
|
||||
|
||||
if location.line then
|
||||
return location.line
|
||||
elseif location.start then
|
||||
return location.start.line
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
--- Check if location is valid for a buffer
|
||||
--- @param location table|nil Location data
|
||||
--- @param bufnr number Buffer number
|
||||
--- @return boolean True if location is valid for the buffer
|
||||
function M.is_valid_location(location, bufnr)
|
||||
if not location or not vim.api.nvim_buf_is_valid(bufnr) then return false end
|
||||
|
||||
local line_count = vim.api.nvim_buf_line_count(bufnr)
|
||||
|
||||
if location.line then
|
||||
return location.line > 0 and location.line <= line_count
|
||||
elseif location.start and location['end'] then
|
||||
return location.start.line > 0 and location.start.line <= line_count
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
--- Format location for display
|
||||
--- @param location table|nil Location data
|
||||
--- @return string Formatted location string
|
||||
function M.format_location(location)
|
||||
if not location then return '' end
|
||||
|
||||
if location.line and location.col then
|
||||
return string.format(':%d:%d', location.line, location.col)
|
||||
elseif location.line then
|
||||
return string.format(':%d', location.line)
|
||||
elseif location.start and location['end'] then
|
||||
-- Handle nested structure with optional column information
|
||||
if location.start.col and location['end'].col then
|
||||
return string.format(
|
||||
':%d:%d-%d:%d',
|
||||
location.start.line,
|
||||
location.start.col,
|
||||
location['end'].line,
|
||||
location['end'].col
|
||||
)
|
||||
else
|
||||
return string.format(':%d-%d', location.start.line, location['end'].line)
|
||||
end
|
||||
end
|
||||
|
||||
return ''
|
||||
end
|
||||
|
||||
return M
|
||||
+51
-280
@@ -1,267 +1,22 @@
|
||||
local fuzzy = require('fff.fuzzy')
|
||||
if not fuzzy then error('Failed to load fff.fuzzy module. Ensure the Rust backend is compiled and available.') end
|
||||
-- PERF: By default, this plugin initializes itself lazily,
|
||||
-- so we do not require any modules at the top of this module.
|
||||
|
||||
local M = {}
|
||||
M.config = {}
|
||||
|
||||
M.state = { initialized = false }
|
||||
|
||||
--- Setup the file picker with the given configuration
|
||||
--- @param config table Configuration options
|
||||
function M.setup(config)
|
||||
local default_config = {
|
||||
base_path = vim.fn.getcwd(),
|
||||
max_results = 100,
|
||||
prompt = '🪿 ', -- Input prompt symbol
|
||||
title = 'FFF Files', -- Window title
|
||||
width = 0.8,
|
||||
height = 0.8,
|
||||
preview = {
|
||||
enabled = true,
|
||||
width = 0.5,
|
||||
max_lines = 5000,
|
||||
max_size = 10 * 1024 * 1024, -- 10MB
|
||||
imagemagick_info_format_str = '%m: %wx%h, %[colorspace], %q-bit',
|
||||
line_numbers = false,
|
||||
wrap_lines = false,
|
||||
show_file_info = true,
|
||||
binary_file_threshold = 1024,
|
||||
filetypes = {
|
||||
svg = { wrap_lines = true },
|
||||
markdown = { wrap_lines = true },
|
||||
text = { wrap_lines = true },
|
||||
log = { tail_lines = 100 },
|
||||
},
|
||||
},
|
||||
keymaps = {
|
||||
close = '<Esc>',
|
||||
select = '<CR>',
|
||||
select_split = '<C-s>',
|
||||
select_vsplit = '<C-v>',
|
||||
select_tab = '<C-t>',
|
||||
move_up = { '<Up>', '<C-p>' },
|
||||
move_down = { '<Down>', '<C-n>' },
|
||||
preview_scroll_up = '<C-u>',
|
||||
preview_scroll_down = '<C-d>',
|
||||
},
|
||||
hl = {
|
||||
border = 'FloatBorder',
|
||||
normal = 'Normal',
|
||||
cursor = 'CursorLine',
|
||||
matched = 'IncSearch',
|
||||
title = 'Title',
|
||||
prompt = 'Question',
|
||||
active_file = 'Visual',
|
||||
frecency = 'Number',
|
||||
debug = 'Comment',
|
||||
},
|
||||
layout = {
|
||||
prompt_position = 'top',
|
||||
preview_position = 'right',
|
||||
preview_width = 0.4,
|
||||
height = 0.8,
|
||||
width = 0.8,
|
||||
},
|
||||
frecency = {
|
||||
enabled = true,
|
||||
db_path = vim.fn.stdpath('cache') .. '/fff_nvim',
|
||||
},
|
||||
debug = {
|
||||
enabled = false,
|
||||
show_scores = false,
|
||||
},
|
||||
logging = {
|
||||
enabled = true,
|
||||
log_file = vim.fn.stdpath('log') .. '/fff.log',
|
||||
log_level = 'info',
|
||||
},
|
||||
ui = {
|
||||
wrap_paths = true,
|
||||
wrap_indent = 2,
|
||||
max_path_width = 80,
|
||||
},
|
||||
image_preview = {
|
||||
enabled = true,
|
||||
max_width = 80,
|
||||
max_height = 24,
|
||||
},
|
||||
icons = {
|
||||
enabled = true,
|
||||
},
|
||||
ui_enabled = true,
|
||||
}
|
||||
|
||||
local merged_config = vim.tbl_deep_extend('force', default_config, config or {})
|
||||
M.config = merged_config
|
||||
|
||||
local db_path = merged_config.frecency.db_path or (vim.fn.stdpath('cache') .. '/fff_nvim')
|
||||
local ok, result = pcall(fuzzy.init_db, db_path, true)
|
||||
if not ok then vim.notify('Failed to initialize frecency database: ' .. result, vim.log.levels.WARN) end
|
||||
|
||||
ok, result = pcall(fuzzy.init_file_picker, merged_config.base_path)
|
||||
if not ok then
|
||||
vim.notify('Failed to initialize file picker: ' .. result, vim.log.levels.ERROR)
|
||||
return false
|
||||
end
|
||||
|
||||
M.state.initialized = true
|
||||
M.config = merged_config
|
||||
|
||||
M.setup_commands()
|
||||
M.setup_global_autocmds()
|
||||
|
||||
local git_utils = require('fff.git_utils')
|
||||
git_utils.setup_highlights()
|
||||
|
||||
if merged_config.logging.enabled then
|
||||
local log_success, log_error =
|
||||
pcall(fuzzy.init_tracing, merged_config.logging.log_file, merged_config.logging.log_level)
|
||||
if log_success then
|
||||
M.log_file_path = log_error
|
||||
else
|
||||
vim.notify('Failed to initialize logging: ' .. (tostring(log_error) or 'unknown error'), vim.log.levels.WARN)
|
||||
end
|
||||
end
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
function M.setup_global_autocmds()
|
||||
local group = vim.api.nvim_create_augroup('fff_file_tracking', { clear = true })
|
||||
|
||||
if M.config.frecency.enabled then
|
||||
vim.api.nvim_create_autocmd({ 'BufRead', 'BufNewFile' }, {
|
||||
group = group,
|
||||
callback = function(args)
|
||||
local file_path = args.file
|
||||
|
||||
if file_path and file_path ~= '' and not vim.startswith(file_path, 'term://') then
|
||||
-- never block the UI
|
||||
vim.schedule(function()
|
||||
local stat = vim.uv.fs_stat(file_path)
|
||||
if stat and stat.type == 'file' then
|
||||
local relative_path = vim.fn.fnamemodify(file_path, ':.')
|
||||
pcall(fuzzy.access_file, relative_path)
|
||||
end
|
||||
end)
|
||||
end
|
||||
end,
|
||||
desc = 'Track file access for FFF frecency',
|
||||
})
|
||||
end
|
||||
|
||||
-- make sure that this won't work correctly if autochdir plugins are enabled
|
||||
-- using a pure :cd command but will work using lua api or :e command
|
||||
vim.api.nvim_create_autocmd('DirChanged', {
|
||||
group = group,
|
||||
callback = function()
|
||||
local new_cwd = vim.v.event.cwd
|
||||
if M.is_initialized() and new_cwd and new_cwd ~= M.config.base_path then
|
||||
vim.schedule(function()
|
||||
local ok, err = pcall(M.change_indexing_directory, new_cwd)
|
||||
if not ok then
|
||||
vim.notify('FFF: Failed to change indexing directory: ' .. tostring(err), vim.log.levels.ERROR)
|
||||
else
|
||||
M.config.base_path = new_cwd
|
||||
end
|
||||
end)
|
||||
end
|
||||
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
|
||||
|
||||
function M.setup_commands()
|
||||
vim.api.nvim_create_user_command('FFFFind', function(opts)
|
||||
if opts.args and opts.args ~= '' then
|
||||
-- If argument looks like a directory, use it as base path
|
||||
if vim.fn.isdirectory(opts.args) == 1 then
|
||||
M.find_files_in_dir(opts.args)
|
||||
else
|
||||
-- Otherwise treat as search query
|
||||
M.search_and_show(opts.args)
|
||||
end
|
||||
else
|
||||
M.find_files()
|
||||
end
|
||||
end, {
|
||||
nargs = '?',
|
||||
complete = function(arg_lead)
|
||||
-- Complete with directories and common search terms
|
||||
local dirs = vim.fn.glob(arg_lead .. '*', false, true)
|
||||
local results = {}
|
||||
for _, dir in ipairs(dirs) do
|
||||
if vim.fn.isdirectory(dir) == 1 then table.insert(results, dir) end
|
||||
end
|
||||
return results
|
||||
end,
|
||||
desc = 'Find files with FFF (use directory path or search query)',
|
||||
})
|
||||
|
||||
vim.api.nvim_create_user_command('FFFScan', function() M.scan_files() end, {
|
||||
desc = 'Scan files for FFF',
|
||||
})
|
||||
|
||||
vim.api.nvim_create_user_command('FFFRefreshGit', function() M.refresh_git_status() end, {
|
||||
desc = 'Manually refresh git status for all files',
|
||||
})
|
||||
|
||||
vim.api.nvim_create_user_command('FFFClearCache', function(opts) M.clear_cache(opts.args) end, {
|
||||
nargs = '?',
|
||||
complete = function() return { 'all', 'frecency', 'files' } end,
|
||||
desc = 'Clear FFF caches (all|frecency|files)',
|
||||
})
|
||||
|
||||
vim.api.nvim_create_user_command('FFFHealth', function() M.health_check() end, {
|
||||
desc = 'Check FFF health',
|
||||
})
|
||||
|
||||
vim.api.nvim_create_user_command('FFFDebug', function(opts)
|
||||
if opts.args == 'toggle' or opts.args == '' then
|
||||
M.config.debug.show_scores = not M.config.debug.show_scores
|
||||
local status = M.config.debug.show_scores and 'enabled' or 'disabled'
|
||||
vim.notify('FFF debug scores ' .. status, vim.log.levels.INFO)
|
||||
elseif opts.args == 'on' then
|
||||
M.config.debug.show_scores = true
|
||||
vim.notify('FFF debug scores enabled', vim.log.levels.INFO)
|
||||
elseif opts.args == 'off' then
|
||||
M.config.debug.show_scores = false
|
||||
vim.notify('FFF debug scores disabled', vim.log.levels.INFO)
|
||||
else
|
||||
vim.notify('Usage: :FFFDebug [on|off|toggle]', vim.log.levels.ERROR)
|
||||
end
|
||||
end, {
|
||||
nargs = '?',
|
||||
complete = function() return { 'on', 'off', 'toggle' } end,
|
||||
desc = 'Toggle FFF debug scores display',
|
||||
})
|
||||
|
||||
vim.api.nvim_create_user_command('FFFOpenLog', function()
|
||||
if M.log_file_path then
|
||||
vim.cmd('tabnew ' .. vim.fn.fnameescape(M.log_file_path))
|
||||
elseif M.config and M.config.logging and M.config.logging.log_file then
|
||||
-- Fallback to the configured log file path even if tracing wasn't initialized
|
||||
vim.cmd('tabnew ' .. vim.fn.fnameescape(M.config.logging.log_file))
|
||||
else
|
||||
vim.notify('Log file path not available', vim.log.levels.ERROR)
|
||||
end
|
||||
end, {
|
||||
desc = 'Open FFF log file in new tab',
|
||||
})
|
||||
end
|
||||
function M.setup(config) vim.g.fff = config end
|
||||
|
||||
--- Find files in current directory
|
||||
function M.find_files()
|
||||
--- @param opts? table Optional configuration {renderer = custom_renderer}
|
||||
function M.find_files(opts)
|
||||
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
|
||||
if picker_ok then
|
||||
picker_ui.open()
|
||||
picker_ui.open(opts)
|
||||
else
|
||||
vim.notify('Failed to load picker UI', vim.log.levels.ERROR)
|
||||
vim.notify('Failed to load picker UI: ' .. picker_ui, vim.log.levels.ERROR)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -277,20 +32,17 @@ end
|
||||
|
||||
--- Trigger rescan of files in the current directory
|
||||
function M.scan_files()
|
||||
local fuzzy = require('fff.core').ensure_initialized()
|
||||
local ok = pcall(fuzzy.scan_files)
|
||||
if ok then
|
||||
local cached_files = pcall(fuzzy.get_cached_files) and fuzzy.get_cached_files() or {}
|
||||
print('Triggered file scan (currently ' .. #cached_files .. ' files cached)')
|
||||
else
|
||||
vim.notify('Failed to scan files', vim.log.levels.ERROR)
|
||||
end
|
||||
if not ok then vim.notify('Failed to scan files', vim.log.levels.ERROR) end
|
||||
end
|
||||
|
||||
--- Refresh git status for the active file lock
|
||||
function M.refresh_git_status()
|
||||
local ok, files = pcall(fuzzy.refresh_git_status)
|
||||
local fuzzy = require('fff.core').ensure_initialized()
|
||||
local ok, updated_files_count = pcall(fuzzy.refresh_git_status)
|
||||
if ok then
|
||||
print('Refreshed git status for ' .. #files .. ' files')
|
||||
vim.notify('Refreshed git status for ' .. tostring(updated_files_count) .. ' files', vim.log.levels.INFO)
|
||||
else
|
||||
vim.notify('Failed to refresh git status', vim.log.levels.ERROR)
|
||||
end
|
||||
@@ -301,8 +53,13 @@ end
|
||||
--- @param max_results number Maximum number of results
|
||||
--- @return table List of matching files
|
||||
function M.search(query, max_results)
|
||||
max_results = max_results or M.config.max_results
|
||||
local ok, search_result = pcall(fuzzy.fuzzy_search_files, query, max_results, nil, nil)
|
||||
local fuzzy = require('fff.core').ensure_initialized()
|
||||
local config = require('fff.conf').get()
|
||||
max_results = max_results or config.max_results
|
||||
local combo_boost_score_multiplier = config.history and config.history.combo_boost_score_multiplier or 100
|
||||
local min_combo_count = config.history and config.history.min_combo_count or 3
|
||||
local ok, search_result =
|
||||
pcall(fuzzy.fuzzy_search_files, query, max_results, nil, nil, false, combo_boost_score_multiplier, min_combo_count)
|
||||
if ok and search_result.items then return search_result.items end
|
||||
return {}
|
||||
end
|
||||
@@ -370,7 +127,7 @@ function M.health_check()
|
||||
messages = {},
|
||||
}
|
||||
|
||||
if not M.is_initialized() then
|
||||
if not require('fff.core').is_file_picker_initialized() then
|
||||
health.ok = false
|
||||
table.insert(health.messages, 'File picker not initialized')
|
||||
else
|
||||
@@ -408,21 +165,6 @@ function M.health_check()
|
||||
return health
|
||||
end
|
||||
|
||||
function M.get_status()
|
||||
local status = 'No files indexed'
|
||||
|
||||
local ok, cached_files = pcall(fuzzy.get_cached_files)
|
||||
if ok and cached_files and #cached_files > 0 then status = string.format('%d files indexed', #cached_files) end
|
||||
|
||||
if M.config and M.config.frecency and M.config.frecency.enabled then
|
||||
status = status .. ' • Frecency tracking enabled'
|
||||
end
|
||||
|
||||
return status
|
||||
end
|
||||
|
||||
function M.is_initialized() return M.state and M.state.initialized or false end
|
||||
|
||||
--- Find files in a specific directory
|
||||
--- @param directory string Directory path to search in
|
||||
function M.find_files_in_dir(directory)
|
||||
@@ -457,14 +199,43 @@ function M.change_indexing_directory(new_path)
|
||||
return false
|
||||
end
|
||||
|
||||
local fuzzy = require('fff.core').ensure_initialized()
|
||||
local ok, result = pcall(fuzzy.restart_index_in_path, expanded_path)
|
||||
if not ok then
|
||||
vim.notify('Failed to change directory: ' .. result, vim.log.levels.ERROR)
|
||||
return false
|
||||
end
|
||||
|
||||
M.config.base_path = expanded_path
|
||||
local config = require('fff.conf').get()
|
||||
config.base_path = expanded_path
|
||||
return true
|
||||
end
|
||||
|
||||
--- Opens the file under the cursor with an optional callback if the only file
|
||||
--- 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')
|
||||
if not picker_ok then
|
||||
vim.notify('Failed to load picker UI', vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
|
||||
picker_ui.open_with_callback(full_path_with_suffix, function(files, metadata, location, get_file_score)
|
||||
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)))
|
||||
|
||||
if location then vim.schedule(function() require('fff.location_utils').jump_to_location(location) end) end
|
||||
|
||||
return true
|
||||
else
|
||||
return false -- Open UI with results
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
+1436
-467
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,326 @@
|
||||
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 git2::Repository;
|
||||
use notify::event::{AccessKind, AccessMode};
|
||||
use notify::{Config, EventKind, RecursiveMode};
|
||||
use notify_debouncer_full::{
|
||||
DebounceEventResult, DebouncedEvent, RecommendedCache, new_debouncer_opt,
|
||||
};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tracing::{Level, error, info, warn};
|
||||
|
||||
type Debouncer = notify_debouncer_full::Debouncer<notify::RecommendedWatcher, RecommendedCache>;
|
||||
|
||||
pub struct BackgroundWatcher {
|
||||
debouncer: Arc<Mutex<Option<Debouncer>>>,
|
||||
}
|
||||
|
||||
const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(250);
|
||||
const MAX_PATHS_THRESHOLD: usize = 1024;
|
||||
|
||||
impl BackgroundWatcher {
|
||||
pub fn new(base_path: PathBuf, git_workdir: Option<PathBuf>) -> Result<Self, Error> {
|
||||
info!(
|
||||
"Initializing background watcher for path: {}",
|
||||
base_path.display()
|
||||
);
|
||||
|
||||
let debouncer = Self::create_debouncer(base_path, git_workdir)?;
|
||||
info!("Background file watcher initialized successfully");
|
||||
|
||||
Ok(Self {
|
||||
debouncer: Arc::new(Mutex::new(Some(debouncer))),
|
||||
})
|
||||
}
|
||||
|
||||
fn create_debouncer(
|
||||
base_path: PathBuf,
|
||||
git_workdir: Option<PathBuf>,
|
||||
) -> 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 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);
|
||||
}
|
||||
Err(errors) => {
|
||||
error!("File watcher errors: {:?}", errors);
|
||||
}
|
||||
}
|
||||
},
|
||||
RecommendedCache::new(),
|
||||
config,
|
||||
)?;
|
||||
|
||||
debouncer.watch(base_path.as_path(), RecursiveMode::Recursive)?;
|
||||
info!("File watcher initizlieed for path: {}", base_path.display());
|
||||
|
||||
Ok(debouncer)
|
||||
}
|
||||
|
||||
pub fn stop(&self) {
|
||||
if let Ok(Some(debouncer)) = self.debouncer.lock().map(|mut debouncer| debouncer.take()) {
|
||||
drop(debouncer);
|
||||
info!("Background file watcher stopped successfully");
|
||||
} else {
|
||||
error!("Failed to stop background watcher");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for BackgroundWatcher {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(mut debouncer_guard) = self.debouncer.lock() {
|
||||
if let Some(debouncer) = debouncer_guard.take() {
|
||||
drop(debouncer);
|
||||
}
|
||||
} else {
|
||||
error!("Failed to acquire debouncer lock to drop");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "fs_events", skip(events), level = Level::DEBUG)]
|
||||
fn handle_debounced_events(events: Vec<DebouncedEvent>, git_workdir: &Option<PathBuf>) {
|
||||
// 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;
|
||||
let mut need_full_git_rescan = false;
|
||||
let mut paths_to_remove = Vec::new();
|
||||
let mut paths_to_add_or_modify = Vec::new();
|
||||
let mut affected_paths_count = 0usize;
|
||||
|
||||
for debounced_event in &events {
|
||||
// It is very important to not react to the access errors because we inevitably
|
||||
// gonna trigger the sync by our own preview or other unnecessary noise
|
||||
if matches!(
|
||||
debounced_event.event.kind,
|
||||
EventKind::Access(
|
||||
AccessKind::Read
|
||||
| AccessKind::Open(_)
|
||||
| AccessKind::Close(AccessMode::Read | AccessMode::Execute)
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
tracing::debug!(event = ?debounced_event.event, "Processing FS event");
|
||||
for path in &debounced_event.event.paths {
|
||||
if is_ignore_definition_path(path) {
|
||||
info!(
|
||||
"Detected change in ignore definition file: {}",
|
||||
path.display()
|
||||
);
|
||||
need_full_rescan = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if is_dotgit_change_affecting_status(path, &repo) {
|
||||
need_full_git_rescan = true;
|
||||
}
|
||||
|
||||
if !should_include_file(path, &repo) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !path.exists() {
|
||||
paths_to_remove.push(path.as_path());
|
||||
} else {
|
||||
paths_to_add_or_modify.push(path.as_path());
|
||||
}
|
||||
}
|
||||
|
||||
affected_paths_count += debounced_event.event.paths.len();
|
||||
if affected_paths_count > MAX_PATHS_THRESHOLD {
|
||||
warn!(
|
||||
"Too many affected paths ({}) in a single batch, triggering full rescan",
|
||||
affected_paths_count
|
||||
);
|
||||
|
||||
need_full_rescan = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if need_full_rescan {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if need_full_rescan {
|
||||
info!(?affected_paths_count, "Triggering full rescan");
|
||||
trigger_full_rescan();
|
||||
return;
|
||||
}
|
||||
|
||||
// It's important to get the allocated sort
|
||||
sort_with_buffer(paths_to_add_or_modify.as_mut_slice(), |a, b| {
|
||||
a.as_os_str().cmp(b.as_os_str())
|
||||
});
|
||||
paths_to_add_or_modify.dedup_by(|a, b| a.as_os_str().eq(b.as_os_str()));
|
||||
|
||||
info!(
|
||||
"Event processing summary: {} to remove, {} to add/modify",
|
||||
paths_to_remove.len(),
|
||||
paths_to_add_or_modify.len()
|
||||
);
|
||||
|
||||
let Some(repo) = repo.as_ref() else {
|
||||
info!("No git repo, skipping git status updates");
|
||||
return;
|
||||
};
|
||||
|
||||
if need_full_git_rescan {
|
||||
info!("Triggering full git rescan");
|
||||
|
||||
if let Err(e) = FilePicker::refresh_git_status_global() {
|
||||
error!("Failed to refresh git status: {:?}", e);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if paths_to_remove.is_empty() && paths_to_add_or_modify.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if let Some(file) = picker.on_create_or_modify(path) {
|
||||
files_to_update_git_status.push(file.path.clone());
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
} else {
|
||||
info!("Successfully updated git statuses in picker");
|
||||
}
|
||||
} else {
|
||||
error!("Failed to acquire picker lock for git status update");
|
||||
}
|
||||
}
|
||||
|
||||
fn trigger_full_rescan() {
|
||||
info!("Triggering full filesystem rescan");
|
||||
|
||||
let Ok(mut file_picker_guard) = FILE_PICKER.write() else {
|
||||
error!("Failed to acquire file picker write lock for full rescan");
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(ref mut picker) = *file_picker_guard else {
|
||||
error!("File picker not initialized, cannot trigger rescan");
|
||||
return;
|
||||
};
|
||||
|
||||
if let Err(e) = picker.trigger_rescan() {
|
||||
error!("Failed to trigger full rescan: {:?}", e);
|
||||
} else {
|
||||
info!("Full filesystem rescan completed successfully");
|
||||
}
|
||||
}
|
||||
|
||||
fn should_include_file(path: &Path, repo: &Option<Repository>) -> bool {
|
||||
if !path.is_file() || is_git_file(path) {
|
||||
return false;
|
||||
}
|
||||
|
||||
repo.as_ref()
|
||||
.is_some_and(|repo| repo.is_path_ignored(path) == Ok(false))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn is_git_file(path: &Path) -> bool {
|
||||
path.components()
|
||||
.any(|component| component.as_os_str() == ".git")
|
||||
}
|
||||
|
||||
pub fn is_dotgit_change_affecting_status(changed: &Path, repo: &Option<Repository>) -> bool {
|
||||
let Some(repo) = repo.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let git_dir = repo.path();
|
||||
|
||||
if let Ok(rel) = changed.strip_prefix(git_dir) {
|
||||
if rel.starts_with("objects") || rel.starts_with("logs") || rel.starts_with("hooks") {
|
||||
return false;
|
||||
}
|
||||
if rel == Path::new("index") || rel == Path::new("index.lock") {
|
||||
return true;
|
||||
}
|
||||
if rel == Path::new("HEAD") {
|
||||
return true;
|
||||
}
|
||||
if rel.starts_with("refs") || rel == Path::new("packed-refs") {
|
||||
return true;
|
||||
}
|
||||
if rel == Path::new("info/exclude") || rel == Path::new("info/sparse-checkout") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some(fname) = rel.file_name().and_then(|f| f.to_str())
|
||||
&& matches!(fname, "MERGE_HEAD" | "CHERRY_PICK_HEAD" | "REVERT_HEAD")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn is_ignore_definition_path(path: &Path) -> bool {
|
||||
matches!(
|
||||
path.file_name().and_then(|f| f.to_str()),
|
||||
Some(".ignore") | Some(".gitignore")
|
||||
)
|
||||
}
|
||||
+11
-1
@@ -1,3 +1,5 @@
|
||||
use std::path::StripPrefixError;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
#[non_exhaustive]
|
||||
pub enum Error {
|
||||
@@ -11,7 +13,7 @@ pub enum Error {
|
||||
AcquireFrecencyLock,
|
||||
#[error("Failed to acquire lock for items by provider")]
|
||||
AcquireItemLock,
|
||||
#[error("Failed to create frecency database directory: {0}")]
|
||||
#[error("Failed to create directory: {0}")]
|
||||
CreateDir(#[from] std::io::Error),
|
||||
#[error("Failed to open frecency database env: {0}")]
|
||||
EnvOpen(#[source] heed::Error),
|
||||
@@ -33,6 +35,12 @@ pub enum Error {
|
||||
DbCommit(#[source] heed::Error),
|
||||
#[error("Failed to start file system watcher: {0}")]
|
||||
FileSystemWatch(#[from] notify::Error),
|
||||
|
||||
#[error("Expected a path to be child of another path: {0}")]
|
||||
StripPrefixError(#[from] StripPrefixError),
|
||||
|
||||
#[error("libgit2 error occurred: {0}")]
|
||||
Git(#[from] git2::Error),
|
||||
}
|
||||
|
||||
impl From<Error> for mlua::Error {
|
||||
@@ -43,3 +51,5 @@ impl From<Error> for mlua::Error {
|
||||
mlua::Error::RuntimeError(string_value)
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileKey {
|
||||
pub path: String,
|
||||
}
|
||||
+373
-494
File diff suppressed because it is too large
Load Diff
+98
-32
@@ -1,13 +1,12 @@
|
||||
use crate::error::Error;
|
||||
use crate::file_key::FileKey;
|
||||
use heed::{
|
||||
types::{Bytes, SerdeBincode},
|
||||
EnvFlags,
|
||||
};
|
||||
use crate::{error::Error, git::is_modified_status};
|
||||
use heed::{Database, Env, EnvOpenOptions};
|
||||
use std::collections::VecDeque;
|
||||
use heed::{
|
||||
EnvFlags,
|
||||
types::{Bytes, SerdeBincode},
|
||||
};
|
||||
use std::fs;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use std::{collections::VecDeque, path::Path};
|
||||
|
||||
const DECAY_CONSTANT: f64 = 0.0693; // ln(2)/10 for 10-day half-life
|
||||
const SECONDS_PER_DAY: f64 = 86400.0;
|
||||
@@ -19,9 +18,9 @@ pub struct FrecencyTracker {
|
||||
db: Database<Bytes, SerdeBincode<VecDeque<u64>>>,
|
||||
}
|
||||
|
||||
const ACCESS_THRESHOLDS: [(i64, u64); 5] = [
|
||||
(12, 60 * 2), // 2 minutes
|
||||
(6, 60 * 10), // 10 minutes
|
||||
const MODIFICATION_THRESHOLDS: [(i64, u64); 5] = [
|
||||
(16, 60 * 2), // 2 minutes
|
||||
(8, 60 * 15), // 15 minutes
|
||||
(4, 60 * 60), // 1 hour
|
||||
(2, 60 * 60 * 24), // 1 day
|
||||
(1, 60 * 60 * 24 * 7), // 1 week
|
||||
@@ -52,9 +51,10 @@ impl FrecencyTracker {
|
||||
})
|
||||
}
|
||||
|
||||
fn get_accesses(&self, file_key: &FileKey) -> Result<Option<VecDeque<u64>>, Error> {
|
||||
fn get_accesses(&self, path: &Path) -> Result<Option<VecDeque<u64>>, Error> {
|
||||
let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?;
|
||||
let key_hash = Self::path_to_hash_bytes(&file_key.path);
|
||||
|
||||
let key_hash = Self::path_to_hash_bytes(path)?;
|
||||
self.db.get(&rtxn, &key_hash).map_err(Error::DbRead)
|
||||
}
|
||||
|
||||
@@ -65,15 +65,19 @@ impl FrecencyTracker {
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn path_to_hash_bytes(path: &str) -> [u8; 32] {
|
||||
*blake3::hash(path.as_bytes()).as_bytes()
|
||||
fn path_to_hash_bytes(path: &Path) -> Result<[u8; 32], Error> {
|
||||
let Some(key) = path.to_str() else {
|
||||
return Err(Error::InvalidPath(path.to_path_buf()));
|
||||
};
|
||||
|
||||
Ok(*blake3::hash(key.as_bytes()).as_bytes())
|
||||
}
|
||||
|
||||
pub fn track_access(&self, file_key: &FileKey) -> Result<(), Error> {
|
||||
pub fn track_access(&self, path: &Path) -> Result<(), Error> {
|
||||
let mut wtxn = self.env.write_txn().map_err(Error::DbStartWriteTxn)?;
|
||||
|
||||
let key_hash = Self::path_to_hash_bytes(&file_key.path);
|
||||
let mut accesses = self.get_accesses(file_key)?.unwrap_or_default();
|
||||
let key_hash = Self::path_to_hash_bytes(path)?;
|
||||
let mut accesses = self.get_accesses(path)?.unwrap_or_default();
|
||||
|
||||
let now = self.get_now();
|
||||
let cutoff_time = now.saturating_sub((MAX_HISTORY_DAYS * SECONDS_PER_DAY) as u64);
|
||||
@@ -86,6 +90,8 @@ impl FrecencyTracker {
|
||||
}
|
||||
|
||||
accesses.push_back(now);
|
||||
tracing::debug!(?path, accesses = accesses.len(), "Tracking access");
|
||||
|
||||
self.db
|
||||
.put(&mut wtxn, &key_hash, &accesses)
|
||||
.map_err(Error::DbWrite)?;
|
||||
@@ -95,10 +101,11 @@ impl FrecencyTracker {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_access_score(&self, file_key: &FileKey) -> i64 {
|
||||
pub fn get_access_score(&self, file_path: &Path) -> i64 {
|
||||
let accesses = self
|
||||
.get_accesses(file_key)
|
||||
.unwrap_or(None)
|
||||
.get_accesses(file_path)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
|
||||
if accesses.is_empty() {
|
||||
@@ -129,23 +136,38 @@ impl FrecencyTracker {
|
||||
normalized_frecency.round() as i64
|
||||
}
|
||||
|
||||
/// Calculate modification frecency score (0-12 points, git-aware)
|
||||
pub fn get_modification_score(&self, modified_time: u64, git_status: &str) -> i64 {
|
||||
let git_shows_changes = matches!(
|
||||
git_status,
|
||||
"modified" | "staged_modified" | "untracked" | "staged_new"
|
||||
);
|
||||
|
||||
if !git_shows_changes {
|
||||
return 0; // No modification score for clean/unchanged files
|
||||
/// Calculating modification score but only if the file is modified in the current git dir
|
||||
pub fn get_modification_score(
|
||||
&self,
|
||||
modified_time: u64,
|
||||
git_status: Option<git2::Status>,
|
||||
) -> i64 {
|
||||
let is_modified_git_status = git_status.is_some_and(is_modified_status);
|
||||
if !is_modified_git_status {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let now = self.get_now();
|
||||
let duration_since = now.saturating_sub(modified_time);
|
||||
|
||||
for (base_points, threshold_seconds) in ACCESS_THRESHOLDS {
|
||||
if duration_since <= threshold_seconds {
|
||||
return base_points * 2;
|
||||
for i in 0..MODIFICATION_THRESHOLDS.len() {
|
||||
let (current_points, current_threshold) = MODIFICATION_THRESHOLDS[i];
|
||||
|
||||
if duration_since <= current_threshold {
|
||||
if i == 0 || duration_since == current_threshold {
|
||||
return current_points;
|
||||
}
|
||||
|
||||
let (prev_points, prev_threshold) = MODIFICATION_THRESHOLDS[i - 1];
|
||||
|
||||
let time_range = current_threshold - prev_threshold;
|
||||
let time_offset = duration_since - prev_threshold;
|
||||
let points_diff = prev_points - current_points;
|
||||
|
||||
let interpolated_score =
|
||||
prev_points - (points_diff * time_offset as i64) / time_range as i64;
|
||||
|
||||
return interpolated_score;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,4 +243,48 @@ mod tests {
|
||||
old_score
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_modification_score_interpolation() {
|
||||
let temp_dir = std::env::temp_dir().join("fff_test_interpolation");
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
let tracker = FrecencyTracker::new(temp_dir.to_str().unwrap(), true).unwrap();
|
||||
|
||||
let current_time = tracker.get_now();
|
||||
let git_status = Some(git2::Status::WT_MODIFIED);
|
||||
|
||||
// At 5 minutes: should interpolate between 16 and 8 points
|
||||
let five_minutes_ago = current_time - (5 * 60);
|
||||
let score = tracker.get_modification_score(five_minutes_ago, git_status);
|
||||
|
||||
// Expected: 16 - (8 * 3 / 13) = 16 - 1 = 15 points
|
||||
// (time_offset = 5-2 = 3, time_range = 15-2 = 13, points_diff = 16-8 = 8)
|
||||
assert_eq!(score, 15, "5 minutes should interpolate to 15 points");
|
||||
|
||||
let two_minutes_ago = current_time - (2 * 60);
|
||||
let score = tracker.get_modification_score(two_minutes_ago, git_status);
|
||||
assert_eq!(score, 16, "2 minutes should be exactly 16 points");
|
||||
|
||||
let fifteen_minutes_ago = current_time - (15 * 60);
|
||||
let score = tracker.get_modification_score(fifteen_minutes_ago, git_status);
|
||||
assert_eq!(score, 8, "15 minutes should be exactly 8 points");
|
||||
|
||||
// At 12 hours: should interpolate between 4 and 2 points
|
||||
let twelve_hours_ago = current_time - (12 * 60 * 60);
|
||||
let score = tracker.get_modification_score(twelve_hours_ago, git_status);
|
||||
// Expected: 4 - (2 * 11 / 23) = 4 - 0 = 4 points (integer division)
|
||||
// (time_offset = 12-1 = 11 hours, time_range = 24-1 = 23 hours, points_diff = 4-2 = 2)
|
||||
assert_eq!(score, 4, "12 hours should interpolate to 4 points");
|
||||
|
||||
// at 18 hours for more significant interpolation
|
||||
let eighteen_hours_ago = current_time - (18 * 60 * 60);
|
||||
let score = tracker.get_modification_score(eighteen_hours_ago, git_status);
|
||||
// Expected: 4 - (2 * 17 / 23) = 4 - 1 = 3 points
|
||||
assert_eq!(score, 3, "18 hours should interpolate to 3 points");
|
||||
|
||||
let score = tracker.get_modification_score(five_minutes_ago, None);
|
||||
assert_eq!(score, 0, "No git status should return 0");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
}
|
||||
|
||||
+93
-44
@@ -1,67 +1,116 @@
|
||||
use crate::error::Result;
|
||||
use git2::{Repository, Status, StatusOptions};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing::{error, info};
|
||||
use std::{
|
||||
fmt::Debug,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
use tracing::debug;
|
||||
|
||||
/// Represents a cache of a single git status query, if there is no
|
||||
/// status aka file is clear but it was specifically requested to updated
|
||||
/// the status is `None` otherwise contains only actual file statuses.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GitStatusCache {
|
||||
paths: Vec<PathBuf>,
|
||||
statuses: Vec<Status>,
|
||||
pub struct GitStatusCache(Vec<(PathBuf, Status)>);
|
||||
|
||||
impl IntoIterator for GitStatusCache {
|
||||
type Item = (PathBuf, Status);
|
||||
type IntoIter = std::vec::IntoIter<Self::Item>;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.0.into_iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl GitStatusCache {
|
||||
fn from_git_entries(mut entries: Vec<(PathBuf, Status)>) -> Self {
|
||||
entries.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
let (paths, statuses) = entries.into_iter().unzip();
|
||||
Self { paths, statuses }
|
||||
pub fn statuses_len(&self) -> usize {
|
||||
self.0.len()
|
||||
}
|
||||
|
||||
pub fn lookup_status(&self, full_path: &Path) -> Option<Status> {
|
||||
match self
|
||||
.paths
|
||||
.binary_search_by(|probe| probe.as_path().cmp(full_path))
|
||||
{
|
||||
Ok(idx) => self.statuses.get(idx).copied(),
|
||||
Err(_) => None,
|
||||
}
|
||||
self.0
|
||||
.binary_search_by(|(path, _)| path.as_path().cmp(full_path))
|
||||
.ok()
|
||||
.and_then(|idx| self.0.get(idx).map(|(_, status)| *status))
|
||||
}
|
||||
|
||||
pub fn read_git_status(git_workdir: Option<&Path>) -> Option<Self> {
|
||||
let git_start = std::time::Instant::now();
|
||||
info!("GIT: Starting git status read");
|
||||
|
||||
let git_workdir = git_workdir.as_ref()?;
|
||||
let repository = Repository::open(git_workdir).ok()?;
|
||||
|
||||
let status_start = std::time::Instant::now();
|
||||
let statuses = repository
|
||||
.statuses(Some(&mut StatusOptions::new().include_untracked(true)))
|
||||
.map_err(|e| {
|
||||
error!("Failed to get git statuses: {}", e);
|
||||
e
|
||||
})
|
||||
.ok()?;
|
||||
let status_time = status_start.elapsed();
|
||||
info!("GIT: Status query completed in {:?}", status_time);
|
||||
|
||||
info!(
|
||||
"GIT STATUS: {} git entries in {}",
|
||||
statuses.len(),
|
||||
git_workdir.display()
|
||||
);
|
||||
#[tracing::instrument(skip(repo, status_options))]
|
||||
fn read_status_impl(repo: &Repository, status_options: &mut StatusOptions) -> Result<Self> {
|
||||
let statuses = repo.statuses(Some(status_options))?;
|
||||
let Some(repo_path) = repo.workdir() else {
|
||||
return Ok(Self(vec![])); // repo is bare
|
||||
};
|
||||
|
||||
let mut entries = Vec::with_capacity(statuses.len());
|
||||
for entry in &statuses {
|
||||
if let Some(entry_path) = entry.path() {
|
||||
let full_path = git_workdir.join(entry_path);
|
||||
let full_path = repo_path.join(entry_path);
|
||||
entries.push((full_path, entry.status()));
|
||||
}
|
||||
}
|
||||
|
||||
let total_time = git_start.elapsed();
|
||||
info!("GIT: Total git status read time {:?}", total_time);
|
||||
Ok(Self(entries))
|
||||
}
|
||||
|
||||
Some(Self::from_git_entries(entries))
|
||||
pub fn read_git_status(
|
||||
git_workdir: Option<&Path>,
|
||||
status_options: &mut StatusOptions,
|
||||
) -> Option<Self> {
|
||||
let git_workdir = git_workdir.as_ref()?;
|
||||
let repository = Repository::open(git_workdir).ok()?;
|
||||
|
||||
let status = Self::read_status_impl(&repository, status_options);
|
||||
|
||||
match status {
|
||||
Ok(status) => Some(status),
|
||||
Err(e) => {
|
||||
tracing::error!(?e, "Failed to read git status");
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(repo), level = tracing::Level::DEBUG)]
|
||||
pub fn git_status_for_paths<TPath: AsRef<Path> + Debug>(
|
||||
repo: &Repository,
|
||||
paths: &[TPath],
|
||||
) -> Result<Self> {
|
||||
if paths.is_empty() {
|
||||
return Ok(Self(vec![]));
|
||||
}
|
||||
|
||||
let Some(workdir) = repo.workdir() else {
|
||||
return Ok(Self(vec![]));
|
||||
};
|
||||
|
||||
// git pathspec is pretty slow and requires to walk the whole directory
|
||||
// so for a single file which is the most general use case we query directly the file
|
||||
if paths.len() == 1 {
|
||||
let full_path = paths[0].as_ref();
|
||||
let relative_path = full_path.strip_prefix(workdir)?;
|
||||
let status = repo.status_file(relative_path)?;
|
||||
|
||||
return Ok(Self(vec![(full_path.to_path_buf(), status)]));
|
||||
}
|
||||
|
||||
let mut status_options = StatusOptions::new();
|
||||
status_options
|
||||
.include_untracked(true)
|
||||
.recurse_untracked_dirs(true)
|
||||
// when reading partial status it's important to include all files requested
|
||||
.include_unmodified(true);
|
||||
|
||||
for path in paths {
|
||||
status_options.pathspec(path.as_ref().strip_prefix(workdir)?);
|
||||
}
|
||||
|
||||
let git_status_cache = Self::read_status_impl(repo, &mut status_options)?;
|
||||
debug!(
|
||||
status_len = git_status_cache.statuses_len(),
|
||||
"Multiple files git status"
|
||||
);
|
||||
|
||||
Ok(git_status_cache)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+36
-3
@@ -1,3 +1,5 @@
|
||||
local download = require('fff.download')
|
||||
|
||||
--- @return string
|
||||
local function get_lib_extension()
|
||||
if jit.os:lower() == 'mac' or jit.os:lower() == 'osx' then return '.dylib' end
|
||||
@@ -7,9 +9,16 @@ end
|
||||
|
||||
-- search for the lib in the /target/release directory with and without the lib prefix
|
||||
-- since MSVC doesn't include the prefix
|
||||
local base_path = debug.getinfo(1).source:match('@?(.*/)')
|
||||
local info = debug.getinfo(1, 'S')
|
||||
local base_path = info and info.source and info.source:match('@?(.*/)') or ''
|
||||
|
||||
-- Fallback: if base_path is nil, try to determine from current file path
|
||||
if not base_path or base_path == '' then
|
||||
base_path = vim.fn.fnamemodify(vim.fn.resolve(vim.fn.expand('<sfile>:p')), ':h') .. '/'
|
||||
end
|
||||
|
||||
local paths = {
|
||||
download.get_binary_cpath_component(),
|
||||
base_path .. '../../../target/release/lib?' .. get_lib_extension(),
|
||||
base_path .. '../../../target/release/?' .. get_lib_extension(),
|
||||
}
|
||||
@@ -20,6 +29,30 @@ if cargo_target_dir then
|
||||
table.insert(paths, cargo_target_dir .. '/release/?' .. get_lib_extension())
|
||||
end
|
||||
|
||||
package.cpath = package.cpath .. ';' .. table.concat(paths, ';')
|
||||
-- Instead of using require (which can find the wrong lib due to cpath pollution),
|
||||
-- load the library directly from the first valid path we find
|
||||
local function try_load_library()
|
||||
for _, path_pattern in ipairs(paths) do
|
||||
local actual_path = path_pattern:gsub('%?', 'fff_nvim')
|
||||
local stat = vim.uv.fs_stat(actual_path)
|
||||
if stat and stat.type == 'file' then
|
||||
local loader, err = package.loadlib(actual_path, 'luaopen_fff_nvim')
|
||||
if err then return nil, string.format('Error loading library from %s: %s', actual_path, err) end
|
||||
if loader then return loader() end
|
||||
end
|
||||
end
|
||||
return nil, 'No valid library found in any search path'
|
||||
end
|
||||
|
||||
return require('fff_nvim')
|
||||
local backend, load_err = try_load_library()
|
||||
if not backend or load_err then
|
||||
local err_msg = string.format(
|
||||
'Failed to load fff rust backend.\nError: %s\nSearched paths:\n%s\nMake sure binary exists or make it exists using \n `:lua require("fff.download").download_or_build_binary()`\nor\n`cargo build --release`\n(and rerun neovim after)',
|
||||
tostring(load_err),
|
||||
vim.inspect(paths)
|
||||
)
|
||||
|
||||
error(err_msg)
|
||||
end
|
||||
|
||||
return backend
|
||||
|
||||
+243
-77
@@ -1,40 +1,74 @@
|
||||
use crate::error::Error;
|
||||
use crate::file_key::FileKey;
|
||||
use crate::file_picker::FilePicker;
|
||||
use crate::file_picker::{FilePicker, FuzzySearchOptions};
|
||||
use crate::frecency::FrecencyTracker;
|
||||
use crate::types::{FileItem, SearchResult};
|
||||
use crate::query_tracker::QueryTracker;
|
||||
use crate::types::PaginationArgs;
|
||||
use mlua::prelude::*;
|
||||
use std::sync::{LazyLock, RwLock};
|
||||
use once_cell::sync::Lazy;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::RwLock;
|
||||
use std::time::Duration;
|
||||
|
||||
mod background_watcher;
|
||||
mod error;
|
||||
mod file_key;
|
||||
mod file_picker;
|
||||
pub mod file_picker;
|
||||
mod frecency;
|
||||
mod git;
|
||||
pub mod git;
|
||||
mod location;
|
||||
mod log;
|
||||
mod path_utils;
|
||||
pub(crate) mod score;
|
||||
mod tracing;
|
||||
pub(crate) mod types;
|
||||
pub mod query_tracker;
|
||||
pub mod score;
|
||||
mod sort_buffer;
|
||||
pub mod types;
|
||||
use mimalloc::MiMalloc;
|
||||
|
||||
static FRECENCY: LazyLock<RwLock<Option<FrecencyTracker>>> = LazyLock::new(|| RwLock::new(None));
|
||||
static FILE_PICKER: LazyLock<RwLock<Option<FilePicker>>> = LazyLock::new(|| RwLock::new(None));
|
||||
#[global_allocator]
|
||||
static GLOBAL: MiMalloc = MiMalloc;
|
||||
|
||||
pub fn init_db(_: &Lua, (db_path, use_unsafe_no_lock): (String, bool)) -> LuaResult<bool> {
|
||||
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 fn init_db(
|
||||
_: &Lua,
|
||||
(frecency_db_path, history_db_path, use_unsafe_no_lock): (String, String, bool),
|
||||
) -> LuaResult<bool> {
|
||||
let mut frecency = FRECENCY.write().map_err(|_| Error::AcquireFrecencyLock)?;
|
||||
if frecency.is_some() {
|
||||
return Ok(false);
|
||||
*frecency = None;
|
||||
}
|
||||
*frecency = Some(FrecencyTracker::new(&db_path, use_unsafe_no_lock)?);
|
||||
*frecency = Some(FrecencyTracker::new(&frecency_db_path, use_unsafe_no_lock)?);
|
||||
tracing::info!("Frecency database initialized at {}", frecency_db_path);
|
||||
|
||||
let mut query_tracker = QUERY_TRACKER
|
||||
.write()
|
||||
.map_err(|_| Error::AcquireFrecencyLock)?;
|
||||
if query_tracker.is_some() {
|
||||
*query_tracker = None;
|
||||
}
|
||||
|
||||
let tracker = QueryTracker::new(&history_db_path, use_unsafe_no_lock)?;
|
||||
*query_tracker = Some(tracker);
|
||||
tracing::info!("Query tracker database initialized at {}", history_db_path);
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn destroy_db(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
pub fn destroy_frecency_db(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
let mut frecency = FRECENCY.write().map_err(|_| Error::AcquireFrecencyLock)?;
|
||||
*frecency = None;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn destroy_query_db(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
let mut query_tracker = QUERY_TRACKER
|
||||
.write()
|
||||
.map_err(|_| Error::AcquireFrecencyLock)?;
|
||||
*query_tracker = None;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn init_file_picker(_: &Lua, base_path: String) -> LuaResult<bool> {
|
||||
let mut file_picker = FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)?;
|
||||
if file_picker.is_some() {
|
||||
@@ -46,12 +80,12 @@ pub fn init_file_picker(_: &Lua, base_path: String) -> LuaResult<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn reinit_file_picker_internal(path: std::path::PathBuf) -> Result<(), Error> {
|
||||
fn reinit_file_picker_internal(path: &Path) -> Result<(), Error> {
|
||||
let mut file_picker = FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)?;
|
||||
|
||||
// drop should clean it anyway but just to be extra sure
|
||||
if let Some(picker) = file_picker.take() {
|
||||
picker.stop_background_monitor()?;
|
||||
if let Some(mut picker) = file_picker.take() {
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
|
||||
let new_picker = FilePicker::new(path.to_string_lossy().to_string())?;
|
||||
@@ -60,7 +94,7 @@ fn reinit_file_picker_internal(path: std::path::PathBuf) -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn restart_index_in_path(_: &Lua, new_path: String) -> LuaResult<bool> {
|
||||
pub fn restart_index_in_path(_: &Lua, new_path: String) -> LuaResult<()> {
|
||||
let path = std::path::PathBuf::from(&new_path);
|
||||
if !path.exists() {
|
||||
return Err(LuaError::RuntimeError(format!(
|
||||
@@ -73,14 +107,26 @@ pub fn restart_index_in_path(_: &Lua, new_path: String) -> LuaResult<bool> {
|
||||
LuaError::RuntimeError(format!("Failed to canonicalize path '{}': {}", new_path, e))
|
||||
})?;
|
||||
|
||||
reinit_file_picker_internal(canonical_path)?;
|
||||
Ok(true)
|
||||
// Spawn a background thread to avoid blocking Lua/UI thread
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) = reinit_file_picker_internal(&canonical_path) {
|
||||
::tracing::error!(
|
||||
?e,
|
||||
?canonical_path,
|
||||
"Failed to index directory after changing"
|
||||
);
|
||||
} else {
|
||||
::tracing::info!(?canonical_path, "Successfully reindexed directory");
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn scan_files(_: &Lua, _: ()) -> LuaResult<()> {
|
||||
let file_picker = FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)?;
|
||||
let mut file_picker = FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)?;
|
||||
let picker = file_picker
|
||||
.as_ref()
|
||||
.as_mut()
|
||||
.ok_or_else(|| Error::FilePickerMissing)?;
|
||||
|
||||
picker.trigger_rescan()?;
|
||||
@@ -88,42 +134,95 @@ pub fn scan_files(_: &Lua, _: ()) -> LuaResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_cached_files(_: &Lua, _: ()) -> LuaResult<Vec<FileItem>> {
|
||||
let file_picker = FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)?;
|
||||
let picker = file_picker
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::FilePickerMissing)?;
|
||||
Ok(picker.get_cached_files())
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn fuzzy_search_files(
|
||||
_: &Lua,
|
||||
(query, max_results, max_threads, current_file): (String, usize, usize, Option<String>),
|
||||
) -> LuaResult<SearchResult> {
|
||||
let time = std::time::Instant::now();
|
||||
let file_picker = FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)?;
|
||||
::tracing::debug!("Fuzzy search started: {:?}", time.elapsed());
|
||||
let picker = file_picker
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::FilePickerMissing)?;
|
||||
lua: &Lua,
|
||||
(
|
||||
query,
|
||||
max_threads,
|
||||
current_file,
|
||||
combo_boost_score_multiplier,
|
||||
min_combo_count,
|
||||
page_index,
|
||||
page_size,
|
||||
): (
|
||||
String,
|
||||
usize,
|
||||
Option<String>,
|
||||
i32,
|
||||
Option<u32>,
|
||||
Option<usize>,
|
||||
Option<usize>,
|
||||
),
|
||||
) -> LuaResult<LuaValue> {
|
||||
let Some(ref mut picker) = *FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)? else {
|
||||
return Err(Error::FilePickerMissing)?;
|
||||
};
|
||||
|
||||
let results = picker.fuzzy_search(&query, max_results, max_threads, current_file.as_ref());
|
||||
Ok(results)
|
||||
let base_path = picker.base_path();
|
||||
let min_combo_count = min_combo_count.unwrap_or(3);
|
||||
|
||||
let last_same_query_entry = {
|
||||
let query_tracker = QUERY_TRACKER
|
||||
.read()
|
||||
.map_err(|_| Error::AcquireFrecencyLock)?;
|
||||
|
||||
if query_tracker.as_ref().is_none() {
|
||||
tracing::warn!("Query tracker not initialized");
|
||||
}
|
||||
|
||||
query_tracker
|
||||
.as_ref()
|
||||
.map(|tracker| tracker.get_last_query_entry(&query, base_path, min_combo_count))
|
||||
.transpose()?
|
||||
.flatten()
|
||||
};
|
||||
|
||||
tracing::debug!(
|
||||
?last_same_query_entry,
|
||||
?base_path,
|
||||
?query,
|
||||
?min_combo_count,
|
||||
?page_index,
|
||||
?page_size,
|
||||
"Fuzzy search parameters"
|
||||
);
|
||||
|
||||
let results = FilePicker::fuzzy_search(
|
||||
picker.get_files(),
|
||||
&query,
|
||||
FuzzySearchOptions {
|
||||
max_threads,
|
||||
current_file: current_file.as_deref(),
|
||||
project_path: Some(picker.base_path()),
|
||||
last_same_query_match: last_same_query_entry.as_ref(),
|
||||
combo_boost_score_multiplier,
|
||||
min_combo_count,
|
||||
pagination: PaginationArgs {
|
||||
offset: page_index.unwrap_or(0),
|
||||
limit: page_size.unwrap_or(0),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
results.into_lua(lua)
|
||||
}
|
||||
|
||||
pub fn access_file(_: &Lua, file_path: String) -> LuaResult<bool> {
|
||||
let frecency = FRECENCY.read().map_err(|_| Error::AcquireFrecencyLock)?;
|
||||
if let Some(ref tracker) = *frecency {
|
||||
let file_key = FileKey {
|
||||
path: file_path.clone(),
|
||||
};
|
||||
tracker.track_access(&file_key)?;
|
||||
}
|
||||
pub fn track_access(_: &Lua, file_path: String) -> LuaResult<bool> {
|
||||
let file_path = PathBuf::from(&file_path);
|
||||
|
||||
let file_picker = FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)?;
|
||||
if let Some(ref picker) = *file_picker {
|
||||
picker.update_single_file_frecency(&file_path)?;
|
||||
}
|
||||
// Track access in frecency DB (expensive LMDB write, ~100-200ms)
|
||||
// Do this WITHOUT holding FILE_PICKER lock to avoid blocking searches
|
||||
let Some(ref frecency) = *FRECENCY.read().map_err(|_| Error::AcquireFrecencyLock)? else {
|
||||
return Ok(false);
|
||||
};
|
||||
frecency.track_access(file_path.as_path())?;
|
||||
|
||||
// Quick lock to update single file's frecency score in picker
|
||||
let Some(ref mut picker) = *FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)? else {
|
||||
return Err(Error::FilePickerMissing)?;
|
||||
};
|
||||
picker.update_single_file_frecency(&file_path, frecency)?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
@@ -149,31 +248,28 @@ pub fn is_scanning(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
Ok(picker.is_scan_active())
|
||||
}
|
||||
|
||||
pub fn refresh_git_status(_: &Lua, _: ()) -> LuaResult<Vec<FileItem>> {
|
||||
let file_picker = FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)?;
|
||||
let picker = file_picker
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::FilePickerMissing)?;
|
||||
|
||||
Ok(picker.refresh_git_status())
|
||||
pub fn refresh_git_status(_: &Lua, _: ()) -> LuaResult<usize> {
|
||||
FilePicker::refresh_git_status_global().map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn update_single_file_frecency(_: &Lua, file_path: String) -> LuaResult<bool> {
|
||||
let file_picker = FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)?;
|
||||
let picker = file_picker
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::FilePickerMissing)?;
|
||||
let Some(ref frecency) = *FRECENCY.read().map_err(|_| Error::AcquireFrecencyLock)? else {
|
||||
return Ok(false);
|
||||
};
|
||||
let Some(ref mut picker) = *FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)? else {
|
||||
return Err(Error::FilePickerMissing)?;
|
||||
};
|
||||
|
||||
picker.update_single_file_frecency(&file_path)?;
|
||||
picker.update_single_file_frecency(&file_path, frecency)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn stop_background_monitor(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
let mut file_picker = FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)?;
|
||||
let picker = file_picker
|
||||
.as_mut()
|
||||
.ok_or_else(|| Error::FilePickerMissing)?;
|
||||
picker.stop_background_monitor()?;
|
||||
let Some(ref mut picker) = *FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)? else {
|
||||
return Err(Error::FilePickerMissing)?;
|
||||
};
|
||||
|
||||
picker.stop_background_monitor();
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
@@ -194,6 +290,61 @@ pub fn cancel_scan(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn track_query_completion(_: &Lua, (query, file_path): (String, String)) -> LuaResult<bool> {
|
||||
// Get the project path before spawning thread
|
||||
let project_path = {
|
||||
let Some(ref picker) = *FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)? else {
|
||||
return Ok(false);
|
||||
};
|
||||
picker.base_path().to_path_buf()
|
||||
};
|
||||
|
||||
// Canonicalize the file path before spawning thread
|
||||
let file_path = match PathBuf::from(&file_path).canonicalize() {
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
tracing::warn!(?file_path, error = ?e, "Failed to canonicalize file path for tracking");
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Spawn background thread to do the actual tracking (expensive DB write)
|
||||
std::thread::spawn(move || {
|
||||
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!(
|
||||
query = %query,
|
||||
file = %file_path.display(),
|
||||
error = ?e,
|
||||
"Failed to track query completion"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn get_historical_query(_: &Lua, offset: usize) -> LuaResult<Option<String>> {
|
||||
let project_path = {
|
||||
let Some(ref picker) = *FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
picker.base_path().to_path_buf()
|
||||
};
|
||||
|
||||
let Some(ref tracker) = *QUERY_TRACKER
|
||||
.read()
|
||||
.map_err(|_| Error::AcquireFrecencyLock)?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
tracker
|
||||
.get_historical_query(&project_path, offset)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn wait_for_initial_scan(_: &Lua, timeout_ms: Option<u64>) -> LuaResult<bool> {
|
||||
let file_picker = FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)?;
|
||||
let picker = file_picker
|
||||
@@ -226,27 +377,28 @@ pub fn init_tracing(
|
||||
_: &Lua,
|
||||
(log_file_path, log_level): (String, Option<String>),
|
||||
) -> LuaResult<String> {
|
||||
let level = log_level.unwrap_or_else(|| "info".to_string());
|
||||
crate::tracing::init_tracing(&log_file_path, &level)
|
||||
crate::log::init_tracing(&log_file_path, log_level.as_deref())
|
||||
.map_err(|e| LuaError::RuntimeError(format!("Failed to initialize tracing: {}", e)))
|
||||
}
|
||||
|
||||
fn create_exports(lua: &Lua) -> LuaResult<LuaTable> {
|
||||
let exports = lua.create_table()?;
|
||||
exports.set("init_db", lua.create_function(init_db)?)?;
|
||||
exports.set("destroy_db", lua.create_function(destroy_db)?)?;
|
||||
exports.set(
|
||||
"destroy_frecency_db",
|
||||
lua.create_function(destroy_frecency_db)?,
|
||||
)?;
|
||||
exports.set("init_file_picker", lua.create_function(init_file_picker)?)?;
|
||||
exports.set(
|
||||
"restart_index_in_path",
|
||||
lua.create_function(restart_index_in_path)?,
|
||||
)?;
|
||||
exports.set("scan_files", lua.create_function(scan_files)?)?;
|
||||
exports.set("get_cached_files", lua.create_function(get_cached_files)?)?;
|
||||
exports.set(
|
||||
"fuzzy_search_files",
|
||||
lua.create_function(fuzzy_search_files)?,
|
||||
)?;
|
||||
exports.set("access_file", lua.create_function(access_file)?)?;
|
||||
exports.set("track_access", lua.create_function(track_access)?)?;
|
||||
exports.set("cancel_scan", lua.create_function(cancel_scan)?)?;
|
||||
exports.set("get_scan_progress", lua.create_function(get_scan_progress)?)?;
|
||||
exports.set(
|
||||
@@ -266,11 +418,25 @@ fn create_exports(lua: &Lua) -> LuaResult<LuaTable> {
|
||||
"cleanup_file_picker",
|
||||
lua.create_function(cleanup_file_picker)?,
|
||||
)?;
|
||||
exports.set("destroy_query_db", lua.create_function(destroy_query_db)?)?;
|
||||
exports.set(
|
||||
"track_query_completion",
|
||||
lua.create_function(track_query_completion)?,
|
||||
)?;
|
||||
exports.set(
|
||||
"get_historical_query",
|
||||
lua.create_function(get_historical_query)?,
|
||||
)?;
|
||||
|
||||
Ok(exports)
|
||||
}
|
||||
|
||||
// https://github.com/mlua-rs/mlua/issues/318
|
||||
#[mlua::lua_module(skip_memory_check)]
|
||||
fn fff_nvim(lua: &Lua) -> LuaResult<LuaTable> {
|
||||
// Install panic hook IMMEDIATELY on module load
|
||||
// This ensures any panics are logged even if init_tracing is never called
|
||||
crate::log::install_panic_hook();
|
||||
|
||||
create_exports(lua)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
#[derive(Debug, Eq, PartialEq, Copy, Clone)]
|
||||
pub enum Location {
|
||||
Line(i32),
|
||||
Range { start: (i32, i32), end: (i32, i32) },
|
||||
Position { line: i32, col: i32 },
|
||||
}
|
||||
|
||||
fn parse_number_pair(location: &str, split_char: char) -> Option<(i32, i32)> {
|
||||
let mut iter = location.split(split_char);
|
||||
|
||||
let start_str = iter.next()?;
|
||||
let end_str = iter.next()?;
|
||||
|
||||
// if there are more than 2 parts it's not the range treat as normal query
|
||||
if iter.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let start = start_str.parse::<i32>().ok()?;
|
||||
let end = end_str.parse::<i32>().ok()?;
|
||||
|
||||
Some((start, end))
|
||||
}
|
||||
|
||||
/// Parse "line-line" format
|
||||
fn parse_simple_range(location: &str) -> Option<Location> {
|
||||
let (start, end) = parse_number_pair(location, '-')?;
|
||||
if end < start {
|
||||
return Some(Location::Line(start));
|
||||
}
|
||||
|
||||
Some(Location::Range {
|
||||
start: (start, 0),
|
||||
end: (end, 0),
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse "line:col-col" format (column range on same line)
|
||||
fn parse_column_range(start_part: &str, end_part: &str) -> Option<Location> {
|
||||
let (line_str, start_col_str) = start_part.split_once(':')?;
|
||||
let line = line_str.parse::<i32>().ok()?;
|
||||
let start_col = start_col_str.parse::<i32>().ok()?;
|
||||
let end_col = end_part.parse::<i32>().ok()?;
|
||||
|
||||
if end_col < start_col {
|
||||
return Some(Location::Line(line));
|
||||
}
|
||||
|
||||
Some(Location::Range {
|
||||
start: (line, start_col),
|
||||
end: (line, end_col),
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse "line:col-line:col" format (position range)
|
||||
fn parse_position_range(start_part: &str, end_part: &str) -> Option<Location> {
|
||||
let (start_line, start_col) = parse_number_pair(start_part, ':')?;
|
||||
let (end_line, end_col) = parse_number_pair(end_part, ':')?;
|
||||
|
||||
if end_line < start_line || (end_line == start_line && end_col < start_col) {
|
||||
return Some(Location::Position {
|
||||
line: start_line,
|
||||
col: start_col,
|
||||
});
|
||||
}
|
||||
|
||||
Some(Location::Range {
|
||||
start: (start_line, start_col),
|
||||
end: (end_line, end_col),
|
||||
})
|
||||
}
|
||||
|
||||
/// Try to parse range patterns (contains '-')
|
||||
fn try_parse_column_range(location: &str) -> Option<Location> {
|
||||
if !location.contains('-') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (start_part, end_part) = location.split_once('-')?;
|
||||
|
||||
// Try position range (line:col-line:col)
|
||||
if start_part.contains(':') && end_part.contains(':') {
|
||||
return parse_position_range(start_part, end_part);
|
||||
}
|
||||
|
||||
// Try column range (line:col-col)
|
||||
if start_part.contains(':') {
|
||||
return parse_column_range(start_part, end_part);
|
||||
}
|
||||
|
||||
// Try simple line range (line-line)
|
||||
parse_simple_range(location)
|
||||
}
|
||||
|
||||
/// Try to parse position patterns (contains ':' but not '-')
|
||||
fn try_parse_column_position(location: &str) -> Option<Location> {
|
||||
if !location.contains(':') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (line_str, col_str) = location.split_once(':')?;
|
||||
let line = line_str.parse::<i32>().ok()?;
|
||||
let col = col_str.parse::<i32>().ok()?;
|
||||
|
||||
Some(Location::Position { line, col })
|
||||
}
|
||||
|
||||
/// Parses various location formats like file:12, file:12:4, file:12-114
|
||||
fn parse_column_location(query: &str) -> Option<(&str, Location)> {
|
||||
let (file_path, location_part) = query.split_once(':')?;
|
||||
|
||||
if let Some(range_location) = try_parse_column_range(location_part) {
|
||||
return Some((file_path, range_location));
|
||||
}
|
||||
|
||||
if let Some(position_location) = try_parse_column_position(location_part) {
|
||||
return Some((file_path, position_location));
|
||||
}
|
||||
|
||||
if let Ok(line_location) = location_part.parse::<i32>() {
|
||||
return Some((file_path, Location::Line(line_location)));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_vstudio_location(query: &str) -> Option<(&str, Location)> {
|
||||
if !query.ends_with(')') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (file_path, location_with_paren) = query.rsplit_once('(')?;
|
||||
let location = location_with_paren.trim_end_matches(')');
|
||||
|
||||
if let Ok(line) = location.parse::<i32>() {
|
||||
return Some((file_path, Location::Line(line)));
|
||||
}
|
||||
|
||||
if let Some((line, col)) = parse_number_pair(location, ',') {
|
||||
return Some((file_path, Location::Position { line, col }));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn parse_location(query: &str) -> (&str, Option<Location>) {
|
||||
// simply ignore the last semicolon even if there are no additional location info
|
||||
let query = query.trim_end_matches([':', '-', '(']);
|
||||
if let Some((path, location)) = parse_column_location(query) {
|
||||
return (path, Some(location));
|
||||
}
|
||||
|
||||
if let Some((path, location)) = parse_vstudio_location(query) {
|
||||
return (path, Some(location));
|
||||
}
|
||||
|
||||
(query, None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
pub use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_location_parsing() {
|
||||
assert_eq!(
|
||||
parse_location("new_file:12"),
|
||||
("new_file", Some(Location::Line(12)))
|
||||
);
|
||||
assert_eq!(parse_location("new_file:12ab"), ("new_file:12ab", None));
|
||||
|
||||
assert_eq!(parse_location("something"), ("something", None));
|
||||
assert_eq!(
|
||||
parse_location("file:12:4"),
|
||||
("file", Some(Location::Position { line: 12, col: 4 }))
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parse_location("file:12-114"),
|
||||
(
|
||||
"file",
|
||||
Some(Location::Range {
|
||||
start: (12, 0),
|
||||
end: (114, 0)
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parse_location("file:12:4-20"),
|
||||
(
|
||||
"file",
|
||||
Some(Location::Range {
|
||||
start: (12, 4),
|
||||
end: (12, 20)
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parse_location("file:100:4-14:20"),
|
||||
("file", Some(Location::Position { line: 100, col: 4 }))
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parse_location("file:12:4-14:20"),
|
||||
(
|
||||
"file",
|
||||
Some(Location::Range {
|
||||
start: (12, 4),
|
||||
end: (14, 20)
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vstudio_parsing() {
|
||||
assert_eq!(
|
||||
parse_location("file(12)"),
|
||||
("file", Some(Location::Line(12)))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_location("file(12,4)"),
|
||||
("file", Some(Location::Position { line: 12, col: 4 }))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trimes_end_character() {
|
||||
assert_eq!(
|
||||
parse_location("file:12-"),
|
||||
("file", Some(Location::Line(12)))
|
||||
);
|
||||
assert_eq!(parse_location("file:-"), ("file", None));
|
||||
assert_eq!(parse_location("file("), ("file", None));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
use crate::error::Error;
|
||||
use std::path::Path;
|
||||
use tracing_appender::non_blocking;
|
||||
use tracing_subscriber::fmt::format::FmtSpan;
|
||||
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
|
||||
|
||||
static TRACING_INITIALIZED: std::sync::OnceLock<tracing_appender::non_blocking::WorkerGuard> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
static PANIC_HOOK_INSTALLED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
|
||||
|
||||
/// Install panic hook that writes to both stderr and a fallback file
|
||||
/// This is called separately from init_tracing to ensure panics are always logged
|
||||
pub fn install_panic_hook() {
|
||||
PANIC_HOOK_INSTALLED.get_or_init(|| {
|
||||
let default_panic = std::panic::take_hook();
|
||||
|
||||
std::panic::set_hook(Box::new(move |panic_info| {
|
||||
let payload = panic_info.payload();
|
||||
let message = if let Some(s) = payload.downcast_ref::<&str>() {
|
||||
s.to_string()
|
||||
} else if let Some(s) = payload.downcast_ref::<String>() {
|
||||
s.clone()
|
||||
} else {
|
||||
"Unknown panic payload".to_string()
|
||||
};
|
||||
|
||||
let location = if let Some(location) = panic_info.location() {
|
||||
format!(
|
||||
"{}:{}:{}",
|
||||
location.file(),
|
||||
location.line(),
|
||||
location.column()
|
||||
)
|
||||
} else {
|
||||
"unknown location".to_string()
|
||||
};
|
||||
|
||||
// Always log to tracing (if initialized)
|
||||
tracing::error!(
|
||||
panic.message = %message,
|
||||
panic.location = %location,
|
||||
"PANIC occurred in FFF.nvim"
|
||||
);
|
||||
|
||||
// Always print to stderr
|
||||
eprintln!("=== FFF.nvim PANIC ===");
|
||||
eprintln!("Message: {}", message);
|
||||
eprintln!("Location: {}", location);
|
||||
eprintln!("======================");
|
||||
|
||||
// Try to write to fallback panic log file
|
||||
if let Some(cache_dir) = dirs::cache_dir() {
|
||||
let panic_log = cache_dir.join("fff_nvim_panic.log");
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
let panic_entry = format!(
|
||||
"\n[{}] PANIC at {}\nMessage: {}\n",
|
||||
timestamp, location, message
|
||||
);
|
||||
|
||||
let _ = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&panic_log)
|
||||
.and_then(|mut f| {
|
||||
use std::io::Write;
|
||||
f.write_all(panic_entry.as_bytes())
|
||||
});
|
||||
|
||||
eprintln!("Panic logged to: {}", panic_log.display());
|
||||
}
|
||||
|
||||
default_panic(panic_info);
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
/// Initialize tracing with single log file
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `log_file_path` - Full path to the log file
|
||||
/// * `log_level` - Log level (trace, debug, info, warn, error)
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Result<String, Error>` - Full path to the log file on success
|
||||
pub fn init_tracing(log_file_path: &str, log_level: Option<&str>) -> Result<String, Error> {
|
||||
// Install panic hook first (does nothing if already installed)
|
||||
install_panic_hook();
|
||||
|
||||
let log_path = Path::new(log_file_path);
|
||||
if let Some(parent) = log_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let file_appender = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true) // creates a new file on every setup
|
||||
.open(log_path)?;
|
||||
|
||||
let level = match log_level
|
||||
.as_ref()
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.as_deref()
|
||||
{
|
||||
Some("trace") => tracing::Level::TRACE,
|
||||
Some("debug") => tracing::Level::DEBUG,
|
||||
Some("info") => tracing::Level::INFO,
|
||||
Some("warn") => tracing::Level::WARN,
|
||||
Some("error") => tracing::Level::ERROR,
|
||||
_ => tracing::Level::INFO,
|
||||
};
|
||||
|
||||
TRACING_INITIALIZED.get_or_init(|| {
|
||||
let (non_blocking_appender, guard) = non_blocking(file_appender);
|
||||
|
||||
let subscriber = tracing_subscriber::registry()
|
||||
.with(
|
||||
fmt::layer()
|
||||
.with_writer(non_blocking_appender)
|
||||
.with_target(true)
|
||||
.with_thread_ids(false)
|
||||
.with_thread_names(false)
|
||||
.with_file(true)
|
||||
.with_line_number(true)
|
||||
.with_ansi(false)
|
||||
.with_span_events(FmtSpan::NEW | FmtSpan::CLOSE),
|
||||
)
|
||||
.with(
|
||||
EnvFilter::builder()
|
||||
.with_default_directive(level.into())
|
||||
.from_env_lossy(),
|
||||
);
|
||||
|
||||
if let Err(e) = tracing::subscriber::set_global_default(subscriber) {
|
||||
eprintln!("Failed to set tracing subscriber: {}", e);
|
||||
} else {
|
||||
tracing::info!(
|
||||
"FFF.nvim tracing initialized with log file: {}",
|
||||
log_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
guard
|
||||
});
|
||||
|
||||
Ok(log_file_path.to_string())
|
||||
}
|
||||
+75
-34
@@ -19,8 +19,14 @@ pub fn calculate_distance_penalty(current_file: Option<&str>, candidate_path: &s
|
||||
return 0; // Same directory, no penalty
|
||||
}
|
||||
|
||||
let current_parts: Vec<&str> = current_dir.split('/').filter(|s| !s.is_empty()).collect();
|
||||
let candidate_parts: Vec<&str> = candidate_dir.split('/').filter(|s| !s.is_empty()).collect();
|
||||
let current_parts: Vec<&str> = current_dir
|
||||
.split(std::path::MAIN_SEPARATOR)
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
let candidate_parts: Vec<&str> = candidate_dir
|
||||
.split(std::path::MAIN_SEPARATOR)
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
let common_len = current_parts
|
||||
.iter()
|
||||
@@ -29,14 +35,12 @@ pub fn calculate_distance_penalty(current_file: Option<&str>, candidate_path: &s
|
||||
.count();
|
||||
|
||||
let current_depth_from_common = current_parts.len() - common_len;
|
||||
let candidate_depth_from_common = candidate_parts.len() - common_len;
|
||||
let total_distance = current_depth_from_common + candidate_depth_from_common;
|
||||
|
||||
if total_distance == 0 {
|
||||
return 0; // Same path
|
||||
if current_depth_from_common == 0 {
|
||||
return 0; // Current file is at the common ancestor level
|
||||
}
|
||||
|
||||
let penalty = -(total_distance as i32 * 2);
|
||||
let penalty = -(current_depth_from_common as i32);
|
||||
|
||||
penalty.max(-20)
|
||||
}
|
||||
@@ -44,47 +48,84 @@ pub fn calculate_distance_penalty(current_file: Option<&str>, candidate_path: &s
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn test_calculate_distance_penalty() {
|
||||
assert_eq!(calculate_distance_penalty(None, "/path/to/file.txt"), 0);
|
||||
|
||||
#[test]
|
||||
#[cfg(not(target_family = "windows"))]
|
||||
fn test_calculate_distance_penalty() {
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(None, "examples/user/test/mod.rs"),
|
||||
0
|
||||
);
|
||||
// Same directory
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(
|
||||
Some("/path/to/current/file.txt"),
|
||||
"/path/to/current/other.txt"
|
||||
Some("examples/user/test/main.rs"),
|
||||
"examples/user/test/mod.rs"
|
||||
),
|
||||
0
|
||||
);
|
||||
//
|
||||
// One level apart
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(
|
||||
Some("examples/user/test/subdir/file.rs"),
|
||||
"examples/user/test/mod.rs"
|
||||
),
|
||||
-1
|
||||
);
|
||||
//
|
||||
// Different subdirectories (same parent)
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(
|
||||
Some("examples/user/test/dir1/file.rs"),
|
||||
"examples/user/test/dir2/mod.rs"
|
||||
),
|
||||
-1
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(Some("/path/to/current/file.txt"), "/path/to/file.txt"),
|
||||
calculate_distance_penalty(
|
||||
Some("examples/audio-announce/src/lib/audio-announce.rs"),
|
||||
"examples/audio-announce/src/main.rs"
|
||||
),
|
||||
-1
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(
|
||||
Some("examples/audio-announce/src/audio-announce.rs"),
|
||||
"examples/pixel/src/main.rs"
|
||||
),
|
||||
-2
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(
|
||||
Some("/path/to/current/file.txt"),
|
||||
"/path/to/other/file.txt"
|
||||
),
|
||||
-4
|
||||
);
|
||||
// Root level files
|
||||
assert_eq!(calculate_distance_penalty(Some("main.rs"), "lib.rs"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_family = "windows")]
|
||||
fn distance_penalty_works_on_windows() {
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(
|
||||
Some("/path/to/current/file.txt"),
|
||||
"/path/to/another/dir/file.txt"
|
||||
),
|
||||
-6
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(Some("/a/b/c/d/file.txt"), "/x/y/z/w/file.txt"),
|
||||
-16
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(Some("/file1.txt"), "/file2.txt"),
|
||||
calculate_distance_penalty(None, "examples\\user\\test\\mod.rs"),
|
||||
0
|
||||
);
|
||||
// Same directory
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(
|
||||
Some("examples\\user\\test\\main.rs"),
|
||||
"examples\\user\\test\\mod.rs"
|
||||
),
|
||||
0
|
||||
);
|
||||
//
|
||||
// One level apart
|
||||
assert_eq!(
|
||||
calculate_distance_penalty(
|
||||
Some("examples\\user\\test\\subdir\\file.rs"),
|
||||
"examples\\user\\test\\mod.rs"
|
||||
),
|
||||
-1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
use crate::error::Error;
|
||||
use heed::types::Bytes;
|
||||
use heed::{Database, Env, EnvOpenOptions};
|
||||
use heed::{EnvFlags, types::SerdeBincode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::VecDeque;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
const MAX_HISTORY_ENTRIES: usize = 128;
|
||||
|
||||
/// Simplified QueryFileEntry without redundant fields
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct QueryMatchEntry {
|
||||
pub file_path: PathBuf, // File that was actually opened
|
||||
pub open_count: u32, // Number of times opened with this query
|
||||
pub last_opened: u64, // Unix timestamp
|
||||
}
|
||||
|
||||
/// Entry for query history tracking
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
struct HistoryEntry {
|
||||
query: String,
|
||||
timestamp: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct QueryTracker {
|
||||
env: Env,
|
||||
// Database for (project_path, query) -> QueryMatchEntry mappings
|
||||
query_file_db: Database<Bytes, SerdeBincode<QueryMatchEntry>>,
|
||||
// Database for project_path -> VecDeque<HistoryEntry> mappings
|
||||
query_history_db: Database<Bytes, SerdeBincode<VecDeque<HistoryEntry>>>,
|
||||
}
|
||||
|
||||
impl QueryTracker {
|
||||
pub fn new(db_path: &str, use_unsafe_no_lock: bool) -> Result<Self, Error> {
|
||||
fs::create_dir_all(db_path).map_err(Error::CreateDir)?;
|
||||
let env = unsafe {
|
||||
let mut opts = EnvOpenOptions::new();
|
||||
opts.max_dbs(16); // Allow up to 16 databases per environment
|
||||
if use_unsafe_no_lock {
|
||||
opts.flags(EnvFlags::NO_LOCK | EnvFlags::NO_SYNC | EnvFlags::NO_META_SYNC);
|
||||
}
|
||||
opts.open(db_path).map_err(Error::EnvOpen)?
|
||||
};
|
||||
|
||||
env.clear_stale_readers()
|
||||
.map_err(Error::DbClearStaleReaders)?;
|
||||
|
||||
let mut wtxn = env.write_txn().map_err(Error::DbStartWriteTxn)?;
|
||||
|
||||
// Create two named databases
|
||||
let query_file_db = env
|
||||
.create_database(&mut wtxn, Some("query_file_associations"))
|
||||
.map_err(Error::DbCreate)?;
|
||||
let query_history_db = env
|
||||
.create_database(&mut wtxn, Some("query_history"))
|
||||
.map_err(Error::DbCreate)?;
|
||||
|
||||
wtxn.commit().map_err(Error::DbCommit)?;
|
||||
|
||||
Ok(QueryTracker {
|
||||
env,
|
||||
query_file_db,
|
||||
query_history_db,
|
||||
})
|
||||
}
|
||||
|
||||
fn get_now(&self) -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn create_query_key(project_path: &Path, query: &str) -> Result<[u8; 32], Error> {
|
||||
let project_str = project_path
|
||||
.to_str()
|
||||
.ok_or_else(|| Error::InvalidPath(project_path.to_path_buf()))?;
|
||||
|
||||
let mut hasher = blake3::Hasher::default();
|
||||
hasher.update(project_str.as_bytes());
|
||||
hasher.update(b"::");
|
||||
hasher.update(query.as_bytes());
|
||||
|
||||
Ok(*hasher.finalize().as_bytes())
|
||||
}
|
||||
|
||||
fn create_project_key(project_path: &Path) -> Result<[u8; 32], Error> {
|
||||
let project_str = project_path
|
||||
.to_str()
|
||||
.ok_or_else(|| Error::InvalidPath(project_path.to_path_buf()))?;
|
||||
|
||||
Ok(*blake3::hash(project_str.as_bytes()).as_bytes())
|
||||
}
|
||||
|
||||
pub fn track_query_completion(
|
||||
&mut self,
|
||||
query: &str,
|
||||
project_path: &Path,
|
||||
file_path: &Path,
|
||||
) -> Result<(), Error> {
|
||||
let now = self.get_now();
|
||||
let file_path_buf = file_path.to_path_buf();
|
||||
|
||||
let query_key = Self::create_query_key(project_path, query)?;
|
||||
let mut wtxn = self.env.write_txn().map_err(Error::DbStartWriteTxn)?;
|
||||
|
||||
let mut entry = self
|
||||
.query_file_db
|
||||
.get(&wtxn, &query_key)
|
||||
.map_err(Error::DbRead)?
|
||||
.unwrap_or_else(|| QueryMatchEntry {
|
||||
file_path: file_path_buf.clone(),
|
||||
open_count: 0,
|
||||
last_opened: now,
|
||||
});
|
||||
|
||||
if entry.file_path == file_path_buf {
|
||||
tracing::debug!(
|
||||
?query,
|
||||
?file_path,
|
||||
"Query completed for same file as last time"
|
||||
);
|
||||
|
||||
// Same file - just increment count
|
||||
entry.open_count += 1;
|
||||
} else {
|
||||
tracing::debug!(
|
||||
?query,
|
||||
?file_path,
|
||||
"Query completed for different file than last time"
|
||||
);
|
||||
|
||||
// Different file - replace and reset count to 1
|
||||
entry.file_path = file_path_buf;
|
||||
entry.open_count = 1;
|
||||
}
|
||||
|
||||
entry.last_opened = now;
|
||||
|
||||
self.query_file_db
|
||||
.put(&mut wtxn, &query_key, &entry)
|
||||
.map_err(Error::DbWrite)?;
|
||||
|
||||
// Update query history database
|
||||
let project_key = Self::create_project_key(project_path)?;
|
||||
let mut history = self
|
||||
.query_history_db
|
||||
.get(&wtxn, &project_key)
|
||||
.map_err(Error::DbRead)?
|
||||
.unwrap_or_default();
|
||||
|
||||
let history_entry = HistoryEntry {
|
||||
query: query.to_string(),
|
||||
timestamp: now,
|
||||
};
|
||||
history.push_back(history_entry);
|
||||
while history.len() > MAX_HISTORY_ENTRIES {
|
||||
history.pop_front();
|
||||
}
|
||||
|
||||
self.query_history_db
|
||||
.put(&mut wtxn, &project_key, &history)
|
||||
.map_err(Error::DbWrite)?;
|
||||
|
||||
wtxn.commit().map_err(Error::DbCommit)?;
|
||||
|
||||
tracing::debug!(?query, ?file_path, "Tracked query completion");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_last_query_entry(
|
||||
&self,
|
||||
query: &str,
|
||||
project_path: &Path,
|
||||
min_combo_count: u32,
|
||||
) -> Result<Option<QueryMatchEntry>, Error> {
|
||||
let query_key = Self::create_query_key(project_path, query)?;
|
||||
tracing::debug!(?query_key, "HASH");
|
||||
let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?;
|
||||
|
||||
let last_match = self
|
||||
.query_file_db
|
||||
.get(&rtxn, &query_key)
|
||||
.map_err(Error::DbRead)?;
|
||||
|
||||
Ok(last_match.filter(|entry| entry.open_count >= min_combo_count))
|
||||
}
|
||||
|
||||
pub fn get_last_query_path(
|
||||
&self,
|
||||
query: &str,
|
||||
project_path: &Path,
|
||||
file_path: &Path,
|
||||
combo_boost: i32,
|
||||
) -> Result<i32, Error> {
|
||||
let query_key = Self::create_query_key(project_path, query)?;
|
||||
tracing::debug!(?query_key, "HASH");
|
||||
let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?;
|
||||
|
||||
match self
|
||||
.query_file_db
|
||||
.get(&rtxn, &query_key)
|
||||
.map_err(Error::DbRead)?
|
||||
{
|
||||
Some(entry) => {
|
||||
// Check if the file path matches and return boost
|
||||
if entry.file_path == file_path && entry.open_count >= 2 {
|
||||
Ok(combo_boost)
|
||||
} else {
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
None => Ok(0), // Query not found
|
||||
}
|
||||
}
|
||||
|
||||
/// Get query from history at a specific offset
|
||||
/// offset=0 returns most recent query, offset=1 returns 2nd most recent, etc.
|
||||
/// Returns None if offset exceeds history length
|
||||
pub fn get_historical_query(
|
||||
&self,
|
||||
project_path: &Path,
|
||||
offset: usize,
|
||||
) -> Result<Option<String>, Error> {
|
||||
let project_key = Self::create_project_key(project_path)?;
|
||||
let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?;
|
||||
|
||||
let mut history = self
|
||||
.query_history_db
|
||||
.get(&rtxn, &project_key)
|
||||
.map_err(Error::DbRead)?
|
||||
.unwrap_or_default();
|
||||
|
||||
// history is FIFO, last element is most recent
|
||||
if history.len() > offset {
|
||||
let index = history.len() - 1 - offset;
|
||||
let record = history.remove(index);
|
||||
|
||||
Ok(record.map(|r| r.query))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::env;
|
||||
|
||||
#[test]
|
||||
fn test_query_tracking() {
|
||||
let temp_dir = env::temp_dir().join("fff_test_query_tracking_new");
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
|
||||
let mut tracker = QueryTracker::new(temp_dir.to_str().unwrap(), true).unwrap();
|
||||
|
||||
let project_path = PathBuf::from("/test/project");
|
||||
let file_path = PathBuf::from("/test/project/src/main.rs");
|
||||
|
||||
// First completion
|
||||
tracker
|
||||
.track_query_completion("main", &project_path, &file_path)
|
||||
.unwrap();
|
||||
let boost = tracker
|
||||
.get_last_query_path("main", &project_path, &file_path, 10000)
|
||||
.unwrap();
|
||||
assert_eq!(boost, 0, "First completion should not boost");
|
||||
|
||||
// Second completion - should boost now
|
||||
tracker
|
||||
.track_query_completion("main", &project_path, &file_path)
|
||||
.unwrap();
|
||||
let boost = tracker
|
||||
.get_last_query_path("main", &project_path, &file_path, 10000)
|
||||
.unwrap();
|
||||
assert_eq!(boost, 10000, "Second completion should boost");
|
||||
|
||||
// Different file for same query - should reset count and no boost
|
||||
let other_file = PathBuf::from("/test/project/src/lib.rs");
|
||||
tracker
|
||||
.track_query_completion("main", &project_path, &other_file)
|
||||
.unwrap();
|
||||
let boost = tracker
|
||||
.get_last_query_path("main", &project_path, &other_file, 10000)
|
||||
.unwrap();
|
||||
assert_eq!(boost, 0, "Different file should reset boost");
|
||||
|
||||
// Original file should no longer get boost (replaced by new file)
|
||||
let boost = tracker
|
||||
.get_last_query_path("main", &project_path, &file_path, 10000)
|
||||
.unwrap();
|
||||
assert_eq!(boost, 0, "Original file should not boost after replacement");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hashing_functions() {
|
||||
let project_path = PathBuf::from("/test/project");
|
||||
|
||||
// Test project key hashing
|
||||
let key1 = QueryTracker::create_project_key(&project_path).unwrap();
|
||||
let key2 = QueryTracker::create_project_key(&project_path).unwrap();
|
||||
assert_eq!(key1, key2, "Same project should hash to same key");
|
||||
|
||||
// Test query key hashing
|
||||
let query_key1 = QueryTracker::create_query_key(&project_path, "test").unwrap();
|
||||
let query_key2 = QueryTracker::create_query_key(&project_path, "test").unwrap();
|
||||
assert_eq!(
|
||||
query_key1, query_key2,
|
||||
"Same project+query should hash to same key"
|
||||
);
|
||||
|
||||
// Different queries should hash differently
|
||||
let query_key3 = QueryTracker::create_query_key(&project_path, "different").unwrap();
|
||||
assert_ne!(
|
||||
query_key1, query_key3,
|
||||
"Different queries should hash to different keys"
|
||||
);
|
||||
|
||||
// Different projects should hash differently
|
||||
let other_project = PathBuf::from("/other/project");
|
||||
let query_key4 = QueryTracker::create_query_key(&other_project, "test").unwrap();
|
||||
assert_ne!(
|
||||
query_key1, query_key4,
|
||||
"Different projects should hash to different keys"
|
||||
);
|
||||
}
|
||||
}
|
||||
+375
-58
@@ -1,33 +1,48 @@
|
||||
use crate::{
|
||||
git::is_modified_status,
|
||||
path_utils::calculate_distance_penalty,
|
||||
sort_buffer::{sort_by_key_with_buffer, sort_with_buffer},
|
||||
types::{FileItem, Score, ScoringContext},
|
||||
};
|
||||
use neo_frizbee::Scoring;
|
||||
use rayon::prelude::*;
|
||||
use std::path::MAIN_SEPARATOR;
|
||||
|
||||
pub fn match_and_score_files(files: &[FileItem], context: &ScoringContext) -> Vec<(usize, Score)> {
|
||||
pub fn match_and_score_files<'a>(
|
||||
files: &'a [FileItem],
|
||||
context: &ScoringContext,
|
||||
) -> (Vec<&'a FileItem>, Vec<Score>, usize) {
|
||||
if context.query.len() < 2 {
|
||||
return score_all_by_frecency(files, context);
|
||||
}
|
||||
|
||||
if files.is_empty() {
|
||||
return Vec::new();
|
||||
return (vec![], vec![], 0);
|
||||
}
|
||||
|
||||
let options = neo_frizbee::Options {
|
||||
let has_uppercase_letter = context.query.chars().any(|c| c.is_uppercase());
|
||||
let options = neo_frizbee::Config {
|
||||
prefilter: true,
|
||||
max_typos: Some(context.max_typos),
|
||||
sort: false,
|
||||
scoring: Scoring {
|
||||
capitalization_bonus: if has_uppercase_letter { 8 } else { 0 },
|
||||
matching_case_bonus: if has_uppercase_letter { 4 } else { 0 },
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
|
||||
let haystack: Vec<&str> = files.iter().map(|f| f.relative_path.as_str()).collect();
|
||||
let query_contains_path_separator = context.query.contains(MAIN_SEPARATOR);
|
||||
let haystack: Vec<&str> = files
|
||||
.iter()
|
||||
.map(|f| f.relative_path_lower.as_str())
|
||||
.collect();
|
||||
tracing::debug!(
|
||||
"Starting fuzzy search for query '{}' in {} files",
|
||||
context.query,
|
||||
haystack.len()
|
||||
);
|
||||
let path_matches =
|
||||
neo_frizbee::match_list_parallel(context.query, &haystack, options, context.max_threads);
|
||||
let path_matches = neo_frizbee::match_list(context.query, &haystack, &options);
|
||||
tracing::debug!(
|
||||
"Matched {} files for query '{}'",
|
||||
path_matches.len(),
|
||||
@@ -37,42 +52,60 @@ pub fn match_and_score_files(files: &[FileItem], context: &ScoringContext) -> Ve
|
||||
// assume that filename should only match if the path matches
|
||||
// we should actually incorporate this bonus by getting this information from neo_frizbee directly
|
||||
// instead of spawning a separate matching process, but it's okay for the beta
|
||||
let haystack_of_filenames = path_matches
|
||||
.par_iter()
|
||||
// Use sequential iteration - this is a simple filtering operation that's faster without Rayon overhead
|
||||
let haystack_of_filenames: Vec<&str> = path_matches
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
files
|
||||
.get(m.index_in_haystack as usize)
|
||||
.map(|f| f.file_name.as_str())
|
||||
.get(m.index as usize)
|
||||
.map(|f| f.file_name_lower.as_str())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
.collect();
|
||||
|
||||
let mut filename_matches = neo_frizbee::match_list_parallel(
|
||||
context.query,
|
||||
&haystack_of_filenames,
|
||||
options,
|
||||
context.max_threads,
|
||||
);
|
||||
filename_matches.par_sort_by_key(|m| m.index_in_haystack);
|
||||
// if there is a / in the query we don't even match filenames
|
||||
let filename_matches = if query_contains_path_separator {
|
||||
vec![]
|
||||
} else {
|
||||
// Use parallel matching only if we have enough filenames to justify overhead
|
||||
// Sequential matching is faster for small result sets (< 1000 matches)
|
||||
let mut list = if haystack_of_filenames.len() > 1000 {
|
||||
neo_frizbee::match_list_parallel(
|
||||
context.query,
|
||||
&haystack_of_filenames,
|
||||
&options,
|
||||
context.max_threads,
|
||||
)
|
||||
} else {
|
||||
neo_frizbee::match_list(context.query, &haystack_of_filenames, &options)
|
||||
};
|
||||
|
||||
// Sequential sort is faster for small lists
|
||||
if list.len() > 1000 {
|
||||
list.par_sort_unstable_by_key(|m| m.index);
|
||||
} else {
|
||||
sort_by_key_with_buffer(&mut list, |m| m.index);
|
||||
}
|
||||
|
||||
list
|
||||
};
|
||||
|
||||
let mut next_filename_match_index = 0;
|
||||
let mut results: Vec<_> = path_matches
|
||||
let results: Vec<_> = path_matches
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, path_match)| {
|
||||
let file_idx = path_match.index_in_haystack as usize;
|
||||
let file_idx = path_match.index as usize;
|
||||
let file = &files[file_idx];
|
||||
|
||||
let mut base_score = path_match.score as i32;
|
||||
let frecency_boost = base_score.saturating_mul(file.total_frecency_score as i32) / 100;
|
||||
let distance_penalty = calculate_distance_penalty(
|
||||
context.current_file.map(|s| s.as_str()),
|
||||
&file.relative_path,
|
||||
);
|
||||
let distance_penalty =
|
||||
calculate_distance_penalty(context.current_file, &file.relative_path);
|
||||
|
||||
let filename_match = filename_matches
|
||||
.get(next_filename_match_index)
|
||||
.and_then(|m| {
|
||||
if m.index_in_haystack == index as u32 {
|
||||
if m.index == index as u32 {
|
||||
next_filename_match_index += 1;
|
||||
Some(m)
|
||||
} else {
|
||||
@@ -85,13 +118,21 @@ pub fn match_and_score_files(files: &[FileItem], context: &ScoringContext) -> Ve
|
||||
Some(filename_match) if filename_match.exact => {
|
||||
filename_match.score as i32 / 5 * 2 // 40% bonus for exact filename match
|
||||
}
|
||||
// 20% bonus for fuzzy filename match but only if the score of matched path is
|
||||
// 16% bonus for fuzzy filename match but only if the score of matched path is
|
||||
// equal or greater than the score of matched filename, thus we are not allowing
|
||||
// typoed filename to score higher than the path match
|
||||
Some(filename_match) if filename_match.score >= path_match.score => {
|
||||
Some(filename_match)
|
||||
if filename_match.score >= path_match.score
|
||||
&& !query_contains_path_separator =>
|
||||
{
|
||||
base_score = filename_match.score as i32;
|
||||
|
||||
base_score / 5
|
||||
(base_score / 6)
|
||||
// for large queries around ~300 score the bonus is too big
|
||||
// it might lead to situations when much more fitting path with a larger
|
||||
// base score getting filtered out by combination of score + filename bonus
|
||||
// so we cap it at 10% of the roughly largest score you can get
|
||||
.min(30)
|
||||
}
|
||||
// 5% bonus for special file but not as much as file name to avoid sitatuions
|
||||
// when you have /user_service/server.rs and /user_service/server/mod.rs
|
||||
@@ -102,14 +143,37 @@ pub fn match_and_score_files(files: &[FileItem], context: &ScoringContext) -> Ve
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
let current_file_penalty = calculate_current_file_penalty(file, base_score, context);
|
||||
|
||||
let combo_match_boost = {
|
||||
let last_same_query_match = context
|
||||
.last_same_query_match
|
||||
.filter(|m| m.file_path.as_os_str() == file.path.as_os_str());
|
||||
|
||||
match last_same_query_match {
|
||||
// if we request a combo match without a boost we have to render it anyway
|
||||
Some(_) if context.min_combo_count == 0 => 1000,
|
||||
Some(combo_match) if combo_match.open_count >= context.min_combo_count => {
|
||||
combo_match.open_count as i32 * context.combo_boost_score_multiplier
|
||||
}
|
||||
// until we hit the combo count threshold, we add a smaller boost because it
|
||||
// makes sense and makes the search more efficient
|
||||
Some(combo_match) => combo_match.open_count as i32 * 5,
|
||||
_ => 0,
|
||||
}
|
||||
};
|
||||
|
||||
let total = base_score
|
||||
.saturating_add(frecency_boost)
|
||||
.saturating_add(distance_penalty)
|
||||
.saturating_add(filename_bonus);
|
||||
.saturating_add(filename_bonus)
|
||||
.saturating_add(current_file_penalty)
|
||||
.saturating_add(combo_match_boost);
|
||||
|
||||
let score = Score {
|
||||
total,
|
||||
base_score,
|
||||
current_file_penalty,
|
||||
filename_bonus,
|
||||
special_filename_bonus: if has_special_filename_bonus {
|
||||
filename_bonus
|
||||
@@ -118,6 +182,8 @@ pub fn match_and_score_files(files: &[FileItem], context: &ScoringContext) -> Ve
|
||||
},
|
||||
frecency_boost,
|
||||
distance_penalty,
|
||||
combo_match_boost,
|
||||
exact_match: path_match.exact || filename_match.is_some_and(|m| m.exact),
|
||||
match_type: match filename_match {
|
||||
Some(filename_match) if filename_match.exact => "exact_filename",
|
||||
Some(_) => "fuzzy_filename",
|
||||
@@ -125,13 +191,11 @@ pub fn match_and_score_files(files: &[FileItem], context: &ScoringContext) -> Ve
|
||||
},
|
||||
};
|
||||
|
||||
(file_idx, score)
|
||||
(file, score)
|
||||
})
|
||||
.collect();
|
||||
|
||||
results.par_sort_by(|a, b| b.1.total.cmp(&a.1.total));
|
||||
|
||||
results
|
||||
sort_and_paginate(results, context)
|
||||
}
|
||||
|
||||
/// Check if a filename is a special entry point file that deserves bonus scoring
|
||||
@@ -159,50 +223,303 @@ fn is_special_entry_point_file(filename: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn score_all_by_frecency(files: &[FileItem], context: &ScoringContext) -> Vec<(usize, Score)> {
|
||||
files
|
||||
fn score_all_by_frecency<'a>(
|
||||
files: &'a [FileItem],
|
||||
context: &ScoringContext,
|
||||
) -> (Vec<&'a FileItem>, Vec<Score>, usize) {
|
||||
let results: Vec<_> = files
|
||||
.par_iter()
|
||||
.enumerate()
|
||||
.map(|(idx, file)| {
|
||||
.map(|file| {
|
||||
let total_frecency_score = file.access_frecency_score as i32
|
||||
+ (file.modification_frecency_score as i32).saturating_mul(4);
|
||||
|
||||
let distance_penalty = calculate_distance_penalty(
|
||||
context.current_file.map(|x| x.as_str()),
|
||||
&file.relative_path,
|
||||
);
|
||||
|
||||
let total = total_frecency_score
|
||||
.saturating_add(distance_penalty)
|
||||
.saturating_add(calculate_file_bonus(file, context));
|
||||
let current_file_penalty =
|
||||
calculate_current_file_penalty(file, total_frecency_score, context);
|
||||
let total = total_frecency_score.saturating_add(current_file_penalty);
|
||||
|
||||
let score = Score {
|
||||
total,
|
||||
base_score: 0,
|
||||
filename_bonus: 0,
|
||||
distance_penalty: 0,
|
||||
special_filename_bonus: 0,
|
||||
combo_match_boost: 0,
|
||||
current_file_penalty,
|
||||
frecency_boost: total_frecency_score,
|
||||
distance_penalty,
|
||||
exact_match: false,
|
||||
match_type: "frecency",
|
||||
};
|
||||
|
||||
(idx, score)
|
||||
(file, score)
|
||||
})
|
||||
.collect()
|
||||
.collect();
|
||||
|
||||
sort_and_paginate(results, context)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn calculate_file_bonus(file: &FileItem, context: &ScoringContext) -> i32 {
|
||||
let mut bonus = 0i32;
|
||||
fn calculate_current_file_penalty(
|
||||
file: &FileItem,
|
||||
base_score: i32,
|
||||
context: &ScoringContext,
|
||||
) -> i32 {
|
||||
let mut penalty = 0i32;
|
||||
|
||||
if let Some(current) = context.current_file {
|
||||
if file.relative_path == *current {
|
||||
bonus -= match file.git_status {
|
||||
Some(status) if is_modified_status(status) => 150,
|
||||
_ => 300,
|
||||
};
|
||||
}
|
||||
if let Some(current) = context.current_file
|
||||
&& file.relative_path.as_str() == current
|
||||
{
|
||||
penalty -= match file.git_status {
|
||||
Some(status) if is_modified_status(status) => base_score / 2,
|
||||
_ => base_score,
|
||||
};
|
||||
}
|
||||
|
||||
bonus
|
||||
penalty
|
||||
}
|
||||
|
||||
/// Sorts elements by total score (descending) and returns the requested page.
|
||||
/// Always returns results in descending order (best scores first).
|
||||
/// The UI layer handles rendering order based on prompt position.
|
||||
#[tracing::instrument(skip_all, level = tracing::Level::DEBUG)]
|
||||
fn sort_and_paginate<'a>(
|
||||
mut results: Vec<(&'a FileItem, Score)>,
|
||||
context: &ScoringContext,
|
||||
) -> (Vec<&'a FileItem>, Vec<Score>, usize) {
|
||||
let total_matched = results.len();
|
||||
|
||||
if total_matched == 0 {
|
||||
return (vec![], vec![], 0);
|
||||
}
|
||||
|
||||
let offset = context.pagination.offset;
|
||||
let limit = if context.pagination.limit > 0 {
|
||||
context.pagination.limit
|
||||
} else {
|
||||
total_matched
|
||||
};
|
||||
|
||||
// Check if offset is out of bounds
|
||||
if offset >= total_matched {
|
||||
tracing::warn!(
|
||||
offset = offset,
|
||||
total_matched = total_matched,
|
||||
"Pagination: offset >= total_matched, returning empty"
|
||||
);
|
||||
|
||||
return (vec![], vec![], total_matched);
|
||||
}
|
||||
|
||||
let items_needed = offset.saturating_add(limit).min(total_matched);
|
||||
// Use partial sort if we need less than half the results and dataset is large
|
||||
let use_partial_sort = items_needed < total_matched / 2 && total_matched > 100;
|
||||
// Always sort in descending order (best scores first)
|
||||
if use_partial_sort {
|
||||
// Partition at position (items_needed - 1) with descending comparator
|
||||
// This puts the highest N needed items at the front
|
||||
results.select_nth_unstable_by(items_needed - 1, |a, b| {
|
||||
b.1.total
|
||||
.cmp(&a.1.total)
|
||||
.then_with(|| b.0.modified.cmp(&a.0.modified))
|
||||
});
|
||||
results.truncate(items_needed);
|
||||
}
|
||||
|
||||
// select nth does not sort the results, we have to sort accordingly anyway
|
||||
sort_with_buffer(&mut results, |a, b| {
|
||||
b.1.total
|
||||
.cmp(&a.1.total)
|
||||
.then_with(|| b.0.modified.cmp(&a.0.modified))
|
||||
});
|
||||
|
||||
// in the best scenario truncation happened in the select_nth step
|
||||
if results.len() > limit {
|
||||
let page_end = std::cmp::min(offset + limit, results.len());
|
||||
let page_size = page_end - offset;
|
||||
|
||||
results.drain(0..offset);
|
||||
results.truncate(page_size);
|
||||
}
|
||||
|
||||
let (items, scores): (Vec<&FileItem>, Vec<Score>) = results.into_iter().unzip();
|
||||
(items, scores, total_matched)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::PaginationArgs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn create_test_file(path: &str, score: i32, modified: u64) -> (FileItem, Score) {
|
||||
let file = FileItem {
|
||||
path: PathBuf::from(path),
|
||||
relative_path: path.to_string(),
|
||||
relative_path_lower: path.to_lowercase(),
|
||||
file_name: path.split('/').last().unwrap_or(path).to_string(),
|
||||
file_name_lower: path.split('/').last().unwrap_or(path).to_lowercase(),
|
||||
size: 0,
|
||||
modified,
|
||||
access_frecency_score: 0,
|
||||
modification_frecency_score: 0,
|
||||
total_frecency_score: 0,
|
||||
git_status: None,
|
||||
};
|
||||
let score_obj = Score {
|
||||
total: score,
|
||||
base_score: score,
|
||||
filename_bonus: 0,
|
||||
distance_penalty: 0,
|
||||
special_filename_bonus: 0,
|
||||
current_file_penalty: 0,
|
||||
frecency_boost: 0,
|
||||
exact_match: false,
|
||||
match_type: "test",
|
||||
combo_match_boost: 0,
|
||||
};
|
||||
(file, score_obj)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_partial_sort_descending() {
|
||||
// Create test data with known scores
|
||||
let test_data = vec![
|
||||
create_test_file("file1.rs", 100, 1000),
|
||||
create_test_file("file2.rs", 200, 2000),
|
||||
create_test_file("file3.rs", 50, 3000),
|
||||
create_test_file("file4.rs", 300, 4000),
|
||||
create_test_file("file5.rs", 150, 5000),
|
||||
create_test_file("file6.rs", 250, 6000),
|
||||
create_test_file("file7.rs", 80, 7000),
|
||||
create_test_file("file8.rs", 180, 8000),
|
||||
create_test_file("file9.rs", 120, 9000),
|
||||
create_test_file("file10.rs", 90, 10000),
|
||||
];
|
||||
|
||||
// Convert to references like the actual function uses
|
||||
let results: Vec<(&FileItem, Score)> = test_data
|
||||
.iter()
|
||||
.map(|(file, score)| (file, score.clone()))
|
||||
.collect();
|
||||
|
||||
let context = ScoringContext {
|
||||
query: "test",
|
||||
max_threads: 1,
|
||||
max_typos: 2,
|
||||
current_file: None,
|
||||
last_same_query_match: None,
|
||||
project_path: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 0,
|
||||
},
|
||||
};
|
||||
|
||||
// Test with full sort - returns all results sorted descending
|
||||
let (items, scores, total) = sort_and_paginate(results.clone(), &context);
|
||||
|
||||
// Should return all 10 items sorted by score descending
|
||||
assert_eq!(total, 10);
|
||||
assert_eq!(scores.len(), 10);
|
||||
assert_eq!(scores[0].total, 300, "First should be highest score");
|
||||
assert_eq!(scores[1].total, 250, "Second should be second highest");
|
||||
assert_eq!(scores[2].total, 200, "Third should be third highest");
|
||||
|
||||
// Verify the files match
|
||||
assert_eq!(items[0].relative_path, "file4.rs");
|
||||
assert_eq!(items[1].relative_path, "file6.rs");
|
||||
assert_eq!(items[2].relative_path, "file2.rs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_partial_sort_with_same_scores() {
|
||||
// Test tiebreaker with modified time
|
||||
let test_data = vec![
|
||||
create_test_file("file1.rs", 100, 5000), // Same score, older
|
||||
create_test_file("file2.rs", 100, 8000), // Same score, newer
|
||||
create_test_file("file3.rs", 100, 3000), // Same score, oldest
|
||||
create_test_file("file4.rs", 200, 1000),
|
||||
create_test_file("file5.rs", 200, 9000), // Higher score, newest
|
||||
];
|
||||
|
||||
let results: Vec<(&FileItem, Score)> = test_data
|
||||
.iter()
|
||||
.map(|(file, score)| (file, score.clone()))
|
||||
.collect();
|
||||
|
||||
let context = ScoringContext {
|
||||
query: "test",
|
||||
max_threads: 1,
|
||||
max_typos: 2,
|
||||
current_file: None,
|
||||
last_same_query_match: None,
|
||||
project_path: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 0,
|
||||
},
|
||||
};
|
||||
|
||||
let (items, scores, _) = sort_and_paginate(results, &context);
|
||||
|
||||
// Should return all 5 items sorted: 200(9000), 200(1000), 100(8000), 100(5000), 100(3000)
|
||||
assert_eq!(scores.len(), 5);
|
||||
assert_eq!(scores[0].total, 200);
|
||||
assert_eq!(items[0].modified, 9000, "First 200 should be newest");
|
||||
assert_eq!(scores[1].total, 200);
|
||||
assert_eq!(items[1].modified, 1000, "Second 200 should be older");
|
||||
assert_eq!(scores[2].total, 100);
|
||||
assert_eq!(items[2].modified, 8000, "First 100 should be newest");
|
||||
assert_eq!(scores[3].total, 100);
|
||||
assert_eq!(items[3].modified, 5000);
|
||||
assert_eq!(scores[4].total, 100);
|
||||
assert_eq!(items[4].modified, 3000, "Last 100 should be oldest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_partial_sort_for_small_results() {
|
||||
// When results.len() <= threshold, should use regular sort
|
||||
let test_data = vec![
|
||||
create_test_file("file1.rs", 100, 1000),
|
||||
create_test_file("file2.rs", 200, 2000),
|
||||
create_test_file("file3.rs", 50, 3000),
|
||||
];
|
||||
|
||||
let results: Vec<(&FileItem, Score)> = test_data
|
||||
.iter()
|
||||
.map(|(file, score)| (file, score.clone()))
|
||||
.collect();
|
||||
|
||||
let context = ScoringContext {
|
||||
query: "test",
|
||||
max_threads: 1,
|
||||
max_typos: 2,
|
||||
current_file: None,
|
||||
last_same_query_match: None,
|
||||
project_path: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 0,
|
||||
},
|
||||
};
|
||||
|
||||
// Returns all results sorted descending
|
||||
let (items, scores, _) = sort_and_paginate(results, &context);
|
||||
|
||||
assert_eq!(scores.len(), 3);
|
||||
assert_eq!(scores[0].total, 200);
|
||||
assert_eq!(scores[1].total, 100);
|
||||
assert_eq!(scores[2].total, 50);
|
||||
assert_eq!(items[0].relative_path, "file2.rs");
|
||||
assert_eq!(items[1].relative_path, "file1.rs");
|
||||
assert_eq!(items[2].relative_path, "file3.rs");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
//! Thread-local sort buffer management for glidesort optimization
|
||||
//!
|
||||
//! This module provides thread-local buffers for glidesort's with_buffer API,
|
||||
//! eliminating allocations in the hot path of fuzzy search operations.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::mem::MaybeUninit;
|
||||
|
||||
// glidesort requires a buffer to allocate, we use one reused buffer as it can grow pretty big
|
||||
// for a large projects, this effectively saves 12kb of allocation on every search in linux repo
|
||||
thread_local! {
|
||||
static SORT_BUFFER: RefCell<Vec<u8>> = RefCell::new(Vec::with_capacity(1024));
|
||||
}
|
||||
|
||||
pub fn sort_with_buffer<T, F>(slice: &mut [T], compare: F)
|
||||
where
|
||||
F: FnMut(&T, &T) -> std::cmp::Ordering,
|
||||
{
|
||||
SORT_BUFFER.with(|buffer| {
|
||||
let mut buffer = buffer.borrow_mut();
|
||||
|
||||
// Calculate required buffer size in u8 units
|
||||
let size_of_t = std::mem::size_of::<MaybeUninit<T>>();
|
||||
let size_of_usize = std::mem::size_of::<u8>();
|
||||
let required_usizes = (slice.len() * size_of_t).div_ceil(size_of_usize);
|
||||
|
||||
// Ensure buffer has enough capacity
|
||||
if buffer.len() < required_usizes {
|
||||
buffer.resize(required_usizes, 0);
|
||||
}
|
||||
|
||||
// Cast u8 buffer to MaybeUninit<T> slice
|
||||
// SAFETY: u8 provides sufficient alignment for most types, and we've ensured
|
||||
// the buffer is large enough
|
||||
let typed_buffer = unsafe {
|
||||
std::slice::from_raw_parts_mut(buffer.as_mut_ptr() as *mut MaybeUninit<T>, slice.len())
|
||||
};
|
||||
|
||||
glidesort::sort_with_buffer_by(slice, typed_buffer, compare);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn sort_by_key_with_buffer<T, K, F>(slice: &mut [T], key_fn: F)
|
||||
where
|
||||
K: Ord,
|
||||
F: FnMut(&T) -> K,
|
||||
{
|
||||
SORT_BUFFER.with(|buffer| {
|
||||
let mut buffer = buffer.borrow_mut();
|
||||
|
||||
// Calculate required buffer size in u8 units
|
||||
let size_of_t = std::mem::size_of::<MaybeUninit<T>>();
|
||||
let size_of_usize = std::mem::size_of::<u8>();
|
||||
let required_usizes = (slice.len() * size_of_t).div_ceil(size_of_usize);
|
||||
|
||||
// Ensure buffer has enough capacity
|
||||
if buffer.len() < required_usizes {
|
||||
buffer.resize(required_usizes, 0);
|
||||
}
|
||||
|
||||
// Cast u8 buffer to MaybeUninit<T> slice
|
||||
// SAFETY: u8 provides sufficient alignment for most types, and we've ensured
|
||||
// the buffer is large enough
|
||||
let typed_buffer = unsafe {
|
||||
std::slice::from_raw_parts_mut(buffer.as_mut_ptr() as *mut MaybeUninit<T>, slice.len())
|
||||
};
|
||||
|
||||
glidesort::sort_with_buffer_by_key(slice, typed_buffer, key_fn);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_sort_with_buffer() {
|
||||
let mut data = vec![5, 2, 8, 1, 9];
|
||||
sort_with_buffer(&mut data, |a, b| a.cmp(b));
|
||||
assert_eq!(data, vec![1, 2, 5, 8, 9]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sort_by_key_with_buffer() {
|
||||
let mut data = vec![(2, "b"), (1, "a"), (3, "c")];
|
||||
sort_by_key_with_buffer(&mut data, |item| item.0);
|
||||
assert_eq!(data, vec![(1, "a"), (2, "b"), (3, "c")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reverse_sort() {
|
||||
let mut data = vec![1, 2, 3, 4, 5];
|
||||
sort_with_buffer(&mut data, |a, b| b.cmp(a));
|
||||
assert_eq!(data, vec![5, 4, 3, 2, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_sorts_reuse_buffer() {
|
||||
// This test verifies that multiple sorts on the same thread reuse the buffer
|
||||
let mut data1 = vec![5, 2, 8, 1, 9];
|
||||
sort_with_buffer(&mut data1, |a, b| a.cmp(b));
|
||||
|
||||
let mut data2 = vec![15, 12, 18, 11, 19];
|
||||
sort_with_buffer(&mut data2, |a, b| a.cmp(b));
|
||||
|
||||
assert_eq!(data1, vec![1, 2, 5, 8, 9]);
|
||||
assert_eq!(data2, vec![11, 12, 15, 18, 19]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_slice() {
|
||||
let mut data: Vec<i32> = vec![];
|
||||
sort_with_buffer(&mut data, |a, b| a.cmp(b));
|
||||
assert_eq!(data, Vec::<i32>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_element() {
|
||||
let mut data = vec![42];
|
||||
sort_with_buffer(&mut data, |a, b| a.cmp(b));
|
||||
assert_eq!(data, vec![42]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_already_sorted() {
|
||||
let mut data = vec![1, 2, 3, 4, 5];
|
||||
sort_with_buffer(&mut data, |a, b| a.cmp(b));
|
||||
assert_eq!(data, vec![1, 2, 3, 4, 5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_duplicates() {
|
||||
let mut data = vec![3, 1, 4, 1, 5, 9, 2, 6, 5];
|
||||
sort_with_buffer(&mut data, |a, b| a.cmp(b));
|
||||
assert_eq!(data, vec![1, 1, 2, 3, 4, 5, 5, 6, 9]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_descending_order() {
|
||||
let mut data = vec![3, 1, 4, 1, 5, 9, 2, 6, 5];
|
||||
sort_with_buffer(&mut data, |a, b| b.cmp(a));
|
||||
assert_eq!(data, vec![9, 6, 5, 5, 4, 3, 2, 1, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_simple_descending() {
|
||||
// Simple test to verify highest scores come first
|
||||
let mut data = vec![100, 300, 200];
|
||||
sort_with_buffer(&mut data, |a, b| b.cmp(a));
|
||||
assert_eq!(data[0], 300, "Highest should be first");
|
||||
assert_eq!(data[1], 200, "Middle should be second");
|
||||
assert_eq!(data[2], 100, "Lowest should be last");
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
use crate::error::Error;
|
||||
use std::path::Path;
|
||||
use tracing_appender::non_blocking;
|
||||
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
|
||||
|
||||
static TRACING_INITIALIZED: std::sync::Once = std::sync::Once::new();
|
||||
|
||||
/// Initialize tracing with single log file
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `log_file_path` - Full path to the log file
|
||||
/// * `log_level` - Log level (trace, debug, info, warn, error)
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Result<String, Error>` - Full path to the log file on success
|
||||
pub fn init_tracing(log_file_path: &str, log_level: &str) -> Result<String, Error> {
|
||||
let log_path = Path::new(log_file_path);
|
||||
|
||||
if let Some(parent) = log_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let log_file_path_clone = log_file_path.to_string();
|
||||
|
||||
// Parse log level, default to INFO if invalid
|
||||
let level = match log_level.to_lowercase().as_str() {
|
||||
"trace" => tracing::Level::TRACE,
|
||||
"debug" => tracing::Level::DEBUG,
|
||||
"info" => tracing::Level::INFO,
|
||||
"warn" => tracing::Level::WARN,
|
||||
"error" => tracing::Level::ERROR,
|
||||
_ => {
|
||||
eprintln!("Invalid log level '{}', defaulting to 'info'", log_level);
|
||||
tracing::Level::INFO
|
||||
}
|
||||
};
|
||||
|
||||
TRACING_INITIALIZED.call_once(|| {
|
||||
let file_appender = std::fs::File::create(&log_file_path_clone).unwrap();
|
||||
let (non_blocking_appender, _guard) = non_blocking(file_appender);
|
||||
|
||||
let subscriber = tracing_subscriber::registry()
|
||||
.with(
|
||||
fmt::layer()
|
||||
.with_writer(non_blocking_appender)
|
||||
.with_target(true)
|
||||
.with_thread_ids(false)
|
||||
.with_thread_names(false)
|
||||
.with_file(true)
|
||||
.with_line_number(true)
|
||||
.with_ansi(false), // No ANSI colors in log files
|
||||
)
|
||||
.with(
|
||||
EnvFilter::builder()
|
||||
.with_default_directive(level.into())
|
||||
.from_env_lossy(),
|
||||
);
|
||||
|
||||
if let Err(e) = tracing::subscriber::set_global_default(subscriber) {
|
||||
eprintln!("Failed to set tracing subscriber: {}", e);
|
||||
} else {
|
||||
tracing::info!(
|
||||
"FFF.nvim tracing initialized with log file: {}",
|
||||
log_file_path_clone
|
||||
);
|
||||
}
|
||||
|
||||
std::panic::set_hook(Box::new(|panic_info| {
|
||||
let payload = panic_info.payload();
|
||||
let message = if let Some(s) = payload.downcast_ref::<&str>() {
|
||||
s.to_string()
|
||||
} else if let Some(s) = payload.downcast_ref::<String>() {
|
||||
s.clone()
|
||||
} else {
|
||||
"Unknown panic payload".to_string()
|
||||
};
|
||||
|
||||
let location = if let Some(location) = panic_info.location() {
|
||||
format!(
|
||||
"{}:{}:{}",
|
||||
location.file(),
|
||||
location.line(),
|
||||
location.column()
|
||||
)
|
||||
} else {
|
||||
"unknown location".to_string()
|
||||
};
|
||||
|
||||
tracing::error!(
|
||||
panic.message = %message,
|
||||
panic.location = %location,
|
||||
"PANIC occurred in FFF.nvim"
|
||||
);
|
||||
|
||||
eprintln!("FFF.nvim PANIC: {} at {}", message, location);
|
||||
}));
|
||||
|
||||
// keep the guard alive by leaking it's okaaaaaaaaaaaay
|
||||
std::mem::forget(_guard);
|
||||
});
|
||||
|
||||
Ok(log_file_path.to_string())
|
||||
}
|
||||
+61
-15
@@ -1,22 +1,21 @@
|
||||
use mlua::prelude::*;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::git::format_git_status;
|
||||
use crate::{git::format_git_status, location::Location, query_tracker::QueryMatchEntry};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileItem {
|
||||
pub path: PathBuf,
|
||||
pub relative_path: String,
|
||||
pub relative_path_lower: String,
|
||||
pub file_name: String,
|
||||
pub extension: String,
|
||||
pub directory: String,
|
||||
pub file_name_lower: String,
|
||||
pub size: u64,
|
||||
pub modified: u64,
|
||||
pub access_frecency_score: i64,
|
||||
pub modification_frecency_score: i64,
|
||||
pub total_frecency_score: i64,
|
||||
pub git_status: Option<git2::Status>,
|
||||
pub is_current_file: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -27,33 +26,46 @@ pub struct Score {
|
||||
pub special_filename_bonus: i32,
|
||||
pub frecency_boost: i32,
|
||||
pub distance_penalty: i32,
|
||||
pub current_file_penalty: i32,
|
||||
pub combo_match_boost: i32,
|
||||
pub exact_match: bool,
|
||||
pub match_type: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PaginationArgs {
|
||||
pub offset: usize,
|
||||
pub limit: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScoringContext<'a> {
|
||||
pub query: &'a str,
|
||||
pub current_file: Option<&'a String>,
|
||||
pub project_path: Option<&'a Path>,
|
||||
pub current_file: Option<&'a str>,
|
||||
pub max_typos: u16,
|
||||
pub max_threads: usize,
|
||||
pub last_same_query_match: Option<&'a QueryMatchEntry>,
|
||||
pub combo_boost_score_multiplier: i32,
|
||||
pub min_combo_count: u32,
|
||||
pub pagination: PaginationArgs,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SearchResult {
|
||||
pub items: Vec<FileItem>,
|
||||
pub struct SearchResult<'a> {
|
||||
pub items: Vec<&'a FileItem>,
|
||||
pub scores: Vec<Score>,
|
||||
pub total_matched: usize,
|
||||
pub total_files: usize,
|
||||
pub location: Option<Location>,
|
||||
}
|
||||
|
||||
impl IntoLua for FileItem {
|
||||
impl IntoLua for &FileItem {
|
||||
fn into_lua(self, lua: &Lua) -> LuaResult<LuaValue> {
|
||||
let table = lua.create_table()?;
|
||||
table.set("path", self.path.to_string_lossy().to_string())?;
|
||||
table.set("relative_path", self.relative_path)?;
|
||||
table.set("name", self.file_name)?;
|
||||
table.set("extension", self.extension)?;
|
||||
table.set("directory", self.directory)?;
|
||||
table.set("relative_path", self.relative_path.clone())?;
|
||||
table.set("name", self.file_name.clone())?;
|
||||
table.set("size", self.size)?;
|
||||
table.set("modified", self.modified)?;
|
||||
table.set("access_frecency_score", self.access_frecency_score)?;
|
||||
@@ -63,7 +75,6 @@ impl IntoLua for FileItem {
|
||||
)?;
|
||||
table.set("total_frecency_score", self.total_frecency_score)?;
|
||||
table.set("git_status", format_git_status(self.git_status))?;
|
||||
table.set("is_current_file", self.is_current_file)?;
|
||||
Ok(LuaValue::Table(table))
|
||||
}
|
||||
}
|
||||
@@ -77,18 +88,53 @@ impl IntoLua for Score {
|
||||
table.set("special_filename_bonus", self.special_filename_bonus)?;
|
||||
table.set("frecency_boost", self.frecency_boost)?;
|
||||
table.set("distance_penalty", self.distance_penalty)?;
|
||||
table.set("current_file_penalty", self.current_file_penalty)?;
|
||||
table.set("combo_match_boost", self.combo_match_boost)?;
|
||||
table.set("match_type", self.match_type)?;
|
||||
table.set("exact_match", self.exact_match)?;
|
||||
Ok(LuaValue::Table(table))
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoLua for SearchResult {
|
||||
struct LuaPosition((i32, i32));
|
||||
|
||||
impl IntoLua for LuaPosition {
|
||||
fn into_lua(self, lua: &Lua) -> LuaResult<LuaValue> {
|
||||
let table = lua.create_table()?;
|
||||
table.set("line", self.0.0)?;
|
||||
table.set("col", self.0.1)?;
|
||||
Ok(LuaValue::Table(table))
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoLua for SearchResult<'_> {
|
||||
fn into_lua(self, lua: &Lua) -> LuaResult<LuaValue> {
|
||||
let table = lua.create_table()?;
|
||||
table.set("items", self.items)?;
|
||||
table.set("scores", self.scores)?;
|
||||
table.set("total_matched", self.total_matched)?;
|
||||
table.set("total_files", self.total_files)?;
|
||||
|
||||
if let Some(location) = &self.location {
|
||||
let location_table = lua.create_table()?;
|
||||
|
||||
match location {
|
||||
Location::Line(line) => {
|
||||
location_table.set("line", *line)?;
|
||||
}
|
||||
Location::Position { line, col } => {
|
||||
location_table.set("line", *line)?;
|
||||
location_table.set("col", *col)?;
|
||||
}
|
||||
Location::Range { start, end } => {
|
||||
location_table.set("start", LuaPosition(*start))?;
|
||||
location_table.set("end", LuaPosition(*end))?;
|
||||
}
|
||||
}
|
||||
|
||||
table.set("location", location_table)?;
|
||||
}
|
||||
|
||||
Ok(LuaValue::Table(table))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
--- Scrollbar module for pagination indicator
|
||||
local M = {}
|
||||
|
||||
-- Internal state
|
||||
local scrollbar_state = {
|
||||
win = nil,
|
||||
buf = nil,
|
||||
ever_shown = false,
|
||||
}
|
||||
|
||||
local ns_id = vim.api.nvim_create_namespace('fff_scrollbar')
|
||||
|
||||
--- Render the scrollbar to show current page position
|
||||
--- Creates the window lazily if needed
|
||||
--- @param layout table Layout info with list_col, list_row, list_width, list_height, show_scrollbar
|
||||
--- @param config table Config with hl (highlight groups)
|
||||
--- @param list_win number List window handle
|
||||
--- @param pagination table Pagination state with page_index, page_size, total_matched
|
||||
--- @param prompt_position string|nil Prompt position ('top' or 'bottom', defaults to 'bottom')
|
||||
function M.render(layout, config, list_win, pagination, prompt_position)
|
||||
if layout.show_scrollbar == false then return end
|
||||
|
||||
-- this is the most often path, we don't want to show scrollbar if use doesn't scrolling
|
||||
if not scrollbar_state.ever_shown and pagination.page_index == 0 then return end
|
||||
|
||||
prompt_position = prompt_position or 'bottom'
|
||||
|
||||
local total_pages = pagination.page_size > 0 and math.ceil(pagination.total_matched / pagination.page_size) or 1
|
||||
local has_multiple_pages = total_pages > 1
|
||||
local scrollbar_exists = scrollbar_state.win and vim.api.nvim_win_is_valid(scrollbar_state.win)
|
||||
|
||||
-- If only one page, hide existing scrollbar and return
|
||||
if not has_multiple_pages then
|
||||
if scrollbar_exists then pcall(vim.api.nvim_win_hide, scrollbar_state.win) end
|
||||
return
|
||||
end
|
||||
|
||||
-- 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')
|
||||
|
||||
scrollbar_state.win = vim.api.nvim_open_win(scrollbar_state.buf, false, {
|
||||
relative = 'editor',
|
||||
width = 1,
|
||||
height = layout.list_height,
|
||||
col = layout.list_col + layout.list_width + 1,
|
||||
row = layout.list_row + 1,
|
||||
border = 'none',
|
||||
style = 'minimal',
|
||||
focusable = false,
|
||||
})
|
||||
|
||||
local scrollbar_hl = string.format('Normal:%s', config.hl.border)
|
||||
vim.api.nvim_win_set_option(scrollbar_state.win, 'winhighlight', scrollbar_hl)
|
||||
|
||||
scrollbar_state.ever_shown = true
|
||||
end
|
||||
|
||||
if not scrollbar_state.buf or not vim.api.nvim_buf_is_valid(scrollbar_state.buf) then return end
|
||||
pcall(vim.api.nvim_win_set_config, scrollbar_state.win, { hide = false })
|
||||
|
||||
local win_height = vim.api.nvim_win_get_height(list_win)
|
||||
|
||||
local thumb_size = math.max(1, math.floor(win_height / total_pages))
|
||||
local scrollbar_range = win_height - thumb_size
|
||||
|
||||
-- inverse the scrollbar when the position is at the bottom
|
||||
local thumb_start
|
||||
if prompt_position == 'bottom' then
|
||||
thumb_start =
|
||||
math.floor(((total_pages - 1 - pagination.page_index) / math.max(1, total_pages - 1)) * scrollbar_range)
|
||||
else
|
||||
thumb_start = math.floor((pagination.page_index / math.max(1, total_pages - 1)) * scrollbar_range)
|
||||
end
|
||||
|
||||
local lines = {}
|
||||
for i = 1, win_height do
|
||||
if i >= thumb_start + 1 and i < thumb_start + thumb_size + 1 then
|
||||
table.insert(lines, '▊') -- Thick block for thumb
|
||||
else
|
||||
table.insert(lines, '│') -- Thin line for track
|
||||
end
|
||||
end
|
||||
|
||||
pcall(vim.api.nvim_buf_set_option, scrollbar_state.buf, 'modifiable', true)
|
||||
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_buf_clear_namespace, scrollbar_state.buf, ns_id, 0, -1)
|
||||
if thumb_size > 0 then
|
||||
pcall(vim.api.nvim_buf_set_extmark, scrollbar_state.buf, ns_id, thumb_start, 0, {
|
||||
end_row = thumb_start + thumb_size,
|
||||
end_col = 0,
|
||||
hl_group = config.hl.scrollbar,
|
||||
hl_eol = true,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
function M.cleanup()
|
||||
if scrollbar_state.win and vim.api.nvim_win_is_valid(scrollbar_state.win) then
|
||||
pcall(vim.api.nvim_win_close, scrollbar_state.win, true)
|
||||
end
|
||||
|
||||
if scrollbar_state.buf and vim.api.nvim_buf_is_valid(scrollbar_state.buf) then
|
||||
pcall(vim.api.nvim_buf_delete, scrollbar_state.buf, { force = true })
|
||||
end
|
||||
|
||||
scrollbar_state.win = nil
|
||||
scrollbar_state.buf = nil
|
||||
scrollbar_state.ever_shown = false
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -17,4 +17,77 @@ function M.format_file_size(size)
|
||||
end
|
||||
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
|
||||
--- @param terminal_height number Terminal height for function calls
|
||||
--- @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
|
||||
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)
|
||||
|
||||
if success and validator(result) then
|
||||
return result
|
||||
else
|
||||
if not success then
|
||||
vim.notify('FFF: Error in ' .. error_context .. ' function: ' .. tostring(result), vim.log.levels.WARN)
|
||||
end
|
||||
return fallback
|
||||
end
|
||||
else
|
||||
return config_value
|
||||
end
|
||||
end
|
||||
|
||||
--- Validate numeric ratio (0 < value <= 1)
|
||||
--- @param value any Value to validate
|
||||
--- @return boolean True if valid numeric ratio
|
||||
function M.is_valid_ratio(value) return type(value) == 'number' and value > 0 and value <= 1 end
|
||||
|
||||
--- Validate position string
|
||||
--- @param value any Value to validate
|
||||
--- @param values table List of valid values strings
|
||||
--- @return boolean True if valid position
|
||||
function M.is_one_of(value, values)
|
||||
if type(value) ~= 'string' then return false end
|
||||
for _, pos in ipairs(values) do
|
||||
if value == pos then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Recursively create directories using uv.fs_mkdir
|
||||
local function mkdir_recursive(path, callback)
|
||||
vim.uv.fs_stat(path, function(err, stat)
|
||||
if not err and stat then
|
||||
callback(true, nil)
|
||||
return
|
||||
end
|
||||
|
||||
local parent = vim.fn.fnamemodify(path, ':h')
|
||||
if parent == path or parent == '' or parent == '.' then
|
||||
callback(false, 'Cannot create root directory')
|
||||
return
|
||||
end
|
||||
|
||||
mkdir_recursive(parent, function(parent_ok, parent_err)
|
||||
if not parent_ok then
|
||||
callback(false, parent_err)
|
||||
return
|
||||
end
|
||||
|
||||
uv.fs_mkdir(path, 493, function(mkdir_err) -- 493 = 0755 octal
|
||||
if mkdir_err and not mkdir_err:match('EEXIST') then
|
||||
callback(false, 'Failed to create directory: ' .. mkdir_err)
|
||||
return
|
||||
end
|
||||
callback(true, nil)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
local M = {}
|
||||
|
||||
local uv = vim and vim.uv or require('luv')
|
||||
|
||||
-- Get the system triple (target triple for the current platform)
|
||||
function M.get_triple()
|
||||
local os_name = uv.os_uname().sysname:lower()
|
||||
local arch = uv.os_uname().machine:lower()
|
||||
|
||||
-- Normalize OS name
|
||||
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'
|
||||
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'
|
||||
end
|
||||
|
||||
-- Normalize architecture
|
||||
if arch == 'x86_64' or arch == 'amd64' then
|
||||
arch = 'x86_64'
|
||||
elseif arch == 'aarch64' or arch == 'arm64' then
|
||||
arch = 'aarch64'
|
||||
elseif arch:match('^arm') then
|
||||
arch = 'arm'
|
||||
end
|
||||
|
||||
return arch .. '-' .. os_name
|
||||
end
|
||||
|
||||
-- Get the library extension for the current platform
|
||||
function M.get_lib_extension()
|
||||
local os_name = uv.os_uname().sysname:lower()
|
||||
if os_name == 'darwin' then
|
||||
return 'dylib'
|
||||
elseif os_name:match('windows') or os_name:match('mingw') or os_name:match('msys') then
|
||||
return 'dll'
|
||||
else
|
||||
return 'so'
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
+104
-1
@@ -1 +1,104 @@
|
||||
require('fff.main')
|
||||
if vim.g.fff_loaded then return end
|
||||
vim.g.fff_loaded = true
|
||||
|
||||
-- Defer indexing until after UIEnter, so as to not block the UI.
|
||||
-- This is equivalent to lazy.nvim's VeryLazy, but works with all plugin managers.
|
||||
|
||||
local init = vim.schedule_wrap(function()
|
||||
if vim.v.exiting ~= vim.NIL then return end
|
||||
-- PERF: We query the vim.g.fff config to avoid eagerly requiring Lua modules
|
||||
local lazy_sync = vim.tbl_get(vim.g, 'fff', 'lazy_sync')
|
||||
if lazy_sync == nil or not lazy_sync then require('fff.core').ensure_initialized() end
|
||||
end)
|
||||
|
||||
if vim.v.vim_did_enter == 1 then
|
||||
init()
|
||||
else
|
||||
vim.api.nvim_create_autocmd('UIEnter', {
|
||||
group = vim.api.nvim_create_augroup('fff.main', {}),
|
||||
once = true,
|
||||
nested = true,
|
||||
callback = init,
|
||||
})
|
||||
end
|
||||
|
||||
vim.api.nvim_create_user_command('FFFFind', function(opts)
|
||||
local fff = require('fff')
|
||||
if opts.args and opts.args ~= '' then
|
||||
-- If argument looks like a directory, use it as base path
|
||||
if vim.fn.isdirectory(opts.args) == 1 then
|
||||
fff.find_files_in_dir(opts.args)
|
||||
else
|
||||
-- Otherwise treat as search query
|
||||
fff.search_and_show(opts.args)
|
||||
end
|
||||
else
|
||||
fff.find_files()
|
||||
end
|
||||
end, {
|
||||
nargs = '?',
|
||||
complete = function(arg_lead)
|
||||
-- Complete with directories and common search terms
|
||||
local dirs = vim.fn.glob(arg_lead .. '*', false, true)
|
||||
local results = {}
|
||||
for _, dir in ipairs(dirs) do
|
||||
if vim.fn.isdirectory(dir) == 1 then table.insert(results, dir) end
|
||||
end
|
||||
return results
|
||||
end,
|
||||
desc = 'Find files with FFF (use directory path or search query)',
|
||||
})
|
||||
|
||||
vim.api.nvim_create_user_command('FFFScan', function() require('fff').scan_files() end, {
|
||||
desc = 'Scan files for FFF',
|
||||
})
|
||||
|
||||
vim.api.nvim_create_user_command('FFFRefreshGit', function() require('fff').refresh_git_status() end, {
|
||||
desc = 'Manually refresh git status for all files',
|
||||
})
|
||||
|
||||
vim.api.nvim_create_user_command('FFFClearCache', function(opts) require('fff').clear_cache(opts.args) end, {
|
||||
nargs = '?',
|
||||
complete = function() return { 'all', 'frecency', 'files' } end,
|
||||
desc = 'Clear FFF caches (all|frecency|files)',
|
||||
})
|
||||
|
||||
vim.api.nvim_create_user_command('FFFHealth', function() require('fff').health_check() end, {
|
||||
desc = 'Check FFF health',
|
||||
})
|
||||
|
||||
vim.api.nvim_create_user_command('FFFDebug', function(opts)
|
||||
local config = require('fff.conf').get()
|
||||
if opts.args == 'toggle' or opts.args == '' then
|
||||
config.debug.show_scores = not config.debug.show_scores
|
||||
local status = config.debug.show_scores and 'enabled' or 'disabled'
|
||||
vim.notify('FFF debug scores ' .. status, vim.log.levels.INFO)
|
||||
elseif opts.args == 'on' then
|
||||
config.debug.show_scores = true
|
||||
vim.notify('FFF debug scores enabled', vim.log.levels.INFO)
|
||||
elseif opts.args == 'off' then
|
||||
config.debug.show_scores = false
|
||||
vim.notify('FFF debug scores disabled', vim.log.levels.INFO)
|
||||
else
|
||||
vim.notify('Usage: :FFFDebug [on|off|toggle]', vim.log.levels.ERROR)
|
||||
end
|
||||
end, {
|
||||
nargs = '?',
|
||||
complete = function() return { 'on', 'off', 'toggle' } end,
|
||||
desc = 'Toggle FFF debug scores display',
|
||||
})
|
||||
|
||||
vim.api.nvim_create_user_command('FFFOpenLog', function()
|
||||
local fff = require('fff')
|
||||
local config = require('fff.conf').get()
|
||||
if fff.log_file_path then
|
||||
vim.cmd('tabnew ' .. vim.fn.fnameescape(fff.log_file_path))
|
||||
elseif config and config.logging and config.logging.log_file then
|
||||
-- Fallback to the configured log file path even if tracing wasn't initialized
|
||||
vim.cmd('tabnew ' .. vim.fn.fnameescape(config.logging.log_file))
|
||||
else
|
||||
vim.notify('Log file path not available', vim.log.levels.ERROR)
|
||||
end
|
||||
end, {
|
||||
desc = 'Open FFF log file in new tab',
|
||||
})
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
[toolchain]
|
||||
channel = "nightly-2025-07-27"
|
||||
channel = "nightly-2025-09-01"
|
||||
components = [
|
||||
"clippy-preview",
|
||||
"rustfmt-preview",
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/// Simple search profiler that directly uses scan_filesystem without background thread overhead
|
||||
use fff_nvim::file_picker::FilePicker;
|
||||
use std::time::Instant;
|
||||
|
||||
fn main() {
|
||||
let big_repo_path = std::path::PathBuf::from("./big-repo");
|
||||
|
||||
if !big_repo_path.exists() {
|
||||
eprintln!(
|
||||
"./big-repo directory does not exist. Run git clone https://github.com/torvalds/linux.git big-repo"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let canonical_path = big_repo_path
|
||||
.canonicalize()
|
||||
.expect("Failed to canonicalize path");
|
||||
|
||||
eprintln!("Loading files from: {:?}", canonical_path);
|
||||
|
||||
// Directly scan without background thread
|
||||
let start = Instant::now();
|
||||
let files = {
|
||||
use ignore::WalkBuilder;
|
||||
let mut files = Vec::new();
|
||||
|
||||
WalkBuilder::new(&canonical_path)
|
||||
.hidden(false)
|
||||
.build()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_type().is_some_and(|ft| ft.is_file()))
|
||||
.for_each(|entry| {
|
||||
let path = entry.path().to_path_buf();
|
||||
let relative =
|
||||
pathdiff::diff_paths(&path, &canonical_path).unwrap_or_else(|| path.clone());
|
||||
|
||||
let relative_path = relative.to_string_lossy().into_owned();
|
||||
let file_name = entry.file_name().to_string_lossy().into_owned();
|
||||
|
||||
files.push(fff_nvim::types::FileItem {
|
||||
path,
|
||||
relative_path_lower: relative_path.to_lowercase(),
|
||||
relative_path,
|
||||
file_name_lower: file_name.to_lowercase(),
|
||||
file_name,
|
||||
size: entry.metadata().ok().map_or(0, |m| m.len()),
|
||||
modified: 0,
|
||||
access_frecency_score: 0,
|
||||
modification_frecency_score: 0,
|
||||
total_frecency_score: 0,
|
||||
git_status: None,
|
||||
});
|
||||
});
|
||||
|
||||
files
|
||||
};
|
||||
|
||||
eprintln!(
|
||||
"✓ Loaded {} files in {:.2}s\n",
|
||||
files.len(),
|
||||
start.elapsed().as_secs_f64()
|
||||
);
|
||||
|
||||
// Test queries
|
||||
let test_queries = vec![
|
||||
("short_common", "mod", 500),
|
||||
("medium_specific", "controller", 200),
|
||||
("long_rare", "user_authentication", 100),
|
||||
("typo_resistant", "contrlr", 200),
|
||||
("path_like", "src/lib", 150),
|
||||
("single_char", "a", 300),
|
||||
("two_char", "st", 300),
|
||||
("partial_word", "test", 200),
|
||||
("deep_path", "drivers/net", 100),
|
||||
("extension", ".rs", 200),
|
||||
];
|
||||
|
||||
eprintln!("Running search profiler...");
|
||||
eprintln!("Query | Iterations | Total Time | Avg Time | Matches");
|
||||
eprintln!("----------------------|------------|------------|-----------|--------");
|
||||
|
||||
let global_start = Instant::now();
|
||||
let mut total_iterations = 0;
|
||||
|
||||
for (name, query, iterations) in test_queries {
|
||||
let start = Instant::now();
|
||||
let mut match_count = 0;
|
||||
|
||||
for _ in 0..iterations {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
&files,
|
||||
query,
|
||||
fff_nvim::file_picker::FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: fff_nvim::types::PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
},
|
||||
},
|
||||
);
|
||||
match_count += results.total_matched;
|
||||
}
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
let avg_time = elapsed / iterations as u32;
|
||||
|
||||
eprintln!(
|
||||
"{:<21} | {:>10} | {:>9.2}s | {:>7}µs | {}",
|
||||
name,
|
||||
iterations,
|
||||
elapsed.as_secs_f64(),
|
||||
avg_time.as_micros(),
|
||||
match_count / iterations
|
||||
);
|
||||
|
||||
total_iterations += iterations;
|
||||
}
|
||||
|
||||
let total_time = global_start.elapsed();
|
||||
|
||||
eprintln!("\n=== Summary ===");
|
||||
eprintln!("Total searches: {}", total_iterations);
|
||||
eprintln!("Total time: {:.2}s", total_time.as_secs_f64());
|
||||
eprintln!(
|
||||
"Average per search: {}µs",
|
||||
(total_time.as_micros() as usize) / total_iterations
|
||||
);
|
||||
eprintln!(
|
||||
"Searches per sec: {:.0}",
|
||||
total_iterations as f64 / total_time.as_secs_f64()
|
||||
);
|
||||
eprintln!(
|
||||
"\nYou can now run: perf record -g --call-graph dwarf -F 999 ./target/release/search_only"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
use fff_nvim::{FILE_PICKER, file_picker::FilePicker};
|
||||
use std::env;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
fn get_mem_stat() -> Result<(usize, usize, usize), Box<dyn std::error::Error>> {
|
||||
// Use system memory info since jemalloc-ctl conflicts
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
use std::process::Command;
|
||||
let pid = std::process::id();
|
||||
let output = Command::new("ps")
|
||||
.args(["-o", "rss=", "-p", &pid.to_string()])
|
||||
.output()?;
|
||||
|
||||
let rss_str = String::from_utf8(output.stdout)?;
|
||||
let rss_kb: usize = rss_str.trim().parse()?;
|
||||
let rss_bytes = rss_kb * 1024;
|
||||
Ok((rss_bytes, rss_bytes, rss_bytes))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let pid = std::process::id();
|
||||
let status_path = format!("/proc/{}/status", pid);
|
||||
let content = std::fs::read_to_string(status_path)?;
|
||||
|
||||
for line in content.lines() {
|
||||
if line.starts_with("VmRSS:") {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if let Ok(rss_kb) = parts[1].parse::<usize>()
|
||||
&& parts.len() >= 2
|
||||
{
|
||||
let rss_bytes = rss_kb * 1024;
|
||||
return Ok((rss_bytes, rss_bytes, rss_bytes));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err("Could not find VmRSS in /proc/pid/status".into())
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
{
|
||||
Ok((0, 0, 0))
|
||||
}
|
||||
}
|
||||
|
||||
fn format_bytes(bytes: usize) -> String {
|
||||
const UNITS: &[&str] = &["B", "KB", "MB", "GB"];
|
||||
let mut size = bytes as f64;
|
||||
let mut unit_index = 0;
|
||||
|
||||
while size >= 1024.0 && unit_index < UNITS.len() - 1 {
|
||||
size /= 1024.0;
|
||||
unit_index += 1;
|
||||
}
|
||||
|
||||
format!("{:.2} {}", size, UNITS[unit_index])
|
||||
}
|
||||
|
||||
fn test_search_memory_pattern(
|
||||
name: &str,
|
||||
iterations: usize,
|
||||
query_pattern: impl Fn(usize) -> String,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("🧪 {}", name);
|
||||
|
||||
let (initial_allocated, initial_active, initial_resident) = get_mem_stat()?;
|
||||
println!(
|
||||
" Initial - Allocated: {}, Active: {}, Resident: {}",
|
||||
format_bytes(initial_allocated),
|
||||
format_bytes(initial_active),
|
||||
format_bytes(initial_resident)
|
||||
);
|
||||
|
||||
let mut max_allocated = initial_allocated;
|
||||
let mut max_active = initial_active;
|
||||
let mut max_resident = initial_resident;
|
||||
|
||||
for i in 0..iterations {
|
||||
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 search_result = FilePicker::fuzzy_search(
|
||||
picker.get_files(),
|
||||
&query,
|
||||
fff_nvim::file_picker::FuzzySearchOptions {
|
||||
max_threads: 1 + (i % 4),
|
||||
current_file: None,
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: fff_nvim::types::PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 50 + (i % 50),
|
||||
},
|
||||
},
|
||||
);
|
||||
(search_result.items.len(), search_result.total_matched)
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if i % 100 == 0 {
|
||||
let (allocated, active, resident) = get_mem_stat()?;
|
||||
max_allocated = max_allocated.max(allocated);
|
||||
max_active = max_active.max(active);
|
||||
max_resident = max_resident.max(resident);
|
||||
|
||||
if i % 500 == 0 {
|
||||
println!(
|
||||
" Iteration {}: {} results, Allocated: {} (+{})",
|
||||
i,
|
||||
result_count,
|
||||
format_bytes(allocated),
|
||||
format_bytes(allocated.saturating_sub(initial_allocated))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if i % 200 == 199 {
|
||||
thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
|
||||
let (final_allocated, final_active, final_resident) = get_mem_stat()?;
|
||||
|
||||
println!(
|
||||
" Final - Allocated: {} (+{}), Active: {} (+{}), Resident: {} (+{})",
|
||||
format_bytes(final_allocated),
|
||||
format_bytes(final_allocated.saturating_sub(initial_allocated)),
|
||||
format_bytes(final_active),
|
||||
format_bytes(final_active.saturating_sub(initial_active)),
|
||||
format_bytes(final_resident),
|
||||
format_bytes(final_resident.saturating_sub(initial_resident))
|
||||
);
|
||||
|
||||
println!(
|
||||
" Peak - Allocated: {} (+{}), Active: {} (+{}), Resident: {} (+{})",
|
||||
format_bytes(max_allocated),
|
||||
format_bytes(max_allocated.saturating_sub(initial_allocated)),
|
||||
format_bytes(max_active),
|
||||
format_bytes(max_active.saturating_sub(initial_active)),
|
||||
format_bytes(max_resident),
|
||||
format_bytes(max_resident.saturating_sub(initial_resident))
|
||||
);
|
||||
|
||||
let growth_per_search =
|
||||
(final_allocated.saturating_sub(initial_allocated)) as f64 / iterations as f64;
|
||||
println!(
|
||||
" Average growth per search: {:.2} bytes",
|
||||
growth_per_search
|
||||
);
|
||||
println!();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
let base_path = if args.len() > 1 {
|
||||
args[1].clone()
|
||||
} else {
|
||||
env::current_dir()?.to_str().unwrap_or(".").to_string()
|
||||
};
|
||||
|
||||
println!("🧪 FFF.nvim Jemalloc Memory Profiler");
|
||||
println!("====================================");
|
||||
println!("Test directory: {}", base_path);
|
||||
println!();
|
||||
|
||||
// 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())?);
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
&& !picker.is_scan_active()
|
||||
&& !picker.get_files().is_empty()
|
||||
{
|
||||
break;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
|
||||
let file_count = {
|
||||
let file_picker_guard = FILE_PICKER.read()?;
|
||||
file_picker_guard.as_ref().unwrap().get_files().len()
|
||||
};
|
||||
|
||||
println!("📊 Found {} files", file_count);
|
||||
|
||||
let (initial_allocated, initial_active, initial_resident) = get_mem_stat()?;
|
||||
println!("🧠 Baseline memory:");
|
||||
println!(" Allocated: {}", format_bytes(initial_allocated));
|
||||
println!(" Active: {}", format_bytes(initial_active));
|
||||
println!(" Resident: {}", format_bytes(initial_resident));
|
||||
println!();
|
||||
|
||||
// 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())?;
|
||||
|
||||
// 2. Cycling through different queries
|
||||
test_search_memory_pattern("Cycling Queries (1000x)", 1000, |i| {
|
||||
let queries = [
|
||||
"test", "main", "lib", "src", "mod", "file", "picker", "fuzzy", "search",
|
||||
];
|
||||
queries[i % queries.len()].to_string()
|
||||
})?;
|
||||
|
||||
// 3. Unique queries each time - worst case for any caching
|
||||
test_search_memory_pattern("Unique Queries (500x)", 500, |i| {
|
||||
format!("unique_query_{}", i)
|
||||
})?;
|
||||
|
||||
// 4. Queries that return many results
|
||||
test_search_memory_pattern(
|
||||
"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, |_| {
|
||||
"zzzz_no_match_expected".to_string()
|
||||
})?;
|
||||
|
||||
// 6. Long intensive test
|
||||
test_search_memory_pattern("Long Intensive Test (2000x)", 2000, |i| {
|
||||
let patterns = [
|
||||
"rs", "lua", "toml", "mod", "lib", "main", "test", "src", "file",
|
||||
];
|
||||
format!("{}{}", patterns[i % patterns.len()], i % 100)
|
||||
})?;
|
||||
|
||||
let (final_allocated, final_active, final_resident) = get_mem_stat()?;
|
||||
|
||||
println!("🏁 Final Memory Stats:");
|
||||
println!(
|
||||
" Allocated: {} (growth: {})",
|
||||
format_bytes(final_allocated),
|
||||
format_bytes(final_allocated.saturating_sub(initial_allocated))
|
||||
);
|
||||
println!(
|
||||
" Active: {} (growth: {})",
|
||||
format_bytes(final_active),
|
||||
format_bytes(final_active.saturating_sub(initial_active))
|
||||
);
|
||||
println!(
|
||||
" Resident: {} (growth: {})",
|
||||
format_bytes(final_resident),
|
||||
format_bytes(final_resident.saturating_sub(initial_resident))
|
||||
);
|
||||
|
||||
let total_searches = 5500;
|
||||
let avg_growth =
|
||||
(final_allocated.saturating_sub(initial_allocated)) as f64 / total_searches as f64;
|
||||
println!(" Average growth per search: {:.2} bytes", avg_growth);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
use fff_nvim::FILE_PICKER;
|
||||
use fff_nvim::file_picker::FilePicker;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Wait for background scan to complete
|
||||
fn wait_for_scan(timeout_secs: u64) -> Result<usize, String> {
|
||||
let start = Instant::now();
|
||||
let timeout = Duration::from_secs(timeout_secs);
|
||||
let mut iteration = 0;
|
||||
|
||||
loop {
|
||||
iteration += 1;
|
||||
|
||||
let picker_guard = FILE_PICKER
|
||||
.read()
|
||||
.map_err(|_| "Failed to acquire read lock")?;
|
||||
if let Some(ref picker) = *picker_guard {
|
||||
let is_scanning = picker.is_scan_active();
|
||||
let file_count = picker.get_files().len();
|
||||
|
||||
if iteration % 20 == 0 {
|
||||
eprintln!(
|
||||
" [{:.1}s] Scanning: {}, Files: {}",
|
||||
start.elapsed().as_secs_f64(),
|
||||
is_scanning,
|
||||
file_count
|
||||
);
|
||||
}
|
||||
|
||||
if !is_scanning && file_count > 0 {
|
||||
return Ok(file_count);
|
||||
}
|
||||
} else if iteration % 20 == 0 {
|
||||
eprintln!(
|
||||
" [{:.1}s] FilePicker is None",
|
||||
start.elapsed().as_secs_f64()
|
||||
);
|
||||
}
|
||||
|
||||
if start.elapsed() > timeout {
|
||||
return Err(format!("Scan timed out after {} seconds", timeout_secs));
|
||||
}
|
||||
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<fff_nvim::types::FileItem>, String> {
|
||||
let picker_guard = FILE_PICKER
|
||||
.read()
|
||||
.map_err(|_| "Failed to acquire read lock")?;
|
||||
if let Some(ref picker) = *picker_guard {
|
||||
Ok(picker.get_files().to_vec())
|
||||
} else {
|
||||
Err("FilePicker not initialized".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let big_repo_path = std::path::PathBuf::from("./big-repo");
|
||||
|
||||
if !big_repo_path.exists() {
|
||||
eprintln!(
|
||||
"./big-repo directory does not exist. Run git clone https://github.com/torvalds/linux.git big-repo"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let canonical_path = big_repo_path
|
||||
.canonicalize()
|
||||
.expect("Failed to canonicalize path");
|
||||
|
||||
eprintln!("Initializing FilePicker for: {:?}", canonical_path);
|
||||
init_file_picker(&canonical_path.to_string_lossy()).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");
|
||||
eprintln!("✓ Indexed {} files\n", file_count);
|
||||
|
||||
let files = get_files().expect("Failed to get files");
|
||||
|
||||
// Test queries representing different search patterns
|
||||
let test_queries = vec![
|
||||
("short_common", "mod", 5000),
|
||||
("medium_specific", "controller", 2000),
|
||||
("long_rare", "user_authentication", 1000),
|
||||
("typo_resistant", "contrlr", 2000),
|
||||
("path_like", "src/lib", 1500),
|
||||
("single_char", "a", 3000),
|
||||
("two_char", "st", 3000),
|
||||
("partial_word", "test", 2000),
|
||||
("deep_path", "drivers/net", 1000),
|
||||
("extension", ".rs", 2000),
|
||||
];
|
||||
|
||||
eprintln!("Running search profiler...");
|
||||
eprintln!("Query | Iterations | Total Time | Avg Time | Matches");
|
||||
eprintln!("----------------------|------------|------------|-----------|--------");
|
||||
|
||||
let global_start = Instant::now();
|
||||
let mut total_iterations = 0;
|
||||
|
||||
for (name, query, iterations) in test_queries {
|
||||
let start = Instant::now();
|
||||
let mut match_count = 0;
|
||||
|
||||
for _ in 0..iterations {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
&files,
|
||||
query,
|
||||
fff_nvim::file_picker::FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: fff_nvim::types::PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
match_count += results.total_matched;
|
||||
}
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
let avg_time = elapsed / iterations as u32;
|
||||
|
||||
eprintln!(
|
||||
"{:<21} | {:>10} | {:>9.2}s | {:>7}µs | {}",
|
||||
name,
|
||||
iterations,
|
||||
elapsed.as_secs_f64(),
|
||||
avg_time.as_micros(),
|
||||
match_count / iterations
|
||||
);
|
||||
|
||||
total_iterations += iterations;
|
||||
}
|
||||
|
||||
let total_time = global_start.elapsed();
|
||||
|
||||
eprintln!("\n=== Summary ===");
|
||||
eprintln!("Total searches: {}", total_iterations);
|
||||
eprintln!("Total time: {:.2}s", total_time.as_secs_f64());
|
||||
eprintln!(
|
||||
"Average per search: {}µs",
|
||||
(total_time.as_micros() as usize) / total_iterations
|
||||
);
|
||||
eprintln!(
|
||||
"Searches per sec: {:.0}",
|
||||
total_iterations as f64 / total_time.as_secs_f64()
|
||||
);
|
||||
|
||||
// Keep the program alive briefly so perf can capture everything
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
use fff_nvim::{FILE_PICKER, file_picker::FilePicker};
|
||||
use std::env;
|
||||
use std::io::{self, Write};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn get_memory_usage() -> Result<u64, Box<dyn std::error::Error>> {
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
{
|
||||
return Err("Memory usage check is only supported on Linux and macOS".into());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let pid = std::process::id();
|
||||
use std::process::Command;
|
||||
let output = Command::new("ps")
|
||||
.args(["-o", "rss=", "-p", &pid.to_string()])
|
||||
.output()?;
|
||||
|
||||
let rss_str = String::from_utf8(output.stdout)?;
|
||||
let rss_kb: u64 = rss_str.trim().parse()?;
|
||||
|
||||
Ok(rss_kb * 1024)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let pid = std::process::id();
|
||||
let status_path = format!("/proc/{}/status", pid);
|
||||
let content = std::fs::read_to_string(status_path)?;
|
||||
|
||||
for line in content.lines() {
|
||||
if line.starts_with("VmRSS:") {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if let Ok(rss_kb) = parts[1].parse::<u64>()
|
||||
&& parts.len() >= 2
|
||||
{
|
||||
return Ok(rss_kb * 1024); // Convert KB to bytes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err("Could not find VmRSS in /proc/pid/status".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn format_bytes(bytes: u64) -> String {
|
||||
const UNITS: &[&str] = &["B", "KB", "MB", "GB"];
|
||||
let mut size = bytes as f64;
|
||||
let mut unit_index = 0;
|
||||
|
||||
while size >= 1024.0 && unit_index < UNITS.len() - 1 {
|
||||
size /= 1024.0;
|
||||
unit_index += 1;
|
||||
}
|
||||
|
||||
format!("{:.2} {}", size, UNITS[unit_index])
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if !cfg!(target_os = "linux") && !cfg!(target_os = "macos") {
|
||||
eprintln!("This test is only supported on Linux and macOS.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let args: Vec<String> = env::args().collect();
|
||||
let base_path = if args.len() > 1 {
|
||||
args[1].clone()
|
||||
} else {
|
||||
env::current_dir()?.to_str().unwrap_or(".").to_string()
|
||||
};
|
||||
|
||||
println!("🧪 FFF.nvim Memory Leak Test (Using Crate)");
|
||||
println!("==========================================");
|
||||
println!("Test directory: {}", base_path);
|
||||
println!();
|
||||
|
||||
// Initialize the file picker directly
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for initial scan to complete
|
||||
println!("⏳ Waiting for initial file scan to complete...");
|
||||
let mut wait_time = 0;
|
||||
let mut scan_completed = false;
|
||||
|
||||
loop {
|
||||
if let Ok(file_picker_guard) = FILE_PICKER.read()
|
||||
&& let Some(ref picker) = *file_picker_guard
|
||||
{
|
||||
if !picker.is_scan_active() {
|
||||
println!("Scan inactive, checking file count...");
|
||||
let file_count = picker.get_files().len();
|
||||
if file_count > 0 {
|
||||
println!("Async scan found {} files", file_count);
|
||||
scan_completed = true;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
println!("Scan active, waiting...");
|
||||
}
|
||||
}
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
wait_time += 100;
|
||||
if wait_time > 10000 {
|
||||
println!("Timeout waiting for async scan, triggering manual scan...");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
{
|
||||
match picker.trigger_rescan() {
|
||||
Ok(_) => println!("Manual rescan completed"),
|
||||
Err(e) => println!("Manual rescan failed: {:?}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let initial_file_count = {
|
||||
let file_picker_guard = FILE_PICKER.read()?;
|
||||
if let Some(ref picker) = *file_picker_guard {
|
||||
let files = picker.get_files();
|
||||
println!("Found {} files in picker", files.len());
|
||||
if !files.is_empty() {
|
||||
println!("Sample files:");
|
||||
for (i, file) in files.iter().take(5).enumerate() {
|
||||
println!(" {}. {}", i + 1, file.relative_path);
|
||||
}
|
||||
}
|
||||
files.len()
|
||||
} else {
|
||||
println!("No picker found in FILE_PICKER static!");
|
||||
0
|
||||
}
|
||||
};
|
||||
|
||||
println!(
|
||||
"📊 Initial scan complete: {} files found",
|
||||
initial_file_count
|
||||
);
|
||||
|
||||
if initial_file_count == 0 {
|
||||
eprintln!("❌ No files found in directory. Cannot proceed with memory test.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// Record initial memory usage
|
||||
let initial_memory = get_memory_usage().unwrap_or(0);
|
||||
println!("🧠 Initial memory usage: {}", format_bytes(initial_memory));
|
||||
println!();
|
||||
|
||||
// Test queries to cycle through
|
||||
let test_queries = vec![
|
||||
"rs", "mod", "lib", "main", "test", "src", "lua", "cargo", "toml", "init", "config",
|
||||
"util", "file", "picker", "fuzzy", "search", "git", "fn", "struct", "impl", "pub", "use",
|
||||
"let", "match", "if", "for", "async", "await", "Result", "Error", "Vec", "String",
|
||||
"Option",
|
||||
];
|
||||
|
||||
let mut last_memory_check = Instant::now();
|
||||
let mut peak_memory = initial_memory;
|
||||
let mut memory_samples = Vec::new();
|
||||
|
||||
println!("🔥 Starting intensive search test...");
|
||||
println!("Press Ctrl+C to stop the test");
|
||||
println!();
|
||||
let mut search_count = 0;
|
||||
|
||||
while search_count < 1000 {
|
||||
search_count += 1;
|
||||
|
||||
// Perform search directly on FilePicker
|
||||
let query = test_queries[search_count % test_queries.len()];
|
||||
let max_results = 50 + (search_count % 100); // Vary result count
|
||||
let max_threads = 1 + (search_count % 8); // Vary thread count
|
||||
|
||||
let search_start = Instant::now();
|
||||
let (result_count, search_duration) = {
|
||||
let file_picker_guard = FILE_PICKER.read().unwrap();
|
||||
if let Some(ref picker) = *file_picker_guard {
|
||||
let search_result = FilePicker::fuzzy_search(
|
||||
picker.get_files(),
|
||||
query,
|
||||
fff_nvim::file_picker::FuzzySearchOptions {
|
||||
max_threads,
|
||||
current_file: None,
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: fff_nvim::types::PaginationArgs {
|
||||
offset: 0,
|
||||
limit: max_results,
|
||||
},
|
||||
},
|
||||
);
|
||||
let duration = search_start.elapsed();
|
||||
(search_result.items.len(), duration)
|
||||
} else {
|
||||
(0, search_start.elapsed())
|
||||
}
|
||||
};
|
||||
|
||||
search_count += 1;
|
||||
|
||||
// Check memory every 100 searches or every 5 seconds
|
||||
let now = Instant::now();
|
||||
if (search_count % 100 == 0
|
||||
|| now.duration_since(last_memory_check) > Duration::from_secs(5))
|
||||
&& let Ok(current_memory) = get_memory_usage()
|
||||
{
|
||||
memory_samples.push(current_memory);
|
||||
|
||||
if current_memory > peak_memory {
|
||||
peak_memory = current_memory;
|
||||
}
|
||||
|
||||
let memory_growth = current_memory.saturating_sub(initial_memory);
|
||||
|
||||
println!(
|
||||
"🔍 Search #{}: '{}' -> {} results in {:?} | Memory: {} (+{}) | Peak: {}",
|
||||
search_count,
|
||||
query,
|
||||
result_count,
|
||||
search_duration,
|
||||
format_bytes(current_memory),
|
||||
format_bytes(memory_growth),
|
||||
format_bytes(peak_memory)
|
||||
);
|
||||
|
||||
last_memory_check = now;
|
||||
|
||||
// Calculate memory growth trend over last 10 samples
|
||||
if memory_samples.len() >= 10 {
|
||||
let recent_samples = &memory_samples[memory_samples.len() - 10..];
|
||||
let first_recent = recent_samples[0];
|
||||
let last_recent = recent_samples[recent_samples.len() - 1];
|
||||
|
||||
if last_recent > first_recent {
|
||||
let recent_growth = last_recent - first_recent;
|
||||
if recent_growth > 1024 * 1024 {
|
||||
// More than 1MB growth in recent samples
|
||||
println!(
|
||||
"⚠️ POTENTIAL LEAK: Recent memory growth: {}",
|
||||
format_bytes(recent_growth)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
io::stdout().flush().unwrap();
|
||||
}
|
||||
|
||||
// Brief pause to prevent overwhelming the system
|
||||
if search_count % 10 == 0 {
|
||||
thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
|
||||
// Force cleanup test every 1000 searches
|
||||
if search_count % 1000 == 0 {
|
||||
println!("🧹 Forcing potential cleanup at search #{}", search_count);
|
||||
// Force garbage collection by creating and dropping temporary data
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
+103
-81
@@ -2,34 +2,32 @@
|
||||
#![allow(dead_code)]
|
||||
#![allow(clippy::enum_variant_names)]
|
||||
|
||||
#[path = "../../lua/fff/rust/error.rs"]
|
||||
mod error;
|
||||
#[path = "../../lua/fff/rust/file_key.rs"]
|
||||
mod file_key;
|
||||
#[path = "../../lua/fff/rust/file_picker.rs"]
|
||||
mod file_picker;
|
||||
#[path = "../../lua/fff/rust/frecency.rs"]
|
||||
mod frecency;
|
||||
#[path = "../../lua/fff/rust/git.rs"]
|
||||
mod git;
|
||||
#[path = "../../lua/fff/rust/path_utils.rs"]
|
||||
mod path_utils;
|
||||
#[path = "../../lua/fff/rust/score.rs"]
|
||||
mod score;
|
||||
#[path = "../../lua/fff/rust/types.rs"]
|
||||
mod types;
|
||||
|
||||
use file_picker::FilePicker;
|
||||
use frecency::FrecencyTracker;
|
||||
use fff_nvim::{FILE_PICKER, FRECENCY, file_picker::FilePicker, git::format_git_status};
|
||||
use std::env;
|
||||
use std::io::{self, Write};
|
||||
use std::sync::{LazyLock, RwLock};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::git::format_git_status;
|
||||
fn cleanup_global_state() {
|
||||
// Clean up file picker
|
||||
{
|
||||
let mut file_picker = FILE_PICKER.write().unwrap();
|
||||
if let Some(mut picker) = file_picker.take() {
|
||||
let _ = picker.stop_background_monitor();
|
||||
drop(picker);
|
||||
println!("🧹 FilePicker cleaned up");
|
||||
}
|
||||
}
|
||||
|
||||
static FRECENCY: LazyLock<RwLock<Option<FrecencyTracker>>> = LazyLock::new(|| RwLock::new(None));
|
||||
// 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();
|
||||
@@ -39,45 +37,66 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
env::current_dir()?.to_str().unwrap_or(".").to_string()
|
||||
};
|
||||
|
||||
let picker = match FilePicker::new(base_path.clone()) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
eprintln!("❌ Failed to create FilePicker: {:?}", e);
|
||||
// Set up signal handler for graceful shutdown
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
let r = running.clone();
|
||||
|
||||
ctrlc::set_handler(move || {
|
||||
println!("\n🛑 Received interrupt signal, shutting down...");
|
||||
cleanup_global_state();
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
let initial_files = picker.get_cached_files();
|
||||
println!("Initial file count: {}", initial_files.len());
|
||||
|
||||
if !initial_files.is_empty() {
|
||||
println!("Sample files:");
|
||||
for (i, file) in initial_files.iter().take(5).enumerate() {
|
||||
println!(
|
||||
" {}. {} ({})",
|
||||
i + 1,
|
||||
file.relative_path,
|
||||
format_git_status(file.git_status)
|
||||
);
|
||||
}
|
||||
if initial_files.len() > 5 {
|
||||
println!(" ... and {} more files", initial_files.len() - 5);
|
||||
}
|
||||
*file_picker = Some(FilePicker::new(base_path.clone())?);
|
||||
}
|
||||
|
||||
// Get initial file count from global state
|
||||
let initial_count = {
|
||||
let file_picker = FILE_PICKER.read().unwrap();
|
||||
let files = file_picker.as_ref().unwrap().get_files();
|
||||
println!("Initial file count: {}", files.len());
|
||||
|
||||
if !files.is_empty() {
|
||||
println!("Sample files:");
|
||||
for (i, file) in files.iter().take(5).enumerate() {
|
||||
println!(
|
||||
" {}. {} ({})",
|
||||
i + 1,
|
||||
file.relative_path,
|
||||
format_git_status(file.git_status)
|
||||
);
|
||||
}
|
||||
if files.len() > 5 {
|
||||
println!(" ... and {} more files", files.len() - 5);
|
||||
}
|
||||
}
|
||||
files.len()
|
||||
};
|
||||
|
||||
println!("{:=<60}", "");
|
||||
println!("🔴 LIVE FILE MONITORING - Press Ctrl+C to stop");
|
||||
println!("{:=<60}", "");
|
||||
|
||||
let mut last_count = initial_files.len();
|
||||
let mut last_count = initial_count;
|
||||
let mut iteration = 0;
|
||||
|
||||
loop {
|
||||
while running.load(Ordering::SeqCst) {
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
iteration += 1;
|
||||
|
||||
let current_files = picker.get_cached_files();
|
||||
let current_count = current_files.len();
|
||||
let current_count = {
|
||||
let file_picker = FILE_PICKER.read().unwrap();
|
||||
file_picker.as_ref().unwrap().get_files().len()
|
||||
};
|
||||
|
||||
if current_count != last_count {
|
||||
let timestamp = chrono::Local::now().format("%H:%M:%S%.3f");
|
||||
@@ -89,20 +108,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
timestamp, added, current_count
|
||||
);
|
||||
|
||||
if let Some(newest_files) = current_files
|
||||
.iter()
|
||||
.rev()
|
||||
.take(added)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect::<Vec<_>>()
|
||||
.get(0..added.min(3))
|
||||
{
|
||||
for file in newest_files {
|
||||
println!(" ➕ {}", file.relative_path);
|
||||
}
|
||||
// Show some recently added files
|
||||
let file_picker = FILE_PICKER.read().unwrap();
|
||||
let files = file_picker.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!(
|
||||
@@ -121,14 +134,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
timestamp, current_count
|
||||
);
|
||||
|
||||
let mut git_stats = std::collections::HashMap::new();
|
||||
for file in ¤t_files {
|
||||
let file_picker = FILE_PICKER.read().unwrap();
|
||||
let current_files = file_picker.as_ref().unwrap().get_files();
|
||||
|
||||
git_stats.clear();
|
||||
for file in current_files {
|
||||
let status = format_git_status(file.git_status);
|
||||
*git_stats.entry(status).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
let git_stats_copy = git_stats.clone();
|
||||
|
||||
if !git_stats.is_empty() {
|
||||
print!(" 📊 Git status: ");
|
||||
for (status, count) in &git_stats {
|
||||
@@ -136,26 +150,29 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
println!(" 🔄 Testing git status refresh...");
|
||||
let refreshed_files = picker.refresh_git_status();
|
||||
let mut new_git_stats = std::collections::HashMap::new();
|
||||
for file in &refreshed_files {
|
||||
let status = format_git_status(file.git_status);
|
||||
*new_git_stats.entry(status).or_insert(0) += 1;
|
||||
}
|
||||
if new_git_stats != git_stats_copy {
|
||||
print!(" ✨ Git status changed after refresh: ");
|
||||
for (status, count) in &new_git_stats {
|
||||
print!("{}:{} ", status, count);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
if iteration % 40 == 0 {
|
||||
let search_results = picker.fuzzy_search("rs", 5, 2, None);
|
||||
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 search_results = FilePicker::fuzzy_search(
|
||||
files,
|
||||
"rs",
|
||||
fff_nvim::file_picker::FuzzySearchOptions {
|
||||
max_threads: 2,
|
||||
current_file: None,
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: fff_nvim::types::PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 5,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
println!(
|
||||
"🔍 [{}] Search test 'rs': {} matches",
|
||||
timestamp,
|
||||
@@ -175,8 +192,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
score.total
|
||||
);
|
||||
}
|
||||
drop(file_picker);
|
||||
}
|
||||
|
||||
io::stdout().flush().unwrap();
|
||||
}
|
||||
|
||||
// Clean up before exit
|
||||
cleanup_global_state();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user