Compare commits
92 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 547a654ccc | |||
| 3fd35a40fa | |||
| 7b046a7f44 | |||
| 056e6c9fdf | |||
| 8c14d30f5c | |||
| eb619749ab | |||
| 9e51132156 | |||
| 7efaceb7ec | |||
| 0e75b1c1db | |||
| 4a1297dbb8 | |||
| b5807d5efd | |||
| 6f380b2982 | |||
| 69a1587c10 | |||
| f9ee0a20d8 | |||
| 4b31ac8311 | |||
| 7fffe4f670 | |||
| be94f5aea2 | |||
| cdd38155fc | |||
| 3ee2cb116f | |||
| f4a5692c43 | |||
| fb280b0be6 | |||
| d3e34a7802 | |||
| 5607e61ef7 | |||
| 76bd9a1509 | |||
| aa8d56ea91 | |||
| a75fe334db | |||
| 3aafc930ba |
@@ -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
+578
-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,21 +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",
|
||||
@@ -248,13 +438,28 @@ 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]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||
dependencies = [
|
||||
"foreign-types-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.1"
|
||||
@@ -273,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"
|
||||
@@ -300,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"
|
||||
@@ -313,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"
|
||||
@@ -351,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"
|
||||
@@ -518,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"
|
||||
@@ -530,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",
|
||||
]
|
||||
|
||||
@@ -590,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"
|
||||
@@ -616,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"
|
||||
@@ -664,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"
|
||||
@@ -738,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",
|
||||
@@ -767,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",
|
||||
@@ -815,12 +1120,53 @@ 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"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"cfg-if",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-src"
|
||||
version = "300.5.1+3.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "735230c832b28c000e3bc117119e6466a663ec73506bc0a9907ea4187508e42a"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.109"
|
||||
@@ -829,10 +1175,17 @@ checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"openssl-src",
|
||||
"pkg-config",
|
||||
"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"
|
||||
@@ -938,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"
|
||||
@@ -953,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"
|
||||
@@ -983,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",
|
||||
]
|
||||
|
||||
@@ -991,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"
|
||||
@@ -1021,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"
|
||||
@@ -1071,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"
|
||||
@@ -1157,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"
|
||||
@@ -1200,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"
|
||||
@@ -1290,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"
|
||||
@@ -1398,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"
|
||||
@@ -1481,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"
|
||||
@@ -1573,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]]
|
||||
@@ -1598,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"
|
||||
@@ -1631,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"
|
||||
@@ -1643,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"
|
||||
@@ -1655,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"
|
||||
@@ -1679,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"
|
||||
@@ -1691,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"
|
||||
@@ -1703,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"
|
||||
@@ -1715,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"
|
||||
@@ -1766,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"
|
||||
|
||||
+36
-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,20 +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
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<img alt="Contributors" src="https://img.shields.io/github/contributors/dmtrKovalenko/fff.nvim?color=%23DDB6F2&label=CONTRIBUTORS&logo=git&style=for-the-badge&logoColor=D9E0EE&labelColor=302D41"/></a>
|
||||
</p>
|
||||
|
||||
**FFF** stands for ~freakin fast fuzzy file finder~ (pick 3) and it is an opinionated fuzzy file picker for neovim. Just for files, but we'll try to solve file picking completely.
|
||||
**FFF** stands for ~freakin fast fuzzy file finder~ (pick 3) and it is an opinionated fuzzy file picker for neovim. Just for files, but we'll try to solve file picking completely.
|
||||
|
||||
It comes with a dedicated rust backend runtime that keep tracks of the file index, your file access and modifications, git status, and provides a comprehensive typo-resistant fuzzy search experience.
|
||||
|
||||
@@ -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,99 +40,210 @@ 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",
|
||||
opts = {
|
||||
-- pass here all the options
|
||||
},
|
||||
keys = {
|
||||
{
|
||||
"<leader>ff", -- try it if you didn't it is a banger keybinding for a picker
|
||||
function()
|
||||
require("fff").toggle()
|
||||
end,
|
||||
desc = "Toggle FFF",
|
||||
'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 = { -- (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() 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' })
|
||||
|
||||
-- Key mappings (supports both single keys and arrays for multiple bindings)
|
||||
keymaps = {
|
||||
close = '<Esc>',
|
||||
select = '<CR>',
|
||||
select_split = '<C-s>',
|
||||
select_vsplit = '<C-v>',
|
||||
select_tab = '<C-t>',
|
||||
move_up = { '<Up>', '<C-p>' }, -- Multiple bindings supported
|
||||
move_down = { '<Down>', '<C-n>' }, -- Multiple bindings supported
|
||||
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)
|
||||
-- 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
|
||||
},
|
||||
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
|
||||
|
||||
#### Multiple Key Bindings
|
||||
|
||||
You can assign multiple key combinations to the same action:
|
||||
#### Available Methods
|
||||
|
||||
```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
|
||||
}
|
||||
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
|
||||
```
|
||||
|
||||
#### Commands
|
||||
|
||||
FFF.nvim provides several commands for interacting with the file picker:
|
||||
|
||||
- `: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
|
||||
|
||||
The input field automatically handles multiline clipboard content by joining all lines into a single search query. This is particularly useful when copying file paths from terminal output.
|
||||
@@ -144,58 +256,63 @@ 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
|
||||
|
||||
|
||||
### 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' }
|
||||
````
|
||||
|
||||
## Configuration
|
||||
|
||||
### Default Configuration
|
||||
|
||||
FFF.nvim comes with sensible defaults. Here's the complete default configuration:
|
||||
|
||||
```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
|
||||
preview_width = 0.5, -- Preview pane width as fraction of picker
|
||||
prompt = '🪿 ', -- Input prompt symbol
|
||||
title = 'FFF Files', -- Window title
|
||||
max_results = 60, -- Maximum search results to display
|
||||
max_threads = 4, -- Maximum threads for fuzzy search
|
||||
|
||||
keymaps = {
|
||||
close = '<Esc>',
|
||||
select = '<CR>',
|
||||
select_split = '<C-s>',
|
||||
select_vsplit = '<C-v>',
|
||||
select_tab = '<C-t>',
|
||||
move_up = { '<Up>', '<C-p>' }, -- Multiple bindings supported
|
||||
move_down = { '<Down>', '<C-n>' }, -- Multiple bindings supported
|
||||
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',
|
||||
},
|
||||
|
||||
debug = {
|
||||
show_scores = true, -- We hope for your collaboratio
|
||||
},
|
||||
})
|
||||
: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);
|
||||
@@ -1,15 +0,0 @@
|
||||
fn main() {
|
||||
// delete existing version file created by downloader
|
||||
let _ = std::fs::remove_file("target/release/version");
|
||||
|
||||
// get current sha from git
|
||||
let output = std::process::Command::new("git")
|
||||
.args(["rev-parse", "HEAD"])
|
||||
.output()
|
||||
.unwrap();
|
||||
let sha = String::from_utf8(output.stdout).unwrap();
|
||||
|
||||
// write to version
|
||||
std::fs::create_dir_all("target/release").unwrap();
|
||||
std::fs::write("target/release/version", sha.trim()).unwrap();
|
||||
}
|
||||
+208
-119
@@ -1,11 +1,10 @@
|
||||
*fff.nvim.txt* For Neovim >= 0.8.0 Last change: 2025 August 02
|
||||
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2025 October 17
|
||||
|
||||
==============================================================================
|
||||
Table of Contents *fff.nvim-table-of-contents*
|
||||
|
||||
- Features |fff.nvim-features|
|
||||
- Installation |fff.nvim-installation|
|
||||
- Configuration |fff.nvim-configuration|
|
||||
FFF.nvimFinally a smart fuzzy file picker for neovim.
|
||||
|
||||
|
||||
@@ -24,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.
|
||||
@@ -43,85 +43,148 @@ 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",
|
||||
opts = {
|
||||
-- pass here all the options
|
||||
},
|
||||
keys = {
|
||||
{
|
||||
"<leader>ff", -- try it if you didn't it is a banger keybinding for a picker
|
||||
function()
|
||||
require("fff").toggle()
|
||||
end,
|
||||
desc = "Toggle FFF",
|
||||
'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 = { -- (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() 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' })
|
||||
|
||||
-- Key mappings (supports both single keys and arrays for multiple bindings)
|
||||
keymaps = {
|
||||
close = '<Esc>',
|
||||
select = '<CR>',
|
||||
select_split = '<C-s>',
|
||||
select_vsplit = '<C-v>',
|
||||
select_tab = '<C-t>',
|
||||
move_up = { '<Up>', '<C-p>' }, -- Multiple bindings supported
|
||||
move_down = { '<Down>', '<C-n>' }, -- Multiple bindings supported
|
||||
preview_scroll_up = '<C-u>',
|
||||
preview_scroll_down = '<C-d>',
|
||||
},
|
||||
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,
|
||||
row = nil, -- ratio (0.0 = top edge, 1.0 = bottom edge) nil is centered
|
||||
col = nil, -- ratio (0.0 = left edge, 1.0 = right edge) nil is centered
|
||||
prompt_position = 'bottom', -- or 'top'
|
||||
preview_position = 'right', -- or 'left', 'right', 'top', 'bottom'
|
||||
preview_size = 0.5,
|
||||
},
|
||||
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>',
|
||||
},
|
||||
hl = {
|
||||
border = 'FloatBorder',
|
||||
normal = 'Normal',
|
||||
cursor = 'CursorLine',
|
||||
matched = 'IncSearch',
|
||||
title = 'Title',
|
||||
prompt = 'Question',
|
||||
active_file = 'Visual',
|
||||
frecency = 'Number',
|
||||
debug = 'Comment',
|
||||
},
|
||||
frecency = {
|
||||
enabled = true,
|
||||
db_path = vim.fn.stdpath('cache') .. '/fff_nvim',
|
||||
},
|
||||
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',
|
||||
}
|
||||
})
|
||||
<
|
||||
|
||||
@@ -129,15 +192,40 @@ configuration:
|
||||
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
|
||||
<
|
||||
|
||||
|
||||
COMMANDS
|
||||
|
||||
FFF.nvim provides several commands for interacting with the file picker:
|
||||
|
||||
- `: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
|
||||
|
||||
|
||||
MULTIPLE KEY BINDINGS
|
||||
|
||||
You can assign multiple key combinations to the same action:
|
||||
|
||||
>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
|
||||
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
|
||||
}
|
||||
<
|
||||
|
||||
@@ -157,64 +245,65 @@ 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' }
|
||||
|
||||
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
|
||||
<
|
||||
|
||||
|
||||
CONFIGURATION *fff.nvim-configuration*
|
||||
Or manually open the log file at `~/.local/state/nvim/log/fff.log` (default
|
||||
location).
|
||||
|
||||
|
||||
DEFAULT CONFIGURATION ~
|
||||
COMMON ISSUES
|
||||
|
||||
FFF.nvim comes with sensible defaults. Here’s the complete default
|
||||
configuration:
|
||||
**File picker not initializing:**
|
||||
|
||||
>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
|
||||
preview_width = 0.5, -- Preview pane width as fraction of picker
|
||||
prompt = '🪿 ', -- Input prompt symbol
|
||||
title = 'FFF Files', -- Window title
|
||||
max_results = 60, -- Maximum search results to display
|
||||
max_threads = 4, -- Maximum threads for fuzzy search
|
||||
|
||||
keymaps = {
|
||||
close = '<Esc>',
|
||||
select = '<CR>',
|
||||
select_split = '<C-s>',
|
||||
select_vsplit = '<C-v>',
|
||||
select_tab = '<C-t>',
|
||||
move_up = { '<Up>', '<C-p>' }, -- Multiple bindings supported
|
||||
move_down = { '<Down>', '<C-n>' }, -- Multiple bindings supported
|
||||
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',
|
||||
},
|
||||
|
||||
debug = {
|
||||
show_scores = true, -- We hope for your collaboratio
|
||||
},
|
||||
})
|
||||
<
|
||||
- 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>
|
||||
|
||||
|
||||
@@ -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
+98
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"nodes": {
|
||||
"crane": {
|
||||
"locked": {
|
||||
"lastModified": 1765739568,
|
||||
"narHash": "sha256-gQYx35Of4UDKUjAYvmxjUEh/DdszYeTtT6MDin4loGE=",
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"rev": "67d2baff0f9f677af35db61b32b5df6863bcc075",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1765644376,
|
||||
"narHash": "sha256-yqHBL2wYGwjGL2GUF2w3tofWl8qO9tZEuI4wSqbCrtE=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "23735a82a828372c4ef92c660864e82fbe2f5fbe",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"crane": "crane",
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1765680428,
|
||||
"narHash": "sha256-fyPmRof9SZeI14ChPk5rVPOm7ISiiGkwGCunkhM+eUg=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "eb3898d8ef143d4bf0f7f2229105fc51c7731b2f",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
{
|
||||
description = "fff.nvim";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
||||
|
||||
crane.url = "github:ipetkov/crane";
|
||||
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
|
||||
rust-overlay = {
|
||||
url = "github:oxalica/rust-overlay";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
};
|
||||
|
||||
outputs =
|
||||
{
|
||||
self,
|
||||
nixpkgs,
|
||||
crane,
|
||||
flake-utils,
|
||||
rust-overlay,
|
||||
...
|
||||
}:
|
||||
flake-utils.lib.eachDefaultSystem (
|
||||
system:
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
overlays = [ (import rust-overlay) ];
|
||||
};
|
||||
|
||||
rustToolchain = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml;
|
||||
|
||||
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
|
||||
|
||||
# Common arguments can be set here to avoid repeating them later
|
||||
# Note: changes here will rebuild all dependency crates
|
||||
commonArgs = {
|
||||
src = craneLib.cleanCargoSource ./.;
|
||||
strictDeps = true;
|
||||
|
||||
nativeBuildInputs = [ pkgs.pkg-config pkgs.perl ];
|
||||
buildInputs = with pkgs; [
|
||||
# Add additional build inputs here
|
||||
openssl
|
||||
];
|
||||
};
|
||||
|
||||
my-crate = craneLib.buildPackage (
|
||||
commonArgs
|
||||
// {
|
||||
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
|
||||
|
||||
}
|
||||
);
|
||||
# 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 ${my-crate}/lib/libfff_nvim.dylib target/release/libfff_nvim.dylib
|
||||
else
|
||||
cp -vf ${my-crate}/lib/libfff_nvim.so target/release/libfff_nvim.so
|
||||
fi
|
||||
echo "Library copied to target/release/"
|
||||
'';
|
||||
in
|
||||
{
|
||||
checks = {
|
||||
inherit 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;
|
||||
};
|
||||
|
||||
# Add the release command
|
||||
apps.release = flake-utils.lib.mkApp {
|
||||
drv = pkgs.writeShellScriptBin "release" copy-dynamic-library;
|
||||
};
|
||||
|
||||
devShells.default = craneLib.devShell {
|
||||
# Inherit inputs from checks.
|
||||
checks = self.checks.${system};
|
||||
# Extra inputs can be added here; cargo and rustc are provided by default.
|
||||
packages = [
|
||||
# pkgs.ripgrep
|
||||
];
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
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,
|
||||
}
|
||||
|
||||
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)
|
||||
local list_config = vim.api.nvim_win_get_config(list_win)
|
||||
local combo_header_row = list_config.row + combo_header_line
|
||||
|
||||
-- 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
|
||||
|
||||
-- Cache current values
|
||||
overlay_state.last_row = combo_header_row
|
||||
overlay_state.last_col = list_config.col
|
||||
overlay_state.last_border_hl = border_hl
|
||||
|
||||
-- Update both overlays in a batch to minimize API calls
|
||||
local left_buf = get_or_create_overlay_buf('left_buf')
|
||||
local right_buf = get_or_create_overlay_buf('right_buf')
|
||||
|
||||
-- Update content for both buffers
|
||||
update_overlay_content(left_buf, LEFT_OVERLAY_CONTENT, border_hl)
|
||||
update_overlay_content(right_buf, RIGHT_OVERLAY_CONTENT, border_hl)
|
||||
|
||||
-- Position both windows
|
||||
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
|
||||
|
||||
-- Clear cache
|
||||
overlay_state.last_row = nil
|
||||
overlay_state.last_col = nil
|
||||
overlay_state.last_border_hl = nil
|
||||
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
|
||||
|
||||
function M.render_highlights_and_overlays(
|
||||
combo_item_index,
|
||||
text_len,
|
||||
list_buf,
|
||||
list_win,
|
||||
ns_id,
|
||||
border_hl,
|
||||
item_to_lines
|
||||
)
|
||||
if not combo_item_index then
|
||||
clear_overlays_internal()
|
||||
return
|
||||
end
|
||||
|
||||
local combo_item_lines = item_to_lines[combo_item_index]
|
||||
if not combo_item_lines then
|
||||
clear_overlays_internal()
|
||||
return
|
||||
end
|
||||
|
||||
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)
|
||||
end
|
||||
|
||||
function M.get_overlay_widths() return LEFT_OVERLAY_WIDTH, RIGHT_OVERLAY_WIDTH end
|
||||
|
||||
function M.cleanup() clear_overlays_internal() end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,231 @@
|
||||
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',
|
||||
-- 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)
|
||||
},
|
||||
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 uv = vim and vim.uv or require('luv')
|
||||
|
||||
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 = 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')
|
||||
uv.fs_mkdir(dir, 493, function(err) -- 493 = 0755 octal
|
||||
if err and not err:match('EEXIST') then
|
||||
callback(false, 'Failed to create directory: ' .. 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
|
||||
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
|
||||
+20
-153
@@ -7,49 +7,14 @@ local icon_providers = {
|
||||
|
||||
M.provider = nil
|
||||
M.provider_name = nil
|
||||
|
||||
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 = {}
|
||||
M.setup_attempted = false
|
||||
M.setup_failed = false
|
||||
|
||||
function M.setup()
|
||||
if M.provider_name then return true end
|
||||
if M.setup_failed then return false end
|
||||
|
||||
M.setup_attempted = true
|
||||
|
||||
for _, provider_name in ipairs(icon_providers) do
|
||||
local ok, provider = pcall(require, provider_name)
|
||||
@@ -60,150 +25,52 @@ function M.setup()
|
||||
end
|
||||
end
|
||||
|
||||
vim.notify('FFF Icons: No icon provider found. Please install nvim-web-devicons or mini.icons', vim.log.levels.WARN)
|
||||
M.setup_failed = true
|
||||
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
|
||||
|
||||
+155
-108
@@ -1,29 +1,94 @@
|
||||
--- Image handling module for file picker
|
||||
--- Simple implementation that delegates to Snacks.nvim
|
||||
local utils = require('fff.utils')
|
||||
|
||||
local M = {}
|
||||
|
||||
-- Track active image placements per buffer
|
||||
local function get_main_config()
|
||||
local main = require('fff.main')
|
||||
return main.config
|
||||
end
|
||||
|
||||
local active_placements = {} ---@type table<number, any>
|
||||
local loading_jobs = {} ---@type table<number, {metadata_job?: any}>
|
||||
|
||||
-- Helper function to safely set buffer lines
|
||||
local function safe_set_buffer_lines(bufnr, start, end_line, strict_indexing, lines)
|
||||
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return false end
|
||||
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 - metadata_lines_count - 2, 5)
|
||||
|
||||
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
|
||||
|
||||
-- Make buffer modifiable temporarily
|
||||
local was_modifiable = vim.api.nvim_buf_get_option(bufnr, 'modifiable')
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
|
||||
|
||||
-- Set the lines
|
||||
local ok, err = pcall(vim.api.nvim_buf_set_lines, bufnr, start, end_line, strict_indexing, lines)
|
||||
|
||||
-- Restore modifiable state
|
||||
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, buffer_lines)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', was_modifiable)
|
||||
|
||||
return ok
|
||||
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
|
||||
|
||||
-- Common image extensions (SVG excluded for text preview)
|
||||
local IMAGE_EXTENSIONS = {
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
@@ -41,7 +106,6 @@ local IMAGE_EXTENSIONS = {
|
||||
'.avif',
|
||||
}
|
||||
|
||||
--- Check if file is an image
|
||||
--- @param file_path string Path to the file
|
||||
--- @return boolean True if file is an image
|
||||
function M.is_image(file_path)
|
||||
@@ -55,127 +119,110 @@ function M.is_image(file_path)
|
||||
return false
|
||||
end
|
||||
|
||||
--- Check if we're in Kitty terminal
|
||||
--- @return boolean True if in Kitty
|
||||
function M.is_kitty() return vim.env.KITTY_PID ~= nil 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
|
||||
|
||||
-- Close any tracked placement for this buffer
|
||||
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
|
||||
end
|
||||
|
||||
local ok, snacks = pcall(require, 'snacks')
|
||||
if ok and snacks.image and snacks.image.placement then
|
||||
-- Use the proper Snacks.nvim placement cleanup API
|
||||
-- This removes all image placements for the specified buffer
|
||||
pcall(snacks.image.placement.clean, bufnr)
|
||||
end
|
||||
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
|
||||
--- @param max_height number Maximum height in characters
|
||||
--- @return boolean
|
||||
function M.display_image(file_path, bufnr, max_width, max_height)
|
||||
max_width = max_width or 80
|
||||
max_height = max_height or 24
|
||||
vim.api.nvim_buf_set_option(bufnr, 'number', false)
|
||||
|
||||
if not M.is_kitty() then
|
||||
M.display_image_info(file_path, bufnr, 'Not in Kitty terminal')
|
||||
return
|
||||
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 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
|
||||
|
||||
-- Try Snacks.nvim first (most reliable)
|
||||
local ok, snacks = pcall(require, 'snacks')
|
||||
if ok and snacks.image and snacks.image.buf then
|
||||
-- Clear any existing image attachments first
|
||||
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)
|
||||
|
||||
-- Clear buffer content to prevent text/image overlap
|
||||
safe_set_buffer_lines(bufnr, 0, -1, false, {})
|
||||
vim.schedule(function()
|
||||
local success, placement = pcall(snacks.image.placement.new, bufnr, file_path, {
|
||||
pos = { image_content_starts_at_line, 1 },
|
||||
inline = true,
|
||||
fit = 'contain',
|
||||
auto_resize = true,
|
||||
})
|
||||
|
||||
-- Configure Snacks image to prevent repetition
|
||||
local success, placement = pcall(snacks.image.buf.attach, bufnr, {
|
||||
src = file_path,
|
||||
fit = 'contain', -- Fit image within bounds without repetition
|
||||
})
|
||||
if success and placement then active_placements[bufnr] = placement end
|
||||
end)
|
||||
|
||||
if success and placement then
|
||||
-- Track the placement so we can clean it up later
|
||||
active_placements[bufnr] = placement
|
||||
return -- Successfully attached image, we're done
|
||||
else
|
||||
M.display_image_info(file_path, bufnr, 'Snacks.nvim failed: ' .. tostring(placement or 'unknown error'))
|
||||
return
|
||||
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')
|
||||
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 = {}
|
||||
|
||||
local stat = vim.loop.fs_stat(file_path)
|
||||
if stat then
|
||||
table.insert(info, string.format('📁 File: %s', vim.fn.fnamemodify(file_path, ':t')))
|
||||
table.insert(info, string.format('📏 Size: %d bytes', stat.size))
|
||||
table.insert(info, string.format('🕒 Modified: %s', os.date('%Y-%m-%d %H:%M:%S', stat.mtime.sec)))
|
||||
end
|
||||
|
||||
local width, height = M.get_image_dimensions(file_path)
|
||||
if width and height then table.insert(info, string.format('🖼️ Dimensions: %dx%d pixels', width, height)) end
|
||||
|
||||
local ext = string.lower(vim.fn.fnamemodify(file_path, ':e'))
|
||||
if ext ~= '' then table.insert(info, string.format('🎨 Format: %s', ext:upper())) end
|
||||
|
||||
table.insert(info, '')
|
||||
table.insert(info, '┌─ Image Preview Debug ─┐')
|
||||
table.insert(info, string.format('│ Kitty: %s │', M.is_kitty() and 'Yes' or 'No'))
|
||||
table.insert(info, string.format('│ KITTY_PID: %s │', vim.env.KITTY_PID or 'nil'))
|
||||
if reason then
|
||||
table.insert(info, string.format('│ Issue: %s', reason:sub(1, 16)))
|
||||
if #reason > 16 then table.insert(info, string.format('│ %s', reason:sub(17, 32))) end
|
||||
end
|
||||
table.insert(info, '└─────────────────────────┘')
|
||||
|
||||
safe_set_buffer_lines(bufnr, 0, -1, false, info)
|
||||
end
|
||||
|
||||
--- Get image dimensions using file command
|
||||
--- @param file_path string Path to the image file
|
||||
--- @return number|nil, number|nil Width and height in pixels
|
||||
function M.get_image_dimensions(file_path)
|
||||
-- Try file command first
|
||||
local cmd = string.format('file "%s"', file_path)
|
||||
local result = vim.fn.system(cmd)
|
||||
|
||||
if vim.v.shell_error == 0 then
|
||||
local width, height = result:match('(%d+)%s*x%s*(%d+)')
|
||||
if width and height then return tonumber(width), tonumber(height) end
|
||||
end
|
||||
|
||||
-- Fallback to identify command (ImageMagick)
|
||||
cmd = string.format('identify -format "%%w %%h" "%s" 2>/dev/null', file_path)
|
||||
result = vim.fn.system(cmd)
|
||||
|
||||
if vim.v.shell_error == 0 then
|
||||
local width, height = result:match('(%d+)%s+(%d+)')
|
||||
if width and height then return tonumber(width), tonumber(height) end
|
||||
end
|
||||
|
||||
return nil, nil
|
||||
return false
|
||||
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,20 +149,17 @@ 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 total_files, scanned_files, is_scanning
|
||||
--- @return table Progress information with scanned_files_count, is_scanning
|
||||
function M.get_scan_progress()
|
||||
if not M.state.initialized then return { total_files = 0, scanned_files = 0, is_scanning = false } end
|
||||
if not M.state.initialized then return { total_files = 0, scanned_files_count = 0, is_scanning = false } end
|
||||
|
||||
local ok, result = pcall(fuzzy.get_scan_progress)
|
||||
if not ok then
|
||||
vim.notify('Failed to get scan progress: ' .. result, vim.log.levels.WARN)
|
||||
return { total_files = 0, scanned_files = 0, is_scanning = false }
|
||||
return { scanned_files_count = 0, is_scanning = false }
|
||||
end
|
||||
|
||||
return result
|
||||
end
|
||||
|
||||
|
||||
+641
-436
File diff suppressed because it is too large
Load Diff
+11
-1
@@ -1,3 +1,4 @@
|
||||
---@class fff.fuzzy
|
||||
local M = {}
|
||||
|
||||
-- Try to load the Rust module
|
||||
@@ -15,18 +16,27 @@ M.get_keyword_range = rust_module.get_keyword_range
|
||||
M.guess_edit_range = rust_module.guess_edit_range
|
||||
M.get_words = rust_module.get_words
|
||||
M.init_file_picker = rust_module.init_file_picker
|
||||
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
|
||||
M.get_scan_progress = rust_module.get_scan_progress
|
||||
M.is_scanning = rust_module.is_scanning
|
||||
M.refresh_git_status = rust_module.refresh_git_status
|
||||
M.update_single_file_frecency = rust_module.update_single_file_frecency
|
||||
M.stop_background_monitor = rust_module.stop_background_monitor
|
||||
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_highlight(git_status)
|
||||
ensure_cache()
|
||||
return 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[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[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
|
||||
+89
-368
@@ -1,232 +1,13 @@
|
||||
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 = 100,
|
||||
max_size = 1024 * 1024, -- 1MB
|
||||
},
|
||||
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_default_keymaps()
|
||||
M.setup_commands()
|
||||
|
||||
if merged_config.frecency.enabled then M.setup_global_file_tracking() end
|
||||
|
||||
local git_utils = require('fff.git_utils')
|
||||
git_utils.setup_highlights()
|
||||
|
||||
if merged_config.logging.enabled then
|
||||
local fuzzy = require('fff.fuzzy')
|
||||
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
|
||||
|
||||
--- Setup default keymaps
|
||||
function M.setup_default_keymaps()
|
||||
vim.keymap.set('n', '<leader>ff', function() M.find_files() end, { desc = 'Find files' })
|
||||
vim.keymap.set('n', '<leader>ft', function() M.toggle() end, { desc = 'Toggle file picker' })
|
||||
vim.keymap.set('n', '<leader>fg', function() M.find_in_git_root() end, { desc = 'Find files in git root' })
|
||||
vim.keymap.set('n', '<leader>fr', function() M.find_recent() end, { desc = 'Find recent files' })
|
||||
end
|
||||
|
||||
function M.setup_global_file_tracking()
|
||||
local group = vim.api.nvim_create_augroup('fff_file_tracking', { clear = true })
|
||||
|
||||
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.loop.fs_stat(file_path)
|
||||
if stat and stat.type == 'file' then
|
||||
local fuzzy = require('fff.fuzzy')
|
||||
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
|
||||
|
||||
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, cmd_line, cursor_pos)
|
||||
-- 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(arg_lead, cmd_line, cursor_pos) 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()
|
||||
@@ -234,69 +15,33 @@ function M.find_files()
|
||||
if picker_ok then
|
||||
picker_ui.open()
|
||||
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
|
||||
|
||||
--- Find files in specific directory
|
||||
--- @param dir string Directory path
|
||||
function M.find_files_in_dir(dir)
|
||||
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
|
||||
if picker_ok then
|
||||
picker_ui.open({ cwd = dir })
|
||||
else
|
||||
vim.notify('Failed to load picker UI', vim.log.levels.ERROR)
|
||||
end
|
||||
end
|
||||
|
||||
--- Find files in git repository root
|
||||
function M.find_in_git_root()
|
||||
-- Check if we're in a git repo first
|
||||
local git_root = vim.fn.system('git rev-parse --show-toplevel 2>/dev/null'):gsub('\n', '')
|
||||
if vim.v.shell_error ~= 0 then
|
||||
vim.notify('Not in a git repository', vim.log.levels.WARN)
|
||||
return
|
||||
end
|
||||
|
||||
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
|
||||
if picker_ok then
|
||||
picker_ui.open({ title = 'Git Files', cwd = git_root })
|
||||
else
|
||||
vim.notify('Failed to load picker UI', vim.log.levels.ERROR)
|
||||
end
|
||||
M.find_files_in_dir(git_root)
|
||||
end
|
||||
|
||||
--- Find recent files (frecency based)
|
||||
function M.find_recent()
|
||||
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
|
||||
if picker_ok then
|
||||
picker_ui.open({ title = 'Recent Files' })
|
||||
else
|
||||
vim.notify('Failed to load picker UI', vim.log.levels.ERROR)
|
||||
end
|
||||
end
|
||||
|
||||
--- Toggle file picker
|
||||
function M.toggle() M.find_files() end
|
||||
|
||||
--- Scan files
|
||||
--- Trigger rescan of files in the current directory
|
||||
function M.scan_files()
|
||||
local fuzzy = require('fff.fuzzy')
|
||||
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 all cached files
|
||||
--- Refresh git status for the active file lock
|
||||
function M.refresh_git_status()
|
||||
local fuzzy = require('fff.fuzzy')
|
||||
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
|
||||
@@ -307,9 +52,13 @@ end
|
||||
--- @param max_results number Maximum number of results
|
||||
--- @return table List of matching files
|
||||
function M.search(query, max_results)
|
||||
local fuzzy = require('fff.fuzzy')
|
||||
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
|
||||
@@ -371,98 +120,13 @@ function M.get_preview(file_path)
|
||||
return table.concat(lines, '\n')
|
||||
end
|
||||
|
||||
function M.debug_file_ordering()
|
||||
print('FFF Debug File Ordering')
|
||||
print('=======================')
|
||||
|
||||
if not M.is_initialized() then
|
||||
print('File picker not initialized. Run :FFFScan first.')
|
||||
return
|
||||
end
|
||||
|
||||
local picker = M.picker or M.core
|
||||
print('Getting top 10 files with debug info...')
|
||||
|
||||
-- Enable debug mode temporarily
|
||||
local old_debug = M.config.debug.show_scores
|
||||
M.config.debug.show_scores = true
|
||||
|
||||
-- Search with empty query to get default ordering
|
||||
local files = picker.search_files('', 10)
|
||||
|
||||
print('🏆 TOP FILES (in order they appear):')
|
||||
print('=' .. string.rep('=', 70))
|
||||
|
||||
for i, file in ipairs(files) do
|
||||
local frecency_stars = ''
|
||||
if file.frecency_score > 0 then frecency_stars = ' ⭐' .. file.frecency_score end
|
||||
|
||||
-- Extract directory information
|
||||
local dir = vim.fn.fnamemodify(file.relative_path, ':h')
|
||||
local filename = vim.fn.fnamemodify(file.relative_path, ':t')
|
||||
local dir_display = (dir == '.' or dir == '') and 'root' or dir
|
||||
|
||||
-- Get score information for this file
|
||||
local score = picker.get_file_score(i)
|
||||
|
||||
print(string.format('%2d. %s%s', i, filename, frecency_stars))
|
||||
print(string.format(' Path: %s/', dir_display))
|
||||
print(string.format(' Debug: %s', score and score.match_type or 'no debug info'))
|
||||
if score then
|
||||
print(
|
||||
string.format(
|
||||
' Total Score: %d (base=%d, name_bonus=%d, special_bonus=%d, frec=%d, dist=%d)',
|
||||
score.total,
|
||||
score.base_score,
|
||||
score.filename_bonus,
|
||||
score.special_filename_bonus,
|
||||
score.frecency_boost,
|
||||
score.distance_penalty
|
||||
)
|
||||
)
|
||||
else
|
||||
print(' Total Score: N/A (no score data)')
|
||||
end
|
||||
|
||||
local now = os.time()
|
||||
local age_hours = math.floor((now - file.modified) / 3600)
|
||||
local age_days = math.floor(age_hours / 24)
|
||||
print(string.format(' Age: %d hours (%d days) since last modified', age_hours, age_days))
|
||||
print('')
|
||||
end
|
||||
|
||||
print('💡 EXPLANATION:')
|
||||
print('• Files are sorted by FRECENCY first (⭐ score), then by modification time')
|
||||
print('• Frecency combines how often AND how recently you accessed files')
|
||||
print('• The file at #1 has either:')
|
||||
print(' - Highest frecency score, OR')
|
||||
print(' - Same frecency as others but most recent modification')
|
||||
|
||||
M.config.debug.show_scores = old_debug
|
||||
end
|
||||
|
||||
function M.health_check()
|
||||
local health = {
|
||||
ok = true,
|
||||
messages = {},
|
||||
}
|
||||
|
||||
local errors = check_dependencies()
|
||||
if #errors > 0 then
|
||||
health.ok = false
|
||||
for _, error in ipairs(errors) do
|
||||
table.insert(health.messages, error)
|
||||
end
|
||||
end
|
||||
|
||||
local ui_errors = check_ui_dependencies()
|
||||
if #ui_errors > 0 then
|
||||
table.insert(health.messages, 'UI not available: ' .. table.concat(ui_errors, ', '))
|
||||
else
|
||||
table.insert(health.messages, '✓ UI available')
|
||||
end
|
||||
|
||||
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
|
||||
@@ -500,20 +164,77 @@ function M.health_check()
|
||||
return health
|
||||
end
|
||||
|
||||
function M.get_status()
|
||||
local status = 'No files indexed'
|
||||
|
||||
local fuzzy = require('fff.fuzzy')
|
||||
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'
|
||||
--- Find files in a specific directory
|
||||
--- @param directory string Directory path to search in
|
||||
function M.find_files_in_dir(directory)
|
||||
if not directory then
|
||||
vim.notify('Directory path required for find_files_in_dir', vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
|
||||
return status
|
||||
M.change_indexing_directory(directory)
|
||||
|
||||
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
|
||||
if picker_ok then
|
||||
picker_ui.open({ title = 'Files in ' .. vim.fn.fnamemodify(directory, ':t') })
|
||||
else
|
||||
vim.notify('Failed to load picker UI', vim.log.levels.ERROR)
|
||||
end
|
||||
end
|
||||
|
||||
function M.is_initialized() return M.state and M.state.initialized or false end
|
||||
--- Change the base directory for the file picker
|
||||
--- @param new_path string New directory path to use as base
|
||||
--- @return boolean `true` if successful, `false` otherwise
|
||||
function M.change_indexing_directory(new_path)
|
||||
if not new_path or new_path == '' then
|
||||
vim.notify('Directory path is required', vim.log.levels.ERROR)
|
||||
return false
|
||||
end
|
||||
|
||||
local expanded_path = vim.fn.expand(new_path)
|
||||
|
||||
if vim.fn.isdirectory(expanded_path) ~= 1 then
|
||||
vim.notify('Directory does not exist: ' .. expanded_path, vim.log.levels.ERROR)
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
+1479
-470
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")
|
||||
)
|
||||
}
|
||||
+22
-17
@@ -1,22 +1,20 @@
|
||||
use std::path::StripPrefixError;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
#[non_exhaustive]
|
||||
pub enum Error {
|
||||
#[error("Thread panicked")]
|
||||
ThreadPanic,
|
||||
#[error("Invalid path {0}")]
|
||||
InvalidPath(std::path::PathBuf),
|
||||
#[error("File picker not initialized")]
|
||||
FilePickerMissing,
|
||||
#[error("Failed to acquire lock for frecency")]
|
||||
AcquireFrecencyLock,
|
||||
|
||||
#[error("Failed to acquire lock for items by provider")]
|
||||
AcquireItemLock,
|
||||
|
||||
#[error("Attempted to use frecency before initialization")]
|
||||
UseFrecencyBeforeInit,
|
||||
|
||||
#[error(
|
||||
"Attempted to fuzzy match for provider {provider_id} before setting the provider's items"
|
||||
)]
|
||||
FuzzyBeforeSetItems { provider_id: String },
|
||||
|
||||
#[error("Failed to create frecency database directory: {0}")]
|
||||
CreateDir(#[source] std::io::Error),
|
||||
#[error("Failed to create directory: {0}")]
|
||||
CreateDir(#[from] std::io::Error),
|
||||
#[error("Failed to open frecency database env: {0}")]
|
||||
EnvOpen(#[source] heed::Error),
|
||||
#[error("Failed to create frecency database: {0}")]
|
||||
@@ -35,16 +33,23 @@ pub enum Error {
|
||||
DbWrite(#[source] heed::Error),
|
||||
#[error("Failed to commit write transaction to frecency database: {0}")]
|
||||
DbCommit(#[source] heed::Error),
|
||||
#[error("Failed to start file system watcher: {0}")]
|
||||
FileSystemWatch(#[from] notify::Error),
|
||||
|
||||
#[error("Invalid file path: {0}")]
|
||||
InvalidPath(String),
|
||||
#[error("Expected a path to be child of another path: {0}")]
|
||||
StripPrefixError(#[from] StripPrefixError),
|
||||
|
||||
#[error("Failed to scan directory: {0}")]
|
||||
DirectoryScan(String),
|
||||
#[error("libgit2 error occurred: {0}")]
|
||||
Git(#[from] git2::Error),
|
||||
}
|
||||
|
||||
impl From<Error> for mlua::Error {
|
||||
fn from(value: Error) -> Self {
|
||||
mlua::Error::RuntimeError(value.to_string())
|
||||
let string_value = value.to_string();
|
||||
|
||||
::tracing::error!(string_value);
|
||||
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,
|
||||
}
|
||||
+404
-463
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);
|
||||
}
|
||||
}
|
||||
|
||||
+94
-45
@@ -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 mut entries = Vec::new();
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+46
-11
@@ -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,15 +9,48 @@ 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('@?(.*/)')
|
||||
package.cpath = package.cpath
|
||||
.. ';'
|
||||
.. base_path
|
||||
.. '../../../target/release/lib?'
|
||||
.. get_lib_extension()
|
||||
.. ';'
|
||||
.. base_path
|
||||
.. '../../../target/release/?'
|
||||
.. get_lib_extension()
|
||||
local info = debug.getinfo(1, 'S')
|
||||
local base_path = info and info.source and info.source:match('@?(.*/)') or ''
|
||||
|
||||
return require('fff_nvim')
|
||||
-- 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(),
|
||||
}
|
||||
|
||||
local cargo_target_dir = os.getenv('CARGO_TARGET_DIR')
|
||||
if cargo_target_dir then
|
||||
table.insert(paths, cargo_target_dir .. '/release/lib?' .. get_lib_extension())
|
||||
table.insert(paths, cargo_target_dir .. '/release/?' .. get_lib_extension())
|
||||
end
|
||||
|
||||
-- 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 loader then return loader() end
|
||||
end
|
||||
end
|
||||
return nil, 'No valid library found in any search path'
|
||||
end
|
||||
|
||||
local backend, load_err = try_load_library()
|
||||
if not backend then
|
||||
local err_msg = string.format(
|
||||
'Failed to load fff rust backend.\nError: %s\nSearched paths:\n%s\nMake sure binary exists with `cargo build --release`',
|
||||
tostring(load_err),
|
||||
vim.inspect(paths)
|
||||
)
|
||||
error(err_msg)
|
||||
end
|
||||
|
||||
return backend
|
||||
|
||||
+315
-68
@@ -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,46 +80,150 @@ pub fn init_file_picker(_: &Lua, base_path: String) -> LuaResult<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
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(mut picker) = file_picker.take() {
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
|
||||
let new_picker = FilePicker::new(path.to_string_lossy().to_string())?;
|
||||
*file_picker = Some(new_picker);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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!(
|
||||
"Path does not exist: {}",
|
||||
new_path
|
||||
)));
|
||||
}
|
||||
|
||||
let canonical_path = path.canonicalize().map_err(|e| {
|
||||
LuaError::RuntimeError(format!("Failed to canonicalize path '{}': {}", new_path, e))
|
||||
})?;
|
||||
|
||||
// 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()
|
||||
.ok_or_else(|| Error::InvalidPath("File picker not initialized".to_string()))?;
|
||||
.as_mut()
|
||||
.ok_or_else(|| Error::FilePickerMissing)?;
|
||||
|
||||
picker.trigger_rescan()?;
|
||||
::tracing::info!("scan_files trigger_rescan completed");
|
||||
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::InvalidPath("File picker not initialized".to_string()))?;
|
||||
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::InvalidPath("File picker not initialized".to_string()))?;
|
||||
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 };
|
||||
tracker.track_access(&file_key)?;
|
||||
}
|
||||
pub fn track_access(_: &Lua, file_path: String) -> LuaResult<bool> {
|
||||
let file_path = PathBuf::from(&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)
|
||||
}
|
||||
|
||||
@@ -93,12 +231,11 @@ pub fn get_scan_progress(lua: &Lua, _: ()) -> LuaResult<LuaValue> {
|
||||
let file_picker = FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)?;
|
||||
let picker = file_picker
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::InvalidPath("File picker not initialized".to_string()))?;
|
||||
.ok_or_else(|| Error::FilePickerMissing)?;
|
||||
let progress = picker.get_scan_progress();
|
||||
|
||||
let table = lua.create_table()?;
|
||||
table.set("total_files", progress.total_files)?;
|
||||
table.set("scanned_files", progress.scanned_files)?;
|
||||
table.set("scanned_files_count", progress.scanned_files_count)?;
|
||||
table.set("is_scanning", progress.is_scanning)?;
|
||||
Ok(LuaValue::Table(table))
|
||||
}
|
||||
@@ -107,69 +244,161 @@ pub fn is_scanning(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
let file_picker = FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)?;
|
||||
let picker = file_picker
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::InvalidPath("File picker not initialized".to_string()))?;
|
||||
.ok_or_else(|| Error::FilePickerMissing)?;
|
||||
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::InvalidPath("File picker not initialized".to_string()))?;
|
||||
pub fn refresh_git_status(_: &Lua, _: ()) -> LuaResult<usize> {
|
||||
FilePicker::refresh_git_status_global().map_err(Into::into)
|
||||
}
|
||||
|
||||
Ok(picker.refresh_git_status())
|
||||
pub fn update_single_file_frecency(_: &Lua, file_path: String) -> LuaResult<bool> {
|
||||
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, frecency)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn stop_background_monitor(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
let file_picker = FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)?;
|
||||
let picker = file_picker
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::InvalidPath("File picker not initialized".to_string()))?;
|
||||
let Some(ref mut picker) = *FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)? else {
|
||||
return Err(Error::FilePickerMissing)?;
|
||||
};
|
||||
|
||||
picker.stop_background_monitor();
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn cleanup_file_picker(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
let mut file_picker = FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)?;
|
||||
if let Some(picker) = file_picker.take() {
|
||||
drop(picker);
|
||||
::tracing::info!("FilePicker cleanup completed");
|
||||
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::InvalidPath("File picker not initialized".to_string()))?;
|
||||
.ok_or_else(|| Error::FilePickerMissing)?;
|
||||
|
||||
let timeout = Duration::from_millis(timeout_ms.unwrap_or(5000)); // Default 5s timeout
|
||||
let timeout_ms = timeout_ms.unwrap_or(500);
|
||||
let timeout_duration = Duration::from_millis(timeout_ms);
|
||||
let start_time = std::time::Instant::now();
|
||||
let mut sleep_duration = Duration::from_millis(1);
|
||||
|
||||
while picker.is_scan_active() && start_time.elapsed() < timeout {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
while picker.is_scan_active() {
|
||||
if start_time.elapsed() >= timeout_duration {
|
||||
::tracing::warn!("wait_for_initial_scan timed out after {}ms", timeout_ms);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
std::thread::sleep(sleep_duration);
|
||||
sleep_duration = std::cmp::min(sleep_duration * 2, Duration::from_millis(50));
|
||||
}
|
||||
|
||||
Ok(!picker.is_scan_active())
|
||||
::tracing::debug!(
|
||||
"wait_for_initial_scan completed in {:?}",
|
||||
start_time.elapsed()
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -185,11 +414,29 @@ fn create_exports(lua: &Lua) -> LuaResult<LuaTable> {
|
||||
"wait_for_initial_scan",
|
||||
lua.create_function(wait_for_initial_scan)?,
|
||||
)?;
|
||||
exports.set(
|
||||
"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"
|
||||
);
|
||||
}
|
||||
}
|
||||
+387
-65
@@ -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,
|
||||
);
|
||||
// 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
|
||||
};
|
||||
|
||||
filename_matches.par_sort_by_key(|m| m.index_in_haystack);
|
||||
let mut next_filename_match_index = 0;
|
||||
let mut results: Vec<_> = path_matches
|
||||
let results: Vec<_> = path_matches
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, neo_frizbee_match)| {
|
||||
let file_idx = neo_frizbee_match.index_in_haystack as usize;
|
||||
.map(|(index, path_match)| {
|
||||
let file_idx = path_match.index as usize;
|
||||
let file = &files[file_idx];
|
||||
|
||||
let base_score = neo_frizbee_match.score as i32;
|
||||
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 {
|
||||
@@ -80,31 +113,67 @@ pub fn match_and_score_files(files: &[FileItem], context: &ScoringContext) -> Ve
|
||||
}
|
||||
});
|
||||
|
||||
tracing::debug!(filename_match = ?filename_match, "Filename match for file {}", index);
|
||||
|
||||
let mut has_special_filename_bonus = false;
|
||||
let filename_bonus = match filename_match {
|
||||
Some(filename_match) if filename_match.exact => {
|
||||
base_score / 5 * 2 // 40% bonus for exact filename match
|
||||
filename_match.score as i32 / 5 * 2 // 40% bonus for exact filename match
|
||||
}
|
||||
Some(_) => base_score / 5, // 20% bonus for fuzzy filename match
|
||||
// 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
|
||||
&& !query_contains_path_separator =>
|
||||
{
|
||||
base_score = filename_match.score as i32;
|
||||
|
||||
(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
|
||||
None if is_special_entry_point_file(&file.file_name) => {
|
||||
// 18% bonus special filename just as much as exact path
|
||||
// but a little bit less to give preference to the actual file if present
|
||||
has_special_filename_bonus = true;
|
||||
base_score * 18 / 100
|
||||
base_score * 5 / 100
|
||||
}
|
||||
_ => 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,
|
||||
}
|
||||
None => 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
|
||||
@@ -113,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",
|
||||
@@ -120,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
|
||||
@@ -154,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,109 +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).map_err(|e| {
|
||||
Error::InvalidPath(format!(
|
||||
"Failed to create log directory {}: {}",
|
||||
parent.display(),
|
||||
e
|
||||
))
|
||||
})?;
|
||||
}
|
||||
|
||||
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,104 @@
|
||||
--- 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
|
||||
function M.render(layout, config, list_win, pagination)
|
||||
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
|
||||
|
||||
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
|
||||
local thumb_start = math.floor((pagination.page_index / math.max(1, total_pages - 1)) * scrollbar_range)
|
||||
|
||||
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
|
||||
@@ -0,0 +1,62 @@
|
||||
local M = {}
|
||||
|
||||
--- Format file size into human-readable string
|
||||
--- @param size number File size in bytes
|
||||
--- @return string Formatted size string (e.g., "1.2 KB", "3.4 MB")
|
||||
function M.format_file_size(size)
|
||||
if not size or size < 0 then return 'Unknown' end
|
||||
|
||||
if size < 1024 then
|
||||
return string.format('%d B', size)
|
||||
elseif size < 1024 * 1024 then
|
||||
return string.format('%.1f KB', size / 1024)
|
||||
elseif size < 1024 * 1024 * 1024 then
|
||||
return string.format('%.1f MB', size / (1024 * 1024))
|
||||
else
|
||||
return string.format('%.1f GB', size / (1024 * 1024 * 1024))
|
||||
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
|
||||
|
||||
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(())
|
||||
}
|
||||
+104
-81
@@ -1,34 +1,33 @@
|
||||
#![allow(clippy::all)]
|
||||
#![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();
|
||||
@@ -38,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");
|
||||
@@ -88,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!(
|
||||
@@ -120,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 {
|
||||
@@ -135,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,
|
||||
@@ -174,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