Compare commits

...

9 Commits

Author SHA1 Message Date
Dmitriy Kovalenko c4a55070e2 fix: Make directory change file walking non-blocking 2025-12-10 16:33:28 -08:00
Dmitriy Kovalenko 2c55114048 fix: Outdated/wrong git status (#188)
Resolved internal error cause by our FS notifier backend which caused
outdated git status in the picker. This should eliminate the process
completely also improved the logging of errors and improved the
perfromance and logic around the handling background events
2025-12-08 18:51:41 -08:00
Dmitriy Kovalenko d88922e6c7 fix: Correctly count file access on reopen (#185) 2025-12-06 21:40:56 -08:00
Dmitriy Kovalenko 9edf195c8f feat: Significant performance improvements up to +190% (#182) 2025-12-05 23:44:14 -08:00
Amadeus Demarzi 65aeacf9e2 feat: Make input and picker borders connected (#177)
* Make input/picker feel connected

* comment content tweak

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

I decided to call plenary as a lot of users have it installed anyway but
if it is not installed just AI generated list of the most common file
type flaws including ts
2025-10-17 17:28:21 -07:00
ohbentos 3c76ba523f feat: add ratio-based row/col positioning with centered defaults (#171) 2025-10-17 17:03:07 -07:00
Dmitriy Kovalenko 51f32597de feat: Location input for picker + open_file_under_cursor (#173) 2025-10-17 09:59:33 -07:00
31 changed files with 2742 additions and 330 deletions
+1 -3
View File
@@ -32,6 +32,4 @@ jobs:
- uses: stefanzweifel/git-auto-commit-action@v6
with:
commit_author: ${{ steps.last-commit.outputs.author }}
commit_message: ${{ steps.last-commit.outputs.message }}
commit_options: "--amend --no-edit"
push_options: "--force"
commit_message: chore: Update docs for - ${{ steps.last-commit.outputs.message }}
+3
View File
@@ -10,3 +10,6 @@ result
.repro/
.wrangler/
*.so
big-repo/
# all the perf like utility files
*.data
-1
View File
@@ -1,4 +1,3 @@
syntax = "LuaJIT"
column_width = 120
line_endings = "Unix"
indent_type = "Spaces"
Generated
+247 -5
View File
@@ -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
View File
@@ -1,7 +1,7 @@
[package]
name = "fff_nvim"
version = "0.1.0"
edition = "2021"
edition = "2024"
[lib]
path = "lua/fff/rust/lib.rs"
@@ -14,13 +14,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
+485
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -16,7 +16,7 @@ local function setup_global_autocmds(config)
local group = vim.api.nvim_create_augroup('fff_file_tracking', { clear = true })
if config.frecency.enabled then
vim.api.nvim_create_autocmd({ 'BufReadPost' }, {
vim.api.nvim_create_autocmd({ 'BufEnter' }, {
group = group,
desc = 'Track file access for FFF frecency',
callback = function(args)
+1 -1
View File
@@ -140,7 +140,7 @@ function M.clear_buffer_images(bufnr)
pcall(vim.api.nvim_buf_clear_namespace, bufnr, -1, 0, -1)
end
--- Load metadat of the image, displays it and display image in paralallel
--- Load metadata of the image, displays it and display image in paralallel
--- Fully asynchronous
--- @param file_path string Path to the image file
--- @param bufnr number Buffer number to display in
+7
View File
@@ -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
+123 -8
View File
@@ -1,9 +1,36 @@
local utils = require('fff.utils')
local file_picker = require('fff.file_picker')
local image = require('fff.file_picker.image')
local location_utils = require('fff.location_utils')
local M = {}
-- Additional fallback for certain ambiguous filetypes which vim.filetype.match is not handling correctly
local function get_fixed_filetype_detection(extension)
local extension_map = {
ts = 'typescript',
tex = 'latex',
md = 'markdown',
txt = 'text',
}
return extension_map[extension]
end
local function detect_filetype(file_path)
local has_plenary, plenary_filetype = pcall(require, 'plenary.filetype')
if has_plenary then
local detected = plenary_filetype.detect(file_path)
if detected and detected ~= '' then return detected end
end
local builtin_filetype = vim.filetype.match({ filename = file_path })
if builtin_filetype and builtin_filetype ~= '' then return builtin_filetype end
local extension = vim.fn.fnamemodify(file_path, ':e'):lower()
return get_fixed_filetype_detection(extension)
end
local function set_buffer_lines(bufnr, lines)
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
@@ -115,13 +142,38 @@ local function read_file_streaming_async(file_path, bufnr, callback)
return
end
load_next_chunk_async(M.config.chunk_size, function(data, err)
-- Calculate initial chunk size based on location information
local initial_chunk_size = M.config.chunk_size
if M.state.location then
local target_line = location_utils.get_target_line(M.state.location)
if target_line then
-- Estimate bytes needed: assume ~100 bytes per line average
-- Add some buffer (50%) to account for variation in line lengths
local estimated_bytes = target_line * 100 * 1.5
-- Cap at reasonable maximum to avoid memory issues
local max_initial_chunk = M.config.max_size or (10 * 1024 * 1024) -- 10MB default
initial_chunk_size = math.min(estimated_bytes, max_initial_chunk)
-- Ensure we don't go below the standard chunk size
initial_chunk_size = math.max(initial_chunk_size, M.config.chunk_size)
end
end
load_next_chunk_async(initial_chunk_size, function(data, err)
if data and data ~= '' then
-- there seems to be no other way to append the buffer other than the lines :(
local lines = vim.split(data, '\n', { plain = true })
M.state.loaded_lines = #lines
M.state.content_height = #lines
-- If we have a location and didn't load enough lines, try to load more
if M.state.location then
local target_line = location_utils.get_target_line(M.state.location)
if target_line and #lines < target_line and M.state.has_more_content then
-- Schedule additional loading after the initial callback
vim.schedule(function() ensure_content_loaded_async(target_line) end)
end
end
callback(lines, err)
else
callback(nil, err)
@@ -213,11 +265,19 @@ M.state = {
has_more_content = true,
file_handle = nil,
file_operation = nil, -- Ongoing file operation: {fd?: any, file_path?: string, position?: number}
location = nil, -- Current location data for highlighting
location_namespace = nil, -- Namespace for location highlighting
}
--- Setup preview configuration
--- @param config table Configuration options
function M.setup(config) M.config = config or {} end
function M.setup(config)
M.config = config or {}
-- Create namespace for location highlighting
if not M.state.location_namespace then
M.state.location_namespace = vim.api.nvim_create_namespace('fff_preview_location')
end
end
--- Check if file is too big for initial preview (inspired by snacks.nvim)
--- @param file_path string Path to the file
@@ -408,7 +468,7 @@ function M.get_file_info(file_path)
}
info.extension = vim.fn.fnamemodify(file_path, ':e'):lower()
info.filetype = vim.filetype.match({ filename = file_path }) or 'text'
info.filetype = detect_filetype(file_path) or 'text'
info.size_formatted = utils.format_file_size(info.size)
info.modified_formatted = os.date('%Y-%m-%d %H:%M:%S', info.modified)
info.accessed_formatted = os.date('%Y-%m-%d %H:%M:%S', info.accessed)
@@ -517,6 +577,9 @@ function M.preview_file(file_path, bufnr)
M.state.scroll_offset = 0
-- Apply location highlighting if available (delayed to ensure buffer is ready)
vim.schedule(function() M.apply_location_highlighting(bufnr) end)
return true
end
end
@@ -552,6 +615,9 @@ function M.preview_file(file_path, bufnr)
M.state.content_height = #content
M.state.scroll_offset = 0
-- Apply location highlighting if available (delayed to ensure buffer is ready)
vim.schedule(function() M.apply_location_highlighting(bufnr) end)
end
end)
@@ -620,14 +686,15 @@ end
function M.get_file_config(file_path)
if not M.config or not M.config.filetypes then return {} end
local filetype = vim.filetype.match({ filename = file_path }) or 'text'
local filetype = detect_filetype(file_path) or 'text'
return M.config.filetypes[filetype] or {}
end
--- @param file_path string Path to the file or directory
--- @param bufnr number Buffer number for preview
--- @param location table|nil Optional location data for highlighting
--- @return boolean if the preview was successful
function M.preview(file_path, bufnr)
function M.preview(file_path, bufnr, location)
if not file_path or file_path == '' then
-- Don't immediately clear - let the previous content stay visible
-- Only clear if we really need to show "No file selected"
@@ -648,6 +715,7 @@ function M.preview(file_path, bufnr)
M.state.current_file = file_path
M.state.bufnr = bufnr
M.state.location = location
if image.is_image(file_path) then
M.clear_buffer(bufnr)
@@ -682,9 +750,7 @@ function M.scroll(lines)
if current_buffer_lines < buffer_needed and M.state.has_more_content then
-- Load more content asynchronously but don't wait for it
ensure_content_loaded_async(target_line, function(success)
-- Content loaded in background, no need to recalculate scroll here
end)
ensure_content_loaded_async(target_line)
end
end
@@ -744,6 +810,10 @@ function M.clear_preview_visual_state(bufnr)
-- Only clear visual state, don't affect buffer functionality
-- Clear namespaces and extmarks for this buffer only
vim.api.nvim_buf_clear_namespace(bufnr, -1, 0, -1)
-- Clear location highlights
if M.state.location_namespace then location_utils.clear_location_highlights(bufnr, M.state.location_namespace) end
local wins = vim.fn.win_findbuf(bufnr)
for _, win in ipairs(wins) do
@@ -790,6 +860,51 @@ function M.clear()
M.state.current_file = nil
M.state.scroll_offset = 0
M.state.content_height = 0
M.state.location = nil
end
--- Apply location highlighting to the preview buffer
--- @param bufnr number Buffer number
function M.apply_location_highlighting(bufnr)
-- Ensure namespace is created
if not M.state.location_namespace then
M.state.location_namespace = vim.api.nvim_create_namespace('fff_preview_location')
end
-- Always clear previous location highlights first
if vim.api.nvim_buf_is_valid(bufnr) then
location_utils.clear_location_highlights(bufnr, M.state.location_namespace)
end
if not M.state.location then return end
-- Apply highlighting
location_utils.highlight_location(bufnr, M.state.location, M.state.location_namespace)
if M.state.winid and vim.api.nvim_win_is_valid(M.state.winid) then
local target_line = location_utils.get_target_line(M.state.location)
if target_line then M.scroll_to_line(target_line) end
end
end
--- Scroll preview to a specific line
--- @param line number Target line number (1-indexed)
function M.scroll_to_line(line)
if not M.state.winid or not vim.api.nvim_win_is_valid(M.state.winid) then return end
if not M.state.bufnr or not vim.api.nvim_buf_is_valid(M.state.bufnr) then return end
local win_height = vim.api.nvim_win_get_height(M.state.winid)
local buffer_lines = vim.api.nvim_buf_line_count(M.state.bufnr)
local target_line = math.max(1, math.min(line, buffer_lines))
local half_screen = math.floor(win_height / 2)
local new_offset = math.max(0, target_line - half_screen)
M.state.scroll_offset = new_offset
pcall(vim.api.nvim_win_call, M.state.winid, function()
vim.api.nvim_win_set_cursor(M.state.winid, { target_line, 0 })
vim.cmd('normal! zt')
end)
end
return M
+200
View File
@@ -0,0 +1,200 @@
local M = {}
--- Jump to a location in the current buffer
--- @param location table|nil Location data from search results
function M.jump_to_location(location)
if not location then return end
local current_buf = vim.api.nvim_get_current_buf()
local line_count = vim.api.nvim_buf_line_count(current_buf)
if location.line then
local target_line = math.max(1, math.min(location.line, line_count))
local target_col = location.col and math.max(0, location.col - 1) or 0
vim.api.nvim_win_set_cursor(0, { target_line, target_col })
vim.cmd('normal! zz')
elseif location.start and location['end'] then
-- Extract line numbers from nested structure
local start_line = math.max(1, math.min(location.start.line, line_count))
local end_line = math.max(start_line, math.min(location['end'].line, line_count))
-- start in the visual mode and selecting the range backwards so the cursor ends up at the start
vim.api.nvim_win_set_cursor(0, { end_line, 0 })
vim.cmd('normal! V')
if end_line > start_line then vim.cmd('normal! ' .. (end_line - start_line) .. 'k') end
vim.cmd('normal! zz')
end
end
--- Highlight a location range in a buffer using extmarks
--- @param bufnr number Buffer number
--- @param location table|nil Location data from search results
--- @param namespace number Namespace for extmarks
--- @return table|nil Highlight extmark details for cleanup
function M.highlight_location(bufnr, location, namespace)
if not location or not vim.api.nvim_buf_is_valid(bufnr) then return nil end
local line_count = vim.api.nvim_buf_line_count(bufnr)
local extmarks = {}
if location.line then
local target_line = math.max(1, math.min(location.line, line_count))
if location.col then
local target_col = math.max(0, location.col - 1)
local line_content = vim.api.nvim_buf_get_lines(bufnr, target_line - 1, target_line, false)[1] or ''
local end_col = math.min(target_col + 1, #line_content)
local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, target_line - 1, target_col, {
end_col = end_col,
hl_group = 'IncSearch', -- inc search are better visible for a single chars
priority = 1000,
})
if ok then table.insert(extmarks, { id = mark_id, line = target_line - 1 }) end
else
local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, target_line - 1, 0, {
line_hl_group = 'Visual',
priority = 1000,
})
if ok then table.insert(extmarks, { id = mark_id, line = target_line - 1 }) end
end
elseif location.start and location['end'] then
local start_line = math.max(1, math.min(location.start.line, line_count))
local end_line = math.max(start_line, math.min(location['end'].line, line_count))
-- Check if we have column information for exact range highlighting
if location.start.col and location['end'].col then
if start_line == end_line then
-- Single line range with columns: highlight exact character range
local start_col = math.max(0, location.start.col - 1)
local end_col = location['end'].col - 1
local line_content = vim.api.nvim_buf_get_lines(bufnr, start_line - 1, start_line, false)[1] or ''
end_col = math.min(end_col, #line_content)
local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, start_line - 1, start_col, {
end_col = end_col,
hl_group = 'IncSearch',
priority = 1000,
})
if ok then table.insert(extmarks, { id = mark_id, line = start_line - 1 }) end
else
-- Multi-line range with exact columns: highlight precise ranges
for line = start_line, end_line do
local line_start_col, line_end_col
if line == start_line then
-- First line: from start_col to end of line
line_start_col = math.max(0, location.start.col - 1)
local line_content = vim.api.nvim_buf_get_lines(bufnr, line - 1, line, false)[1] or ''
line_end_col = #line_content
elseif line == end_line then
-- Last line: from beginning to end_col
line_start_col = 0
line_end_col = location['end'].col - 1
local line_content = vim.api.nvim_buf_get_lines(bufnr, line - 1, line, false)[1] or ''
line_end_col = math.min(line_end_col, #line_content)
else
-- Middle lines: entire line
line_start_col = 0
local line_content = vim.api.nvim_buf_get_lines(bufnr, line - 1, line, false)[1] or ''
line_end_col = #line_content
end
local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, line - 1, line_start_col, {
end_col = line_end_col,
hl_group = 'Visual',
priority = 1000,
})
if ok then table.insert(extmarks, { id = mark_id, line = line - 1 }) end
end
end
else
-- Multi-line or no columns: highlight entire lines
for line = start_line, end_line do
local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, line - 1, 0, {
line_hl_group = 'Visual',
priority = 1000,
})
if ok then table.insert(extmarks, { id = mark_id, line = line - 1 }) end
end
end
end
return #extmarks > 0 and extmarks or nil
end
--- Clear location highlights from a buffer
--- @param bufnr number Buffer number
--- @param namespace number Namespace for extmarks
function M.clear_location_highlights(bufnr, namespace)
if vim.api.nvim_buf_is_valid(bufnr) then vim.api.nvim_buf_clear_namespace(bufnr, namespace, 0, -1) end
end
--- Get the target line for scrolling preview to location
--- @param location table|nil Location data from search results
--- @return number|nil Target line number (1-indexed) for scrolling
function M.get_target_line(location)
if not location then return nil end
if location.line then
return location.line
elseif location.start then
return location.start.line
end
return nil
end
--- Check if location is valid for a buffer
--- @param location table|nil Location data
--- @param bufnr number Buffer number
--- @return boolean True if location is valid for the buffer
function M.is_valid_location(location, bufnr)
if not location or not vim.api.nvim_buf_is_valid(bufnr) then return false end
local line_count = vim.api.nvim_buf_line_count(bufnr)
if location.line then
return location.line > 0 and location.line <= line_count
elseif location.start and location['end'] then
return location.start.line > 0 and location.start.line <= line_count
end
return false
end
--- Format location for display
--- @param location table|nil Location data
--- @return string Formatted location string
function M.format_location(location)
if not location then return '' end
if location.line and location.col then
return string.format(':%d:%d', location.line, location.col)
elseif location.line then
return string.format(':%d', location.line)
elseif location.start and location['end'] then
-- Handle nested structure with optional column information
if location.start.col and location['end'].col then
return string.format(
':%d:%d-%d:%d',
location.start.line,
location.start.col,
location['end'].line,
location['end'].col
)
else
return string.format(':%d-%d', location.start.line, location['end'].line)
end
end
return ''
end
return M
+27
View File
@@ -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
View File
@@ -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)
+181 -75
View File
@@ -1,23 +1,27 @@
use crate::FILE_PICKER;
use crate::error::Error;
use crate::file_picker::FilePicker;
use crate::git::GitStatusCache;
use crate::FILE_PICKER;
use crate::sort_buffer::sort_with_buffer;
use git2::Repository;
use notify::RecursiveMode;
use notify_debouncer_mini::{new_debouncer, DebounceEventResult, DebouncedEvent};
use notify::event::{AccessKind, AccessMode};
use notify::{Config, EventKind, RecursiveMode};
use notify_debouncer_full::{
DebounceEventResult, DebouncedEvent, RecommendedCache, new_debouncer_opt,
};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tracing::{debug, error, info, warn};
use tracing::{Level, error, info, warn};
type Debouncer = notify_debouncer_mini::Debouncer<notify::RecommendedWatcher>;
type Debouncer = notify_debouncer_full::Debouncer<notify::RecommendedWatcher, RecommendedCache>;
pub struct BackgroundWatcher {
debouncer: Arc<Mutex<Option<Debouncer>>>,
}
const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(500);
const MAX_PATHS_THRESHOLD: usize = 50;
const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(250);
const MAX_PATHS_THRESHOLD: usize = 1024;
impl BackgroundWatcher {
pub fn new(base_path: PathBuf, git_workdir: Option<PathBuf>) -> Result<Self, Error> {
@@ -38,22 +42,29 @@ impl BackgroundWatcher {
base_path: PathBuf,
git_workdir: Option<PathBuf>,
) -> Result<Debouncer, Error> {
let mut debouncer = new_debouncer(DEBOUNCE_TIMEOUT, {
move |result: DebounceEventResult| match result {
Ok(events) => {
if !events.is_empty() {
// do not follow symlinks as then notifiers spawns a bunch of events for symlinked
// files that could be git ignored, we have to property differentiate those and if
// the file was edited through a
let config = Config::default().with_follow_symlinks(false);
let mut debouncer = new_debouncer_opt(
DEBOUNCE_TIMEOUT,
Some(DEBOUNCE_TIMEOUT / 2), // tick rate for the event span
{
move |result: DebounceEventResult| match result {
Ok(events) => {
handle_debounced_events(events, &git_workdir);
}
Err(errors) => {
error!("File watcher errors: {:?}", errors);
}
}
Err(errors) => {
error!("File watcher errors: {:?}", errors);
}
}
})?;
},
RecommendedCache::new(),
config,
)?;
debouncer
.watcher()
.watch(base_path.as_path(), RecursiveMode::Recursive)?;
debouncer.watch(base_path.as_path(), RecursiveMode::Recursive)?;
info!("File watcher initizlieed for path: {}", base_path.display());
Ok(debouncer)
@@ -81,79 +92,180 @@ impl Drop for BackgroundWatcher {
}
}
#[tracing::instrument(name = "fs_events", skip(events), level = Level::DEBUG)]
fn handle_debounced_events(events: Vec<DebouncedEvent>, git_workdir: &Option<PathBuf>) {
debug!("Processing {} debounced events", events.len());
let Ok(mut file_picker_guard) = FILE_PICKER.write() else {
error!("Failed to acquire file picker write lock");
return;
};
let Some(ref mut picker) = *file_picker_guard else {
error!("File picker not initialized");
return;
};
let mut need_full_git_rescan = false;
// this will be called very often, we have to minimiy the lock time for file picker
let repo = git_workdir.as_ref().and_then(|p| Repository::open(p).ok());
let mut files_to_update_git_status = Vec::with_capacity(events.len() * 2);
let mut need_full_rescan = false;
let mut need_full_git_rescan = false;
let mut paths_to_remove = Vec::new();
let mut paths_to_add_or_modify = Vec::new();
let mut affected_paths_count = 0usize;
for event in &events {
let path = &event.path;
if is_ignore_definition_path(path) {
info!(
"Detected change in the ignore definition file: {}",
path.display()
);
return trigger_full_rescan(picker);
}
if is_dotgit_change_affecting_status(path, &repo) {
need_full_git_rescan = true;
}
if !should_include_file(path, &repo) {
for debounced_event in &events {
// It is very important to not react to the access errors because we inevitably
// gonna trigger the sync by our own preview or other unnecessary noise
if matches!(
debounced_event.event.kind,
EventKind::Access(
AccessKind::Read
| AccessKind::Open(_)
| AccessKind::Close(AccessMode::Read | AccessMode::Execute)
)
) {
continue;
}
debug!("Handling fs event: {:?}", event);
tracing::debug!(event = ?debounced_event.event, "Processing FS event");
for path in &debounced_event.event.paths {
if is_ignore_definition_path(path) {
info!(
"Detected change in ignore definition file: {}",
path.display()
);
need_full_rescan = true;
break;
}
affected_paths_count += 1;
if is_dotgit_change_affecting_status(path, &repo) {
need_full_git_rescan = true;
}
if !should_include_file(path, &repo) {
continue;
}
if !path.exists() {
paths_to_remove.push(path.as_path());
} else {
paths_to_add_or_modify.push(path.as_path());
}
}
affected_paths_count += debounced_event.event.paths.len();
if affected_paths_count > MAX_PATHS_THRESHOLD {
warn!(
"Too many affected paths ({}) in a single batch, triggering full rescan",
affected_paths_count
);
return trigger_full_rescan(picker);
need_full_rescan = true;
break;
}
if !path.exists() {
picker.remove_file_by_path(path);
continue;
}
let file = picker.on_create_or_modify(path);
if let Some(file) = file {
files_to_update_git_status.push(file.relative_path.clone());
if need_full_rescan {
break;
}
}
if need_full_rescan {
info!(?affected_paths_count, "Triggering full rescan");
trigger_full_rescan();
return;
}
// It's important to get the allocated sort
sort_with_buffer(paths_to_add_or_modify.as_mut_slice(), |a, b| {
a.as_os_str().cmp(b.as_os_str())
});
paths_to_add_or_modify.dedup_by(|a, b| a.as_os_str().eq(b.as_os_str()));
info!(
"Event processing summary: {} to remove, {} to add/modify",
paths_to_remove.len(),
paths_to_add_or_modify.len()
);
let Some(repo) = repo.as_ref() else {
info!("No git repo, skipping git status updates");
return;
};
if need_full_git_rescan {
drop(file_picker_guard); // it's going to be relocked after rescan
info!("Triggering full git rescan by the notification results");
info!("Triggering full git rescan");
if let Err(e) = FilePicker::refresh_git_status_global() {
error!("Failed to refresh git status: {:?}", e);
}
} else if let Some(repo) = repo.as_ref() {
let status = GitStatusCache::git_status_for_paths(repo, &files_to_update_git_status);
return;
}
if paths_to_remove.is_empty() && paths_to_add_or_modify.is_empty() {
return;
}
let files_to_update_git_status = {
let Ok(mut file_picker_guard) = FILE_PICKER.write() else {
error!("Failed to acquire file picker write lock");
return;
};
let Some(ref mut picker) = *file_picker_guard else {
error!("File picker not initialized");
return;
};
// Apply file removals
for path in paths_to_remove {
picker.remove_file_by_path(path);
}
// Apply file additions/modifications and collect paths for git status update
let mut files_to_update_git_status = Vec::with_capacity(paths_to_add_or_modify.len());
for path in paths_to_add_or_modify {
if let Some(file) = picker.on_create_or_modify(path) {
files_to_update_git_status.push(file.path.clone());
}
}
files_to_update_git_status
};
info!(
"Fetching git status for {} files",
files_to_update_git_status.len()
);
let status = match GitStatusCache::git_status_for_paths(repo, &files_to_update_git_status) {
Ok(status) => status,
Err(e) => {
tracing::error!(?e, "Failed to query git statue");
return;
}
};
// only lock the picker for theshortest possitble time
if let Ok(mut file_picker_guard) = FILE_PICKER.write()
&& let Some(ref mut picker) = *file_picker_guard
{
if let Err(e) = picker.update_git_statuses(status) {
error!("Failed to update git statuses: {:?}", e);
} else {
info!("Successfully updated git statuses in picker");
}
} else {
error!("Failed to acquire picker lock for git status update");
}
}
fn trigger_full_rescan() {
info!("Triggering full filesystem rescan");
let Ok(mut file_picker_guard) = FILE_PICKER.write() else {
error!("Failed to acquire file picker write lock for full rescan");
return;
};
let Some(ref mut picker) = *file_picker_guard else {
error!("File picker not initialized, cannot trigger rescan");
return;
};
if let Err(e) = picker.trigger_rescan() {
error!("Failed to trigger full rescan: {:?}", e);
} else {
info!("Full filesystem rescan completed successfully");
}
}
@@ -166,12 +278,6 @@ fn should_include_file(path: &Path, repo: &Option<Repository>) -> bool {
.is_some_and(|repo| repo.is_path_ignored(path) == Ok(false))
}
fn trigger_full_rescan(picker: &mut FilePicker) {
if let Err(e) = picker.trigger_rescan() {
error!("Failed to trigger full rescan: {:?}", e);
}
}
#[inline]
fn is_git_file(path: &Path) -> bool {
path.components()
@@ -202,10 +308,10 @@ pub fn is_dotgit_change_affecting_status(changed: &Path, repo: &Option<Repositor
return true;
}
if let Some(fname) = rel.file_name().and_then(|f| f.to_str()) {
if matches!(fname, "MERGE_HEAD" | "CHERRY_PICK_HEAD" | "REVERT_HEAD") {
return true;
}
if let Some(fname) = rel.file_name().and_then(|f| f.to_str())
&& matches!(fname, "MERGE_HEAD" | "CHERRY_PICK_HEAD" | "REVERT_HEAD")
{
return true;
}
}
+10
View File
@@ -1,3 +1,5 @@
use std::path::StripPrefixError;
#[derive(thiserror::Error, Debug)]
#[non_exhaustive]
pub enum Error {
@@ -33,6 +35,12 @@ pub enum Error {
DbCommit(#[source] heed::Error),
#[error("Failed to start file system watcher: {0}")]
FileSystemWatch(#[from] notify::Error),
#[error("Expected a path to be child of another path: {0}")]
StripPrefixError(#[from] StripPrefixError),
#[error("libgit2 error occurred: {0}")]
Git(#[from] git2::Error),
}
impl From<Error> for mlua::Error {
@@ -43,3 +51,5 @@ impl From<Error> for mlua::Error {
mlua::Error::RuntimeError(string_value)
}
}
pub type Result<T> = std::result::Result<T, Error>;
+63 -50
View File
@@ -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 })
})
}
+4 -4
View File
@@ -1,9 +1,9 @@
use crate::{error::Error, git::is_modified_status};
use heed::{
types::{Bytes, SerdeBincode},
EnvFlags,
};
use heed::{Database, Env, EnvOpenOptions};
use heed::{
EnvFlags,
types::{Bytes, SerdeBincode},
};
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
use std::{collections::VecDeque, path::Path};
+41 -26
View File
@@ -1,9 +1,10 @@
use crate::error::Result;
use git2::{Repository, Status, StatusOptions};
use std::{
fmt::Debug,
path::{Path, PathBuf},
};
use tracing::{debug, error, info};
use tracing::debug;
/// Represents a cache of a single git status query, if there is no
/// status aka file is clear but it was specifically requested to updated
@@ -32,19 +33,12 @@ impl GitStatusCache {
.and_then(|idx| self.0.get(idx).map(|(_, status)| *status))
}
fn read_status_impl(repo: &Repository, status_options: &mut StatusOptions) -> Option<Self> {
let status_start = std::time::Instant::now();
info!("GIT: Reading git status");
let statuses = repo
.statuses(Some(status_options))
.map_err(|e| {
error!("Failed to get git statuses: {}", e);
e
})
.ok()?;
let status_time = status_start.elapsed();
let repo_path = repo.path().parent()?;
info!("GIT: Status query completed in {:?}", status_time);
#[tracing::instrument(skip(repo, status_options))]
fn read_status_impl(repo: &Repository, status_options: &mut StatusOptions) -> Result<Self> {
let statuses = repo.statuses(Some(status_options))?;
let Some(repo_path) = repo.workdir() else {
return Ok(Self(vec![])); // repo is bare
};
let mut entries = Vec::with_capacity(statuses.len());
for entry in &statuses {
@@ -54,7 +48,7 @@ impl GitStatusCache {
}
}
Some(Self(entries))
Ok(Self(entries))
}
pub fn read_git_status(
@@ -64,20 +58,42 @@ impl GitStatusCache {
let git_workdir = git_workdir.as_ref()?;
let repository = Repository::open(git_workdir).ok()?;
Self::read_status_impl(&repository, status_options)
let status = Self::read_status_impl(&repository, status_options);
match status {
Ok(status) => Some(status),
Err(e) => {
tracing::error!(?e, "Failed to read git status");
None
}
}
}
#[tracing::instrument(skip(repo), level = tracing::Level::DEBUG)]
pub fn git_status_for_paths<TPath: AsRef<Path> + Debug>(
repo: &Repository,
paths: &[TPath],
) -> Option<Self> {
) -> Result<Self> {
if paths.is_empty() {
return None;
return Ok(Self(vec![]));
}
debug!(?paths, "Git partial git status for paths");
let mut status_options = StatusOptions::new();
let Some(workdir) = repo.workdir() else {
return Ok(Self(vec![]));
};
// git pathspec is pretty slow and requires to walk the whole directory
// so for a single file which is the most general use case we query directly the file
if paths.len() == 1 {
let full_path = paths[0].as_ref();
let relative_path = full_path.strip_prefix(workdir)?;
let status = repo.status_file(relative_path)?;
return Ok(Self(vec![(full_path.to_path_buf(), status)]));
}
let mut status_options = StatusOptions::new();
status_options
.include_untracked(true)
.recurse_untracked_dirs(true)
@@ -85,17 +101,16 @@ impl GitStatusCache {
.include_unmodified(true);
for path in paths {
status_options.pathspec(path.as_ref());
status_options.pathspec(path.as_ref().strip_prefix(workdir)?);
}
let statuses = Self::read_status_impl(repo, &mut status_options)?;
let git_status_cache = Self::read_status_impl(repo, &mut status_options)?;
debug!(
"Git partial status for paths {:?} returned {} entries",
statuses,
statuses.statuses_len()
status_len = git_status_cache.statuses_len(),
"Multiple files git status"
);
Some(statuses)
Ok(git_status_cache)
}
}
+26 -9
View File
@@ -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)
+238
View File
@@ -0,0 +1,238 @@
#[derive(Debug, Eq, PartialEq, Copy, Clone)]
pub enum Location {
Line(i32),
Range { start: (i32, i32), end: (i32, i32) },
Position { line: i32, col: i32 },
}
fn parse_number_pair(location: &str, split_char: char) -> Option<(i32, i32)> {
let mut iter = location.split(split_char);
let start_str = iter.next()?;
let end_str = iter.next()?;
// if there are more than 2 parts it's not the range treat as normal query
if iter.next().is_some() {
return None;
}
let start = start_str.parse::<i32>().ok()?;
let end = end_str.parse::<i32>().ok()?;
Some((start, end))
}
/// Parse "line-line" format
fn parse_simple_range(location: &str) -> Option<Location> {
let (start, end) = parse_number_pair(location, '-')?;
if end < start {
return Some(Location::Line(start));
}
Some(Location::Range {
start: (start, 0),
end: (end, 0),
})
}
/// Parse "line:col-col" format (column range on same line)
fn parse_column_range(start_part: &str, end_part: &str) -> Option<Location> {
let (line_str, start_col_str) = start_part.split_once(':')?;
let line = line_str.parse::<i32>().ok()?;
let start_col = start_col_str.parse::<i32>().ok()?;
let end_col = end_part.parse::<i32>().ok()?;
if end_col < start_col {
return Some(Location::Line(line));
}
Some(Location::Range {
start: (line, start_col),
end: (line, end_col),
})
}
/// Parse "line:col-line:col" format (position range)
fn parse_position_range(start_part: &str, end_part: &str) -> Option<Location> {
let (start_line, start_col) = parse_number_pair(start_part, ':')?;
let (end_line, end_col) = parse_number_pair(end_part, ':')?;
if end_line < start_line || (end_line == start_line && end_col < start_col) {
return Some(Location::Position {
line: start_line,
col: start_col,
});
}
Some(Location::Range {
start: (start_line, start_col),
end: (end_line, end_col),
})
}
/// Try to parse range patterns (contains '-')
fn try_parse_column_range(location: &str) -> Option<Location> {
if !location.contains('-') {
return None;
}
let (start_part, end_part) = location.split_once('-')?;
// Try position range (line:col-line:col)
if start_part.contains(':') && end_part.contains(':') {
return parse_position_range(start_part, end_part);
}
// Try column range (line:col-col)
if start_part.contains(':') {
return parse_column_range(start_part, end_part);
}
// Try simple line range (line-line)
parse_simple_range(location)
}
/// Try to parse position patterns (contains ':' but not '-')
fn try_parse_column_position(location: &str) -> Option<Location> {
if !location.contains(':') {
return None;
}
let (line_str, col_str) = location.split_once(':')?;
let line = line_str.parse::<i32>().ok()?;
let col = col_str.parse::<i32>().ok()?;
Some(Location::Position { line, col })
}
/// Parses various location formats like file:12, file:12:4, file:12-114
fn parse_column_location(query: &str) -> Option<(&str, Location)> {
let (file_path, location_part) = query.split_once(':')?;
if let Some(range_location) = try_parse_column_range(location_part) {
return Some((file_path, range_location));
}
if let Some(position_location) = try_parse_column_position(location_part) {
return Some((file_path, position_location));
}
if let Ok(line_location) = location_part.parse::<i32>() {
return Some((file_path, Location::Line(line_location)));
}
None
}
fn parse_vstudio_location(query: &str) -> Option<(&str, Location)> {
if !query.ends_with(')') {
return None;
}
let (file_path, location_with_paren) = query.rsplit_once('(')?;
let location = location_with_paren.trim_end_matches(')');
if let Ok(line) = location.parse::<i32>() {
return Some((file_path, Location::Line(line)));
}
if let Some((line, col)) = parse_number_pair(location, ',') {
return Some((file_path, Location::Position { line, col }));
}
None
}
pub fn parse_location(query: &str) -> (&str, Option<Location>) {
// simply ignore the last semicolon even if there are no additional location info
let query = query.trim_end_matches([':', '-', '(']);
if let Some((path, location)) = parse_column_location(query) {
return (path, Some(location));
}
if let Some((path, location)) = parse_vstudio_location(query) {
return (path, Some(location));
}
(query, None)
}
#[cfg(test)]
mod tests {
pub use super::*;
#[test]
fn test_location_parsing() {
assert_eq!(
parse_location("new_file:12"),
("new_file", Some(Location::Line(12)))
);
assert_eq!(parse_location("new_file:12ab"), ("new_file:12ab", None));
assert_eq!(parse_location("something"), ("something", None));
assert_eq!(
parse_location("file:12:4"),
("file", Some(Location::Position { line: 12, col: 4 }))
);
assert_eq!(
parse_location("file:12-114"),
(
"file",
Some(Location::Range {
start: (12, 0),
end: (114, 0)
})
)
);
assert_eq!(
parse_location("file:12:4-20"),
(
"file",
Some(Location::Range {
start: (12, 4),
end: (12, 20)
})
)
);
assert_eq!(
parse_location("file:100:4-14:20"),
("file", Some(Location::Position { line: 100, col: 4 }))
);
assert_eq!(
parse_location("file:12:4-14:20"),
(
"file",
Some(Location::Range {
start: (12, 4),
end: (14, 20)
})
)
);
}
#[test]
fn test_vstudio_parsing() {
assert_eq!(
parse_location("file(12)"),
("file", Some(Location::Line(12)))
);
assert_eq!(
parse_location("file(12,4)"),
("file", Some(Location::Position { line: 12, col: 4 }))
);
}
#[test]
fn trimes_end_character() {
assert_eq!(
parse_location("file:12-"),
("file", Some(Location::Line(12)))
);
assert_eq!(parse_location("file:-"), ("file", None));
assert_eq!(parse_location("file("), ("file", None));
}
}
+264 -24
View File
@@ -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");
}
}
+155
View File
@@ -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");
}
}
+4 -2
View File
@@ -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
View File
@@ -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))
}
}
+130
View File
@@ -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
View File
@@ -1,4 +1,4 @@
use fff_nvim::{file_picker::FilePicker, FILE_PICKER};
use fff_nvim::{FILE_PICKER, file_picker::FilePicker};
use std::env;
use std::thread;
use std::time::Duration;
@@ -28,11 +28,11 @@ fn get_mem_stat() -> Result<(usize, usize, usize), Box<dyn std::error::Error>> {
for line in content.lines() {
if line.starts_with("VmRSS:") {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
if let Ok(rss_kb) = parts[1].parse::<usize>() {
let rss_bytes = rss_kb * 1024;
return Ok((rss_bytes, rss_bytes, rss_bytes));
}
if let Ok(rss_kb) = parts[1].parse::<usize>()
&& parts.len() >= 2
{
let rss_bytes = rss_kb * 1024;
return Ok((rss_bytes, rss_bytes, rss_bytes));
}
}
}
@@ -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));
}
+164
View File
@@ -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
View File
@@ -1,4 +1,4 @@
use fff_nvim::{file_picker::FilePicker, FILE_PICKER};
use fff_nvim::{FILE_PICKER, file_picker::FilePicker};
use std::env;
use std::io::{self, Write};
use std::thread;
@@ -33,10 +33,10 @@ fn get_memory_usage() -> Result<u64, Box<dyn std::error::Error>> {
for line in content.lines() {
if line.starts_with("VmRSS:") {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
if let Ok(rss_kb) = parts[1].parse::<u64>() {
return Ok(rss_kb * 1024); // Convert KB to bytes
}
if let Ok(rss_kb) = parts[1].parse::<u64>()
&& parts.len() >= 2
{
return Ok(rss_kb * 1024); // Convert KB to bytes
}
}
}
@@ -101,19 +101,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut scan_completed = false;
loop {
if let Ok(file_picker_guard) = FILE_PICKER.read() {
if let Some(ref picker) = *file_picker_guard {
if !picker.is_scan_active() {
println!("Scan inactive, checking file count...");
let file_count = picker.get_files().len();
if file_count > 0 {
println!("Async scan found {} files", file_count);
scan_completed = true;
break;
}
} else {
println!("Scan active, waiting...");
if let Ok(file_picker_guard) = FILE_PICKER.read()
&& let Some(ref picker) = *file_picker_guard
{
if !picker.is_scan_active() {
println!("Scan inactive, checking file count...");
let file_count = picker.get_files().len();
if file_count > 0 {
println!("Async scan found {} files", file_count);
scan_completed = true;
break;
}
} else {
println!("Scan active, waiting...");
}
}
thread::sleep(Duration::from_millis(100));
@@ -127,12 +127,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// If async scan didn't work, trigger a manual scan
if !scan_completed {
println!("Triggering manual rescan...");
if let Ok(mut file_picker_guard) = FILE_PICKER.write() {
if let Some(ref mut picker) = *file_picker_guard {
match picker.trigger_rescan() {
Ok(_) => println!("Manual rescan completed"),
Err(e) => println!("Manual rescan failed: {:?}", e),
}
if let Ok(mut file_picker_guard) = FILE_PICKER.write()
&& let Some(ref mut picker) = *file_picker_guard
{
match picker.trigger_rescan() {
Ok(_) => println!("Manual rescan completed"),
Err(e) => println!("Manual rescan failed: {:?}", e),
}
}
}
@@ -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 -2
View File
@@ -2,11 +2,11 @@
#![allow(dead_code)]
#![allow(clippy::enum_variant_names)]
use fff_nvim::{file_picker::FilePicker, git::format_git_status, FILE_PICKER, FRECENCY};
use fff_nvim::{FILE_PICKER, FRECENCY, file_picker::FilePicker, git::format_git_status};
use std::env;
use std::io::{self, Write};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::Duration;