Compare commits

...

26 Commits

Author SHA1 Message Date
Dmitriy Kovalenko 2f546c3ec2 fix: Fully support winborder setting
docs / docs (push) Has been cancelled
closes https://github.com/dmtrKovalenko/fff.nvim/issues/195
2026-01-08 20:48:49 -08:00
Dmitriy Kovalenko ca4c32d364 fix: show_file_info option will work not and moved under debug (#210)
* fix: show_file_info option will work not and moved under debug

closes https://github.com/dmtrKovalenko/fff.nvim/issues/207

* chore: Update docs for - fix: show_file_info option will work not and moved under debug
2026-01-08 19:38:00 -08:00
Dmitriy Kovalenko f6af8353c3 fix: Download lua script (#206) 2025-12-18 21:22:50 -08:00
Dmitriy Kovalenko b384bf7dad fix: Not downloadable binary (#205)
closes https://github.com/dmtrKovalenko/fff.nvim/issues/196

This issue was related to the fact that I chagned vim.mkdir to
vim.uv.fs_mkdir but it is not recursive so when you installed a fresh
version it never created the dir for it.
2025-12-18 18:08:34 -08:00
Dmitriy Kovalenko 2951756ae3 fix: Restore scroll position after last match result showing (#204)
* fix: Scroll position after last match result showing

* chore: Update docs for - fix: Scroll position after last match result showing

* fix: Scroll position after last match result showing
2025-12-18 17:17:01 -08:00
Mohamed Ibraheem c17056bcb6 fix: directory_path highlight is not working if there are no icons (#203) 2025-12-18 17:10:01 -08:00
Dmitriy Kovalenko abfa5d0ef7 feat: Add config param for git based filename highlighting (#202)
* feat: Add config param for git based filename highlighting

* chore: Update docs for - feat: Add config param for git based filename highlighting
2025-12-16 17:34:27 -08:00
Dmitriy Kovalenko d997344fd7 fix: Invert pagination scrollbar when the prompt is in the bottom (#201) 2025-12-16 13:16:45 -08:00
Dmitriy Kovalenko e3ba972db6 fix: Pagination layout when combo box renderer used (#200) 2025-12-16 13:13:24 -08:00
Dmitriy Kovalenko a4f87bd4f2 fix: Do not rely on the global cpath (#199)
As appeared package.cpath is global and other plugins can pollute it so
we can not rely on this. Specifcially codesnap.nvim is breaking fff
2025-12-15 16:56:48 -08:00
Dmitriy Kovalenko 7fd361a369 feat: Pagination (#194)
closes https://github.com/dmtrKovalenko/fff.nvim/issues/83
2025-12-15 16:37:37 -08:00
Dmitriy Kovalenko c477f12487 chore: Remove not-required notifications (#198) 2025-12-15 14:08:01 -08:00
Frank Ebel 8fe26ad4bd docs: fix installation guide for vim.pack (#197) 2025-12-15 10:13:43 -08:00
Dmitriy Kovalenko 003c05cf6d feat: Multi-select and sending to quickfix (#193) 2025-12-14 17:53:05 -08:00
Dmitriy Kovalenko 0312492570 chore: Add all highlights groups to config and improve icons highlight resolution (#190) 2025-12-14 17:15:30 -08:00
Dmitriy Kovalenko e3f788f87b feat: Resume last query + last same query match boost (#187) 2025-12-12 16:31:13 -08:00
Dmitriy Kovalenko 53acaf90ab fix: Make directory change file walking non-blocking (#189) 2025-12-10 16:42:53 -08:00
Dmitriy Kovalenko 2c55114048 fix: Outdated/wrong git status (#188)
Resolved internal error cause by our FS notifier backend which caused
outdated git status in the picker. This should eliminate the process
completely also improved the logging of errors and improved the
perfromance and logic around the handling background events
2025-12-08 18:51:41 -08:00
Dmitriy Kovalenko d88922e6c7 fix: Correctly count file access on reopen (#185) 2025-12-06 21:40:56 -08:00
Dmitriy Kovalenko 9edf195c8f feat: Significant performance improvements up to +190% (#182) 2025-12-05 23:44:14 -08:00
Amadeus Demarzi 65aeacf9e2 feat: Make input and picker borders connected (#177)
* Make input/picker feel connected

* comment content tweak

* fix stylua
2025-11-03 10:48:21 -08:00
Dmitriy Kovalenko e8850c3c62 fix: Crash on ts filetype if plenary is not installed (#176)
(dumb regression from the previous PR)
2025-10-19 18:32:42 -07:00
Dmitriy Kovalenko ee8bd6e839 fix: Autofiletype detection for certain types (#175)
closes https://github.com/dmtrKovalenko/fff.nvim/issues/174

I decided to call plenary as a lot of users have it installed anyway but
if it is not installed just AI generated list of the most common file
type flaws including ts
2025-10-17 17:28:21 -07:00
ohbentos 3c76ba523f feat: add ratio-based row/col positioning with centered defaults (#171) 2025-10-17 17:03:07 -07:00
Dmitriy Kovalenko 51f32597de feat: Location input for picker + open_file_under_cursor (#173) 2025-10-17 09:59:33 -07:00
莊加旭 (Max Chuang) 2df06289c9 fix: fix cpath component on windows (#166)
closes https://github.com/dmtrKovalenko/fff.nvim/issues/164
2025-10-07 15:56:35 -07:00
47 changed files with 5937 additions and 919 deletions
+1 -3
View File
@@ -32,6 +32,4 @@ jobs:
- uses: stefanzweifel/git-auto-commit-action@v6
with:
commit_author: ${{ steps.last-commit.outputs.author }}
commit_message: ${{ steps.last-commit.outputs.message }}
commit_options: "--amend --no-edit"
push_options: "--force"
commit_message: "chore: Update docs for - ${{ steps.last-commit.outputs.message }}"
+3
View File
@@ -10,3 +10,6 @@ result
.repro/
.wrangler/
*.so
big-repo/
# all the perf like utility files
*.data
+4
View File
@@ -0,0 +1,4 @@
empty_config.lua
benches/
doc/
-1
View File
@@ -1,4 +1,3 @@
syntax = "LuaJIT"
column_width = 120
line_endings = "Unix"
indent_type = "Spaces"
Generated
+440 -7
View File
@@ -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"
@@ -141,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"
@@ -153,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"
@@ -196,6 +315,12 @@ 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"
@@ -215,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"
@@ -261,27 +407,44 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
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-mini",
"notify-debouncer-full",
"once_cell",
"openssl",
"pathdiff",
"rand",
"rayon",
"serde",
"smartstring",
"tempfile",
"thiserror 2.0.12",
"tracing",
"tracing-appender",
"tracing-subscriber",
]
[[package]]
name = "file-id"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1fc6a637b6dc58414714eddd9170ff187ecb0933d4c7024d1abbd23a3cc26e9"
dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "foreign-types"
version = "0.3.2"
@@ -315,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"
@@ -342,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"
@@ -355,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"
@@ -393,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"
@@ -560,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"
@@ -572,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",
]
@@ -642,6 +859,16 @@ dependencies = [
"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"
@@ -844,15 +1071,16 @@ dependencies = [
]
[[package]]
name = "notify-debouncer-mini"
version = "0.7.0"
name = "notify-debouncer-full"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17849edfaabd9a5fef1c606d99cfc615a8e99f7ac4366406d86c7942a3184cf2"
checksum = "375bd3a138be7bfeff3480e4a623df4cbfb55b79df617c055cd810ba466fa078"
dependencies = [
"file-id",
"log",
"notify",
"notify-types",
"tempfile",
"walkdir",
]
[[package]]
@@ -892,6 +1120,12 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "oorandom"
version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "openssl"
version = "0.10.73"
@@ -946,6 +1180,12 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "overload"
version = "0.1.1"
@@ -1051,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"
@@ -1066,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"
@@ -1096,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",
]
@@ -1104,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"
@@ -1134,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"
@@ -1283,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"
@@ -1333,7 +1654,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1"
dependencies = [
"fastrand",
"getrandom",
"getrandom 0.3.3",
"once_cell",
"rustix",
"windows-sys 0.59.0",
@@ -1429,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"
@@ -1537,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"
@@ -1620,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"
@@ -1710,6 +2057,15 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
@@ -1728,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"
@@ -1761,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"
@@ -1773,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"
@@ -1785,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"
@@ -1809,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"
@@ -1821,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"
@@ -1833,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"
@@ -1845,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"
@@ -1896,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"
+26 -2
View File
@@ -1,7 +1,7 @@
[package]
name = "fff_nvim"
version = "0.1.0"
edition = "2021"
edition = "2024"
[lib]
path = "lua/fff/rust/lib.rs"
@@ -14,13 +14,26 @@ 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"
@@ -28,12 +41,23 @@ mimalloc = "0.1.47"
mlua = { version = "0.11.1", features = ["module", "luajit"] }
neo_frizbee = { version = "0.7.1" }
notify = "8.1.0"
notify-debouncer-mini = "0.7"
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
+111 -16
View File
@@ -80,10 +80,10 @@ FFF.nvim requires:
```lua
vim.pack.add({ 'https://github.com/dmtrKovalenko/fff.nvim' })
nvim.create_autocmd('PackChanged', {
vim.api.nvim_create_autocmd('PackChanged', {
callback = function(event)
if event.data.updated then
require('fff.download').download_or_build_binary()
if event.data.updated then
require('fff.download').download_or_build_binary()
end
end,
})
@@ -123,6 +123,7 @@ require('fff').setup({
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,
@@ -132,7 +133,6 @@ require('fff').setup({
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 },
@@ -145,11 +145,17 @@ require('fff').setup({
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',
@@ -161,14 +167,54 @@ require('fff').setup({
active_file = 'Visual',
frecency = 'Number',
debug = 'Comment',
combo_header = 'Number',
scrollbar = 'Comment', -- Highlight for scrollbar thumb (track uses border)
directory_path = 'Comment', -- Highlight for directory path in file list
-- Multi-select highlights
selected = 'FFFSelected',
selected_active = 'FFFSelectedActive',
-- Git text highlights for file names
git_staged = 'FFFGitStaged',
git_modified = 'FFFGitModified',
git_deleted = 'FFFGitDeleted',
git_renamed = 'FFFGitRenamed',
git_untracked = 'FFFGitUntracked',
git_ignored = 'FFFGitIgnored',
-- Git sign/border highlights
git_sign_staged = 'FFFGitSignStaged',
git_sign_modified = 'FFFGitSignModified',
git_sign_deleted = 'FFFGitSignDeleted',
git_sign_renamed = 'FFFGitSignRenamed',
git_sign_untracked = 'FFFGitSignUntracked',
git_sign_ignored = 'FFFGitSignIgnored',
-- Git sign selected highlights
git_sign_staged_selected = 'FFFGitSignStagedSelected',
git_sign_modified_selected = 'FFFGitSignModifiedSelected',
git_sign_deleted_selected = 'FFFGitSignDeletedSelected',
git_sign_renamed_selected = 'FFFGitSignRenamedSelected',
git_sign_untracked_selected = 'FFFGitSignUntrackedSelected',
git_sign_ignored_selected = 'FFFGitSignIgnoredSelected',
},
-- Store file open frecency
frecency = {
enabled = true,
db_path = vim.fn.stdpath('cache') .. '/fff_nvim',
},
-- Store successfully opened queries with respective matches
history = {
enabled = true,
db_path = vim.fn.stdpath('data') .. '/fff_queries',
min_combo_count = 3, -- file will get a boost if it was selected 3 in a row times per specific query
combo_boost_score_multiplier = 100, -- Score multiplier for combo matches
},
-- Git integration
git = {
status_text_color = false, -- Apply git status colors to filename text (default: false, only sign column)
},
debug = {
enabled = false, -- Set to true to show scores in the UI
show_scores = false,
show_file_info = false, -- Show file info panel in preview
},
logging = {
enabled = true,
@@ -203,18 +249,6 @@ FFF.nvim provides several commands for interacting with the file picker:
- `: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
}
```
#### 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.
@@ -227,6 +261,67 @@ Toggle scoring information display:
- Use `:FFFDebug` command
- Enable by default with `debug.show_scores = true`
#### Multi-Select and Quickfix Integration
Select multiple files and send them to Neovim's quickfix list (keymaps are configurable):
- `<Tab>` - Toggle selection for the current file (shows thick border `▊` in signcolumn)
- `<C-q>` - Send selected files to quickfix list and close picker
#### Git Status Highlighting
FFF integrates with git to show file status through sign column indicators (enabled by default) and optional filename text coloring.
**Sign Column Indicators** (enabled by default) - Border characters shown in the sign column:
```lua
hl = {
git_sign_staged = 'FFFGitSignStaged',
git_sign_modified = 'FFFGitSignModified',
git_sign_deleted = 'FFFGitSignDeleted',
git_sign_renamed = 'FFFGitSignRenamed',
git_sign_untracked = 'FFFGitSignUntracked',
git_sign_ignored = 'FFFGitSignIgnored',
}
```
**Text Highlights** (opt-in) - Apply colors to filenames based on git status:
To enable git status text coloring, set `git.status_text_color = true`:
```lua
require('fff').setup({
git = {
status_text_color = true, -- Enable git status colors on filename text
},
hl = {
git_staged = 'FFFGitStaged', -- Files staged for commit
git_modified = 'FFFGitModified', -- Modified unstaged files
git_deleted = 'FFFGitDeleted', -- Deleted files
git_renamed = 'FFFGitRenamed', -- Renamed files
git_untracked = 'FFFGitUntracked', -- New untracked files
git_ignored = 'FFFGitIgnored', -- Git-ignored files
}
})
```
The plugin provides sensible default highlight groups that link to common git highlight groups (e.g., GitSignsAdd, GitSignsChange). You can override these with your own custom highlight groups to match your colorscheme.
**Example - Custom Bright Colors for Text:**
```lua
vim.api.nvim_set_hl(0, 'CustomGitModified', { fg = '#FFA500' })
vim.api.nvim_set_hl(0, 'CustomGitUntracked', { fg = '#00FF00' })
require('fff').setup({
git = {
status_text_color = true,
},
hl = {
git_modified = 'CustomGitModified',
git_untracked = 'CustomGitUntracked',
}
})
```
### Troubleshooting
#### Health Check
+667
View File
@@ -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);
+224
View File
@@ -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);
+121 -18
View File
@@ -1,4 +1,4 @@
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2025 September 17
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 January 09
==============================================================================
Table of Contents *fff.nvim-table-of-contents*
@@ -86,10 +86,10 @@ VIM.PACK
>lua
vim.pack.add({ 'https://github.com/dmtrKovalenko/fff.nvim' })
nvim.create_autocmd('PackChanged', {
vim.api.nvim_create_autocmd('PackChanged', {
callback = function(event)
if event.data.updated then
require('fff.download').download_or_build_binary()
if event.data.updated then
require('fff.download').download_or_build_binary()
end
end,
})
@@ -131,6 +131,7 @@ all available options:
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,
@@ -140,7 +141,6 @@ all available options:
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 },
@@ -153,11 +153,17 @@ all available options:
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',
@@ -169,14 +175,54 @@ all available options:
active_file = 'Visual',
frecency = 'Number',
debug = 'Comment',
combo_header = 'Number',
scrollbar = 'Comment', -- Highlight for scrollbar thumb (track uses border)
directory_path = 'Comment', -- Highlight for directory path in file list
-- Multi-select highlights
selected = 'FFFSelected',
selected_active = 'FFFSelectedActive',
-- Git text highlights for file names
git_staged = 'FFFGitStaged',
git_modified = 'FFFGitModified',
git_deleted = 'FFFGitDeleted',
git_renamed = 'FFFGitRenamed',
git_untracked = 'FFFGitUntracked',
git_ignored = 'FFFGitIgnored',
-- Git sign/border highlights
git_sign_staged = 'FFFGitSignStaged',
git_sign_modified = 'FFFGitSignModified',
git_sign_deleted = 'FFFGitSignDeleted',
git_sign_renamed = 'FFFGitSignRenamed',
git_sign_untracked = 'FFFGitSignUntracked',
git_sign_ignored = 'FFFGitSignIgnored',
-- Git sign selected highlights
git_sign_staged_selected = 'FFFGitSignStagedSelected',
git_sign_modified_selected = 'FFFGitSignModifiedSelected',
git_sign_deleted_selected = 'FFFGitSignDeletedSelected',
git_sign_renamed_selected = 'FFFGitSignRenamedSelected',
git_sign_untracked_selected = 'FFFGitSignUntrackedSelected',
git_sign_ignored_selected = 'FFFGitSignIgnoredSelected',
},
-- Store file open frecency
frecency = {
enabled = true,
db_path = vim.fn.stdpath('cache') .. '/fff_nvim',
},
-- Store successfully opened queries with respective matches
history = {
enabled = true,
db_path = vim.fn.stdpath('data') .. '/fff_queries',
min_combo_count = 3, -- file will get a boost if it was selected 3 in a row times per specific query
combo_boost_score_multiplier = 100, -- Score multiplier for combo matches
},
-- Git integration
git = {
status_text_color = false, -- Apply git status colors to filename text (default: false, only sign column)
},
debug = {
enabled = false, -- Set to true to show scores in the UI
show_scores = false,
show_file_info = false, -- Show file info panel in preview
},
logging = {
enabled = true,
@@ -215,19 +261,6 @@ FFF.nvim provides several commands for interacting with the file picker:
- `: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
}
<
MULTILINE PASTE SUPPORT
The input field automatically handles multiline clipboard content by joining
@@ -244,6 +277,76 @@ Toggle scoring information display:
- Enable by default with `debug.show_scores = true`
MULTI-SELECT AND QUICKFIX INTEGRATION
Select multiple files and send them to Neovims quickfix list (keymaps are
configurable):
- `<Tab>` - Toggle selection for the current file (shows thick border `▊` in signcolumn)
- `<C-q>` - Send selected files to quickfix list and close picker
GIT STATUS HIGHLIGHTING
FFF integrates with git to show file status through sign column indicators
(enabled by default) and optional filename text coloring.
**Sign Column Indicators** (enabled by default) - Border characters shown in
the sign column:
>lua
hl = {
git_sign_staged = 'FFFGitSignStaged',
git_sign_modified = 'FFFGitSignModified',
git_sign_deleted = 'FFFGitSignDeleted',
git_sign_renamed = 'FFFGitSignRenamed',
git_sign_untracked = 'FFFGitSignUntracked',
git_sign_ignored = 'FFFGitSignIgnored',
}
<
**Text Highlights** (opt-in) - Apply colors to filenames based on git status:
To enable git status text coloring, set `git.status_text_color = true`:
>lua
require('fff').setup({
git = {
status_text_color = true, -- Enable git status colors on filename text
},
hl = {
git_staged = 'FFFGitStaged', -- Files staged for commit
git_modified = 'FFFGitModified', -- Modified unstaged files
git_deleted = 'FFFGitDeleted', -- Deleted files
git_renamed = 'FFFGitRenamed', -- Renamed files
git_untracked = 'FFFGitUntracked', -- New untracked files
git_ignored = 'FFFGitIgnored', -- Git-ignored files
}
})
<
The plugin provides sensible default highlight groups that link to common git
highlight groups (e.g., GitSignsAdd, GitSignsChange). You can override these
with your own custom highlight groups to match your colorscheme.
**Example - Custom Bright Colors for Text:**
>lua
vim.api.nvim_set_hl(0, 'CustomGitModified', { fg = '#FFA500' })
vim.api.nvim_set_hl(0, 'CustomGitUntracked', { fg = '#00FF00' })
require('fff').setup({
git = {
status_text_color = true,
},
hl = {
git_modified = 'CustomGitModified',
git_untracked = 'CustomGitUntracked',
}
})
<
TROUBLESHOOTING ~
+62
View File
@@ -0,0 +1,62 @@
-- Single file Neovim config for testing fff.nvim locally
-- Usage: nvim -u /Users/neogoose/dev/fff.nvim/init.lua
-- Set up lazy.nvim plugin manager
local lazypath = vim.fn.stdpath('data') .. '/lazy/lazy.nvim'
if not vim.loop.fs_stat(lazypath) then
vim.fn.system({
'git',
'clone',
'--filter=blob:none',
'https://github.com/folke/lazy.nvim.git',
'--branch=stable',
lazypath,
})
end
vim.opt.rtp:prepend(lazypath)
require('lazy').setup({
{
dir = '~/dev/fff.nvim',
'https://github.com/dmtrKovalenko/fff.nvim',
build = function()
-- this will download prebuild binary or try to use existing rustup toolchain to build from source
-- (if you are using lazy you can use gb for rebuilding a plugin if needed)
require('fff.download').download_or_build_binary()
end,
dependencies = {
'nvim-tree/nvim-web-devicons', -- Optional: for file icons
-- {
-- 'nvim-mini/mini.icons',
-- version = false,
-- config = true,
-- },
},
config = function()
require('fff').setup({
-- Configure fff.nvim here
ui = {
width = 0.8,
height = 0.8,
},
file_picker = {
auto_reload_on_write = true,
frecency_boost = true,
},
})
end,
},
}, {
root = vim.fn.stdpath('data') .. '/fff-empty-test',
lockfile = vim.fn.stdpath('data') .. '/fff-empty-test.json',
})
vim.opt.number = true
vim.opt.relativenumber = true
vim.keymap.set('n', 'ff', function() require('fff').find_files() end, { desc = 'Find files' })
vim.keymap.set('n', 'fg', function() require('fff').find_in_git_root() end, { desc = 'Find files in git root' })
vim.keymap.set('n', 'fr', function() require('fff').scan_files() end, { desc = 'Rescan files' })
vim.keymap.set('n', 'fs', function() require('fff').refresh_git_status() end, { desc = 'Refresh git status' })
vim.notify('FFF.nvim local config loaded! Press ff', vim.log.levels.INFO)
Generated
+9 -9
View File
@@ -2,11 +2,11 @@
"nodes": {
"crane": {
"locked": {
"lastModified": 1753316655,
"narHash": "sha256-tzWa2kmTEN69OEMhxFy+J2oWSvZP5QhEgXp3TROOzl0=",
"lastModified": 1765739568,
"narHash": "sha256-gQYx35Of4UDKUjAYvmxjUEh/DdszYeTtT6MDin4loGE=",
"owner": "ipetkov",
"repo": "crane",
"rev": "f35a3372d070c9e9ccb63ba7ce347f0634ddf3d2",
"rev": "67d2baff0f9f677af35db61b32b5df6863bcc075",
"type": "github"
},
"original": {
@@ -35,11 +35,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1754060105,
"narHash": "sha256-di5L6e5Iiv+oegS07j9h23FdqEpXn0ZQqMlDOEMw1EY=",
"lastModified": 1765644376,
"narHash": "sha256-yqHBL2wYGwjGL2GUF2w3tofWl8qO9tZEuI4wSqbCrtE=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e7eabdc701d7dbb810fd91a97ec358caa4c1fc50",
"rev": "23735a82a828372c4ef92c660864e82fbe2f5fbe",
"type": "github"
},
"original": {
@@ -64,11 +64,11 @@
]
},
"locked": {
"lastModified": 1756866691,
"narHash": "sha256-YWJsM0HfdFLcaoP5OeyzjX6MjGnJ0Acm+bg1QN8MKjo=",
"lastModified": 1765680428,
"narHash": "sha256-fyPmRof9SZeI14ChPk5rVPOm7ISiiGkwGCunkhM+eUg=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "fb6dab6f320291a8edd31c1d67f078c6f7384a02",
"rev": "eb3898d8ef143d4bf0f7f2229105fc51c7731b2f",
"type": "github"
},
"original": {
+2 -1
View File
@@ -80,7 +80,8 @@
pname = "fff.nvim";
version = "main";
src = pkgs.lib.cleanSource ./.;
patchPhase = copy-dynamic-library;
postPatch = copy-dynamic-library;
doCheck = false; # Skip require check since we have a Rust FFI component
};
};
+238
View File
@@ -0,0 +1,238 @@
local M = {}
local overlay_state = {
left_buf = nil,
left_win = nil,
right_buf = nil,
right_win = nil,
ns_id = nil,
-- Cache last position to avoid unnecessary updates
last_row = nil,
last_col = nil,
last_border_hl = nil,
-- Track if combo was rendered in last call
was_rendered = false,
}
local LEFT_OVERLAY_CONTENT = '├────'
local RIGHT_OVERLAY_CONTENT = '─┤'
local LEFT_OVERLAY_WIDTH = vim.fn.strdisplaywidth(LEFT_OVERLAY_CONTENT)
local LEFT_HEADER_PADDING = LEFT_OVERLAY_WIDTH - 2
local RIGHT_OVERLAY_WIDTH = vim.fn.strdisplaywidth(RIGHT_OVERLAY_CONTENT)
local COMBO_TEXT_FORMAT = 'Last Match (×%d combo) '
local LAST_MATCH_TEXT_FORMAT = 'Last Match '
function M.init(ns_id) overlay_state.ns_id = ns_id end
local function detect_combo_item(items, file_picker, combo_boost_score_multiplier)
if not items or #items == 0 then return nil, 0 end
local first_score = file_picker.get_file_score(1)
local last_score = file_picker.get_file_score(#items)
if first_score.combo_match_boost > combo_boost_score_multiplier then
return 1, first_score.combo_match_boost / combo_boost_score_multiplier
elseif last_score.combo_match_boost > combo_boost_score_multiplier then
return #items, last_score.combo_match_boost / combo_boost_score_multiplier
end
return nil, 0
end
local function create_header_text(combo_count, win_width, disable_combo_display)
local combo_text = nil
if disable_combo_display then
combo_text = LAST_MATCH_TEXT_FORMAT
else
combo_text = string.format(COMBO_TEXT_FORMAT, combo_count)
end
local text_len = vim.fn.strdisplaywidth(combo_text)
local available_for_content = win_width - LEFT_HEADER_PADDING - RIGHT_OVERLAY_WIDTH
local remaining_dashes = math.max(0, available_for_content - text_len)
return string.rep(' ', LEFT_HEADER_PADDING) .. combo_text .. string.rep('', remaining_dashes), text_len
end
local function apply_header_highlights(buf, ns_id, line_idx, text_len, border_hl)
local config = require('fff.conf').get()
vim.api.nvim_buf_add_highlight(buf, ns_id, border_hl, line_idx - 1, 0, -1)
vim.api.nvim_buf_add_highlight(
buf,
ns_id,
config.hl.combo_header,
line_idx - 1,
LEFT_HEADER_PADDING,
LEFT_HEADER_PADDING + text_len
)
end
local function get_or_create_overlay_buf(state_key)
if not overlay_state[state_key] or not vim.api.nvim_buf_is_valid(overlay_state[state_key]) then
overlay_state[state_key] = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_option(overlay_state[state_key], 'bufhidden', 'wipe')
end
return overlay_state[state_key]
end
local function update_overlay_content(buf, content, border_hl)
-- Batch all buffer operations together for performance
vim.api.nvim_buf_set_option(buf, 'modifiable', true)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { content })
vim.api.nvim_buf_clear_namespace(buf, overlay_state.ns_id, 0, -1)
vim.api.nvim_buf_add_highlight(buf, overlay_state.ns_id, border_hl, 0, 0, -1)
vim.api.nvim_buf_set_option(buf, 'modifiable', false)
end
local function position_overlay_window(state_key, buf, width, row, col)
local win_config = {
relative = 'editor',
width = width,
height = 1,
row = row,
col = col,
style = 'minimal',
border = 'none',
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)
-- combo_header_line is a 1-based buffer line index (includes any padding offset)
-- list_config.row is where the window starts (0-based, at the top border)
-- Content starts at list_config.row + 1 (after the top border)
-- Buffer line 1 -> screen row (list_config.row + 1)
-- Buffer line N -> screen row (list_config.row + N)
-- Since combo_header_line is 1-based, the formula naturally works out
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
overlay_state.last_row = combo_header_row
overlay_state.last_col = list_config.col
overlay_state.last_border_hl = border_hl
local left_buf = get_or_create_overlay_buf('left_buf')
local right_buf = get_or_create_overlay_buf('right_buf')
update_overlay_content(left_buf, LEFT_OVERLAY_CONTENT, border_hl)
update_overlay_content(right_buf, RIGHT_OVERLAY_CONTENT, border_hl)
position_overlay_window('left_win', left_buf, LEFT_OVERLAY_WIDTH, combo_header_row, list_config.col)
position_overlay_window(
'right_win',
right_buf,
RIGHT_OVERLAY_WIDTH,
combo_header_row,
list_config.col + list_config.width
)
end
local function clear_overlays_internal()
if overlay_state.left_win and vim.api.nvim_win_is_valid(overlay_state.left_win) then
vim.api.nvim_win_close(overlay_state.left_win, true)
overlay_state.left_win = nil
end
if overlay_state.right_win and vim.api.nvim_win_is_valid(overlay_state.right_win) then
vim.api.nvim_win_close(overlay_state.right_win, true)
overlay_state.right_win = nil
end
overlay_state.last_row = nil
overlay_state.last_col = nil
overlay_state.last_border_hl = nil
-- Note: we intentionally don't clear was_rendered here to track the transition
end
function M.detect_and_prepare(items, file_picker, win_width, combo_boost_score_multiplier, disable_combo_display)
local combo_item_index, combo_count = detect_combo_item(items, file_picker, combo_boost_score_multiplier)
if not combo_item_index then return false, nil, 0, nil end
local header_line, text_len = create_header_text(combo_count, win_width, disable_combo_display)
return true, header_line, text_len, combo_item_index
end
--- Render combo highlights and overlays
--- @return boolean was_hidden True if combo was just hidden (was rendered before, not now)
function M.render_highlights_and_overlays(
combo_item_index,
text_len,
list_buf,
list_win,
ns_id,
border_hl,
item_to_lines,
prompt_position,
total_items
)
local was_rendered_before = overlay_state.was_rendered
local is_rendering_now = false
if not combo_item_index then
clear_overlays_internal()
else
local combo_item_lines = item_to_lines[combo_item_index]
if not combo_item_lines then
clear_overlays_internal()
else
local combo_header_line_idx = combo_item_lines.first
apply_header_highlights(list_buf, ns_id, combo_header_line_idx, text_len, border_hl)
if prompt_position == 'bottom' and total_items and total_items > 1 then
combo_header_line_idx = combo_header_line_idx - 1
end
-- when rendering items in the reverse order for some reason this makes the
-- indexing shifted by one in the internal list config, so just adjust for that
update_overlays(list_win, combo_header_line_idx, border_hl)
is_rendering_now = true
end
end
overlay_state.was_rendered = is_rendering_now
-- Return true if combo was just hidden (transition from visible to hidden)
return was_rendered_before and not is_rendering_now
end
--- Get the combo header text for a given item
--- @param combo_count number The combo multiplier count
--- @param win_width number Window width for formatting
--- @param disable_combo_display boolean Whether to show combo count
--- @return string header_text The formatted header line
--- @return number text_len Length of the header text (without padding)
function M.get_combo_header_text(combo_count, win_width, disable_combo_display)
return create_header_text(combo_count, win_width, disable_combo_display)
end
function M.get_overlay_widths() return LEFT_OVERLAY_WIDTH, RIGHT_OVERLAY_WIDTH end
function M.cleanup()
clear_overlays_internal()
overlay_state.was_rendered = false
end
return M
+42 -2
View File
@@ -1,6 +1,6 @@
local M = {}
---@class fff.conf.State
--@class fff.conf.State
local state = {
---@type table | nil
config = nil,
@@ -113,6 +113,7 @@ local function init()
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,
@@ -122,7 +123,6 @@ local function init()
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 },
@@ -140,6 +140,9 @@ local function init()
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',
@@ -151,14 +154,51 @@ local function init()
active_file = 'Visual',
frecency = 'Number',
debug = 'Comment',
combo_header = 'Number',
scrollbar = 'Comment',
directory_path = 'Comment', -- Highlight for directory path in file list
-- Multi-select highlights
selected = 'FFFSelected',
selected_active = 'FFFSelectedActive',
-- Git text highlights for file names
git_staged = 'FFFGitStaged',
git_modified = 'FFFGitModified',
git_deleted = 'FFFGitDeleted',
git_renamed = 'FFFGitRenamed',
git_untracked = 'FFFGitUntracked',
git_ignored = 'FFFGitIgnored',
-- Git sign/border highlights
git_sign_staged = 'FFFGitSignStaged',
git_sign_modified = 'FFFGitSignModified',
git_sign_deleted = 'FFFGitSignDeleted',
git_sign_renamed = 'FFFGitSignRenamed',
git_sign_untracked = 'FFFGitSignUntracked',
git_sign_ignored = 'FFFGitSignIgnored',
-- Git sign selected highlights
git_sign_staged_selected = 'FFFGitSignStagedSelected',
git_sign_modified_selected = 'FFFGitSignModifiedSelected',
git_sign_deleted_selected = 'FFFGitSignDeletedSelected',
git_sign_renamed_selected = 'FFFGitSignRenamedSelected',
git_sign_untracked_selected = 'FFFGitSignUntrackedSelected',
git_sign_ignored_selected = 'FFFGitSignIgnoredSelected',
},
frecency = {
enabled = true,
db_path = vim.fn.stdpath('cache') .. '/fff_nvim',
},
history = {
enabled = true,
db_path = vim.fn.stdpath('data') .. '/fff_queries',
min_combo_count = 3, -- Minimum selections before combo boost applies (3 = boost starts on 3rd selection)
combo_boost_score_multiplier = 100, -- Score multiplier for combo matches (files repeatedly opened with same query)
},
git = {
status_text_color = false, -- Apply git status colors to filename text (default: false, only sign column)
},
debug = {
enabled = false, -- Set to true to show scores in the UI
show_scores = false,
show_file_info = false, -- Show file info panel in preview
},
logging = {
enabled = true,
+14 -11
View File
@@ -16,7 +16,7 @@ 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({ 'BufReadPost' }, {
vim.api.nvim_create_autocmd({ 'BufEnter' }, {
group = group,
desc = 'Track file access for FFF frecency',
callback = function(args)
@@ -28,11 +28,7 @@ local function setup_global_autocmds(config)
vim.uv.fs_realpath(file_path, function(rp_err, real_path)
if rp_err or not real_path then return end
local ok, track_err = pcall(fuzzy.track_access, real_path)
if not ok then
vim.notify('FFF: Failed to track file access: ' .. tostring(track_err), vim.log.levels.ERROR)
end
pcall(fuzzy.track_access, real_path)
end)
end)
end,
@@ -47,9 +43,14 @@ local function setup_global_autocmds(config)
local new_cwd = vim.v.event.cwd
if state.initialized and new_cwd and new_cwd ~= config.base_path then
vim.schedule(function()
local picker = require('fff.main')
local ok, err = pcall(picker.change_indexing_directory, new_cwd)
-- 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)
@@ -82,9 +83,11 @@ M.ensure_initialized = function()
end
end
local db_path = 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
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
+14 -7
View File
@@ -1,6 +1,6 @@
local M = {}
local system = require('fff.utils.system')
local uv = vim and vim.uv or require('luv')
local fs_utils = require('fff.utils.fs')
local GITHUB_REPO = 'dmtrKovalenko/fff.nvim'
@@ -14,7 +14,7 @@ local function get_current_version(plugin_dir, callback)
end)
end
local function get_binary_dir(plugin_dir) return plugin_dir .. '/../target' 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)
@@ -24,7 +24,7 @@ end
local function binary_exists(plugin_dir)
local binary_path = get_binary_path(plugin_dir)
local stat = uv.fs_stat(binary_path)
local stat = vim.uv.fs_stat(binary_path)
return stat and stat.type == 'file'
end
@@ -32,9 +32,9 @@ 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)
fs_utils.mkdir_recursive(dir, function(mkdir_ok, mkdir_err)
if not mkdir_ok then
callback(false, mkdir_err)
return
end
@@ -89,7 +89,7 @@ local function download_from_github(version, binary_path, opts, callback)
local ok, err_msg = pcall(function() package.loadlib(binary_path, 'luaopen_fff_nvim') end)
if not ok then
uv.fs_unlink(binary_path)
vim.uv.fs_unlink(binary_path)
callback(false, 'Downloaded binary is not valid: ' .. (err_msg or 'unknown error'))
return
end
@@ -187,4 +187,11 @@ function M.get_binary_path()
return get_binary_path(plugin_dir)
end
function M.get_binary_cpath_component()
local plugin_dir = vim.fn.fnamemodify(debug.getinfo(1, 'S').source:sub(2), ':h:h')
local binary_dir = get_binary_dir(plugin_dir)
local extension = system.get_lib_extension()
return binary_dir .. '/lib?.' .. extension
end
return M
+14 -153
View File
@@ -10,46 +10,6 @@ M.provider_name = nil
M.setup_attempted = false
M.setup_failed = false
local directory_configs = {
['nvim-web-devicons'] = {
default = { icon = '󰉋', hl = 'DevIconDefault' },
open = { icon = '󰝰', hl = 'DevIconDefault' },
closed = { icon = '󰉋', hl = 'DevIconDefault' },
git = { icon = '', hl = 'DevIconGitIgnore' },
node_modules = { icon = '', hl = 'DevIconNodeModules' },
hidden = { icon = '󰘓', hl = 'DevIconDefault' },
},
['mini.icons'] = {
default = { icon = '󰉋', color = '#7aa2f7' },
open = { icon = '󰝰', color = '#7aa2f7' },
closed = { icon = '󰉋', color = '#7aa2f7' },
git = { icon = '', color = '#e24329' },
node_modules = { icon = '', color = '#8cc84b' },
hidden = { icon = '󰘓', color = '#6d8086' },
},
}
-- Special directory names and their icons
local special_directories = {
['.git'] = 'git',
['node_modules'] = 'node_modules',
['.vscode'] = 'hidden',
['.idea'] = 'hidden',
['.cache'] = 'hidden',
['.config'] = 'hidden',
['__pycache__'] = 'hidden',
['.pytest_cache'] = 'hidden',
['target'] = 'hidden',
['dist'] = 'hidden',
['build'] = 'hidden',
['out'] = 'hidden',
['.next'] = 'hidden',
['.nuxt'] = 'hidden',
['coverage'] = 'hidden',
}
M.highlight_cache = {}
function M.setup()
if M.provider_name then return true end
if M.setup_failed then return false end
@@ -66,150 +26,51 @@ function M.setup()
end
M.setup_failed = true
vim.notify('FFF Icons: No icon provider found. Please install nvim-web-devicons or mini.icons', vim.log.levels.WARN)
return false
end
--- Get icon for a directory
--- @param dirname string The directory name
--- @return string, string Icon and color/highlight
--- @return string|nil, string|nil Icon and highlight group (nil if no provider)
function M.get_directory_icon(dirname)
if not M.setup() then
return '󰉋', '#7aa2f7' -- Default folder icon if no provider
end
if not M.setup() then return nil, nil end
local dir_type = 'default'
local basename = vim.fn.fnamemodify(dirname, ':t')
if special_directories[basename] then
dir_type = special_directories[basename]
elseif basename:match('^%.') then
dir_type = 'hidden'
end
local config = directory_configs[M.provider_name]
if not config or not config[dir_type] then dir_type = 'default' end
local icon_data = config[dir_type]
if M.provider_name == 'nvim-web-devicons' then
-- For nvim-web-devicons, try to get the actual icon first
if M.provider.get_icon then
local provider_icon, provider_hl = M.provider.get_icon(basename, nil, { default = false })
if provider_icon and provider_icon ~= '' then
return provider_icon, M.resolve_color(provider_hl or icon_data.hl)
end
local icon, hl = M.provider.get_icon(basename, nil, { default = true })
if icon and icon ~= '' and hl then return icon, hl end
end
-- Use our configured icon
return icon_data.icon, M.resolve_color(icon_data.hl)
elseif M.provider_name == 'mini.icons' then
-- For mini.icons, try to get directory-specific icon
if M.provider.get then
local provider_data = M.provider.get('directory', basename)
if provider_data and provider_data.glyph and provider_data.glyph ~= '' then
return provider_data.glyph, M.get_color_from_highlight(provider_data.hl)
end
local icon, hl, is_default = M.provider.get('directory', basename)
if icon and icon ~= '' and hl then return icon, hl end
end
-- Use our configured icon
return icon_data.icon, icon_data.color
end
-- Fallback (shouldn't reach here)
return '󰉋', '#7aa2f7'
return nil, nil
end
--- Get icon for a file
--- @param filename string The filename
--- @param extension string The file extension (without dot)
--- @param is_directory boolean Whether this is a directory
--- @return string, string Icon and color
--- @return string|nil, string|nil Icon and highlight group (nil if no provider)
function M.get_icon(filename, extension, is_directory)
if not M.setup() then
if is_directory then
return '󰉋', '#7aa2f7'
else
return '󰈙', '#6d8086'
end
end
if not M.setup() then return nil, nil end
if is_directory then return M.get_directory_icon(filename) end
local icon, color_or_hl
if M.provider_name == 'nvim-web-devicons' then
icon, color_or_hl = M.provider.get_icon(filename, extension, { default = true })
if icon and icon ~= '' then return icon, M.resolve_color(color_or_hl) end
local icon, hl = M.provider.get_icon(filename, extension, { default = true })
if icon and icon ~= '' and hl then return icon, hl end
elseif M.provider_name == 'mini.icons' then
local icon_data = M.provider.get('file', filename)
if icon_data and icon_data.glyph and icon_data.glyph ~= '' then
return icon_data.glyph, M.get_color_from_highlight(icon_data.hl)
end
local icon, hl, _ = M.provider.get('file', filename)
if icon and icon ~= '' and hl then return icon, hl end
end
return '󰈙', '#6d8086'
end
--- Get folder icon (kept for compatibility)
--- @return string, string Icon and color
function M.get_folder_icon() return M.get_directory_icon('folder') end
--- Resolve color from highlight group or hex
--- @param color_or_hl string|nil Color hex or highlight group name
--- @return string Hex color
function M.resolve_color(color_or_hl)
if not color_or_hl or color_or_hl == '' then return '#6d8086' end
-- If it's already a hex color, return as-is
if color_or_hl:match('^#%x%x%x%x%x%x$') then return color_or_hl end
-- Try to resolve as highlight group
return M.get_color_from_highlight(color_or_hl)
end
--- Get hex color from highlight group
--- @param hl_group string Highlight group name
--- @return string Hex color
function M.get_color_from_highlight(hl_group)
if not hl_group or hl_group == '' then return '#6d8086' end
local ok, hl = pcall(vim.api.nvim_get_hl, 0, { name = hl_group })
if ok and hl and hl.fg then return string.format('#%06x', hl.fg) end
return '#6d8086' -- Fallback color
end
--- Get icon with display formatting and highlight group creation
--- @param filename string The filename
--- @param extension string The file extension (without dot)
--- @param is_directory boolean Whether this is a directory
--- @return string, string Icon and highlight group name
function M.get_icon_display(filename, extension, is_directory)
local icon, color = M.get_icon(filename, extension, is_directory)
local hl_group = M.create_icon_highlight(color)
return icon, hl_group
end
--- Create or get cached highlight group for icon color
--- @param color string Hex color
--- @return string Highlight group name
function M.create_icon_highlight(color)
if not color or color == '' then color = '#6d8086' end
if not color:match('^#%x%x%x%x%x%x$') then color = M.resolve_color(color) end
local hl_name = 'FFFIcon' .. color:gsub('#', ''):upper()
if M.highlight_cache[hl_name] then return hl_name end
local ok = pcall(vim.api.nvim_set_hl, 0, hl_name, { fg = color })
if not ok then
color = '#6d8086'
hl_name = 'FFFIcon6D8086'
vim.api.nvim_set_hl(0, hl_name, { fg = color })
end
M.highlight_cache[hl_name] = true
return hl_name
return nil, nil
end
--- Check if directories are supported by current provider
+1 -1
View File
@@ -140,7 +140,7 @@ function M.clear_buffer_images(bufnr)
pcall(vim.api.nvim_buf_clear_namespace, bufnr, -1, 0, -1)
end
--- Load metadat of the image, displays it and display image in paralallel
--- 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
+48 -20
View File
@@ -11,17 +11,7 @@ M.state = {
}
function M.setup()
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
local config = require('fff.conf').get()
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 false
end
M.state.initialized = true
M.state.base_path = config.base_path
@@ -42,29 +32,60 @@ function M.scan_files()
end
--- 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 Maximum number of results (optional)
--- @param max_threads number Maximum number of threads (optional)
--- @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 reverse_order boolean Reverse order of results
--- @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, max_results, max_threads, current_file, reverse_order)
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 pagination support
--- Results are always returned in descending order (best scores first)
--- @param query string Search query
--- @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_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 config.max_results
max_threads = max_threads or 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, reverse_order)
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
@@ -78,6 +99,13 @@ 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
+123 -8
View File
@@ -1,9 +1,36 @@
local utils = require('fff.utils')
local file_picker = require('fff.file_picker')
local image = require('fff.file_picker.image')
local location_utils = require('fff.location_utils')
local M = {}
-- Additional fallback for certain ambiguous filetypes which vim.filetype.match is not handling correctly
local function get_fixed_filetype_detection(extension)
local extension_map = {
ts = 'typescript',
tex = 'latex',
md = 'markdown',
txt = 'text',
}
return extension_map[extension]
end
local function detect_filetype(file_path)
local has_plenary, plenary_filetype = pcall(require, 'plenary.filetype')
if has_plenary then
local detected = plenary_filetype.detect(file_path)
if detected and detected ~= '' then return detected end
end
local builtin_filetype = vim.filetype.match({ filename = file_path })
if builtin_filetype and builtin_filetype ~= '' then return builtin_filetype end
local extension = vim.fn.fnamemodify(file_path, ':e'):lower()
return get_fixed_filetype_detection(extension)
end
local function set_buffer_lines(bufnr, lines)
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
@@ -115,13 +142,38 @@ local function read_file_streaming_async(file_path, bufnr, callback)
return
end
load_next_chunk_async(M.config.chunk_size, function(data, err)
-- Calculate initial chunk size based on location information
local initial_chunk_size = M.config.chunk_size
if M.state.location then
local target_line = location_utils.get_target_line(M.state.location)
if target_line then
-- Estimate bytes needed: assume ~100 bytes per line average
-- Add some buffer (50%) to account for variation in line lengths
local estimated_bytes = target_line * 100 * 1.5
-- Cap at reasonable maximum to avoid memory issues
local max_initial_chunk = M.config.max_size or (10 * 1024 * 1024) -- 10MB default
initial_chunk_size = math.min(estimated_bytes, max_initial_chunk)
-- Ensure we don't go below the standard chunk size
initial_chunk_size = math.max(initial_chunk_size, M.config.chunk_size)
end
end
load_next_chunk_async(initial_chunk_size, function(data, err)
if data and data ~= '' then
-- there seems to be no other way to append the buffer other than the lines :(
local lines = vim.split(data, '\n', { plain = true })
M.state.loaded_lines = #lines
M.state.content_height = #lines
-- If we have a location and didn't load enough lines, try to load more
if M.state.location then
local target_line = location_utils.get_target_line(M.state.location)
if target_line and #lines < target_line and M.state.has_more_content then
-- Schedule additional loading after the initial callback
vim.schedule(function() ensure_content_loaded_async(target_line) end)
end
end
callback(lines, err)
else
callback(nil, err)
@@ -213,11 +265,19 @@ M.state = {
has_more_content = true,
file_handle = nil,
file_operation = nil, -- Ongoing file operation: {fd?: any, file_path?: string, position?: number}
location = nil, -- Current location data for highlighting
location_namespace = nil, -- Namespace for location highlighting
}
--- Setup preview configuration
--- @param config table Configuration options
function M.setup(config) M.config = config or {} end
function M.setup(config)
M.config = config or {}
-- Create namespace for location highlighting
if not M.state.location_namespace then
M.state.location_namespace = vim.api.nvim_create_namespace('fff_preview_location')
end
end
--- Check if file is too big for initial preview (inspired by snacks.nvim)
--- @param file_path string Path to the file
@@ -408,7 +468,7 @@ function M.get_file_info(file_path)
}
info.extension = vim.fn.fnamemodify(file_path, ':e'):lower()
info.filetype = vim.filetype.match({ filename = file_path }) or 'text'
info.filetype = detect_filetype(file_path) or 'text'
info.size_formatted = utils.format_file_size(info.size)
info.modified_formatted = os.date('%Y-%m-%d %H:%M:%S', info.modified)
info.accessed_formatted = os.date('%Y-%m-%d %H:%M:%S', info.accessed)
@@ -517,6 +577,9 @@ function M.preview_file(file_path, bufnr)
M.state.scroll_offset = 0
-- Apply location highlighting if available (delayed to ensure buffer is ready)
vim.schedule(function() M.apply_location_highlighting(bufnr) end)
return true
end
end
@@ -552,6 +615,9 @@ function M.preview_file(file_path, bufnr)
M.state.content_height = #content
M.state.scroll_offset = 0
-- Apply location highlighting if available (delayed to ensure buffer is ready)
vim.schedule(function() M.apply_location_highlighting(bufnr) end)
end
end)
@@ -620,14 +686,15 @@ end
function M.get_file_config(file_path)
if not M.config or not M.config.filetypes then return {} end
local filetype = vim.filetype.match({ filename = file_path }) or 'text'
local filetype = detect_filetype(file_path) or 'text'
return M.config.filetypes[filetype] or {}
end
--- @param file_path string Path to the file or directory
--- @param bufnr number Buffer number for preview
--- @param location table|nil Optional location data for highlighting
--- @return boolean if the preview was successful
function M.preview(file_path, bufnr)
function M.preview(file_path, bufnr, location)
if not file_path or file_path == '' then
-- Don't immediately clear - let the previous content stay visible
-- Only clear if we really need to show "No file selected"
@@ -648,6 +715,7 @@ function M.preview(file_path, bufnr)
M.state.current_file = file_path
M.state.bufnr = bufnr
M.state.location = location
if image.is_image(file_path) then
M.clear_buffer(bufnr)
@@ -682,9 +750,7 @@ function M.scroll(lines)
if current_buffer_lines < buffer_needed and M.state.has_more_content then
-- Load more content asynchronously but don't wait for it
ensure_content_loaded_async(target_line, function(success)
-- Content loaded in background, no need to recalculate scroll here
end)
ensure_content_loaded_async(target_line)
end
end
@@ -744,6 +810,10 @@ function M.clear_preview_visual_state(bufnr)
-- Only clear visual state, don't affect buffer functionality
-- Clear namespaces and extmarks for this buffer only
vim.api.nvim_buf_clear_namespace(bufnr, -1, 0, -1)
-- Clear location highlights
if M.state.location_namespace then location_utils.clear_location_highlights(bufnr, M.state.location_namespace) end
local wins = vim.fn.win_findbuf(bufnr)
for _, win in ipairs(wins) do
@@ -790,6 +860,51 @@ function M.clear()
M.state.current_file = nil
M.state.scroll_offset = 0
M.state.content_height = 0
M.state.location = nil
end
--- Apply location highlighting to the preview buffer
--- @param bufnr number Buffer number
function M.apply_location_highlighting(bufnr)
-- Ensure namespace is created
if not M.state.location_namespace then
M.state.location_namespace = vim.api.nvim_create_namespace('fff_preview_location')
end
-- Always clear previous location highlights first
if vim.api.nvim_buf_is_valid(bufnr) then
location_utils.clear_location_highlights(bufnr, M.state.location_namespace)
end
if not M.state.location then return end
-- Apply highlighting
location_utils.highlight_location(bufnr, M.state.location, M.state.location_namespace)
if M.state.winid and vim.api.nvim_win_is_valid(M.state.winid) then
local target_line = location_utils.get_target_line(M.state.location)
if target_line then M.scroll_to_line(target_line) end
end
end
--- Scroll preview to a specific line
--- @param line number Target line number (1-indexed)
function M.scroll_to_line(line)
if not M.state.winid or not vim.api.nvim_win_is_valid(M.state.winid) then return end
if not M.state.bufnr or not vim.api.nvim_buf_is_valid(M.state.bufnr) then return end
local win_height = vim.api.nvim_win_get_height(M.state.winid)
local buffer_lines = vim.api.nvim_buf_line_count(M.state.bufnr)
local target_line = math.max(1, math.min(line, buffer_lines))
local half_screen = math.floor(win_height / 2)
local new_offset = math.max(0, target_line - half_screen)
M.state.scroll_offset = new_offset
pcall(vim.api.nvim_win_call, M.state.winid, function()
vim.api.nvim_win_set_cursor(M.state.winid, { target_line, 0 })
vim.cmd('normal! zt')
end)
end
return M
+248
View File
@@ -0,0 +1,248 @@
--- File Renderer
--- Simple renderer for file items with 2 functions: render_line and apply_highlights
local M = {}
--- Render Context passed to renderer functions
--- @class RenderContext
--- @field config table User configuration from conf.get()
--- @field items table[] Array of file items being rendered
--- @field cursor number Current cursor position (1-based index into items)
--- @field win_height number Window height in lines
--- @field win_width number Window width in columns
--- @field max_path_width number Maximum width for file paths
--- @field debug_enabled boolean Whether debug mode is enabled (shows frecency scores)
--- @field prompt_position string Prompt position: 'top' or 'bottom'
--- @field has_combo boolean Whether combo boost is active
--- @field combo_header_line string Formatted combo header line (if has_combo)
--- @field combo_header_text_len number Length of combo header text (if has_combo)
--- @field combo_item_index number Index of item with combo (usually 1)
--- @field display_start number Start index for displayed items
--- @field display_end number End index for displayed items
--- @field iter_start number Iteration start (may differ from display_start for bottom prompt)
--- @field iter_end number Iteration end (may differ from display_end for bottom prompt)
--- @field iter_step number Iteration step (1 for top prompt, -1 for bottom prompt)
--- @field format_file_display fun(item: table, max_width: number): string, string Helper function to format filename and dir path
--- @field selected_files table<string, boolean> Map of selected file paths
--- @field query string Current search query
--- @field renderer table|nil Custom renderer (if provided via opts)
--- File Item structure from Rust
--- @class FileItem
--- @field path string Absolute file path
--- @field relative_path string Relative file path from base directory
--- @field name string File name
--- @field extension string File extension
--- @field size number File size in bytes
--- @field modified number Last modified timestamp
--- @field total_frecency_score number Total frecency score
--- @field access_frecency_score number Access-based frecency score
--- @field modification_frecency_score number Modification-based frecency score
--- @field git_status number|nil Git status enum (if file is in git repo)
--- Renderer Interface:
--- @field render_line fun(item: FileItem, ctx: RenderContext, item_idx: number): string[] Returns array of line strings
--- @field apply_highlights fun(item: FileItem, ctx: RenderContext, item_idx: number, buf: number, ns_id: number, line_idx: number, line_content: string): nil Applies highlights to the rendered line
--- Render a file item line
--- @param item FileItem File item from Rust
--- @param ctx RenderContext Render context with all state
--- @param item_idx number Item index (1-based)
--- @return string[] Array of line strings (1 or 2 lines if combo)
function M.render_line(item, ctx, item_idx)
local icons = require('fff.file_picker.icons')
local lines = {}
-- Check if this should have combo header (first item with combo boost)
local has_combo = item_idx == 1 and ctx.has_combo and ctx.combo_header_line
if has_combo then table.insert(lines, ctx.combo_header_line) end
-- Get icon
local icon, icon_hl_group = icons.get_icon(item.name, item.extension, false)
-- Build frecency indicator (debug mode only)
local frecency = ''
if ctx.debug_enabled then
local total = item.total_frecency_score or 0
local access = item.access_frecency_score or 0
local mod = item.modification_frecency_score or 0
if total > 0 then
local indicator = ''
if mod >= 6 then
indicator = '🔥'
elseif access >= 4 then
indicator = ''
elseif total >= 3 then
indicator = ''
elseif total >= 1 then
indicator = ''
end
frecency = string.format(' %s%d', indicator, total)
end
end
-- Format filename and path
local icon_width = icon and (vim.fn.strdisplaywidth(icon) + 1) or 0
local available_width = math.max(ctx.max_path_width - icon_width - #frecency, 40)
local filename, dir_path = ctx.format_file_display(item, available_width)
-- Build line
local line = icon and string.format('%s %s %s%s', icon, filename, dir_path, frecency)
or string.format('%s %s%s', filename, dir_path, frecency)
local padding = math.max(0, ctx.win_width - vim.fn.strdisplaywidth(line) + 5)
table.insert(lines, line .. string.rep(' ', padding))
return lines
end
--- Apply highlights to a rendered line
--- @param item FileItem File item from Rust
--- @param ctx RenderContext Render context with all state
--- @param item_idx number Item index (1-based)
--- @param buf number Buffer handle
--- @param ns_id number Namespace ID
--- @param line_idx number 1-based line index in buffer
--- @param line_content string The actual line content
function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_content)
local icons = require('fff.file_picker.icons')
local git_utils = require('fff.git_utils')
local file_picker = require('fff.file_picker')
local is_cursor = (ctx.cursor == item_idx)
local score = file_picker.get_file_score(item_idx)
local is_current_file = score and score.current_file_penalty and score.current_file_penalty < 0
-- Get icon and paths
local icon, icon_hl_group = icons.get_icon(item.name, item.extension, false)
local icon_width = icon and (vim.fn.strdisplaywidth(icon) + 1) or 0
local available_width = math.max(ctx.max_path_width - icon_width, 40)
local filename, dir_path = ctx.format_file_display(item, available_width)
-- 1. Cursor highlight
if is_cursor then
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
end_col = 0,
end_row = line_idx,
hl_group = ctx.config.hl.active_file,
hl_eol = true,
priority = 100,
})
end
-- 2. Icon
if icon and icon_hl_group and vim.fn.strdisplaywidth(icon) > 0 then
local icon_hl = is_current_file and 'Comment' or icon_hl_group
vim.api.nvim_buf_add_highlight(buf, ns_id, icon_hl, line_idx - 1, 0, vim.fn.strdisplaywidth(icon))
end
-- 3. Git text color (filename)
if ctx.config.git and ctx.config.git.status_text_color and icon and #filename > 0 then
local git_text_hl = item.git_status and git_utils.get_text_highlight(item.git_status) or nil
if git_text_hl and git_text_hl ~= '' and not is_current_file then
local filename_start = #icon + 1
vim.api.nvim_buf_add_highlight(buf, ns_id, git_text_hl, line_idx - 1, filename_start, filename_start + #filename)
end
end
-- 4. Frecency indicator
if ctx.debug_enabled then
local start_pos, end_pos = line_content:find('[⭐🔥✨•]%d+')
if start_pos then
vim.api.nvim_buf_add_highlight(buf, ns_id, ctx.config.hl.frecency, line_idx - 1, start_pos - 1, end_pos)
end
end
-- 5. Directory path (dimmed)
if #filename > 0 and #dir_path > 0 then
local prefix_len = #filename + 1 -- filename bytes + space
if icon then
prefix_len = prefix_len + #icon + 1 -- if icon add icon bytes + space
end
vim.api.nvim_buf_add_highlight(
buf,
ns_id,
ctx.config.hl.directory_path or 'Comment',
line_idx - 1,
prefix_len,
prefix_len + #dir_path
)
end
-- 6. Current file
if is_current_file then
if not is_cursor then vim.api.nvim_buf_add_highlight(buf, ns_id, 'Comment', line_idx - 1, 0, -1) end
local virt_text_hl = is_cursor and ctx.config.hl.active_file or 'Comment'
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
virt_text = { { ' (current)', virt_text_hl } },
virt_text_pos = 'right_align',
})
end
-- 7. Git sign
if item.git_status and git_utils.should_show_border(item.git_status) then
local border_char = git_utils.get_border_char(item.git_status)
local border_hl
if is_cursor then
local base_hl = git_utils.get_border_highlight(item.git_status)
if base_hl and base_hl ~= '' then
local border_fg = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID(base_hl)), 'fg')
local cursor_bg = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID(ctx.config.hl.active_file)), 'bg')
local temp_hl_name = 'FFFGitBorderSelected_' .. item_idx
if border_fg ~= '' and cursor_bg ~= '' then
vim.api.nvim_set_hl(0, temp_hl_name, { fg = border_fg, bg = cursor_bg })
border_hl = temp_hl_name
else
border_hl = git_utils.get_border_highlight_selected(item.git_status)
end
else
border_hl = ctx.config.hl.active_file
end
else
border_hl = git_utils.get_border_highlight(item.git_status)
end
if border_hl and border_hl ~= '' then
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
sign_text = border_char,
sign_hl_group = border_hl,
priority = 1000,
})
end
elseif is_cursor then
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
sign_text = ' ',
sign_hl_group = ctx.config.hl.active_file,
priority = 1000,
})
end
-- 8. Selection
if ctx.selected_files and ctx.selected_files[item.path] then
local selection_hl = is_cursor and ctx.config.hl.selected_active or ctx.config.hl.selected
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
sign_text = '',
sign_hl_group = selection_hl,
priority = 1001,
})
end
-- 9. Query match
if ctx.query and ctx.query ~= '' then
local match_start, match_end = string.find(line_content, ctx.query, 1)
if match_start and match_end then
vim.api.nvim_buf_add_highlight(
buf,
ns_id,
ctx.config.hl.matched or 'IncSearch',
line_idx - 1,
match_start - 1,
match_end
)
end
end
end
return M
+6
View File
@@ -33,4 +33,10 @@ M.cleanup_file_picker = rust_module.cleanup_file_picker
M.init_tracing = rust_module.init_tracing
M.wait_for_initial_scan = rust_module.wait_for_initial_scan
-- Query tracking functions
M.init_query_db = rust_module.init_query_db
M.destroy_query_db = rust_module.destroy_query_db
M.track_query_completion = rust_module.track_query_completion
M.get_historical_query = rust_module.get_historical_query
return M
+107 -46
View File
@@ -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,39 +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
M.border_highlights_selected = {
untracked = 'FFFGitSignUntrackedSelected',
modified = 'FFFGitSignModifiedSelected',
deleted = 'FFFGitSignDeletedSelected',
renamed = 'FFFGitSignRenamedSelected',
staged_new = 'FFFGitSignStagedSelected',
staged_modified = 'FFFGitSignStagedSelected',
staged_deleted = 'FFFGitSignStagedSelected',
ignored = 'FFFGitSignIgnoredSelected',
clean = '',
clear = '',
unknown = 'FFFGitSignUntrackedSelected',
}
--- Build and cache highlight mappings from config
local function ensure_cache()
if highlights_cache then return end
function M.get_highlight(git_status) return M.highlights[git_status] or '' end
local config = require('fff.conf').get()
function M.get_border_highlight(git_status) return M.border_highlights[git_status] or '' end
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,
}
function M.get_border_highlight_selected(git_status) return M.border_highlights_selected[git_status] or '' end
border_highlights_cache = {
untracked = config.hl.git_sign_untracked,
modified = config.hl.git_sign_modified,
deleted = config.hl.git_sign_deleted,
renamed = config.hl.git_sign_renamed,
staged_new = config.hl.git_sign_staged,
staged_modified = config.hl.git_sign_staged,
staged_deleted = config.hl.git_sign_staged,
ignored = config.hl.git_sign_ignored,
clean = '',
clear = '',
unknown = config.hl.git_sign_untracked,
}
border_highlights_selected_cache = {
untracked = config.hl.git_sign_untracked_selected,
modified = config.hl.git_sign_modified_selected,
deleted = config.hl.git_sign_deleted_selected,
renamed = config.hl.git_sign_renamed_selected,
staged_new = config.hl.git_sign_staged_selected,
staged_modified = config.hl.git_sign_staged_selected,
staged_deleted = config.hl.git_sign_staged_selected,
ignored = config.hl.git_sign_ignored_selected,
clean = '',
clear = '',
unknown = config.hl.git_sign_untracked_selected,
}
end
--- Get highlight group for git status text
--- @param git_status string Git status
--- @return string Highlight group name
function M.get_text_highlight(git_status)
ensure_cache()
return highlights_cache and highlights_cache[git_status] or ''
end
--- Get border highlight group for git status
--- @param git_status string Git status
--- @return string Highlight group name
function M.get_border_highlight(git_status)
ensure_cache()
return border_highlights_cache and border_highlights_cache[git_status] or ''
end
--- Get selected border highlight group for git status
--- @param git_status string Git status
--- @return string Highlight group name
function M.get_border_highlight_selected(git_status)
ensure_cache()
return border_highlights_selected_cache and border_highlights_selected_cache[git_status] or ''
end
function M.get_border_char(git_status) return M.border_chars[git_status] or '' end
@@ -110,12 +140,12 @@ function M.setup_highlights()
{ '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 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')
local gui_bg = visual_bg_gui ~= '' and visual_bg_gui or 'NONE'
local cterm_bg = visual_bg_cterm ~= '' and visual_bg_cterm or 'NONE'
@@ -130,6 +160,37 @@ function M.setup_highlights()
)
)
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
+200
View File
@@ -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
+37 -5
View File
@@ -10,12 +10,13 @@ M.state = { initialized = false }
function M.setup(config) vim.g.fff = config end
--- Find files in current directory
function M.find_files()
--- @param opts? table Optional configuration {renderer = custom_renderer}
function M.find_files(opts)
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
if picker_ok then
picker_ui.open()
picker_ui.open(opts)
else
vim.notify('Failed to load picker UI', vim.log.levels.ERROR)
vim.notify('Failed to load picker UI: ' .. picker_ui, vim.log.levels.ERROR)
end
end
@@ -53,8 +54,12 @@ end
--- @return table List of matching files
function M.search(query, max_results)
local fuzzy = require('fff.core').ensure_initialized()
max_results = max_results or require('fff.config').get().max_results
local ok, search_result = pcall(fuzzy.fuzzy_search_files, query, max_results, nil, nil)
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
@@ -206,4 +211,31 @@ function M.change_indexing_directory(new_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
+899 -251
View File
File diff suppressed because it is too large Load Diff
+181 -75
View File
@@ -1,23 +1,27 @@
use crate::FILE_PICKER;
use crate::error::Error;
use crate::file_picker::FilePicker;
use crate::git::GitStatusCache;
use crate::FILE_PICKER;
use crate::sort_buffer::sort_with_buffer;
use git2::Repository;
use notify::RecursiveMode;
use notify_debouncer_mini::{new_debouncer, DebounceEventResult, DebouncedEvent};
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::{debug, error, info, warn};
use tracing::{Level, error, info, warn};
type Debouncer = notify_debouncer_mini::Debouncer<notify::RecommendedWatcher>;
type Debouncer = notify_debouncer_full::Debouncer<notify::RecommendedWatcher, RecommendedCache>;
pub struct BackgroundWatcher {
debouncer: Arc<Mutex<Option<Debouncer>>>,
}
const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(500);
const MAX_PATHS_THRESHOLD: usize = 50;
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> {
@@ -38,22 +42,29 @@ impl BackgroundWatcher {
base_path: PathBuf,
git_workdir: Option<PathBuf>,
) -> Result<Debouncer, Error> {
let mut debouncer = new_debouncer(DEBOUNCE_TIMEOUT, {
move |result: DebounceEventResult| match result {
Ok(events) => {
if !events.is_empty() {
// 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);
}
}
Err(errors) => {
error!("File watcher errors: {:?}", errors);
}
}
})?;
},
RecommendedCache::new(),
config,
)?;
debouncer
.watcher()
.watch(base_path.as_path(), RecursiveMode::Recursive)?;
debouncer.watch(base_path.as_path(), RecursiveMode::Recursive)?;
info!("File watcher initizlieed for path: {}", base_path.display());
Ok(debouncer)
@@ -81,79 +92,180 @@ impl Drop for BackgroundWatcher {
}
}
#[tracing::instrument(name = "fs_events", skip(events), level = Level::DEBUG)]
fn handle_debounced_events(events: Vec<DebouncedEvent>, git_workdir: &Option<PathBuf>) {
debug!("Processing {} debounced events", events.len());
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;
};
let mut need_full_git_rescan = false;
// 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 files_to_update_git_status = Vec::with_capacity(events.len() * 2);
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 event in &events {
let path = &event.path;
if is_ignore_definition_path(path) {
info!(
"Detected change in the ignore definition file: {}",
path.display()
);
return trigger_full_rescan(picker);
}
if is_dotgit_change_affecting_status(path, &repo) {
need_full_git_rescan = true;
}
if !should_include_file(path, &repo) {
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;
}
debug!("Handling fs event: {:?}", event);
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;
}
affected_paths_count += 1;
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
);
return trigger_full_rescan(picker);
need_full_rescan = true;
break;
}
if !path.exists() {
picker.remove_file_by_path(path);
continue;
}
let file = picker.on_create_or_modify(path);
if let Some(file) = file {
files_to_update_git_status.push(file.relative_path.clone());
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 {
drop(file_picker_guard); // it's going to be relocked after rescan
info!("Triggering full git rescan by the notification results");
info!("Triggering full git rescan");
if let Err(e) = FilePicker::refresh_git_status_global() {
error!("Failed to refresh git status: {:?}", e);
}
} else if let Some(repo) = repo.as_ref() {
let status = GitStatusCache::git_status_for_paths(repo, &files_to_update_git_status);
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");
}
}
@@ -166,12 +278,6 @@ fn should_include_file(path: &Path, repo: &Option<Repository>) -> bool {
.is_some_and(|repo| repo.is_path_ignored(path) == Ok(false))
}
fn trigger_full_rescan(picker: &mut FilePicker) {
if let Err(e) = picker.trigger_rescan() {
error!("Failed to trigger full rescan: {:?}", e);
}
}
#[inline]
fn is_git_file(path: &Path) -> bool {
path.components()
@@ -202,10 +308,10 @@ pub fn is_dotgit_change_affecting_status(changed: &Path, repo: &Option<Repositor
return true;
}
if let Some(fname) = rel.file_name().and_then(|f| f.to_str()) {
if matches!(fname, "MERGE_HEAD" | "CHERRY_PICK_HEAD" | "REVERT_HEAD") {
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;
}
}
+10
View File
@@ -1,3 +1,5 @@
use std::path::StripPrefixError;
#[derive(thiserror::Error, Debug)]
#[non_exhaustive]
pub enum Error {
@@ -33,6 +35,12 @@ pub enum Error {
DbCommit(#[source] heed::Error),
#[error("Failed to start file system watcher: {0}")]
FileSystemWatch(#[from] notify::Error),
#[error("Expected a path to be child of another path: {0}")]
StripPrefixError(#[from] StripPrefixError),
#[error("libgit2 error occurred: {0}")]
Git(#[from] git2::Error),
}
impl From<Error> for mlua::Error {
@@ -43,3 +51,5 @@ impl From<Error> for mlua::Error {
mlua::Error::RuntimeError(string_value)
}
}
pub type Result<T> = std::result::Result<T, Error>;
+96 -62
View File
@@ -2,20 +2,34 @@ use crate::background_watcher::BackgroundWatcher;
use crate::error::Error;
use crate::frecency::FrecencyTracker;
use crate::git::GitStatusCache;
use crate::location::parse_location;
use crate::query_tracker::QueryMatchEntry;
use crate::score::match_and_score_files;
use crate::types::{FileItem, ScoringContext, SearchResult};
use crate::types::{FileItem, PaginationArgs, ScoringContext, SearchResult};
use git2::{Repository, Status, StatusOptions};
use rayon::prelude::*;
use std::fmt::Debug;
use std::path::{Path, PathBuf};
use std::sync::{
atomic::{AtomicBool, AtomicUsize, Ordering},
Arc,
atomic::{AtomicBool, AtomicUsize, Ordering},
};
use std::time::SystemTime;
use tracing::{debug, error, info, warn};
use tracing::{Level, debug, error, info, warn};
use crate::{FILE_PICKER, FRECENCY};
#[derive(Debug, Clone, Copy)]
pub struct FuzzySearchOptions<'a> {
pub max_threads: usize,
pub current_file: Option<&'a str>,
pub project_path: Option<&'a Path>,
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)]
struct FileSync {
pub files: Vec<FileItem>,
@@ -32,7 +46,7 @@ impl FileSync {
fn find_file_index(&self, path: &Path) -> Result<usize, usize> {
self.files
.binary_search_by(|file| file.path.as_path().cmp(path))
.binary_search_by(|file| file.path.as_os_str().cmp(path.as_os_str()))
}
}
@@ -65,7 +79,9 @@ impl FileItem {
Self {
path,
relative_path_lower: relative_path.to_lowercase(),
relative_path,
file_name_lower: name.to_lowercase(),
file_name: name,
size,
modified,
@@ -119,6 +135,10 @@ impl std::fmt::Debug for FilePicker {
}
impl FilePicker {
pub fn base_path(&self) -> &Path {
&self.base_path
}
pub fn git_root(&self) -> Option<&Path> {
self.sync_data.git_workdir.as_deref()
}
@@ -158,40 +178,46 @@ impl FilePicker {
pub fn fuzzy_search<'a>(
files: &'a [FileItem],
query: &'a str,
max_results: usize,
max_threads: usize,
current_file: Option<&'a str>,
reverse_order: bool,
options: FuzzySearchOptions<'a>,
) -> SearchResult<'a> {
let max_threads = max_threads.max(1);
let max_threads = options.max_threads.max(1);
debug!(
?query,
?max_results,
pagination = ?options.pagination,
?max_threads,
?current_file,
current_file = ?options.current_file,
"Fuzzy search",
);
let total_files = files.len();
let (query, location) = parse_location(query);
// small queries with a large number of results can match absolutely everything
let max_typos = (query.len() as u16 / 4).clamp(2, 6);
let context = ScoringContext {
query,
project_path: options.project_path,
max_typos,
max_threads,
current_file,
max_results,
reverse_order,
current_file: options.current_file,
last_same_query_match: options.last_same_query_match,
combo_boost_score_multiplier: options.combo_boost_score_multiplier,
min_combo_count: options.min_combo_count,
pagination: options.pagination,
};
let time = std::time::Instant::now();
// Match, score, and paginate files (all done in sort_and_truncate)
let (items, scores, total_matched) = match_and_score_files(files, &context);
debug!(
?query,
completed_in = ?time.elapsed(),
top_position = ?items.first(),
total_matched,
returned_count = items.len(),
pagination = ?options.pagination,
"Fuzzy search completed",
);
@@ -200,6 +226,7 @@ impl FilePicker {
scores,
total_matched,
total_files,
location,
}
}
@@ -212,14 +239,7 @@ impl FilePicker {
}
}
pub fn update_git_statuses(
&mut self,
status_cache: Option<GitStatusCache>,
) -> Result<(), Error> {
let Some(status_cache) = status_cache else {
return Ok(());
};
pub fn update_git_statuses(&mut self, status_cache: GitStatusCache) -> Result<(), Error> {
debug!(
statuses_count = status_cache.statuses_len(),
"Updating git status",
@@ -235,6 +255,8 @@ impl FilePicker {
if let Some(frecency) = frecency.as_ref() {
file.update_frecency_scores(frecency)?;
}
} else {
error!(?path, "Couldn't update the git status for path");
}
Ok(())
@@ -275,8 +297,14 @@ impl FilePicker {
.as_mut()
.ok_or_else(|| Error::FilePickerMissing)?;
let statuses_count = git_status.as_ref().map_or(0, |cache| cache.statuses_len());
picker.update_git_statuses(git_status)?;
let statuses_count = if let Some(git_status) = git_status {
let count = git_status.statuses_len();
picker.update_git_statuses(git_status)?;
count
} else {
0
};
Ok(statuses_count)
}
@@ -286,10 +314,10 @@ impl FilePicker {
file_path: impl AsRef<Path>,
frecency_tracker: &FrecencyTracker,
) -> Result<(), Error> {
if let Ok(index) = self.sync_data.find_file_index(file_path.as_ref()) {
if let Some(file) = self.sync_data.files.get_mut(index) {
file.update_frecency_scores(frecency_tracker)?;
}
if let Ok(index) = self.sync_data.find_file_index(file_path.as_ref())
&& let Some(file) = self.sync_data.files.get_mut(index)
{
file.update_frecency_scores(frecency_tracker)?;
}
Ok(())
@@ -331,7 +359,8 @@ impl FilePicker {
}
}
pub fn on_create_or_modify(&mut self, path: impl AsRef<Path>) -> Option<&FileItem> {
#[tracing::instrument(skip(self), name = "timing_update", level = Level::DEBUG)]
pub fn on_create_or_modify(&mut self, path: impl AsRef<Path> + Debug) -> Option<&FileItem> {
let path = path.as_ref();
match self.sync_data.find_file_index(path) {
Ok(pos) => {
@@ -404,14 +433,17 @@ impl FilePicker {
self.is_scanning.store(true, Ordering::Relaxed);
self.scanned_files_count.store(0, Ordering::Relaxed);
if let Ok(sync) = scan_filesystem(&self.base_path, &self.scanned_files_count) {
info!(
"Filesystem scan completed: found {} files",
sync.files.len()
);
self.sync_data = sync
} else {
warn!("Filesystem scan failed");
let scan_result = scan_filesystem(&self.base_path, &self.scanned_files_count);
match scan_result {
Ok(sync) => {
info!(
"Filesystem scan completed: found {} files",
sync.files.len()
);
self.sync_data = sync
}
Err(error) => error!(?error, "Failed to scan file system"),
}
self.is_scanning.store(false, Ordering::Relaxed);
@@ -448,10 +480,10 @@ fn spawn_scan_and_watcher(
);
git_workdir = sync.git_workdir.clone();
if let Ok(mut file_picker_guard) = crate::FILE_PICKER.write() {
if let Some(ref mut picker) = *file_picker_guard {
picker.sync_data = sync;
}
if let Ok(mut file_picker_guard) = crate::FILE_PICKER.write()
&& let Some(ref mut picker) = *file_picker_guard
{
picker.sync_data = sync;
}
}
Err(e) => {
@@ -464,10 +496,10 @@ fn spawn_scan_and_watcher(
Ok(watcher) => {
info!("Background file watcher initialized successfully");
if let Ok(mut file_picker_guard) = crate::FILE_PICKER.write() {
if let Some(ref mut picker) = *file_picker_guard {
picker.background_watcher = Some(watcher);
}
if let Ok(mut file_picker_guard) = crate::FILE_PICKER.write()
&& let Some(ref mut picker) = *file_picker_guard
{
picker.background_watcher = Some(watcher);
}
}
Err(e) => {
@@ -512,6 +544,7 @@ fn scan_filesystem(
.recurse_untracked_dirs(true)
.exclude_submodules(true),
);
(git_workdir, status_cache)
});
@@ -534,24 +567,24 @@ fn scan_filesystem(
let base_path = base_path.to_path_buf();
Box::new(move |result| {
if let Ok(entry) = result {
if entry.file_type().is_some_and(|ft| ft.is_file()) {
let path = entry.path();
if let Ok(entry) = result
&& entry.file_type().is_some_and(|ft| ft.is_file())
{
let path = entry.path();
if is_git_file(path) {
return WalkState::Continue;
}
if is_git_file(path) {
return WalkState::Continue;
}
let file_item = FileItem::new(
path.to_path_buf(),
&base_path,
None, // Git status will be added after join
);
let file_item = FileItem::new(
path.to_path_buf(),
&base_path,
None, // Git status will be added after join
);
if let Ok(mut files_vec) = files.lock() {
files_vec.push(file_item);
counter.fetch_add(1, Ordering::Relaxed);
}
if let Ok(mut files_vec) = files.lock() {
files_vec.push(file_item);
counter.fetch_add(1, Ordering::Relaxed);
}
}
WalkState::Continue
@@ -589,7 +622,8 @@ fn scan_filesystem(
files.len()
);
files.par_sort_unstable_by(|a, b| a.path.cmp(&b.path));
// Sort by OsStr instead of Path to avoid expensive component-by-component comparison
files.par_sort_unstable_by(|a, b| a.path.as_os_str().cmp(b.path.as_os_str()));
Ok(FileSync { files, git_workdir })
})
}
+4 -4
View File
@@ -1,9 +1,9 @@
use crate::{error::Error, git::is_modified_status};
use heed::{
types::{Bytes, SerdeBincode},
EnvFlags,
};
use heed::{Database, Env, EnvOpenOptions};
use heed::{
EnvFlags,
types::{Bytes, SerdeBincode},
};
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
use std::{collections::VecDeque, path::Path};
+41 -26
View File
@@ -1,9 +1,10 @@
use crate::error::Result;
use git2::{Repository, Status, StatusOptions};
use std::{
fmt::Debug,
path::{Path, PathBuf},
};
use tracing::{debug, error, info};
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
@@ -32,19 +33,12 @@ impl GitStatusCache {
.and_then(|idx| self.0.get(idx).map(|(_, status)| *status))
}
fn read_status_impl(repo: &Repository, status_options: &mut StatusOptions) -> Option<Self> {
let status_start = std::time::Instant::now();
info!("GIT: Reading git status");
let statuses = repo
.statuses(Some(status_options))
.map_err(|e| {
error!("Failed to get git statuses: {}", e);
e
})
.ok()?;
let status_time = status_start.elapsed();
let repo_path = repo.path().parent()?;
info!("GIT: Status query completed in {:?}", status_time);
#[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 {
@@ -54,7 +48,7 @@ impl GitStatusCache {
}
}
Some(Self(entries))
Ok(Self(entries))
}
pub fn read_git_status(
@@ -64,20 +58,42 @@ impl GitStatusCache {
let git_workdir = git_workdir.as_ref()?;
let repository = Repository::open(git_workdir).ok()?;
Self::read_status_impl(&repository, status_options)
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],
) -> Option<Self> {
) -> Result<Self> {
if paths.is_empty() {
return None;
return Ok(Self(vec![]));
}
debug!(?paths, "Git partial git status for paths");
let mut status_options = StatusOptions::new();
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)
@@ -85,17 +101,16 @@ impl GitStatusCache {
.include_unmodified(true);
for path in paths {
status_options.pathspec(path.as_ref());
status_options.pathspec(path.as_ref().strip_prefix(workdir)?);
}
let statuses = Self::read_status_impl(repo, &mut status_options)?;
let git_status_cache = Self::read_status_impl(repo, &mut status_options)?;
debug!(
"Git partial status for paths {:?} returned {} entries",
statuses,
statuses.statuses_len()
status_len = git_status_cache.statuses_len(),
"Multiple files git status"
);
Some(statuses)
Ok(git_status_cache)
}
}
+31 -6
View File
@@ -9,10 +9,16 @@ end
-- search for the lib in the /target/release directory with and without the lib prefix
-- since MSVC doesn't include the prefix
local base_path = debug.getinfo(1).source:match('@?(.*/)')
local info = debug.getinfo(1, 'S')
local base_path = info and info.source and info.source:match('@?(.*/)') or ''
-- Fallback: if base_path is nil, try to determine from current file path
if not base_path or base_path == '' then
base_path = vim.fn.fnamemodify(vim.fn.resolve(vim.fn.expand('<sfile>:p')), ':h') .. '/'
end
local paths = {
download.get_binary_path(),
download.get_binary_cpath_component(),
base_path .. '../../../target/release/lib?' .. get_lib_extension(),
base_path .. '../../../target/release/?' .. get_lib_extension(),
}
@@ -23,11 +29,30 @@ if cargo_target_dir then
table.insert(paths, cargo_target_dir .. '/release/?' .. get_lib_extension())
end
package.cpath = package.cpath .. ';' .. table.concat(paths, ';')
-- Instead of using require (which can find the wrong lib due to cpath pollution),
-- load the library directly from the first valid path we find
local function try_load_library()
for _, path_pattern in ipairs(paths) do
local actual_path = path_pattern:gsub('%?', 'fff_nvim')
local stat = vim.uv.fs_stat(actual_path)
if stat and stat.type == 'file' then
local loader, err = package.loadlib(actual_path, 'luaopen_fff_nvim')
if err then return nil, string.format('Error loading library from %s: %s', actual_path, err) end
if loader then return loader() end
end
end
return nil, 'No valid library found in any search path'
end
local ok, backend = pcall(require, 'fff_nvim')
if not ok then
error('Failed to load fff rust backend. Make sure that it has been downloaded or built with `cargo build --release`')
local backend, load_err = try_load_library()
if not backend or load_err then
local err_msg = string.format(
'Failed to load fff rust backend.\nError: %s\nSearched paths:\n%s\nMake sure binary exists or make it exists using \n `:lua require("fff.download").download_or_build_binary()`\nor\n`cargo build --release`\n(and rerun neovim after)',
tostring(load_err),
vim.inspect(paths)
)
error(err_msg)
end
return backend
+189 -24
View File
@@ -1,9 +1,11 @@
use crate::error::Error;
use crate::file_picker::FilePicker;
use crate::file_picker::{FilePicker, FuzzySearchOptions};
use crate::frecency::FrecencyTracker;
use crate::query_tracker::QueryTracker;
use crate::types::PaginationArgs;
use mlua::prelude::*;
use once_cell::sync::Lazy;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::RwLock;
use std::time::Duration;
@@ -12,9 +14,12 @@ mod error;
pub mod file_picker;
mod frecency;
pub mod git;
mod location;
mod log;
mod path_utils;
pub mod query_tracker;
pub mod score;
mod tracing;
mod sort_buffer;
pub mod types;
use mimalloc::MiMalloc;
@@ -23,22 +28,47 @@ static GLOBAL: MiMalloc = MiMalloc;
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, (db_path, use_unsafe_no_lock): (String, bool)) -> LuaResult<bool> {
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() {
@@ -50,7 +80,7 @@ pub fn init_file_picker(_: &Lua, base_path: String) -> LuaResult<bool> {
Ok(true)
}
fn reinit_file_picker_internal(path: std::path::PathBuf) -> Result<(), Error> {
fn reinit_file_picker_internal(path: &Path) -> Result<(), Error> {
let mut file_picker = FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)?;
// drop should clean it anyway but just to be extra sure
@@ -64,7 +94,7 @@ fn reinit_file_picker_internal(path: std::path::PathBuf) -> Result<(), Error> {
Ok(())
}
pub fn restart_index_in_path(_: &Lua, new_path: String) -> LuaResult<bool> {
pub fn restart_index_in_path(_: &Lua, new_path: String) -> LuaResult<()> {
let path = std::path::PathBuf::from(&new_path);
if !path.exists() {
return Err(LuaError::RuntimeError(format!(
@@ -77,8 +107,20 @@ pub fn restart_index_in_path(_: &Lua, new_path: String) -> LuaResult<bool> {
LuaError::RuntimeError(format!("Failed to canonicalize path '{}': {}", new_path, e))
})?;
reinit_file_picker_internal(canonical_path)?;
Ok(true)
// Spawn a background thread to avoid blocking Lua/UI thread
std::thread::spawn(move || {
if let Err(e) = reinit_file_picker_internal(&canonical_path) {
::tracing::error!(
?e,
?canonical_path,
"Failed to index directory after changing"
);
} else {
::tracing::info!(?canonical_path, "Successfully reindexed directory");
}
});
Ok(())
}
pub fn scan_files(_: &Lua, _: ()) -> LuaResult<()> {
@@ -92,43 +134,94 @@ pub fn scan_files(_: &Lua, _: ()) -> LuaResult<()> {
Ok(())
}
#[allow(clippy::type_complexity)]
pub fn fuzzy_search_files(
lua: &Lua,
(query, max_results, max_threads, current_file, order_reverse): (
(
query,
max_threads,
current_file,
combo_boost_score_multiplier,
min_combo_count,
page_index,
page_size,
): (
String,
usize,
usize,
Option<String>,
bool,
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 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,
max_results,
max_threads,
current_file.as_deref(),
order_reverse,
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 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)?;
};
let file_path = PathBuf::from(&file_path).canonicalize()?;
frecency.track_access(file_path.as_path())?;
picker.update_single_file_frecency(&file_path, frecency)?;
Ok(true)
@@ -197,6 +290,61 @@ pub fn cancel_scan(_: &Lua, _: ()) -> LuaResult<bool> {
Ok(true)
}
pub fn track_query_completion(_: &Lua, (query, file_path): (String, String)) -> LuaResult<bool> {
// Get the project path before spawning thread
let project_path = {
let Some(ref picker) = *FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)? else {
return Ok(false);
};
picker.base_path().to_path_buf()
};
// Canonicalize the file path before spawning thread
let file_path = match PathBuf::from(&file_path).canonicalize() {
Ok(path) => path,
Err(e) => {
tracing::warn!(?file_path, error = ?e, "Failed to canonicalize file path for tracking");
return Ok(false);
}
};
// Spawn background thread to do the actual tracking (expensive DB write)
std::thread::spawn(move || {
if let Ok(Some(tracker)) = QUERY_TRACKER.write().as_deref_mut()
&& let Err(e) = tracker.track_query_completion(&query, &project_path, &file_path)
{
tracing::error!(
query = %query,
file = %file_path.display(),
error = ?e,
"Failed to track query completion"
);
}
});
Ok(true)
}
pub fn get_historical_query(_: &Lua, offset: usize) -> LuaResult<Option<String>> {
let project_path = {
let Some(ref picker) = *FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)? else {
return Ok(None);
};
picker.base_path().to_path_buf()
};
let Some(ref tracker) = *QUERY_TRACKER
.read()
.map_err(|_| Error::AcquireFrecencyLock)?
else {
return Ok(None);
};
tracker
.get_historical_query(&project_path, offset)
.map_err(Into::into)
}
pub fn wait_for_initial_scan(_: &Lua, timeout_ms: Option<u64>) -> LuaResult<bool> {
let file_picker = FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)?;
let picker = file_picker
@@ -229,14 +377,17 @@ pub fn init_tracing(
_: &Lua,
(log_file_path, log_level): (String, Option<String>),
) -> LuaResult<String> {
crate::tracing::init_tracing(&log_file_path, log_level.as_deref())
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",
@@ -267,11 +418,25 @@ fn create_exports(lua: &Lua) -> LuaResult<LuaTable> {
"cleanup_file_picker",
lua.create_function(cleanup_file_picker)?,
)?;
exports.set("destroy_query_db", lua.create_function(destroy_query_db)?)?;
exports.set(
"track_query_completion",
lua.create_function(track_query_completion)?,
)?;
exports.set(
"get_historical_query",
lua.create_function(get_historical_query)?,
)?;
Ok(exports)
}
// https://github.com/mlua-rs/mlua/issues/318
#[mlua::lua_module(skip_memory_check)]
fn fff_nvim(lua: &Lua) -> LuaResult<LuaTable> {
// Install panic hook IMMEDIATELY on module load
// This ensures any panics are logged even if init_tracing is never called
crate::log::install_panic_hook();
create_exports(lua)
}
+238
View File
@@ -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));
}
}
+79 -32
View File
@@ -1,11 +1,84 @@
use crate::error::Error;
use std::path::Path;
use tracing_appender::non_blocking;
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
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
@@ -15,6 +88,9 @@ static TRACING_INITIALIZED: std::sync::OnceLock<tracing_appender::non_blocking::
/// # 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)?;
@@ -51,7 +127,8 @@ pub fn init_tracing(log_file_path: &str, log_level: Option<&str>) -> Result<Stri
.with_thread_names(false)
.with_file(true)
.with_line_number(true)
.with_ansi(false),
.with_ansi(false)
.with_span_events(FmtSpan::NEW | FmtSpan::CLOSE),
)
.with(
EnvFilter::builder()
@@ -68,36 +145,6 @@ pub fn init_tracing(log_file_path: &str, log_level: Option<&str>) -> Result<Stri
);
}
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);
}));
guard
});
+334
View File
@@ -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"
);
}
}
+293 -45
View File
@@ -1,12 +1,12 @@
use std::path::MAIN_SEPARATOR;
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<'a>(
files: &'a [FileItem],
@@ -33,9 +33,9 @@ pub fn match_and_score_files<'a>(
};
let query_contains_path_separator = context.query.contains(MAIN_SEPARATOR);
let haystack: Vec<_> = files
let haystack: Vec<&str> = files
.iter()
.map(|f| f.relative_path.to_lowercase())
.map(|f| f.relative_path_lower.as_str())
.collect();
tracing::debug!(
"Starting fuzzy search for query '{}' in {} files",
@@ -52,27 +52,39 @@ pub fn match_and_score_files<'a>(
// 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 as usize)
.map(|f| f.file_name.to_lowercase())
.map(|f| f.file_name_lower.as_str())
})
.collect::<Vec<_>>();
.collect();
// if there is a / in the query we don't even match filenames
let filename_matches = if query_contains_path_separator {
vec![]
} else {
let mut list = neo_frizbee::match_list_parallel(
context.query,
&haystack_of_filenames,
&options,
context.max_threads,
);
// 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)
};
list.par_sort_unstable_by_key(|m| m.index);
// 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
};
@@ -132,15 +144,31 @@ pub fn match_and_score_files<'a>(
};
let current_file_penalty = calculate_current_file_penalty(file, base_score, context);
if current_file_penalty < 0 {
tracing::debug!(file =?file.relative_path, ?current_file_penalty, "Applied penalty");
}
let combo_match_boost = {
let last_same_query_match = context
.last_same_query_match
.filter(|m| m.file_path.as_os_str() == file.path.as_os_str());
match last_same_query_match {
// if we request a combo match without a boost we have to render it anyway
Some(_) if context.min_combo_count == 0 => 1000,
Some(combo_match) if combo_match.open_count >= context.min_combo_count => {
combo_match.open_count as i32 * context.combo_boost_score_multiplier
}
// until we hit the combo count threshold, we add a smaller boost because it
// makes sense and makes the search more efficient
Some(combo_match) => combo_match.open_count as i32 * 5,
_ => 0,
}
};
let total = base_score
.saturating_add(frecency_boost)
.saturating_add(distance_penalty)
.saturating_add(filename_bonus)
.saturating_add(current_file_penalty);
.saturating_add(current_file_penalty)
.saturating_add(combo_match_boost);
let score = Score {
total,
@@ -154,6 +182,8 @@ pub fn match_and_score_files<'a>(
},
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",
@@ -165,7 +195,7 @@ pub fn match_and_score_files<'a>(
})
.collect();
sort_and_truncate(results, context)
sort_and_paginate(results, context)
}
/// Check if a filename is a special entry point file that deserves bonus scoring
@@ -213,8 +243,10 @@ fn score_all_by_frecency<'a>(
filename_bonus: 0,
distance_penalty: 0,
special_filename_bonus: 0,
combo_match_boost: 0,
current_file_penalty,
frecency_boost: total_frecency_score,
exact_match: false,
match_type: "frecency",
};
@@ -222,7 +254,7 @@ fn score_all_by_frecency<'a>(
})
.collect();
sort_and_truncate(results, context)
sort_and_paginate(results, context)
}
#[inline]
@@ -233,45 +265,261 @@ fn calculate_current_file_penalty(
) -> i32 {
let mut penalty = 0i32;
if let Some(current) = context.current_file {
if file.relative_path.as_str() == current {
penalty -= match file.git_status {
Some(status) if is_modified_status(status) => base_score / 2,
_ => base_score,
};
tracing::debug!(file =?file.relative_path, current=?context.current_file, ?penalty, "Calculating current file penalty");
}
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,
};
}
penalty
}
/// Dynamically sorts and returns the top results either in ascending or descending order
fn sort_and_truncate<'a>(
/// 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 context.reverse_order {
results.sort_by(|a, b| {
a.1.total
.cmp(&b.1.total)
.then_with(|| a.0.modified.cmp(&b.0.modified))
});
if results.len() > context.max_results {
results.drain(0..(total_matched - context.max_results));
}
if total_matched == 0 {
return (vec![], vec![], 0);
}
let offset = context.pagination.offset;
let limit = if context.pagination.limit > 0 {
context.pagination.limit
} else {
results.sort_by(|a, b| {
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(context.max_results);
results.truncate(items_needed);
}
let (items, scores) = results.into_iter().unzip();
// 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");
}
}
+154
View File
@@ -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");
}
}
+52 -4
View File
@@ -1,13 +1,15 @@
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 file_name_lower: String,
pub size: u64,
pub modified: u64,
pub access_frecency_score: i64,
@@ -25,17 +27,28 @@ pub struct Score {
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 project_path: Option<&'a Path>,
pub current_file: Option<&'a str>,
pub max_results: usize,
pub max_typos: u16,
pub max_threads: usize,
pub reverse_order: bool,
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)]
@@ -44,6 +57,7 @@ pub struct SearchResult<'a> {
pub scores: Vec<Score>,
pub total_matched: usize,
pub total_files: usize,
pub location: Option<Location>,
}
impl IntoLua for &FileItem {
@@ -75,7 +89,20 @@ impl IntoLua for Score {
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))
}
}
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))
}
}
@@ -87,6 +114,27 @@ impl IntoLua for SearchResult<'_> {
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))
}
}
+115
View File
@@ -0,0 +1,115 @@
--- Scrollbar module for pagination indicator
local M = {}
-- Internal state
local scrollbar_state = {
win = nil,
buf = nil,
ever_shown = false,
}
local ns_id = vim.api.nvim_create_namespace('fff_scrollbar')
--- Render the scrollbar to show current page position
--- Creates the window lazily if needed
--- @param layout table Layout info with list_col, list_row, list_width, list_height, show_scrollbar
--- @param config table Config with hl (highlight groups)
--- @param list_win number List window handle
--- @param pagination table Pagination state with page_index, page_size, total_matched
--- @param prompt_position string|nil Prompt position ('top' or 'bottom', defaults to 'bottom')
function M.render(layout, config, list_win, pagination, prompt_position)
if layout.show_scrollbar == false then return end
-- this is the most often path, we don't want to show scrollbar if use doesn't scrolling
if not scrollbar_state.ever_shown and pagination.page_index == 0 then return end
prompt_position = prompt_position or 'bottom'
local total_pages = pagination.page_size > 0 and math.ceil(pagination.total_matched / pagination.page_size) or 1
local has_multiple_pages = total_pages > 1
local scrollbar_exists = scrollbar_state.win and vim.api.nvim_win_is_valid(scrollbar_state.win)
-- If only one page, hide existing scrollbar and return
if not has_multiple_pages then
if scrollbar_exists then pcall(vim.api.nvim_win_hide, scrollbar_state.win) end
return
end
-- rendering in a separate buffer to overflow the border
if not scrollbar_exists then
scrollbar_state.buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_option(scrollbar_state.buf, 'bufhidden', 'wipe')
scrollbar_state.win = vim.api.nvim_open_win(scrollbar_state.buf, false, {
relative = 'editor',
width = 1,
height = layout.list_height,
col = layout.list_col + layout.list_width + 1,
row = layout.list_row + 1,
border = 'none',
style = 'minimal',
focusable = false,
})
local scrollbar_hl = string.format('Normal:%s', config.hl.border)
vim.api.nvim_win_set_option(scrollbar_state.win, 'winhighlight', scrollbar_hl)
scrollbar_state.ever_shown = true
end
if not scrollbar_state.buf or not vim.api.nvim_buf_is_valid(scrollbar_state.buf) then return end
pcall(vim.api.nvim_win_set_config, scrollbar_state.win, { hide = false })
local win_height = vim.api.nvim_win_get_height(list_win)
local thumb_size = math.max(1, math.floor(win_height / total_pages))
local scrollbar_range = win_height - thumb_size
-- inverse the scrollbar when the position is at the bottom
local thumb_start
if prompt_position == 'bottom' then
thumb_start =
math.floor(((total_pages - 1 - pagination.page_index) / math.max(1, total_pages - 1)) * scrollbar_range)
else
thumb_start = math.floor((pagination.page_index / math.max(1, total_pages - 1)) * scrollbar_range)
end
local lines = {}
for i = 1, win_height do
if i >= thumb_start + 1 and i < thumb_start + thumb_size + 1 then
table.insert(lines, '') -- Thick block for thumb
else
table.insert(lines, '') -- Thin line for track
end
end
pcall(vim.api.nvim_buf_set_option, scrollbar_state.buf, 'modifiable', true)
pcall(vim.api.nvim_buf_set_lines, scrollbar_state.buf, 0, -1, false, lines)
pcall(vim.api.nvim_buf_set_option, scrollbar_state.buf, 'modifiable', false)
pcall(vim.api.nvim_buf_clear_namespace, scrollbar_state.buf, ns_id, 0, -1)
if thumb_size > 0 then
pcall(vim.api.nvim_buf_set_extmark, scrollbar_state.buf, ns_id, thumb_start, 0, {
end_row = thumb_start + thumb_size,
end_col = 0,
hl_group = config.hl.scrollbar,
hl_eol = true,
})
end
end
function M.cleanup()
if scrollbar_state.win and vim.api.nvim_win_is_valid(scrollbar_state.win) then
pcall(vim.api.nvim_win_close, scrollbar_state.win, true)
end
if scrollbar_state.buf and vim.api.nvim_buf_is_valid(scrollbar_state.buf) then
pcall(vim.api.nvim_buf_delete, scrollbar_state.buf, { force = true })
end
scrollbar_state.win = nil
scrollbar_state.buf = nil
scrollbar_state.ever_shown = false
end
return M
+33
View File
@@ -0,0 +1,33 @@
local M = {}
function M.mkdir_recursive(path, callback)
vim.uv.fs_stat(path, function(err, stat)
if not err and stat then
callback(true, nil)
return
end
local parent = vim.fn.fnamemodify(path, ':h')
if parent == path or parent == '' or parent == '.' then
callback(false, 'Cannot create root directory')
return
end
M.mkdir_recursive(parent, function(parent_ok, parent_err)
if not parent_ok then
callback(false, parent_err)
return
end
vim.uv.fs_mkdir(path, 493, function(mkdir_err) -- 493 = 0755 octal
if mkdir_err and not mkdir_err:match('EEXIST') then
callback(false, 'Failed to create directory: ' .. mkdir_err)
return
end
callback(true, nil)
end)
end)
end)
end
return M
+140
View File
@@ -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"
);
}
+24 -16
View File
@@ -1,4 +1,4 @@
use fff_nvim::{file_picker::FilePicker, FILE_PICKER};
use fff_nvim::{FILE_PICKER, file_picker::FilePicker};
use std::env;
use std::thread;
use std::time::Duration;
@@ -28,11 +28,11 @@ fn get_mem_stat() -> Result<(usize, usize, usize), Box<dyn std::error::Error>> {
for line in content.lines() {
if line.starts_with("VmRSS:") {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
if let Ok(rss_kb) = parts[1].parse::<usize>() {
let rss_bytes = rss_kb * 1024;
return Ok((rss_bytes, rss_bytes, rss_bytes));
}
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));
}
}
}
@@ -86,10 +86,18 @@ fn test_search_memory_pattern(
let search_result = FilePicker::fuzzy_search(
picker.get_files(),
&query,
50 + (i % 50), // Vary result count
1 + (i % 4), // Vary thread count
None,
false, // prompt_position not relevant for test
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 {
@@ -177,12 +185,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Wait for initial scan
println!("Waiting for file scan...");
loop {
if let Ok(file_picker_guard) = FILE_PICKER.read() {
if let Some(ref picker) = *file_picker_guard {
if !picker.is_scan_active() && !picker.get_files().is_empty() {
break;
}
}
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));
}
+175
View File
@@ -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));
}
+69 -61
View File
@@ -1,4 +1,4 @@
use fff_nvim::{file_picker::FilePicker, FILE_PICKER};
use fff_nvim::{FILE_PICKER, file_picker::FilePicker};
use std::env;
use std::io::{self, Write};
use std::thread;
@@ -33,10 +33,10 @@ fn get_memory_usage() -> Result<u64, Box<dyn std::error::Error>> {
for line in content.lines() {
if line.starts_with("VmRSS:") {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
if let Ok(rss_kb) = parts[1].parse::<u64>() {
return Ok(rss_kb * 1024); // Convert KB to bytes
}
if let Ok(rss_kb) = parts[1].parse::<u64>()
&& parts.len() >= 2
{
return Ok(rss_kb * 1024); // Convert KB to bytes
}
}
}
@@ -101,19 +101,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut scan_completed = false;
loop {
if let Ok(file_picker_guard) = FILE_PICKER.read() {
if 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...");
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));
@@ -127,12 +127,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// If async scan didn't work, trigger a manual scan
if !scan_completed {
println!("Triggering manual rescan...");
if let Ok(mut file_picker_guard) = FILE_PICKER.write() {
if let Some(ref mut picker) = *file_picker_guard {
match picker.trigger_rescan() {
Ok(_) => println!("Manual rescan completed"),
Err(e) => println!("Manual rescan failed: {:?}", e),
}
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),
}
}
}
@@ -202,10 +202,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let search_result = FilePicker::fuzzy_search(
picker.get_files(),
query,
max_results,
max_threads,
None,
false, // prompt_position not relevant for test
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)
@@ -218,50 +226,50 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// 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)
if (search_count % 100 == 0
|| now.duration_since(last_memory_check) > Duration::from_secs(5))
&& let Ok(current_memory) = get_memory_usage()
{
if let Ok(current_memory) = get_memory_usage() {
memory_samples.push(current_memory);
memory_samples.push(current_memory);
if current_memory > peak_memory {
peak_memory = current_memory;
}
if current_memory > peak_memory {
peak_memory = current_memory;
}
let memory_growth = current_memory.saturating_sub(initial_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)
);
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;
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];
// 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)
);
}
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();
}
io::stdout().flush().unwrap();
}
// Brief pause to prevent overwhelming the system
+18 -3
View File
@@ -2,11 +2,11 @@
#![allow(dead_code)]
#![allow(clippy::enum_variant_names)]
use fff_nvim::{file_picker::FilePicker, git::format_git_status, FILE_PICKER, FRECENCY};
use fff_nvim::{FILE_PICKER, FRECENCY, file_picker::FilePicker, git::format_git_status};
use std::env;
use std::io::{self, Write};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::Duration;
@@ -156,7 +156,22 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
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", 5, 2, None, false);
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",