Compare commits
9 Commits
2df0628
...
fix/nonblock
| Author | SHA1 | Date | |
|---|---|---|---|
| c4a55070e2 | |||
| 2c55114048 | |||
| d88922e6c7 | |||
| 9edf195c8f | |||
| 65aeacf9e2 | |||
| e8850c3c62 | |||
| ee8bd6e839 | |||
| 3c76ba523f | |||
| 51f32597de |
@@ -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 }}
|
||||
|
||||
@@ -10,3 +10,6 @@ result
|
||||
.repro/
|
||||
.wrangler/
|
||||
*.so
|
||||
big-repo/
|
||||
# all the perf like utility files
|
||||
*.data
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
syntax = "LuaJIT"
|
||||
column_width = 120
|
||||
line_endings = "Unix"
|
||||
indent_type = "Spaces"
|
||||
|
||||
Generated
+247
-5
@@ -26,6 +26,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 +115,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 +159,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 +223,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 +302,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"
|
||||
@@ -263,25 +375,37 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"chrono",
|
||||
"criterion",
|
||||
"ctrlc",
|
||||
"git2",
|
||||
"glidesort",
|
||||
"heed",
|
||||
"ignore",
|
||||
"mimalloc",
|
||||
"mlua",
|
||||
"neo_frizbee",
|
||||
"notify",
|
||||
"notify-debouncer-mini",
|
||||
"notify-debouncer-full",
|
||||
"once_cell",
|
||||
"openssl",
|
||||
"pathdiff",
|
||||
"rayon",
|
||||
"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"
|
||||
@@ -342,6 +466,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 +485,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 +534,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 +707,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"
|
||||
@@ -844,15 +1011,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 +1060,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"
|
||||
@@ -1051,6 +1225,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"
|
||||
@@ -1429,6 +1631,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"
|
||||
@@ -1620,6 +1832,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"
|
||||
@@ -1896,6 +2118,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"
|
||||
|
||||
+17
-2
@@ -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,20 @@ 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"
|
||||
|
||||
[dependencies]
|
||||
blake3 = "1.8.2"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
ctrlc = "3.4.2"
|
||||
git2 = "0.20.2"
|
||||
glidesort = "0.1"
|
||||
heed = "0.22.0"
|
||||
ignore = "0.4.22"
|
||||
mimalloc = "0.1.47"
|
||||
@@ -28,7 +35,7 @@ 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"
|
||||
@@ -37,3 +44,11 @@ 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"] }
|
||||
tempfile = "3.8"
|
||||
|
||||
[[bench]]
|
||||
name = "indexing_and_search"
|
||||
harness = false
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
|
||||
use fff_nvim::FILE_PICKER;
|
||||
use fff_nvim::file_picker::FilePicker;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use tracing_subscriber;
|
||||
|
||||
/// 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),
|
||||
black_box(100),
|
||||
black_box(4),
|
||||
black_box(None),
|
||||
black_box(false),
|
||||
);
|
||||
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),
|
||||
black_box(100),
|
||||
black_box(threads),
|
||||
black_box(None),
|
||||
black_box(false),
|
||||
);
|
||||
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),
|
||||
black_box(limit),
|
||||
black_box(4),
|
||||
black_box(None),
|
||||
black_box(false),
|
||||
);
|
||||
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),
|
||||
black_box(100),
|
||||
black_box(4),
|
||||
black_box(None),
|
||||
black_box(false),
|
||||
);
|
||||
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),
|
||||
black_box(100),
|
||||
black_box(4),
|
||||
black_box(None),
|
||||
black_box(false),
|
||||
);
|
||||
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),
|
||||
black_box(100),
|
||||
black_box(4),
|
||||
black_box(None),
|
||||
black_box(true),
|
||||
);
|
||||
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"),
|
||||
black_box(500),
|
||||
black_box(4),
|
||||
black_box(None),
|
||||
black_box(false),
|
||||
);
|
||||
results.total_matched
|
||||
});
|
||||
});
|
||||
|
||||
group.bench_function("reverse_order_large", |b| {
|
||||
b.iter(|| {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box("mod"),
|
||||
black_box(500),
|
||||
black_box(4),
|
||||
black_box(None),
|
||||
black_box(true),
|
||||
);
|
||||
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"),
|
||||
black_box(10),
|
||||
black_box(4),
|
||||
black_box(None),
|
||||
black_box(false),
|
||||
);
|
||||
results.total_matched
|
||||
});
|
||||
});
|
||||
|
||||
group.bench_function("reverse_order_small", |b| {
|
||||
b.iter(|| {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box("controller"),
|
||||
black_box(10),
|
||||
black_box(4),
|
||||
black_box(None),
|
||||
black_box(true),
|
||||
);
|
||||
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,
|
||||
);
|
||||
|
||||
criterion_main!(benches);
|
||||
+3
-1
@@ -1,4 +1,4 @@
|
||||
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2025 October 04
|
||||
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2025 October 17
|
||||
|
||||
==============================================================================
|
||||
Table of Contents *fff.nvim-table-of-contents*
|
||||
@@ -128,6 +128,8 @@ all available options:
|
||||
layout = {
|
||||
height = 0.8,
|
||||
width = 0.8,
|
||||
row = nil, -- ratio (0.0 = top edge, 1.0 = bottom edge) nil is centered
|
||||
col = nil, -- ratio (0.0 = left edge, 1.0 = right edge) nil is centered
|
||||
prompt_position = 'bottom', -- or 'top'
|
||||
preview_position = 'right', -- or 'left', 'right', 'top', 'bottom'
|
||||
preview_size = 0.5,
|
||||
|
||||
Generated
+3
-3
@@ -64,11 +64,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1756866691,
|
||||
"narHash": "sha256-YWJsM0HfdFLcaoP5OeyzjX6MjGnJ0Acm+bg1QN8MKjo=",
|
||||
"lastModified": 1765334520,
|
||||
"narHash": "sha256-jTof2+ir9UPmv4lWksYO6WbaXCC0nsDExrB9KZj7Dz4=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "fb6dab6f320291a8edd31c1d67f078c6f7384a02",
|
||||
"rev": "db61f666aea93b28f644861fbddd37f235cc5983",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -78,6 +78,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -206,4 +206,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
|
||||
|
||||
+235
-43
@@ -6,6 +6,7 @@ local preview = require('fff.file_picker.preview')
|
||||
local icons = require('fff.file_picker.icons')
|
||||
local git_utils = require('fff.git_utils')
|
||||
local utils = require('fff.utils')
|
||||
local location_utils = require('fff.location_utils')
|
||||
|
||||
local function get_prompt_position()
|
||||
local config = M.state.config
|
||||
@@ -251,6 +252,7 @@ M.state = {
|
||||
top = 1,
|
||||
query = '',
|
||||
item_line_map = {},
|
||||
location = nil, -- Current location from search results
|
||||
|
||||
config = nil,
|
||||
|
||||
@@ -258,10 +260,11 @@ M.state = {
|
||||
|
||||
last_status_info = nil,
|
||||
|
||||
search_timer = nil,
|
||||
search_debounce_ms = 50, -- Debounce delay for search
|
||||
|
||||
last_preview_file = nil,
|
||||
last_preview_location = nil, -- Track last preview location to detect changes
|
||||
|
||||
preview_timer = nil, -- Separate timer for preview updates
|
||||
preview_debounce_ms = 100, -- Preview is more expensive, debounce more
|
||||
}
|
||||
|
||||
function M.create_ui()
|
||||
@@ -294,8 +297,39 @@ function M.create_ui()
|
||||
|
||||
local width = math.floor(terminal_width * width_ratio)
|
||||
local height = math.floor(terminal_height * height_ratio)
|
||||
local col = math.floor((vim.o.columns - width) / 2)
|
||||
local row = math.floor((vim.o.lines - height) / 2)
|
||||
|
||||
-- Calculate col and row (support function or number)
|
||||
local col_ratio_default = 0.5 - (width_ratio / 2) -- default center
|
||||
local col_ratio
|
||||
if config.layout.col ~= nil then
|
||||
col_ratio = utils.resolve_config_value(
|
||||
config.layout.col,
|
||||
terminal_width,
|
||||
terminal_height,
|
||||
utils.is_valid_ratio,
|
||||
col_ratio_default,
|
||||
'layout.col'
|
||||
)
|
||||
else
|
||||
col_ratio = col_ratio_default
|
||||
end
|
||||
local row_ratio_default = 0.5 - (height_ratio / 2) -- default center
|
||||
local row_ratio
|
||||
if config.layout.row ~= nil then
|
||||
row_ratio = utils.resolve_config_value(
|
||||
config.layout.row,
|
||||
terminal_width,
|
||||
terminal_height,
|
||||
utils.is_valid_ratio,
|
||||
row_ratio_default,
|
||||
'layout.row'
|
||||
)
|
||||
else
|
||||
row_ratio = row_ratio_default
|
||||
end
|
||||
|
||||
local col = math.floor(terminal_width * col_ratio)
|
||||
local row = math.floor(terminal_height * row_ratio)
|
||||
|
||||
local prompt_position = get_prompt_position()
|
||||
local preview_position = get_preview_position()
|
||||
@@ -350,7 +384,10 @@ function M.create_ui()
|
||||
height = layout.list_height,
|
||||
col = layout.list_col,
|
||||
row = layout.list_row,
|
||||
border = 'single',
|
||||
-- To make the input feel connected with the picker, we customize the
|
||||
-- respective corner border characters based on prompt_position
|
||||
border = prompt_position == 'bottom' and { '┌', '─', '┐', '│', '', '', '', '│' }
|
||||
or { '├', '─', '┤', '│', '┘', '─', '└', '│' },
|
||||
style = 'minimal',
|
||||
}
|
||||
|
||||
@@ -402,7 +439,10 @@ function M.create_ui()
|
||||
height = 1,
|
||||
col = layout.input_col,
|
||||
row = layout.input_row,
|
||||
border = 'single',
|
||||
-- To make the input feel connected with the picker, we customize the
|
||||
-- respective corner border characters based on prompt_position
|
||||
border = prompt_position == 'bottom' and { '├', '─', '┤', '│', '┘', '─', '└', '│' }
|
||||
or { '┌', '─', '┐', '│', '', '', '', '│' },
|
||||
style = 'minimal',
|
||||
}
|
||||
|
||||
@@ -697,12 +737,6 @@ function M.on_input_change()
|
||||
|
||||
M.state.query = query
|
||||
|
||||
if M.state.search_timer then
|
||||
M.state.search_timer:stop()
|
||||
M.state.search_timer:close()
|
||||
M.state.search_timer = nil
|
||||
end
|
||||
|
||||
M.update_results_sync()
|
||||
end
|
||||
|
||||
@@ -739,6 +773,9 @@ function M.update_results_sync()
|
||||
prompt_position == 'bottom'
|
||||
)
|
||||
|
||||
-- Get location from search results
|
||||
M.state.location = file_picker.get_search_location()
|
||||
|
||||
-- because the actual files could be different even with same count
|
||||
M.state.items = results
|
||||
M.state.filtered_items = results
|
||||
@@ -752,6 +789,28 @@ function M.update_results_sync()
|
||||
M.render_debounced()
|
||||
end
|
||||
|
||||
function M.update_preview_debounced()
|
||||
-- Cancel previous preview timer
|
||||
if M.state.preview_timer then
|
||||
M.state.preview_timer:stop()
|
||||
M.state.preview_timer:close()
|
||||
M.state.preview_timer = nil
|
||||
end
|
||||
|
||||
-- Create new timer with longer debounce for expensive preview
|
||||
M.state.preview_timer = vim.loop.new_timer()
|
||||
M.state.preview_timer:start(
|
||||
M.state.preview_debounce_ms,
|
||||
0,
|
||||
vim.schedule_wrap(function()
|
||||
if M.state.active then
|
||||
M.update_preview()
|
||||
M.state.preview_timer = nil
|
||||
end
|
||||
end)
|
||||
)
|
||||
end
|
||||
|
||||
function M.render_debounced()
|
||||
vim.schedule(function()
|
||||
if M.state.active then
|
||||
@@ -1033,6 +1092,7 @@ function M.update_preview()
|
||||
if #items == 0 or M.state.cursor > #items then
|
||||
M.clear_preview()
|
||||
M.state.last_preview_file = nil
|
||||
M.state.last_preview_location = nil
|
||||
return
|
||||
end
|
||||
|
||||
@@ -1040,13 +1100,19 @@ function M.update_preview()
|
||||
if not item then
|
||||
M.clear_preview()
|
||||
M.state.last_preview_file = nil
|
||||
M.state.last_preview_location = nil
|
||||
return
|
||||
end
|
||||
|
||||
if M.state.last_preview_file == item.path then return end
|
||||
-- Check if we need to update the preview (file changed OR location changed)
|
||||
local location_changed = not vim.deep_equal(M.state.last_preview_location, M.state.location)
|
||||
|
||||
if M.state.last_preview_file == item.path and not location_changed then return end
|
||||
|
||||
preview.clear()
|
||||
|
||||
M.state.last_preview_file = item.path
|
||||
M.state.last_preview_location = vim.deepcopy(M.state.location)
|
||||
|
||||
local relative_path = item.relative_path or item.path
|
||||
local max_title_width = vim.api.nvim_win_get_width(M.state.preview_win)
|
||||
@@ -1107,7 +1173,7 @@ function M.update_preview()
|
||||
if M.state.file_info_buf then preview.update_file_info_buffer(item, M.state.file_info_buf, M.state.cursor) end
|
||||
|
||||
preview.set_preview_window(M.state.preview_win)
|
||||
preview.preview(item.path, M.state.preview_buf)
|
||||
preview.preview(item.path, M.state.preview_buf, M.state.location)
|
||||
end
|
||||
|
||||
--- Clear preview
|
||||
@@ -1266,6 +1332,8 @@ function M.select(action)
|
||||
action = action or 'edit'
|
||||
|
||||
local relative_path = vim.fn.fnamemodify(item.path, ':.')
|
||||
local location = M.state.location -- Capture location before closing
|
||||
|
||||
vim.cmd('stopinsert')
|
||||
M.close()
|
||||
|
||||
@@ -1288,6 +1356,11 @@ function M.select(action)
|
||||
elseif action == 'tab' then
|
||||
vim.cmd('tabedit ' .. vim.fn.fnameescape(relative_path))
|
||||
end
|
||||
|
||||
if location then
|
||||
-- Use vim.schedule to ensure the file is fully loaded before jumping
|
||||
vim.schedule(function() location_utils.jump_to_location(location) end)
|
||||
end
|
||||
end
|
||||
|
||||
function M.close()
|
||||
@@ -1325,6 +1398,12 @@ function M.close()
|
||||
end
|
||||
end
|
||||
|
||||
if M.state.preview_timer then
|
||||
M.state.preview_timer:stop()
|
||||
M.state.preview_timer:close()
|
||||
M.state.preview_timer = nil
|
||||
end
|
||||
|
||||
M.state.input_win = nil
|
||||
M.state.list_win = nil
|
||||
M.state.file_info_win = nil
|
||||
@@ -1339,57 +1418,170 @@ function M.close()
|
||||
M.state.query = ''
|
||||
M.state.ns_id = nil
|
||||
M.state.last_preview_file = nil
|
||||
M.state.last_preview_location = nil
|
||||
M.state.current_file_cache = nil
|
||||
|
||||
if M.state.search_timer then
|
||||
M.state.search_timer:stop()
|
||||
M.state.search_timer:close()
|
||||
M.state.search_timer = nil
|
||||
end
|
||||
M.state.location = nil
|
||||
|
||||
-- Clean up picker focus autocmds
|
||||
pcall(vim.api.nvim_del_augroup_by_name, 'fff_picker_focus')
|
||||
end
|
||||
|
||||
function M.open(opts)
|
||||
if M.state.active then return end
|
||||
|
||||
local base_path = opts and opts.cwd or vim.fn.getcwd()
|
||||
|
||||
--- Helper function to determine current file cache for deprioritization
|
||||
--- @param base_path string Base path for relative path calculation
|
||||
--- @return string|nil Current file cache path
|
||||
local function get_current_file_cache(base_path)
|
||||
local current_buf = vim.api.nvim_get_current_buf()
|
||||
if current_buf and vim.api.nvim_buf_is_valid(current_buf) then
|
||||
local current_file = vim.api.nvim_buf_get_name(current_buf)
|
||||
if current_file ~= '' and vim.fn.filereadable(current_file) == 1 then
|
||||
local absolute_path = vim.fn.fnamemodify(current_file, ':p')
|
||||
local relative_path =
|
||||
vim.fn.fnamemodify(vim.fn.resolve(absolute_path), ':s?' .. vim.fn.escape(base_path, '\\') .. '/??')
|
||||
M.state.current_file_cache = relative_path
|
||||
else
|
||||
M.state.current_file_cache = nil
|
||||
end
|
||||
else
|
||||
M.state.current_file_cache = nil
|
||||
end
|
||||
if not current_buf or not vim.api.nvim_buf_is_valid(current_buf) then return nil end
|
||||
|
||||
local current_file = vim.api.nvim_buf_get_name(current_buf)
|
||||
if current_file == '' then return nil end
|
||||
|
||||
-- Use vim.uv.fs_stat to check if file exists and is readable
|
||||
local stat = vim.uv.fs_stat(current_file)
|
||||
if not stat or stat.type ~= 'file' then return nil end
|
||||
|
||||
local absolute_path = vim.fn.fnamemodify(current_file, ':p')
|
||||
local relative_path =
|
||||
vim.fn.fnamemodify(vim.fn.resolve(absolute_path), ':s?' .. vim.fn.escape(base_path, '\\') .. '/??')
|
||||
return relative_path
|
||||
end
|
||||
|
||||
--- Helper function for common picker initialization
|
||||
--- @param opts table|nil Options passed to the picker
|
||||
--- @return table|nil Merged configuration, nil if initialization failed
|
||||
local function initialize_picker(opts)
|
||||
local base_path = opts and opts.cwd or vim.uv.cwd()
|
||||
|
||||
-- Initialize file picker if needed
|
||||
if not file_picker.is_initialized() then
|
||||
if not file_picker.setup() then
|
||||
vim.notify('Failed to initialize file picker', vim.log.levels.ERROR)
|
||||
return
|
||||
return nil
|
||||
end
|
||||
end
|
||||
|
||||
local config = conf.get()
|
||||
M.state.config = vim.tbl_deep_extend('force', config or {}, opts or {})
|
||||
local merged_config = vim.tbl_deep_extend('force', config or {}, opts or {})
|
||||
|
||||
return merged_config, base_path
|
||||
end
|
||||
|
||||
--- Helper function to open UI with optional prefetched results
|
||||
--- @param query string|nil Pre-filled query (nil for empty)
|
||||
--- @param results table|nil Pre-fetched results (nil to search normally)
|
||||
--- @param location table|nil Pre-fetched location data
|
||||
--- @param merged_config table Merged configuration
|
||||
--- @param current_file_cache string|nil Current file cache
|
||||
local function open_ui_with_state(query, results, location, merged_config, current_file_cache)
|
||||
M.state.config = merged_config
|
||||
|
||||
if not M.create_ui() then
|
||||
vim.notify('Failed to create picker UI', vim.log.levels.ERROR)
|
||||
return
|
||||
return false
|
||||
end
|
||||
|
||||
M.state.active = true
|
||||
vim.cmd('startinsert!')
|
||||
M.state.current_file_cache = current_file_cache
|
||||
|
||||
-- Set up initial state
|
||||
if query then
|
||||
M.state.query = query
|
||||
vim.api.nvim_buf_set_lines(M.state.input_buf, 0, -1, false, { M.state.config.prompt .. query })
|
||||
else
|
||||
M.state.query = ''
|
||||
end
|
||||
|
||||
if results then
|
||||
-- Use prefetched results
|
||||
M.state.items = results
|
||||
M.state.filtered_items = results
|
||||
M.state.cursor = #results > 0 and 1 or 1
|
||||
M.state.location = location
|
||||
|
||||
M.render_list()
|
||||
M.update_preview()
|
||||
M.update_status()
|
||||
else
|
||||
M.update_results()
|
||||
M.clear_preview()
|
||||
M.update_status()
|
||||
end
|
||||
|
||||
vim.api.nvim_set_current_win(M.state.input_win)
|
||||
|
||||
-- Position cursor at end of query if there is one
|
||||
if query then
|
||||
vim.schedule(function()
|
||||
if M.state.active and M.state.input_win and vim.api.nvim_win_is_valid(M.state.input_win) then
|
||||
vim.api.nvim_win_set_cursor(M.state.input_win, { 1, #M.state.config.prompt + #query })
|
||||
vim.cmd('startinsert!')
|
||||
end
|
||||
end)
|
||||
else
|
||||
vim.cmd('startinsert!')
|
||||
end
|
||||
|
||||
M.monitor_scan_progress(0)
|
||||
return true
|
||||
end
|
||||
|
||||
--- Execute a search query with callback handling before potentially opening the UI
|
||||
--- @param query string The search query to execute
|
||||
--- @param callback function Function called with results: function(results, metadata, location, get_file_score) -> boolean
|
||||
--- @param opts? table Optional configuration to override defaults (same as M.open)
|
||||
--- @return boolean true if callback handled results, false if UI was opened
|
||||
function M.open_with_callback(query, callback, opts)
|
||||
if M.state.active then return false end
|
||||
|
||||
local merged_config, base_path = initialize_picker(opts)
|
||||
if not merged_config then return false end
|
||||
|
||||
local current_file_cache = get_current_file_cache(base_path)
|
||||
|
||||
local max_results = merged_config.max_results or 100
|
||||
local max_threads = merged_config.max_threads or 4
|
||||
local results = file_picker.search_files(query, max_results, max_threads, current_file_cache, false)
|
||||
|
||||
local metadata = file_picker.get_search_metadata()
|
||||
local location = file_picker.get_search_location()
|
||||
|
||||
local callback_handled = false
|
||||
if type(callback) == 'function' then
|
||||
local ok, result = pcall(callback, results, metadata, location, file_picker.get_file_score)
|
||||
if ok then
|
||||
callback_handled = result == true
|
||||
else
|
||||
vim.notify('Error in search callback: ' .. tostring(result), vim.log.levels.ERROR)
|
||||
end
|
||||
end
|
||||
|
||||
if callback_handled then return true end
|
||||
open_ui_with_state(query, results, location, merged_config, current_file_cache)
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
--- Open the file picker UI
|
||||
--- @param opts? table Optional configuration to override defaults
|
||||
--- @param opts.cwd? string Custom working directory (default: vim.fn.getcwd())
|
||||
--- @param opts.title? string Window title (default: "FFFiles")
|
||||
--- @param opts.prompt? string Input prompt text (default: "🪿 ")
|
||||
--- @param opts.max_results? number Maximum number of results to display (default: 100)
|
||||
--- @param opts.max_threads? number Maximum number of threads for file scanning (default: 4)
|
||||
--- @param opts.layout? table Layout configuration
|
||||
--- @param opts.layout.width? number|function Window width as ratio (0.0-1.0) or function(terminal_width, terminal_height): number (default: 0.8)
|
||||
--- @param opts.layout.height? number|function Window height as ratio (0.0-1.0) or function(terminal_width, terminal_height): number (default: 0.8)
|
||||
--- @param opts.layout.prompt_position? string|function Prompt position: 'top'|'bottom' or function(terminal_width, terminal_height): string (default: 'bottom')
|
||||
--- @param opts.layout.preview_position? string|function Preview position: 'left'|'right'|'top'|'bottom' or function(terminal_width, terminal_height): string (default: 'right')
|
||||
--- @param opts.layout.preview_size? number|function Preview size as ratio (0.0-1.0) or function(terminal_width, terminal_height): number (default: 0.5)
|
||||
function M.open(opts)
|
||||
if M.state.active then return end
|
||||
|
||||
local merged_config, base_path = initialize_picker(opts)
|
||||
if not merged_config then return end
|
||||
|
||||
local current_file_cache = get_current_file_cache(base_path)
|
||||
return open_ui_with_state(nil, nil, nil, merged_config, current_file_cache)
|
||||
end
|
||||
|
||||
function M.monitor_scan_progress(iteration)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
+63
-50
@@ -2,17 +2,19 @@ use crate::background_watcher::BackgroundWatcher;
|
||||
use crate::error::Error;
|
||||
use crate::frecency::FrecencyTracker;
|
||||
use crate::git::GitStatusCache;
|
||||
use crate::location::parse_location;
|
||||
use crate::score::match_and_score_files;
|
||||
use crate::types::{FileItem, 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};
|
||||
|
||||
@@ -32,7 +34,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 +67,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,
|
||||
@@ -173,6 +177,7 @@ impl FilePicker {
|
||||
);
|
||||
|
||||
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);
|
||||
@@ -200,6 +205,7 @@ impl FilePicker {
|
||||
scores,
|
||||
total_matched,
|
||||
total_files,
|
||||
location,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,14 +218,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 +234,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 +276,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 +293,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 +338,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 +412,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 +459,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 +475,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 +523,7 @@ fn scan_filesystem(
|
||||
.recurse_untracked_dirs(true)
|
||||
.exclude_submodules(true),
|
||||
);
|
||||
|
||||
(git_workdir, status_cache)
|
||||
});
|
||||
|
||||
@@ -534,24 +546,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 +601,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 })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+26
-9
@@ -3,7 +3,7 @@ use crate::file_picker::FilePicker;
|
||||
use crate::frecency::FrecencyTracker;
|
||||
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,8 +12,10 @@ mod error;
|
||||
pub mod file_picker;
|
||||
mod frecency;
|
||||
pub mod git;
|
||||
mod location;
|
||||
mod path_utils;
|
||||
pub mod score;
|
||||
pub mod sort_buffer;
|
||||
mod tracing;
|
||||
pub mod types;
|
||||
use mimalloc::MiMalloc;
|
||||
@@ -50,7 +52,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 +66,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 +79,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<()> {
|
||||
@@ -119,16 +133,19 @@ pub fn fuzzy_search_files(
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
+264
-24
@@ -3,6 +3,7 @@ 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;
|
||||
@@ -33,9 +34,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 +53,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
|
||||
};
|
||||
@@ -154,6 +167,7 @@ pub fn match_and_score_files<'a>(
|
||||
},
|
||||
frecency_boost,
|
||||
distance_penalty,
|
||||
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",
|
||||
@@ -215,6 +229,7 @@ fn score_all_by_frecency<'a>(
|
||||
special_filename_bonus: 0,
|
||||
current_file_penalty,
|
||||
frecency_boost: total_frecency_score,
|
||||
exact_match: false,
|
||||
match_type: "frecency",
|
||||
};
|
||||
|
||||
@@ -233,45 +248,270 @@ 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,
|
||||
};
|
||||
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,
|
||||
};
|
||||
|
||||
tracing::debug!(file =?file.relative_path, current=?context.current_file, ?penalty, "Calculating current file penalty");
|
||||
}
|
||||
tracing::debug!(file =?file.relative_path, current=?context.current_file, ?penalty, "Calculating current file penalty");
|
||||
}
|
||||
|
||||
penalty
|
||||
}
|
||||
|
||||
/// Dynamically sorts and returns the top results either in ascending or descending order
|
||||
/// Uses partial sorting for large result sets to improve performance
|
||||
fn sort_and_truncate<'a>(
|
||||
mut results: Vec<(&'a FileItem, Score)>,
|
||||
context: &ScoringContext,
|
||||
) -> (Vec<&'a FileItem>, Vec<Score>, usize) {
|
||||
let total_matched = results.len();
|
||||
|
||||
// For large result sets, use partial sort to avoid sorting everything
|
||||
let threshold = context.max_results * 2;
|
||||
|
||||
if context.reverse_order {
|
||||
results.sort_by(|a, b| {
|
||||
// Ascending order: want highest N items displayed as [low -> high]
|
||||
if results.len() > threshold {
|
||||
// Partition at position (len - max_results) with ascending comparator
|
||||
// This puts the highest max_results items after this position
|
||||
let partition_index = results.len() - context.max_results;
|
||||
results.select_nth_unstable_by(partition_index, |a, b| {
|
||||
a.1.total
|
||||
.cmp(&b.1.total)
|
||||
.then_with(|| a.0.modified.cmp(&b.0.modified))
|
||||
});
|
||||
// Remove everything before partition_index, keeping highest max_results items
|
||||
results.drain(0..partition_index);
|
||||
}
|
||||
|
||||
// Sort remaining results in ascending order using glidesort
|
||||
sort_with_buffer(&mut results, |a, b| {
|
||||
a.1.total
|
||||
.cmp(&b.1.total)
|
||||
.then_with(|| a.0.modified.cmp(&b.0.modified))
|
||||
});
|
||||
|
||||
// If still more than max_results (for small datasets), drain the front
|
||||
if results.len() > context.max_results {
|
||||
results.drain(0..(total_matched - context.max_results));
|
||||
results.drain(0..(results.len() - context.max_results));
|
||||
}
|
||||
} else {
|
||||
results.sort_by(|a, b| {
|
||||
// Descending order: want highest N items displayed as [high -> low]
|
||||
if results.len() > threshold {
|
||||
// Partition at position (max_results - 1) with descending comparator
|
||||
// This puts the highest max_results items at the front
|
||||
results.select_nth_unstable_by(context.max_results - 1, |a, b| {
|
||||
b.1.total
|
||||
.cmp(&a.1.total)
|
||||
.then_with(|| b.0.modified.cmp(&a.0.modified))
|
||||
});
|
||||
// Keep only the first max_results items
|
||||
results.truncate(context.max_results);
|
||||
}
|
||||
|
||||
// Sort remaining results in descending order using glidesort
|
||||
sort_with_buffer(&mut results, |a, b| {
|
||||
b.1.total
|
||||
.cmp(&a.1.total)
|
||||
.then_with(|| b.0.modified.cmp(&a.0.modified))
|
||||
});
|
||||
|
||||
// Ensure we only return max_results items (for small datasets)
|
||||
results.truncate(context.max_results);
|
||||
}
|
||||
|
||||
let (items, scores) = results.into_iter().unzip();
|
||||
(items, scores, total_matched)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
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",
|
||||
};
|
||||
(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_results: 3,
|
||||
max_threads: 1,
|
||||
max_typos: 2,
|
||||
current_file: None,
|
||||
reverse_order: false,
|
||||
};
|
||||
|
||||
// Test with partial sort (threshold = 3 * 2 = 6, our len is 10 > 6)
|
||||
let (items, scores, total) = sort_and_truncate(results.clone(), &context);
|
||||
|
||||
// Should return top 3: 300, 250, 200
|
||||
assert_eq!(total, 10);
|
||||
assert_eq!(scores.len(), 3);
|
||||
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_results: 3,
|
||||
max_threads: 1,
|
||||
max_typos: 2,
|
||||
current_file: None,
|
||||
reverse_order: false,
|
||||
};
|
||||
|
||||
let (items, scores, _) = sort_and_truncate(results, &context);
|
||||
|
||||
// Should return: 200(9000), 200(1000), 100(8000)
|
||||
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, "Third should be newest of 100s");
|
||||
}
|
||||
|
||||
#[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_results: 2,
|
||||
max_threads: 1,
|
||||
max_typos: 2,
|
||||
current_file: None,
|
||||
reverse_order: false,
|
||||
};
|
||||
|
||||
// threshold = 2 * 2 = 4, len = 3 < 4, so regular sort
|
||||
let (items, scores, _) = sort_and_truncate(results, &context);
|
||||
|
||||
assert_eq!(scores.len(), 2);
|
||||
assert_eq!(scores[0].total, 200);
|
||||
assert_eq!(scores[1].total, 100);
|
||||
assert_eq!(items[0].relative_path, "file2.rs");
|
||||
assert_eq!(items[1].relative_path, "file1.rs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reverse_order_partial_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),
|
||||
create_test_file("file4.rs", 300, 4000),
|
||||
create_test_file("file5.rs", 150, 5000),
|
||||
create_test_file("file6.rs", 250, 6000),
|
||||
];
|
||||
|
||||
let results: Vec<(&FileItem, Score)> = test_data
|
||||
.iter()
|
||||
.map(|(file, score)| (file, score.clone()))
|
||||
.collect();
|
||||
|
||||
let context = ScoringContext {
|
||||
query: "test",
|
||||
max_results: 3,
|
||||
max_threads: 1,
|
||||
max_typos: 2,
|
||||
current_file: None,
|
||||
reverse_order: true,
|
||||
};
|
||||
|
||||
let (items, scores, _) = sort_and_truncate(results, &context);
|
||||
|
||||
// Reverse order should return highest 3 in ascending order: 200, 250, 300
|
||||
// This is for bottom-prompt UI where best results appear at bottom near the prompt
|
||||
assert_eq!(scores.len(), 3);
|
||||
assert_eq!(scores[0].total, 200, "First should be third highest");
|
||||
assert_eq!(scores[1].total, 250, "Second should be second highest");
|
||||
assert_eq!(scores[2].total, 300, "Third should be highest");
|
||||
assert_eq!(items[0].relative_path, "file2.rs");
|
||||
assert_eq!(items[1].relative_path, "file6.rs");
|
||||
assert_eq!(items[2].relative_path, "file4.rs");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
//! 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));
|
||||
println!("After sort: {:?}", data);
|
||||
assert_eq!(data[0], 300, "Highest should be first");
|
||||
assert_eq!(data[1], 200, "Middle should be second");
|
||||
assert_eq!(data[2], 100, "Lowest should be last");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
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();
|
||||
@@ -51,7 +52,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()
|
||||
|
||||
+38
-1
@@ -1,13 +1,15 @@
|
||||
use mlua::prelude::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::git::format_git_status;
|
||||
use crate::{git::format_git_status, location::Location};
|
||||
|
||||
#[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,6 +27,7 @@ pub struct Score {
|
||||
pub frecency_boost: i32,
|
||||
pub distance_penalty: i32,
|
||||
pub current_file_penalty: i32,
|
||||
pub exact_match: bool,
|
||||
pub match_type: &'static str,
|
||||
}
|
||||
|
||||
@@ -44,6 +47,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 {
|
||||
@@ -76,6 +80,18 @@ impl IntoLua for Score {
|
||||
table.set("distance_penalty", self.distance_penalty)?;
|
||||
table.set("current_file_penalty", self.current_file_penalty)?;
|
||||
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 +103,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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/// 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, 100, // max_results
|
||||
4, // max_threads
|
||||
None, // current_file
|
||||
false, // reverse_order
|
||||
);
|
||||
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"
|
||||
);
|
||||
}
|
||||
+12
-12
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -177,12 +177,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));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
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, 100, // max_results
|
||||
4, // max_threads
|
||||
None, // current_file
|
||||
false, // reverse_order
|
||||
);
|
||||
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));
|
||||
}
|
||||
+57
-57
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -218,50 +218,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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user