feat: Pagination (#194)
closes https://github.com/dmtrKovalenko/fff.nvim/issues/83
This commit is contained in:
committed by
GitHub
parent
c477f12487
commit
7fd361a369
Generated
+115
@@ -340,6 +340,27 @@ dependencies = [
|
||||
"powerfmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "5.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
|
||||
dependencies = [
|
||||
"dirs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.5"
|
||||
@@ -391,6 +412,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"criterion",
|
||||
"ctrlc",
|
||||
"dirs",
|
||||
"git2",
|
||||
"glidesort",
|
||||
"heed",
|
||||
@@ -837,6 +859,16 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libssh2-sys"
|
||||
version = "0.3.1"
|
||||
@@ -1148,6 +1180,12 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "overload"
|
||||
version = "0.1.1"
|
||||
@@ -1388,6 +1426,17 @@ dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||
dependencies = [
|
||||
"getrandom 0.2.16",
|
||||
"libredox",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.11.1"
|
||||
@@ -2008,6 +2057,15 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||
dependencies = [
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
@@ -2026,6 +2084,21 @@ dependencies = [
|
||||
"windows-targets 0.53.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.48.5",
|
||||
"windows_aarch64_msvc 0.48.5",
|
||||
"windows_i686_gnu 0.48.5",
|
||||
"windows_i686_msvc 0.48.5",
|
||||
"windows_x86_64_gnu 0.48.5",
|
||||
"windows_x86_64_gnullvm 0.48.5",
|
||||
"windows_x86_64_msvc 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
@@ -2059,6 +2132,12 @@ dependencies = [
|
||||
"windows_x86_64_msvc 0.53.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
@@ -2071,6 +2150,12 @@ version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -2083,6 +2168,12 @@ version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -2107,6 +2198,12 @@ version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -2119,6 +2216,12 @@ version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -2131,6 +2234,12 @@ version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
@@ -2143,6 +2252,12 @@ version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
|
||||
@@ -31,6 +31,7 @@ ahash = "0.8"
|
||||
blake3 = "1.8.2"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
ctrlc = "3.4.2"
|
||||
dirs = "5.0"
|
||||
git2 = "0.20.2"
|
||||
glidesort = "0.1"
|
||||
heed = "0.22.0"
|
||||
|
||||
@@ -123,6 +123,7 @@ require('fff').setup({
|
||||
prompt_position = 'bottom', -- or 'top'
|
||||
preview_position = 'right', -- or 'left', 'right', 'top', 'bottom'
|
||||
preview_size = 0.5,
|
||||
show_scrollbar = true, -- Show scrollbar for pagination
|
||||
},
|
||||
preview = {
|
||||
enabled = true,
|
||||
@@ -168,6 +169,7 @@ require('fff').setup({
|
||||
frecency = 'Number',
|
||||
debug = 'Comment',
|
||||
combo_header = 'Number',
|
||||
scrollbar = 'Comment', -- Highlight for scrollbar thumb (track uses border)
|
||||
-- Multi-select highlights
|
||||
selected = 'FFFSelected',
|
||||
selected_active = 'FFFSelectedActive',
|
||||
|
||||
+224
-42
@@ -1,9 +1,9 @@
|
||||
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
|
||||
use fff_nvim::FILE_PICKER;
|
||||
use fff_nvim::file_picker::FilePicker;
|
||||
use fff_nvim::file_picker::{FilePicker, FuzzySearchOptions};
|
||||
use fff_nvim::types::PaginationArgs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use tracing_subscriber;
|
||||
|
||||
/// Initialize tracing to output to console
|
||||
fn init_tracing() {
|
||||
@@ -231,10 +231,19 @@ fn bench_search_queries(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(query),
|
||||
black_box(100),
|
||||
black_box(4),
|
||||
black_box(None),
|
||||
black_box(false),
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
},
|
||||
},
|
||||
);
|
||||
results.total_matched
|
||||
});
|
||||
@@ -269,10 +278,19 @@ fn bench_search_thread_scaling(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(query),
|
||||
black_box(100),
|
||||
black_box(threads),
|
||||
black_box(None),
|
||||
black_box(false),
|
||||
FuzzySearchOptions {
|
||||
max_threads: threads,
|
||||
current_file: None,
|
||||
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
},
|
||||
},
|
||||
);
|
||||
results.total_matched
|
||||
});
|
||||
@@ -305,10 +323,19 @@ fn bench_search_result_limits(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(query),
|
||||
black_box(limit),
|
||||
black_box(4),
|
||||
black_box(None),
|
||||
black_box(false),
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: limit,
|
||||
},
|
||||
},
|
||||
);
|
||||
results.total_matched
|
||||
});
|
||||
@@ -353,10 +380,19 @@ fn bench_search_scalability(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(subset),
|
||||
black_box(query),
|
||||
black_box(100),
|
||||
black_box(4),
|
||||
black_box(None),
|
||||
black_box(false),
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
},
|
||||
},
|
||||
);
|
||||
results.total_matched
|
||||
});
|
||||
@@ -387,10 +423,19 @@ fn bench_search_ordering(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(query),
|
||||
black_box(100),
|
||||
black_box(4),
|
||||
black_box(None),
|
||||
black_box(false),
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
},
|
||||
},
|
||||
);
|
||||
results.total_matched
|
||||
});
|
||||
@@ -402,10 +447,19 @@ fn bench_search_ordering(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(query),
|
||||
black_box(100),
|
||||
black_box(4),
|
||||
black_box(None),
|
||||
black_box(true),
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
},
|
||||
},
|
||||
);
|
||||
results.total_matched
|
||||
});
|
||||
@@ -417,10 +471,19 @@ fn bench_search_ordering(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box("mod"),
|
||||
black_box(500),
|
||||
black_box(4),
|
||||
black_box(None),
|
||||
black_box(false),
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 500,
|
||||
},
|
||||
},
|
||||
);
|
||||
results.total_matched
|
||||
});
|
||||
@@ -431,10 +494,19 @@ fn bench_search_ordering(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box("mod"),
|
||||
black_box(500),
|
||||
black_box(4),
|
||||
black_box(None),
|
||||
black_box(true),
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 500,
|
||||
},
|
||||
},
|
||||
);
|
||||
results.total_matched
|
||||
});
|
||||
@@ -446,10 +518,19 @@ fn bench_search_ordering(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box("controller"),
|
||||
black_box(10),
|
||||
black_box(4),
|
||||
black_box(None),
|
||||
black_box(false),
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
},
|
||||
},
|
||||
);
|
||||
results.total_matched
|
||||
});
|
||||
@@ -460,10 +541,110 @@ fn bench_search_ordering(c: &mut Criterion) {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box("controller"),
|
||||
black_box(10),
|
||||
black_box(4),
|
||||
black_box(None),
|
||||
black_box(true),
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
},
|
||||
},
|
||||
);
|
||||
results.total_matched
|
||||
});
|
||||
});
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
/// Benchmark pagination: first page vs deep page
|
||||
fn bench_pagination_performance(c: &mut Criterion) {
|
||||
let files = match setup_once() {
|
||||
Ok(files) => files,
|
||||
Err(e) => {
|
||||
eprintln!("⚠ Skipping pagination benchmarks: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut group = c.benchmark_group("pagination");
|
||||
group.sample_size(100);
|
||||
|
||||
let query = "mod";
|
||||
let page_size = 40;
|
||||
|
||||
// Benchmark first page (uses partial sort optimization)
|
||||
group.bench_function("page_0_size_40", |b| {
|
||||
b.iter(|| {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(query),
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: page_size,
|
||||
},
|
||||
},
|
||||
);
|
||||
results.total_matched
|
||||
});
|
||||
});
|
||||
|
||||
// Benchmark 10th page (requires full sort, no optimization)
|
||||
group.bench_function("page_10_size_40", |b| {
|
||||
b.iter(|| {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(query),
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
offset: 10,
|
||||
limit: page_size,
|
||||
},
|
||||
},
|
||||
);
|
||||
results.total_matched
|
||||
});
|
||||
});
|
||||
|
||||
// Benchmark 50th page (even deeper pagination)
|
||||
group.bench_function("page_50_size_40", |b| {
|
||||
b.iter(|| {
|
||||
let results = FilePicker::fuzzy_search(
|
||||
black_box(&files),
|
||||
black_box(query),
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
offset: 50,
|
||||
limit: page_size,
|
||||
},
|
||||
},
|
||||
);
|
||||
results.total_matched
|
||||
});
|
||||
@@ -480,6 +661,7 @@ criterion_group!(
|
||||
bench_search_result_limits,
|
||||
bench_search_scalability,
|
||||
bench_search_ordering,
|
||||
bench_pagination_performance,
|
||||
);
|
||||
|
||||
criterion_main!(benches);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
|
||||
use fff_nvim::query_tracker::{QueryMatchEntry, QueryTracker};
|
||||
use fff_nvim::query_tracker::QueryTracker;
|
||||
use rand::distributions::Alphanumeric;
|
||||
use rand::prelude::*;
|
||||
use std::path::PathBuf;
|
||||
@@ -13,7 +13,16 @@ fn generate_random_string(len: usize) -> String {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn generate_test_data(num_entries: usize) -> Vec<QueryMatchEntry> {
|
||||
// Test data structure for benchmarks
|
||||
struct TestQueryEntry {
|
||||
query: String,
|
||||
project_path: PathBuf,
|
||||
file_path: PathBuf,
|
||||
open_count: u32,
|
||||
last_opened: u64,
|
||||
}
|
||||
|
||||
fn generate_test_data(num_entries: usize) -> Vec<TestQueryEntry> {
|
||||
let mut rng = thread_rng();
|
||||
let mut entries = Vec::with_capacity(num_entries);
|
||||
let now = SystemTime::now()
|
||||
@@ -72,7 +81,7 @@ fn generate_test_data(num_entries: usize) -> Vec<QueryMatchEntry> {
|
||||
);
|
||||
let file_path = PathBuf::from(format!("{}/src/{}", project_path, file_name));
|
||||
|
||||
let entry = QueryMatchEntry {
|
||||
let entry = TestQueryEntry {
|
||||
query: query.into(),
|
||||
project_path: PathBuf::from(project_path),
|
||||
file_path,
|
||||
@@ -86,7 +95,7 @@ fn generate_test_data(num_entries: usize) -> Vec<QueryMatchEntry> {
|
||||
entries
|
||||
}
|
||||
|
||||
fn setup_tracker_with_data(entries: &[QueryMatchEntry]) -> (QueryTracker, PathBuf) {
|
||||
fn setup_tracker_with_data(entries: &[TestQueryEntry]) -> (QueryTracker, PathBuf) {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -144,88 +153,6 @@ fn bench_track_query_completion(c: &mut Criterion) {
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_get_query_boost(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("get_query_boost");
|
||||
|
||||
for size in &[100, 1000, 10000] {
|
||||
let entries = generate_test_data(*size);
|
||||
let (tracker, temp_dir) = setup_tracker_with_data(&entries);
|
||||
|
||||
group.bench_with_input(BenchmarkId::new("entries", size), size, |b, _| {
|
||||
let mut rng = thread_rng();
|
||||
b.iter(|| {
|
||||
let entry = entries.choose(&mut rng).unwrap();
|
||||
let boost = black_box(
|
||||
tracker
|
||||
.get_query_boost(
|
||||
black_box(&entry.query),
|
||||
black_box(&entry.project_path),
|
||||
black_box(&entry.file_path),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
black_box(boost);
|
||||
});
|
||||
});
|
||||
|
||||
drop(tracker);
|
||||
cleanup_tracker_dir(temp_dir);
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_get_query_history(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("get_query_history");
|
||||
|
||||
for size in &[100, 1000, 10000] {
|
||||
let entries = generate_test_data(*size);
|
||||
let (tracker, temp_dir) = setup_tracker_with_data(&entries);
|
||||
|
||||
group.bench_with_input(BenchmarkId::new("entries", size), size, |b, _| {
|
||||
let mut rng = thread_rng();
|
||||
b.iter(|| {
|
||||
let project = &entries.choose(&mut rng).unwrap().project_path;
|
||||
let history = black_box(
|
||||
tracker
|
||||
.get_query_history(black_box(project), black_box(50))
|
||||
.unwrap(),
|
||||
);
|
||||
black_box(history);
|
||||
});
|
||||
});
|
||||
|
||||
drop(tracker);
|
||||
cleanup_tracker_dir(temp_dir);
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_cleanup_old_entries(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("cleanup_old_entries");
|
||||
|
||||
for size in &[100, 1000, 10000] {
|
||||
group.bench_with_input(BenchmarkId::new("entries", size), size, |b, &size| {
|
||||
b.iter_batched(
|
||||
|| {
|
||||
let entries = generate_test_data(size);
|
||||
setup_tracker_with_data(&entries)
|
||||
},
|
||||
|(mut tracker, temp_dir)| {
|
||||
let cleaned = black_box(tracker.cleanup_old_entries().unwrap());
|
||||
black_box(cleaned);
|
||||
drop(tracker);
|
||||
cleanup_tracker_dir(temp_dir);
|
||||
},
|
||||
criterion::BatchSize::LargeInput,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_realistic_workload(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("realistic_workload");
|
||||
|
||||
@@ -241,17 +168,17 @@ fn bench_realistic_workload(c: &mut Criterion) {
|
||||
// Simulate realistic usage: 70% lookups, 25% tracking, 5% history
|
||||
match rng.gen_range(0..100) {
|
||||
0..70 => {
|
||||
// Query boost lookup (most common operation)
|
||||
let boost = black_box(
|
||||
// Query entry lookup (most common operation)
|
||||
let entry_result = black_box(
|
||||
tracker
|
||||
.get_query_boost(
|
||||
.get_last_query_entry(
|
||||
black_box(&entry.query),
|
||||
black_box(&entry.project_path),
|
||||
black_box(&entry.file_path),
|
||||
3,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
black_box(boost);
|
||||
black_box(entry_result);
|
||||
}
|
||||
70..95 => {
|
||||
// Track completion (when user opens file)
|
||||
@@ -266,10 +193,10 @@ fn bench_realistic_workload(c: &mut Criterion) {
|
||||
);
|
||||
}
|
||||
95..100 => {
|
||||
// Get history (least common)
|
||||
// Get historical query (least common)
|
||||
let history = black_box(
|
||||
tracker
|
||||
.get_query_history(black_box(&entry.project_path), black_box(20))
|
||||
.get_historical_query(black_box(&entry.project_path), black_box(5))
|
||||
.unwrap(),
|
||||
);
|
||||
black_box(history);
|
||||
@@ -289,9 +216,9 @@ fn bench_realistic_workload(c: &mut Criterion) {
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_track_query_completion,
|
||||
bench_get_query_boost,
|
||||
bench_get_query_history,
|
||||
bench_cleanup_old_entries,
|
||||
// Commented out - methods removed/changed in refactor:
|
||||
// bench_get_query_boost,
|
||||
// bench_cleanup_old_entries,
|
||||
bench_realistic_workload
|
||||
);
|
||||
criterion_main!(benches);
|
||||
|
||||
+7
-1
@@ -17,7 +17,13 @@ vim.opt.rtp:prepend(lazypath)
|
||||
|
||||
require('lazy').setup({
|
||||
{
|
||||
dir = '/Users/neogoose/dev/fff.nvim',
|
||||
dir = '~/dev/fff.nvim',
|
||||
'https://github.com/dmtrKovalenko/fff.nvim',
|
||||
build = function()
|
||||
-- this will download prebuild binary or try to use existing rustup toolchain to build from source
|
||||
-- (if you are using lazy you can use gb for rebuilding a plugin if needed)
|
||||
require('fff.download').download_or_build_binary()
|
||||
end,
|
||||
dependencies = {
|
||||
'nvim-tree/nvim-web-devicons', -- Optional: for file icons
|
||||
-- {
|
||||
|
||||
@@ -113,6 +113,7 @@ local function init()
|
||||
prompt_position = 'bottom', -- or 'top'
|
||||
preview_position = 'right', -- or 'left', 'right', 'top', 'bottom'
|
||||
preview_size = 0.5,
|
||||
show_scrollbar = true, -- Show scrollbar for pagination
|
||||
},
|
||||
preview = {
|
||||
enabled = true,
|
||||
@@ -155,6 +156,7 @@ local function init()
|
||||
frecency = 'Number',
|
||||
debug = 'Comment',
|
||||
combo_header = 'Number',
|
||||
scrollbar = 'Comment',
|
||||
-- Multi-select highlights
|
||||
selected = 'FFFSelected',
|
||||
selected_active = 'FFFSelectedActive',
|
||||
|
||||
@@ -32,43 +32,60 @@ function M.scan_files()
|
||||
end
|
||||
|
||||
--- Search files with fuzzy matching using blink.cmp's advanced algorithm
|
||||
--- Results are always returned in descending order (best scores first)
|
||||
--- @param query string Search query
|
||||
--- @param max_results number|nil Maximum number of results (optional)
|
||||
--- @param max_threads number|nil Maximum number of threads (optional)
|
||||
--- @param current_file string|nil Path to current file to deprioritize (optional)
|
||||
--- @param reverse_order boolean Reverse order of results
|
||||
--- @param min_combo_count_override number|nil Optional override for min_combo_count (nil uses config)
|
||||
--- @return table List of matching files
|
||||
function M.search_files(query, current_file, max_results, max_threads, reverse_order, min_combo_count_override)
|
||||
function M.search_files(query, current_file, max_results, max_threads, min_combo_count_override)
|
||||
-- Delegate to paginated version with offset=0 and limit=max_results
|
||||
return M.search_files_paginated(query, current_file, max_threads, min_combo_count_override, 0, max_results)
|
||||
end
|
||||
|
||||
--- Search files with pagination support
|
||||
--- Results are always returned in descending order (best scores first)
|
||||
--- @param query string Search query
|
||||
--- @param current_file string|nil Path to current file to deprioritize (optional)
|
||||
--- @param max_threads number|nil Maximum number of threads to use
|
||||
--- @param min_combo_count_override number|nil Optional override for min_combo_count (nil uses config)
|
||||
--- @param page_index number Page index (0-based: 0, 1, 2, ...)
|
||||
--- @param page_size number Items per page
|
||||
--- @return table List of matching files
|
||||
function M.search_files_paginated(query, current_file, max_threads, min_combo_count_override, page_index, page_size)
|
||||
local config = require('fff.conf').get()
|
||||
if not M.state.initialized then return {} end
|
||||
|
||||
max_results = max_results or config.max_results or 40
|
||||
max_threads = max_threads or config.max_threads or 4
|
||||
local combo_boost_score_multiplier = config.history and config.history.combo_boost_score_multiplier or 100
|
||||
page_index = page_index or 0
|
||||
page_size = page_size or 0
|
||||
|
||||
-- Use override if provided, otherwise use config value
|
||||
local min_combo_count = min_combo_count_override
|
||||
if min_combo_count == nil then min_combo_count = config.history and config.history.min_combo_count or 3 end
|
||||
|
||||
local combo_boost_score_multiplier = config.history and config.history.combo_boost_score_multiplier or 100
|
||||
|
||||
-- Convert page_index to offset (Rust expects offset in items, not page number)
|
||||
local offset = page_index * page_size
|
||||
|
||||
local ok, search_result = pcall(
|
||||
fuzzy.fuzzy_search_files,
|
||||
query,
|
||||
max_results,
|
||||
max_threads,
|
||||
current_file,
|
||||
reverse_order,
|
||||
combo_boost_score_multiplier,
|
||||
min_combo_count
|
||||
min_combo_count,
|
||||
offset,
|
||||
page_size
|
||||
)
|
||||
|
||||
if not ok then
|
||||
vim.notify('Failed to search files: ' .. tostring(search_result), vim.log.levels.ERROR)
|
||||
return {}
|
||||
end
|
||||
|
||||
-- Store search metadata for UI display
|
||||
M.state.last_search_result = search_result
|
||||
|
||||
return search_result.items
|
||||
end
|
||||
|
||||
|
||||
+334
-151
@@ -8,6 +8,7 @@ local git_utils = require('fff.git_utils')
|
||||
local utils = require('fff.utils')
|
||||
local location_utils = require('fff.location_utils')
|
||||
local combo_renderer = require('fff.combo_renderer')
|
||||
local scrollbar = require('fff.scrollbar')
|
||||
|
||||
local function get_prompt_position()
|
||||
local config = M.state.config
|
||||
@@ -259,6 +260,14 @@ M.state = {
|
||||
history_offset = nil, -- Current offset in history (nil = not cycling, 0 = first query)
|
||||
next_search_force_combo_boost = false, -- Force combo boost on next search (for history recall)
|
||||
|
||||
-- Pagination state
|
||||
pagination = {
|
||||
page_index = 0, -- Current page index (0-based)
|
||||
page_size = 20, -- Items per page (updated dynamically)
|
||||
total_matched = 0, -- Total results from last search
|
||||
prefetch_margin = 5, -- Trigger refetch when within N items of edge
|
||||
},
|
||||
|
||||
config = nil,
|
||||
|
||||
ns_id = nil,
|
||||
@@ -371,6 +380,7 @@ function M.create_ui()
|
||||
}
|
||||
|
||||
local layout = M.calculate_layout_dimensions(layout_config)
|
||||
M.state.layout = layout
|
||||
|
||||
M.state.input_buf = vim.api.nvim_create_buf(false, true)
|
||||
vim.api.nvim_buf_set_option(M.state.input_buf, 'bufhidden', 'wipe')
|
||||
@@ -776,46 +786,150 @@ function M.update_results_sync()
|
||||
|
||||
local prompt_position = get_prompt_position()
|
||||
|
||||
-- Calculate dynamic max_results based on visible window height
|
||||
local dynamic_max_results = M.state.config.max_results
|
||||
-- Calculate page size dynamically based on window height
|
||||
local page_size
|
||||
if M.state.list_win and vim.api.nvim_win_is_valid(M.state.list_win) then
|
||||
local win_height = vim.api.nvim_win_get_height(M.state.list_win)
|
||||
dynamic_max_results = win_height
|
||||
page_size = vim.api.nvim_win_get_height(M.state.list_win)
|
||||
else
|
||||
dynamic_max_results = M.state.config.max_results or 100
|
||||
page_size = M.state.config.max_results or 100
|
||||
end
|
||||
|
||||
-- Update pagination state
|
||||
M.state.pagination.page_size = page_size
|
||||
M.state.pagination.page_index = 0 -- Reset to first page on new search
|
||||
|
||||
-- Check if we should force combo boost for this search (history recall)
|
||||
local min_combo_override = nil
|
||||
if M.state.next_search_force_combo_boost then
|
||||
min_combo_override = 0 -- Force combo boost by setting min_combo_count to 0
|
||||
end
|
||||
|
||||
local results = file_picker.search_files(
|
||||
local results = file_picker.search_files_paginated(
|
||||
M.state.query,
|
||||
M.state.current_file_cache,
|
||||
dynamic_max_results,
|
||||
M.state.config.max_threads,
|
||||
prompt_position == 'bottom',
|
||||
min_combo_override
|
||||
min_combo_override,
|
||||
0,
|
||||
page_size
|
||||
)
|
||||
|
||||
-- Get location from search results
|
||||
M.state.location = file_picker.get_search_location()
|
||||
|
||||
-- because the actual files could be different even with same count
|
||||
local metadata = file_picker.get_search_metadata()
|
||||
M.state.pagination.total_matched = metadata.total_matched
|
||||
|
||||
M.state.items = results
|
||||
M.state.filtered_items = results
|
||||
|
||||
if prompt_position == 'bottom' then
|
||||
M.state.cursor = #results > 0 and #results or 1
|
||||
else
|
||||
M.state.cursor = 1
|
||||
end
|
||||
-- Results always come in descending order (best first) from Rust
|
||||
-- For bottom prompt, we render in reverse so best items appear at bottom
|
||||
-- But cursor index should still point to items[1] (best item)
|
||||
M.state.cursor = #results > 0 and 1 or 1
|
||||
|
||||
M.render_debounced()
|
||||
end
|
||||
|
||||
--- Load page with given page index
|
||||
function M.load_page_at_index(new_page_index, adjust_cursor_fn)
|
||||
local page_size = M.state.pagination.page_size
|
||||
local total = M.state.pagination.total_matched
|
||||
|
||||
-- Protect against division by zero
|
||||
if page_size == 0 or total == 0 then return false end
|
||||
|
||||
-- Calculate max page index
|
||||
local max_page_index = math.max(0, math.ceil(total / page_size) - 1)
|
||||
|
||||
-- Clamp page_index to valid range
|
||||
new_page_index = math.max(0, math.min(new_page_index, max_page_index))
|
||||
|
||||
local prompt_position = get_prompt_position()
|
||||
|
||||
local ok, results = pcall(
|
||||
file_picker.search_files_paginated,
|
||||
M.state.query,
|
||||
M.state.current_file_cache,
|
||||
M.state.config.max_threads,
|
||||
nil, -- No combo boost override for page navigation
|
||||
new_page_index,
|
||||
page_size
|
||||
)
|
||||
|
||||
if not ok then
|
||||
vim.notify('Error in paginated search: ' .. tostring(results), vim.log.levels.ERROR)
|
||||
vim.api.nvim_err_writeln('FFF ERROR: Paginated search failed: ' .. tostring(results))
|
||||
return false
|
||||
end
|
||||
|
||||
if #results == 0 then return false end
|
||||
|
||||
-- CRITICAL: Update total_matched from the latest search metadata
|
||||
-- This prevents stale total_matched values that can cause out-of-bounds pagination
|
||||
local metadata = file_picker.get_search_metadata()
|
||||
M.state.pagination.total_matched = metadata.total_matched
|
||||
|
||||
M.state.items = results
|
||||
M.state.filtered_items = results
|
||||
M.state.pagination.page_index = new_page_index
|
||||
|
||||
-- Adjust cursor position (provided by caller)
|
||||
if adjust_cursor_fn then
|
||||
local ok, err = pcall(adjust_cursor_fn, #results)
|
||||
if not ok then
|
||||
vim.notify('Error in cursor adjustment: ' .. tostring(err), vim.log.levels.ERROR)
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
local ok, err = pcall(M.render_list)
|
||||
if not ok then
|
||||
vim.notify('Error in render_list: ' .. tostring(err), vim.log.levels.ERROR)
|
||||
return false
|
||||
end
|
||||
|
||||
ok, err = pcall(M.update_preview)
|
||||
if not ok then
|
||||
vim.notify('Error in update_preview: ' .. tostring(err), vim.log.levels.ERROR)
|
||||
return false
|
||||
end
|
||||
|
||||
ok, err = pcall(M.update_status)
|
||||
if not ok then
|
||||
vim.notify('Error in update_status: ' .. tostring(err), vim.log.levels.ERROR)
|
||||
return false
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
--- Load next page (scroll down reached end)
|
||||
function M.load_next_page()
|
||||
local page_size = M.state.pagination.page_size
|
||||
local total = M.state.pagination.total_matched
|
||||
local current_page = M.state.pagination.page_index
|
||||
|
||||
-- Protect against division by zero
|
||||
if page_size == 0 or total == 0 then return false end
|
||||
|
||||
local max_page_index = math.max(0, math.ceil(total / page_size) - 1)
|
||||
if current_page >= max_page_index then return false end
|
||||
|
||||
local new_page_index = current_page + 1
|
||||
local prompt_position = get_prompt_position()
|
||||
|
||||
return M.load_page_at_index(new_page_index, function(result_count) M.state.cursor = 1 end)
|
||||
end
|
||||
|
||||
--- Load previous page (scroll up reached beginning)
|
||||
function M.load_previous_page()
|
||||
if M.state.pagination.page_index == 0 then return false end
|
||||
|
||||
local new_page_index = M.state.pagination.page_index - 1
|
||||
local prompt_position = get_prompt_position()
|
||||
|
||||
return M.load_page_at_index(new_page_index, function(result_count) M.state.cursor = result_count end)
|
||||
end
|
||||
|
||||
function M.update_preview_debounced()
|
||||
-- Cancel previous preview timer
|
||||
if M.state.preview_timer then
|
||||
@@ -954,22 +1068,15 @@ function M.render_list()
|
||||
|
||||
local prompt_position = get_prompt_position()
|
||||
|
||||
-- Calculate which items to display based on prompt position
|
||||
-- All items in M.state.items should be displayed (already paginated)
|
||||
local display_start = 1
|
||||
local display_end = display_count
|
||||
local display_end = #items
|
||||
|
||||
if prompt_position == 'bottom' and #items > display_count then
|
||||
-- Bottom prompt: show last N items (including combo if it naturally fits)
|
||||
display_end = #items
|
||||
display_start = math.max(1, display_end - display_count + 1)
|
||||
end
|
||||
|
||||
display_count = display_end - display_start + 1
|
||||
|
||||
if M.state.cursor < display_start then
|
||||
M.state.cursor = display_start
|
||||
elseif M.state.cursor > display_end then
|
||||
M.state.cursor = display_end
|
||||
-- Simple cursor validation
|
||||
if M.state.cursor < 1 then
|
||||
M.state.cursor = 1
|
||||
elseif M.state.cursor > #items then
|
||||
M.state.cursor = #items
|
||||
end
|
||||
|
||||
local padded_lines = {}
|
||||
@@ -977,7 +1084,15 @@ function M.render_list()
|
||||
local path_data = {}
|
||||
local item_to_lines = {} -- Maps item index to its line indices {first_line, last_line}
|
||||
|
||||
for i = display_start, display_end do
|
||||
-- For bottom prompt, iterate in reverse order to render best results at bottom
|
||||
local iter_start, iter_end, iter_step
|
||||
if prompt_position == 'bottom' then
|
||||
iter_start, iter_end, iter_step = display_end, display_start, -1
|
||||
else
|
||||
iter_start, iter_end, iter_step = display_start, display_end, 1
|
||||
end
|
||||
|
||||
for i = iter_start, iter_end, iter_step do
|
||||
local item = items[i]
|
||||
local item_start_line = #padded_lines + 1
|
||||
|
||||
@@ -1075,154 +1190,152 @@ function M.render_list()
|
||||
for i = display_start, display_end do
|
||||
local item = items[i]
|
||||
local item_lines = item_to_lines[i]
|
||||
if not item_lines then goto continue end
|
||||
if item_lines then
|
||||
local is_cursor_item = (M.state.cursor == i)
|
||||
|
||||
local is_cursor_item = (M.state.cursor == i)
|
||||
|
||||
-- Highlight only the content line (last line), not the combo header
|
||||
if is_cursor_item then
|
||||
local content_line = item_lines.last
|
||||
-- Highlight entire line and extend to EOL
|
||||
vim.api.nvim_buf_set_extmark(M.state.list_buf, M.state.ns_id, content_line - 1, 0, {
|
||||
end_col = 0,
|
||||
end_row = content_line,
|
||||
hl_group = M.state.config.hl.active_file,
|
||||
hl_eol = true,
|
||||
priority = 100,
|
||||
})
|
||||
end
|
||||
|
||||
-- Now apply file-specific highlights to the last line
|
||||
local line_idx = item_lines.last
|
||||
local line_content = padded_lines[line_idx]
|
||||
|
||||
if line_content then
|
||||
local icon, icon_hl_group = unpack(icon_data[i])
|
||||
local filename, dir_path = unpack(path_data[i])
|
||||
|
||||
local score = file_picker.get_file_score(i)
|
||||
local is_current_file = score and score.current_file_penalty and score.current_file_penalty < 0
|
||||
|
||||
-- Icon highlighting
|
||||
if icon and icon_hl_group and vim.fn.strdisplaywidth(icon) > 0 then
|
||||
local icon_highlight = is_current_file and 'Comment' or icon_hl_group
|
||||
vim.api.nvim_buf_add_highlight(
|
||||
M.state.list_buf,
|
||||
M.state.ns_id,
|
||||
icon_highlight,
|
||||
line_idx - 1,
|
||||
0,
|
||||
vim.fn.strdisplaywidth(icon)
|
||||
)
|
||||
-- Highlight only the content line (last line), not the combo header
|
||||
if is_cursor_item then
|
||||
local content_line = item_lines.last
|
||||
-- Highlight entire line and extend to EOL
|
||||
vim.api.nvim_buf_set_extmark(M.state.list_buf, M.state.ns_id, content_line - 1, 0, {
|
||||
end_col = 0,
|
||||
end_row = content_line,
|
||||
hl_group = M.state.config.hl.active_file,
|
||||
hl_eol = true,
|
||||
priority = 100,
|
||||
})
|
||||
end
|
||||
|
||||
-- Frecency highlighting
|
||||
if debug_enabled then
|
||||
local star_start, star_end = line_content:find('⭐%d+')
|
||||
if star_start then
|
||||
-- Now apply file-specific highlights to the last line
|
||||
local line_idx = item_lines.last
|
||||
local line_content = padded_lines[line_idx]
|
||||
|
||||
if line_content then
|
||||
local icon, icon_hl_group = unpack(icon_data[i])
|
||||
local filename, dir_path = unpack(path_data[i])
|
||||
|
||||
local score = file_picker.get_file_score(i)
|
||||
local is_current_file = score and score.current_file_penalty and score.current_file_penalty < 0
|
||||
|
||||
-- Icon highlighting
|
||||
if icon and icon_hl_group and vim.fn.strdisplaywidth(icon) > 0 then
|
||||
local icon_highlight = is_current_file and 'Comment' or icon_hl_group
|
||||
vim.api.nvim_buf_add_highlight(
|
||||
M.state.list_buf,
|
||||
M.state.ns_id,
|
||||
M.state.config.hl.frecency,
|
||||
icon_highlight,
|
||||
line_idx - 1,
|
||||
star_start - 1,
|
||||
star_end
|
||||
0,
|
||||
vim.fn.strdisplaywidth(icon)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
local icon_match = line_content:match('^%S+')
|
||||
if icon_match and #filename > 0 and #dir_path > 0 then
|
||||
local prefix_len = #icon_match + 1 + #filename + 1
|
||||
vim.api.nvim_buf_add_highlight(
|
||||
M.state.list_buf,
|
||||
M.state.ns_id,
|
||||
'Comment',
|
||||
line_idx - 1,
|
||||
prefix_len,
|
||||
prefix_len + #dir_path
|
||||
)
|
||||
end
|
||||
|
||||
if is_current_file then
|
||||
if not is_cursor_item then
|
||||
vim.api.nvim_buf_add_highlight(M.state.list_buf, M.state.ns_id, 'Comment', line_idx - 1, 0, -1)
|
||||
-- Frecency highlighting
|
||||
if debug_enabled then
|
||||
local star_start, star_end = line_content:find('⭐%d+')
|
||||
if star_start then
|
||||
vim.api.nvim_buf_add_highlight(
|
||||
M.state.list_buf,
|
||||
M.state.ns_id,
|
||||
M.state.config.hl.frecency,
|
||||
line_idx - 1,
|
||||
star_start - 1,
|
||||
star_end
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
local virt_text_hl = is_cursor_item and M.state.config.hl.active_file or 'Comment'
|
||||
vim.api.nvim_buf_set_extmark(M.state.list_buf, M.state.ns_id, line_idx - 1, 0, {
|
||||
virt_text = { { ' (current)', virt_text_hl } },
|
||||
virt_text_pos = 'right_align',
|
||||
})
|
||||
end
|
||||
local icon_match = line_content:match('^%S+')
|
||||
if icon_match and #filename > 0 and #dir_path > 0 then
|
||||
local prefix_len = #icon_match + 1 + #filename + 1
|
||||
vim.api.nvim_buf_add_highlight(
|
||||
M.state.list_buf,
|
||||
M.state.ns_id,
|
||||
'Comment',
|
||||
line_idx - 1,
|
||||
prefix_len,
|
||||
prefix_len + #dir_path
|
||||
)
|
||||
end
|
||||
|
||||
if item.git_status and git_utils.should_show_border(item.git_status) then
|
||||
local border_char = git_utils.get_border_char(item.git_status)
|
||||
local border_hl
|
||||
if is_current_file then
|
||||
if not is_cursor_item then
|
||||
vim.api.nvim_buf_add_highlight(M.state.list_buf, M.state.ns_id, 'Comment', line_idx - 1, 0, -1)
|
||||
end
|
||||
|
||||
if is_cursor_item then
|
||||
-- When selected, create a combined highlight: border color on cursor background
|
||||
local base_hl = git_utils.get_border_highlight(item.git_status)
|
||||
if base_hl and base_hl ~= '' then
|
||||
-- Get the foreground color from the border highlight
|
||||
local border_fg = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID(base_hl)), 'fg')
|
||||
-- Get the background from cursor highlight
|
||||
local cursor_bg = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID(M.state.config.hl.active_file)), 'bg')
|
||||
local virt_text_hl = is_cursor_item and M.state.config.hl.active_file or 'Comment'
|
||||
vim.api.nvim_buf_set_extmark(M.state.list_buf, M.state.ns_id, line_idx - 1, 0, {
|
||||
virt_text = { { ' (current)', virt_text_hl } },
|
||||
virt_text_pos = 'right_align',
|
||||
})
|
||||
end
|
||||
|
||||
-- Create temporary highlight group
|
||||
local temp_hl_name = 'FFFGitBorderSelected_' .. i
|
||||
if border_fg ~= '' and cursor_bg ~= '' then
|
||||
vim.api.nvim_set_hl(0, temp_hl_name, { fg = border_fg, bg = cursor_bg })
|
||||
border_hl = temp_hl_name
|
||||
if item.git_status and git_utils.should_show_border(item.git_status) then
|
||||
local border_char = git_utils.get_border_char(item.git_status)
|
||||
local border_hl
|
||||
|
||||
if is_cursor_item then
|
||||
-- When selected, create a combined highlight: border color on cursor background
|
||||
local base_hl = git_utils.get_border_highlight(item.git_status)
|
||||
if base_hl and base_hl ~= '' then
|
||||
-- Get the foreground color from the border highlight
|
||||
local border_fg = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID(base_hl)), 'fg')
|
||||
-- Get the background from cursor highlight
|
||||
local cursor_bg = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID(M.state.config.hl.active_file)), 'bg')
|
||||
|
||||
-- Create temporary highlight group
|
||||
local temp_hl_name = 'FFFGitBorderSelected_' .. i
|
||||
if border_fg ~= '' and cursor_bg ~= '' then
|
||||
vim.api.nvim_set_hl(0, temp_hl_name, { fg = border_fg, bg = cursor_bg })
|
||||
border_hl = temp_hl_name
|
||||
else
|
||||
border_hl = git_utils.get_border_highlight_selected(item.git_status)
|
||||
end
|
||||
else
|
||||
border_hl = git_utils.get_border_highlight_selected(item.git_status)
|
||||
border_hl = M.state.config.hl.active_file
|
||||
end
|
||||
else
|
||||
border_hl = M.state.config.hl.active_file
|
||||
border_hl = git_utils.get_border_highlight(item.git_status)
|
||||
end
|
||||
else
|
||||
border_hl = git_utils.get_border_highlight(item.git_status)
|
||||
end
|
||||
|
||||
if border_hl and border_hl ~= '' then
|
||||
if border_hl and border_hl ~= '' then
|
||||
vim.api.nvim_buf_set_extmark(M.state.list_buf, M.state.ns_id, line_idx - 1, 0, {
|
||||
sign_text = border_char,
|
||||
sign_hl_group = border_hl,
|
||||
priority = 1000,
|
||||
})
|
||||
end
|
||||
elseif is_cursor_item then
|
||||
vim.api.nvim_buf_set_extmark(M.state.list_buf, M.state.ns_id, line_idx - 1, 0, {
|
||||
sign_text = border_char,
|
||||
sign_hl_group = border_hl,
|
||||
sign_text = ' ',
|
||||
sign_hl_group = M.state.config.hl.active_file,
|
||||
priority = 1000,
|
||||
})
|
||||
end
|
||||
elseif is_cursor_item then
|
||||
vim.api.nvim_buf_set_extmark(M.state.list_buf, M.state.ns_id, line_idx - 1, 0, {
|
||||
sign_text = ' ',
|
||||
sign_hl_group = M.state.config.hl.active_file,
|
||||
priority = 1000,
|
||||
})
|
||||
end
|
||||
|
||||
if M.state.selected_files[item.path] then
|
||||
local selection_hl = is_cursor_item and M.state.config.hl.selected_active or M.state.config.hl.selected
|
||||
if M.state.selected_files[item.path] then
|
||||
local selection_hl = is_cursor_item and M.state.config.hl.selected_active or M.state.config.hl.selected
|
||||
|
||||
vim.api.nvim_buf_set_extmark(M.state.list_buf, M.state.ns_id, line_idx - 1, 0, {
|
||||
sign_text = '▊',
|
||||
sign_hl_group = selection_hl,
|
||||
priority = 1001, -- Higher than git status (1000)
|
||||
})
|
||||
end
|
||||
vim.api.nvim_buf_set_extmark(M.state.list_buf, M.state.ns_id, line_idx - 1, 0, {
|
||||
sign_text = '▊',
|
||||
sign_hl_group = selection_hl,
|
||||
priority = 1001, -- Higher than git status (1000)
|
||||
})
|
||||
end
|
||||
|
||||
local match_start, match_end = string.find(line_content, M.state.query, 1)
|
||||
if match_start and match_end then
|
||||
vim.api.nvim_buf_add_highlight(
|
||||
M.state.list_buf,
|
||||
M.state.ns_id,
|
||||
config.hl.matched or 'IncSearch',
|
||||
line_idx - 1,
|
||||
match_start - 1,
|
||||
match_end
|
||||
)
|
||||
local match_start, match_end = string.find(line_content, M.state.query, 1)
|
||||
if match_start and match_end then
|
||||
vim.api.nvim_buf_add_highlight(
|
||||
M.state.list_buf,
|
||||
M.state.ns_id,
|
||||
config.hl.matched or 'IncSearch',
|
||||
line_idx - 1,
|
||||
match_start - 1,
|
||||
match_end
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
::continue::
|
||||
end
|
||||
|
||||
combo_renderer.render_highlights_and_overlays(
|
||||
@@ -1235,6 +1348,9 @@ function M.render_list()
|
||||
item_to_lines
|
||||
)
|
||||
end
|
||||
|
||||
-- Render scrollbar (will be created lazily if needed)
|
||||
scrollbar.render(M.state.layout, M.state.config, M.state.list_win, M.state.pagination)
|
||||
end
|
||||
|
||||
function M.update_preview()
|
||||
@@ -1399,7 +1515,40 @@ function M.move_up()
|
||||
if not M.state.active then return end
|
||||
if #M.state.filtered_items == 0 then return end
|
||||
|
||||
M.state.cursor = math.max(M.state.cursor - 1, 1)
|
||||
local prompt_position = get_prompt_position()
|
||||
local items_count = #M.state.filtered_items
|
||||
|
||||
-- Pagination logic depends on prompt position
|
||||
if prompt_position == 'bottom' then
|
||||
-- Bottom prompt with reverse rendering: visually moving UP means cursor INCREASES
|
||||
-- because higher index items are rendered at lower line numbers
|
||||
local near_bottom = M.state.cursor >= (items_count - M.state.pagination.prefetch_margin)
|
||||
local at_last_item = M.state.cursor >= items_count
|
||||
|
||||
if near_bottom and at_last_item then
|
||||
local page_size = M.state.pagination.page_size
|
||||
if page_size > 0 then
|
||||
local max_page = math.max(0, math.ceil(M.state.pagination.total_matched / page_size) - 1)
|
||||
local has_more = M.state.pagination.page_index < max_page
|
||||
if has_more then
|
||||
M.load_next_page()
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
M.state.cursor = math.min(M.state.cursor + 1, items_count)
|
||||
else
|
||||
-- Top prompt: scrolling UP means going to BETTER results (previous page)
|
||||
if M.state.cursor <= M.state.pagination.prefetch_margin + 1 and M.state.cursor <= 1 then
|
||||
if M.state.pagination.page_index > 0 then
|
||||
vim.schedule(M.load_previous_page)
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
M.state.cursor = math.max(M.state.cursor - 1, 1)
|
||||
end
|
||||
|
||||
M.render_list()
|
||||
M.update_preview()
|
||||
@@ -1410,7 +1559,40 @@ function M.move_down()
|
||||
if not M.state.active then return end
|
||||
if #M.state.filtered_items == 0 then return end
|
||||
|
||||
M.state.cursor = math.min(M.state.cursor + 1, #M.state.filtered_items)
|
||||
local prompt_position = get_prompt_position()
|
||||
local items_count = #M.state.filtered_items
|
||||
|
||||
-- Pagination logic depends on prompt position
|
||||
if prompt_position == 'bottom' then
|
||||
-- Bottom prompt with reverse rendering: visually moving DOWN means cursor DECREASES
|
||||
-- because lower index items (better) are rendered at higher line numbers
|
||||
if M.state.cursor <= M.state.pagination.prefetch_margin + 1 and M.state.cursor <= 1 then
|
||||
if M.state.pagination.page_index > 0 then
|
||||
vim.schedule(M.load_previous_page)
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
M.state.cursor = math.max(M.state.cursor - 1, 1)
|
||||
else
|
||||
-- Top prompt: scrolling DOWN means going to WORSE results (next page)
|
||||
local near_bottom = M.state.cursor >= (items_count - M.state.pagination.prefetch_margin)
|
||||
local at_last_item = M.state.cursor >= items_count
|
||||
|
||||
if near_bottom and at_last_item then
|
||||
local page_size = M.state.pagination.page_size
|
||||
if page_size > 0 then
|
||||
local max_page = math.max(0, math.ceil(M.state.pagination.total_matched / page_size) - 1)
|
||||
local has_more = M.state.pagination.page_index < max_page
|
||||
if has_more then
|
||||
M.load_next_page()
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
M.state.cursor = math.min(M.state.cursor + 1, items_count)
|
||||
end
|
||||
|
||||
M.render_list()
|
||||
M.update_preview()
|
||||
@@ -1664,6 +1846,7 @@ function M.close()
|
||||
M.state.active = false
|
||||
|
||||
combo_renderer.cleanup()
|
||||
scrollbar.cleanup()
|
||||
|
||||
local windows = {
|
||||
M.state.input_win,
|
||||
|
||||
@@ -5,7 +5,7 @@ use crate::git::GitStatusCache;
|
||||
use crate::location::parse_location;
|
||||
use crate::query_tracker::QueryMatchEntry;
|
||||
use crate::score::match_and_score_files;
|
||||
use crate::types::{FileItem, ScoringContext, SearchResult};
|
||||
use crate::types::{FileItem, PaginationArgs, ScoringContext, SearchResult};
|
||||
use git2::{Repository, Status, StatusOptions};
|
||||
use rayon::prelude::*;
|
||||
use std::fmt::Debug;
|
||||
@@ -21,14 +21,13 @@ use crate::{FILE_PICKER, FRECENCY};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct FuzzySearchOptions<'a> {
|
||||
pub max_results: usize,
|
||||
pub max_threads: usize,
|
||||
pub current_file: Option<&'a str>,
|
||||
pub reverse_order: bool,
|
||||
pub project_path: Option<&'a Path>,
|
||||
pub last_same_query_match: Option<&'a QueryMatchEntry>,
|
||||
pub combo_boost_score_multiplier: i32,
|
||||
pub min_combo_count: u32,
|
||||
pub pagination: PaginationArgs,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -184,7 +183,7 @@ impl FilePicker {
|
||||
let max_threads = options.max_threads.max(1);
|
||||
debug!(
|
||||
?query,
|
||||
max_results = ?options.max_results,
|
||||
pagination = ?options.pagination,
|
||||
?max_threads,
|
||||
current_file = ?options.current_file,
|
||||
"Fuzzy search",
|
||||
@@ -195,27 +194,30 @@ impl FilePicker {
|
||||
|
||||
// small queries with a large number of results can match absolutely everything
|
||||
let max_typos = (query.len() as u16 / 4).clamp(2, 6);
|
||||
|
||||
let context = ScoringContext {
|
||||
query,
|
||||
project_path: options.project_path,
|
||||
max_typos,
|
||||
max_threads,
|
||||
current_file: options.current_file,
|
||||
max_results: options.max_results,
|
||||
reverse_order: options.reverse_order,
|
||||
last_same_query_match: options.last_same_query_match,
|
||||
combo_boost_score_multiplier: options.combo_boost_score_multiplier,
|
||||
min_combo_count: options.min_combo_count,
|
||||
pagination: options.pagination,
|
||||
};
|
||||
|
||||
let time = std::time::Instant::now();
|
||||
|
||||
// Match, score, and paginate files (all done in sort_and_truncate)
|
||||
let (items, scores, total_matched) = match_and_score_files(files, &context);
|
||||
|
||||
debug!(
|
||||
?query,
|
||||
completed_in = ?time.elapsed(),
|
||||
top_position = ?items.first(),
|
||||
total_matched,
|
||||
returned_count = items.len(),
|
||||
pagination = ?options.pagination,
|
||||
"Fuzzy search completed",
|
||||
);
|
||||
|
||||
|
||||
+24
-6
@@ -2,6 +2,7 @@ use crate::error::Error;
|
||||
use crate::file_picker::{FilePicker, FuzzySearchOptions};
|
||||
use crate::frecency::FrecencyTracker;
|
||||
use crate::query_tracker::QueryTracker;
|
||||
use crate::types::PaginationArgs;
|
||||
use mlua::prelude::*;
|
||||
use once_cell::sync::Lazy;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -133,17 +134,26 @@ pub fn scan_files(_: &Lua, _: ()) -> LuaResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn fuzzy_search_files(
|
||||
lua: &Lua,
|
||||
(
|
||||
query,
|
||||
max_results,
|
||||
max_threads,
|
||||
current_file,
|
||||
order_reverse,
|
||||
combo_boost_score_multiplier,
|
||||
min_combo_count,
|
||||
): (String, usize, usize, Option<String>, bool, i32, Option<u32>),
|
||||
page_index,
|
||||
page_size,
|
||||
): (
|
||||
String,
|
||||
usize,
|
||||
Option<String>,
|
||||
i32,
|
||||
Option<u32>,
|
||||
Option<usize>,
|
||||
Option<usize>,
|
||||
),
|
||||
) -> LuaResult<LuaValue> {
|
||||
let Some(ref mut picker) = *FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)? else {
|
||||
return Err(Error::FilePickerMissing)?;
|
||||
@@ -173,21 +183,25 @@ pub fn fuzzy_search_files(
|
||||
?base_path,
|
||||
?query,
|
||||
?min_combo_count,
|
||||
"Last same query entry"
|
||||
?page_index,
|
||||
?page_size,
|
||||
"Fuzzy search parameters"
|
||||
);
|
||||
|
||||
let results = FilePicker::fuzzy_search(
|
||||
picker.get_files(),
|
||||
&query,
|
||||
FuzzySearchOptions {
|
||||
max_results,
|
||||
max_threads,
|
||||
current_file: current_file.as_deref(),
|
||||
reverse_order: order_reverse,
|
||||
project_path: Some(picker.base_path()),
|
||||
last_same_query_match: last_same_query_entry.as_ref(),
|
||||
combo_boost_score_multiplier,
|
||||
min_combo_count,
|
||||
pagination: PaginationArgs {
|
||||
offset: page_index.unwrap_or(0),
|
||||
limit: page_size.unwrap_or(0),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -420,5 +434,9 @@ fn create_exports(lua: &Lua) -> LuaResult<LuaTable> {
|
||||
// https://github.com/mlua-rs/mlua/issues/318
|
||||
#[mlua::lua_module(skip_memory_check)]
|
||||
fn fff_nvim(lua: &Lua) -> LuaResult<LuaTable> {
|
||||
// Install panic hook IMMEDIATELY on module load
|
||||
// This ensures any panics are logged even if init_tracing is never called
|
||||
crate::log::install_panic_hook();
|
||||
|
||||
create_exports(lua)
|
||||
}
|
||||
|
||||
+75
-30
@@ -7,6 +7,78 @@ use tracing_subscriber::{EnvFilter, fmt, prelude::*};
|
||||
static TRACING_INITIALIZED: std::sync::OnceLock<tracing_appender::non_blocking::WorkerGuard> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
static PANIC_HOOK_INSTALLED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
|
||||
|
||||
/// Install panic hook that writes to both stderr and a fallback file
|
||||
/// This is called separately from init_tracing to ensure panics are always logged
|
||||
pub fn install_panic_hook() {
|
||||
PANIC_HOOK_INSTALLED.get_or_init(|| {
|
||||
let default_panic = std::panic::take_hook();
|
||||
|
||||
std::panic::set_hook(Box::new(move |panic_info| {
|
||||
let payload = panic_info.payload();
|
||||
let message = if let Some(s) = payload.downcast_ref::<&str>() {
|
||||
s.to_string()
|
||||
} else if let Some(s) = payload.downcast_ref::<String>() {
|
||||
s.clone()
|
||||
} else {
|
||||
"Unknown panic payload".to_string()
|
||||
};
|
||||
|
||||
let location = if let Some(location) = panic_info.location() {
|
||||
format!(
|
||||
"{}:{}:{}",
|
||||
location.file(),
|
||||
location.line(),
|
||||
location.column()
|
||||
)
|
||||
} else {
|
||||
"unknown location".to_string()
|
||||
};
|
||||
|
||||
// Always log to tracing (if initialized)
|
||||
tracing::error!(
|
||||
panic.message = %message,
|
||||
panic.location = %location,
|
||||
"PANIC occurred in FFF.nvim"
|
||||
);
|
||||
|
||||
// Always print to stderr
|
||||
eprintln!("=== FFF.nvim PANIC ===");
|
||||
eprintln!("Message: {}", message);
|
||||
eprintln!("Location: {}", location);
|
||||
eprintln!("======================");
|
||||
|
||||
// Try to write to fallback panic log file
|
||||
if let Some(cache_dir) = dirs::cache_dir() {
|
||||
let panic_log = cache_dir.join("fff_nvim_panic.log");
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
let panic_entry = format!(
|
||||
"\n[{}] PANIC at {}\nMessage: {}\n",
|
||||
timestamp, location, message
|
||||
);
|
||||
|
||||
let _ = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&panic_log)
|
||||
.and_then(|mut f| {
|
||||
use std::io::Write;
|
||||
f.write_all(panic_entry.as_bytes())
|
||||
});
|
||||
|
||||
eprintln!("Panic logged to: {}", panic_log.display());
|
||||
}
|
||||
|
||||
default_panic(panic_info);
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
/// Initialize tracing with single log file
|
||||
///
|
||||
/// # Arguments
|
||||
@@ -16,6 +88,9 @@ static TRACING_INITIALIZED: std::sync::OnceLock<tracing_appender::non_blocking::
|
||||
/// # Returns
|
||||
/// * `Result<String, Error>` - Full path to the log file on success
|
||||
pub fn init_tracing(log_file_path: &str, log_level: Option<&str>) -> Result<String, Error> {
|
||||
// Install panic hook first (does nothing if already installed)
|
||||
install_panic_hook();
|
||||
|
||||
let log_path = Path::new(log_file_path);
|
||||
if let Some(parent) = log_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
@@ -70,36 +145,6 @@ pub fn init_tracing(log_file_path: &str, log_level: Option<&str>) -> Result<Stri
|
||||
);
|
||||
}
|
||||
|
||||
std::panic::set_hook(Box::new(|panic_info| {
|
||||
let payload = panic_info.payload();
|
||||
let message = if let Some(s) = payload.downcast_ref::<&str>() {
|
||||
s.to_string()
|
||||
} else if let Some(s) = payload.downcast_ref::<String>() {
|
||||
s.clone()
|
||||
} else {
|
||||
"Unknown panic payload".to_string()
|
||||
};
|
||||
|
||||
let location = if let Some(location) = panic_info.location() {
|
||||
format!(
|
||||
"{}:{}:{}",
|
||||
location.file(),
|
||||
location.line(),
|
||||
location.column()
|
||||
)
|
||||
} else {
|
||||
"unknown location".to_string()
|
||||
};
|
||||
|
||||
tracing::error!(
|
||||
panic.message = %message,
|
||||
panic.location = %location,
|
||||
"PANIC occurred in FFF.nvim"
|
||||
);
|
||||
|
||||
eprintln!("FFF.nvim PANIC: {} at {}", message, location);
|
||||
}));
|
||||
|
||||
guard
|
||||
});
|
||||
|
||||
|
||||
+85
-109
@@ -195,7 +195,7 @@ pub fn match_and_score_files<'a>(
|
||||
})
|
||||
.collect();
|
||||
|
||||
sort_and_truncate(results, context)
|
||||
sort_and_paginate(results, context)
|
||||
}
|
||||
|
||||
/// Check if a filename is a special entry point file that deserves bonus scoring
|
||||
@@ -254,7 +254,7 @@ fn score_all_by_frecency<'a>(
|
||||
})
|
||||
.collect();
|
||||
|
||||
sort_and_truncate(results, context)
|
||||
sort_and_paginate(results, context)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
@@ -277,75 +277,77 @@ fn calculate_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>(
|
||||
/// Sorts elements by total score (descending) and returns the requested page.
|
||||
/// Always returns results in descending order (best scores first).
|
||||
/// The UI layer handles rendering order based on prompt position.
|
||||
#[tracing::instrument(skip_all, level = tracing::Level::DEBUG)]
|
||||
fn sort_and_paginate<'a>(
|
||||
mut results: Vec<(&'a FileItem, Score)>,
|
||||
context: &ScoringContext,
|
||||
) -> (Vec<&'a FileItem>, Vec<Score>, usize) {
|
||||
let total_matched = results.len();
|
||||
|
||||
// For large result sets, use partial sort to avoid sorting everything
|
||||
let threshold = context.max_results * 2;
|
||||
if total_matched == 0 {
|
||||
return (vec![], vec![], 0);
|
||||
}
|
||||
|
||||
if context.reverse_order {
|
||||
// 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..(results.len() - context.max_results));
|
||||
}
|
||||
let offset = context.pagination.offset;
|
||||
let limit = if context.pagination.limit > 0 {
|
||||
context.pagination.limit
|
||||
} else {
|
||||
// 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);
|
||||
}
|
||||
total_matched
|
||||
};
|
||||
|
||||
// Sort remaining results in descending order using glidesort
|
||||
sort_with_buffer(&mut results, |a, b| {
|
||||
// Check if offset is out of bounds
|
||||
if offset >= total_matched {
|
||||
tracing::warn!(
|
||||
offset = offset,
|
||||
total_matched = total_matched,
|
||||
"Pagination: offset >= total_matched, returning empty"
|
||||
);
|
||||
|
||||
return (vec![], vec![], total_matched);
|
||||
}
|
||||
|
||||
let items_needed = offset.saturating_add(limit).min(total_matched);
|
||||
// Use partial sort if we need less than half the results and dataset is large
|
||||
let use_partial_sort = items_needed < total_matched / 2 && total_matched > 100;
|
||||
// Always sort in descending order (best scores first)
|
||||
if use_partial_sort {
|
||||
// Partition at position (items_needed - 1) with descending comparator
|
||||
// This puts the highest N needed items at the front
|
||||
results.select_nth_unstable_by(items_needed - 1, |a, b| {
|
||||
b.1.total
|
||||
.cmp(&a.1.total)
|
||||
.then_with(|| b.0.modified.cmp(&a.0.modified))
|
||||
});
|
||||
|
||||
// Ensure we only return max_results items (for small datasets)
|
||||
results.truncate(context.max_results);
|
||||
results.truncate(items_needed);
|
||||
}
|
||||
|
||||
let (items, scores) = results.into_iter().unzip();
|
||||
// select nth does not sort the results, we have to sort accordingly anyway
|
||||
sort_with_buffer(&mut results, |a, b| {
|
||||
b.1.total
|
||||
.cmp(&a.1.total)
|
||||
.then_with(|| b.0.modified.cmp(&a.0.modified))
|
||||
});
|
||||
|
||||
// in the best scenario truncation happened in the select_nth step
|
||||
if results.len() > limit {
|
||||
let page_end = std::cmp::min(offset + limit, results.len());
|
||||
let page_size = page_end - offset;
|
||||
|
||||
results.drain(0..offset);
|
||||
results.truncate(page_size);
|
||||
}
|
||||
|
||||
let (items, scores): (Vec<&FileItem>, Vec<Score>) = results.into_iter().unzip();
|
||||
(items, scores, total_matched)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::PaginationArgs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn create_test_file(path: &str, score: i32, modified: u64) -> (FileItem, Score) {
|
||||
@@ -401,23 +403,26 @@ mod tests {
|
||||
|
||||
let context = ScoringContext {
|
||||
query: "test",
|
||||
max_results: 3,
|
||||
max_threads: 1,
|
||||
max_typos: 2,
|
||||
current_file: None,
|
||||
reverse_order: false,
|
||||
last_same_query_match: None,
|
||||
project_path: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 0,
|
||||
},
|
||||
};
|
||||
|
||||
// Test with partial sort (threshold = 3 * 2 = 6, our len is 10 > 6)
|
||||
let (items, scores, total) = sort_and_truncate(results.clone(), &context);
|
||||
// Test with full sort - returns all results sorted descending
|
||||
let (items, scores, total) = sort_and_paginate(results.clone(), &context);
|
||||
|
||||
// Should return top 3: 300, 250, 200
|
||||
// Should return all 10 items sorted by score descending
|
||||
assert_eq!(total, 10);
|
||||
assert_eq!(scores.len(), 3);
|
||||
assert_eq!(scores.len(), 10);
|
||||
assert_eq!(scores[0].total, 300, "First should be highest score");
|
||||
assert_eq!(scores[1].total, 250, "Second should be second highest");
|
||||
assert_eq!(scores[2].total, 200, "Third should be third highest");
|
||||
@@ -446,26 +451,34 @@ mod tests {
|
||||
|
||||
let context = ScoringContext {
|
||||
query: "test",
|
||||
max_results: 3,
|
||||
max_threads: 1,
|
||||
max_typos: 2,
|
||||
current_file: None,
|
||||
reverse_order: false,
|
||||
last_same_query_match: None,
|
||||
project_path: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 0,
|
||||
},
|
||||
};
|
||||
|
||||
let (items, scores, _) = sort_and_truncate(results, &context);
|
||||
let (items, scores, _) = sort_and_paginate(results, &context);
|
||||
|
||||
// Should return: 200(9000), 200(1000), 100(8000)
|
||||
// Should return all 5 items sorted: 200(9000), 200(1000), 100(8000), 100(5000), 100(3000)
|
||||
assert_eq!(scores.len(), 5);
|
||||
assert_eq!(scores[0].total, 200);
|
||||
assert_eq!(items[0].modified, 9000, "First 200 should be newest");
|
||||
assert_eq!(scores[1].total, 200);
|
||||
assert_eq!(items[1].modified, 1000, "Second 200 should be older");
|
||||
assert_eq!(scores[2].total, 100);
|
||||
assert_eq!(items[2].modified, 8000, "Third should be newest of 100s");
|
||||
assert_eq!(items[2].modified, 8000, "First 100 should be newest");
|
||||
assert_eq!(scores[3].total, 100);
|
||||
assert_eq!(items[3].modified, 5000);
|
||||
assert_eq!(scores[4].total, 100);
|
||||
assert_eq!(items[4].modified, 3000, "Last 100 should be oldest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -484,66 +497,29 @@ mod tests {
|
||||
|
||||
let context = ScoringContext {
|
||||
query: "test",
|
||||
max_results: 2,
|
||||
max_threads: 1,
|
||||
max_typos: 2,
|
||||
current_file: None,
|
||||
reverse_order: false,
|
||||
last_same_query_match: None,
|
||||
project_path: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 0,
|
||||
},
|
||||
};
|
||||
|
||||
// threshold = 2 * 2 = 4, len = 3 < 4, so regular sort
|
||||
let (items, scores, _) = sort_and_truncate(results, &context);
|
||||
// Returns all results sorted descending
|
||||
let (items, scores, _) = sort_and_paginate(results, &context);
|
||||
|
||||
assert_eq!(scores.len(), 2);
|
||||
assert_eq!(scores.len(), 3);
|
||||
assert_eq!(scores[0].total, 200);
|
||||
assert_eq!(scores[1].total, 100);
|
||||
assert_eq!(scores[2].total, 50);
|
||||
assert_eq!(items[0].relative_path, "file2.rs");
|
||||
assert_eq!(items[1].relative_path, "file1.rs");
|
||||
}
|
||||
|
||||
#[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,
|
||||
last_same_query_match: None,
|
||||
project_path: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
};
|
||||
|
||||
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");
|
||||
assert_eq!(items[2].relative_path, "file3.rs");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,18 +32,23 @@ pub struct Score {
|
||||
pub match_type: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PaginationArgs {
|
||||
pub offset: usize,
|
||||
pub limit: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScoringContext<'a> {
|
||||
pub query: &'a str,
|
||||
pub project_path: Option<&'a Path>,
|
||||
pub current_file: Option<&'a str>,
|
||||
pub max_results: usize,
|
||||
pub max_typos: u16,
|
||||
pub max_threads: usize,
|
||||
pub reverse_order: bool,
|
||||
pub last_same_query_match: Option<&'a QueryMatchEntry>,
|
||||
pub combo_boost_score_multiplier: i32,
|
||||
pub min_combo_count: u32,
|
||||
pub pagination: PaginationArgs,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
--- Scrollbar module for pagination indicator
|
||||
local M = {}
|
||||
|
||||
-- Internal state
|
||||
local scrollbar_state = {
|
||||
win = nil,
|
||||
buf = nil,
|
||||
ever_shown = false,
|
||||
}
|
||||
|
||||
local ns_id = vim.api.nvim_create_namespace('fff_scrollbar')
|
||||
|
||||
--- Render the scrollbar to show current page position
|
||||
--- Creates the window lazily if needed
|
||||
--- @param layout table Layout info with list_col, list_row, list_width, list_height, show_scrollbar
|
||||
--- @param config table Config with hl (highlight groups)
|
||||
--- @param list_win number List window handle
|
||||
--- @param pagination table Pagination state with page_index, page_size, total_matched
|
||||
function M.render(layout, config, list_win, pagination)
|
||||
if layout.show_scrollbar == false then return end
|
||||
|
||||
-- this is the most often path, we don't want to show scrollbar if use doesn't scrolling
|
||||
if not scrollbar_state.ever_shown and pagination.page_index == 0 then return end
|
||||
|
||||
local total_pages = pagination.page_size > 0 and math.ceil(pagination.total_matched / pagination.page_size) or 1
|
||||
local has_multiple_pages = total_pages > 1
|
||||
local scrollbar_exists = scrollbar_state.win and vim.api.nvim_win_is_valid(scrollbar_state.win)
|
||||
|
||||
-- If only one page, hide existing scrollbar and return
|
||||
if not has_multiple_pages then
|
||||
if scrollbar_exists then pcall(vim.api.nvim_win_hide, scrollbar_state.win) end
|
||||
return
|
||||
end
|
||||
|
||||
-- rendering in a separate buffer to overflow the border
|
||||
if not scrollbar_exists then
|
||||
scrollbar_state.buf = vim.api.nvim_create_buf(false, true)
|
||||
vim.api.nvim_buf_set_option(scrollbar_state.buf, 'bufhidden', 'wipe')
|
||||
|
||||
scrollbar_state.win = vim.api.nvim_open_win(scrollbar_state.buf, false, {
|
||||
relative = 'editor',
|
||||
width = 1,
|
||||
height = layout.list_height,
|
||||
col = layout.list_col + layout.list_width + 1,
|
||||
row = layout.list_row + 1,
|
||||
border = 'none',
|
||||
style = 'minimal',
|
||||
focusable = false,
|
||||
})
|
||||
|
||||
local scrollbar_hl = string.format('Normal:%s', config.hl.border)
|
||||
vim.api.nvim_win_set_option(scrollbar_state.win, 'winhighlight', scrollbar_hl)
|
||||
|
||||
scrollbar_state.ever_shown = true
|
||||
end
|
||||
|
||||
if not scrollbar_state.buf or not vim.api.nvim_buf_is_valid(scrollbar_state.buf) then return end
|
||||
pcall(vim.api.nvim_win_set_config, scrollbar_state.win, { hide = false })
|
||||
|
||||
local win_height = vim.api.nvim_win_get_height(list_win)
|
||||
|
||||
local thumb_size = math.max(1, math.floor(win_height / total_pages))
|
||||
local scrollbar_range = win_height - thumb_size
|
||||
local thumb_start = math.floor((pagination.page_index / math.max(1, total_pages - 1)) * scrollbar_range)
|
||||
|
||||
local lines = {}
|
||||
for i = 1, win_height do
|
||||
if i >= thumb_start + 1 and i < thumb_start + thumb_size + 1 then
|
||||
table.insert(lines, '▊') -- Thick block for thumb
|
||||
else
|
||||
table.insert(lines, '│') -- Thin line for track
|
||||
end
|
||||
end
|
||||
|
||||
pcall(vim.api.nvim_buf_set_option, scrollbar_state.buf, 'modifiable', true)
|
||||
pcall(vim.api.nvim_buf_set_lines, scrollbar_state.buf, 0, -1, false, lines)
|
||||
pcall(vim.api.nvim_buf_set_option, scrollbar_state.buf, 'modifiable', false)
|
||||
|
||||
pcall(vim.api.nvim_buf_clear_namespace, scrollbar_state.buf, ns_id, 0, -1)
|
||||
if thumb_size > 0 then
|
||||
pcall(vim.api.nvim_buf_set_extmark, scrollbar_state.buf, ns_id, thumb_start, 0, {
|
||||
end_row = thumb_start + thumb_size,
|
||||
end_col = 0,
|
||||
hl_group = config.hl.scrollbar,
|
||||
hl_eol = true,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
function M.cleanup()
|
||||
if scrollbar_state.win and vim.api.nvim_win_is_valid(scrollbar_state.win) then
|
||||
pcall(vim.api.nvim_win_close, scrollbar_state.win, true)
|
||||
end
|
||||
|
||||
if scrollbar_state.buf and vim.api.nvim_buf_is_valid(scrollbar_state.buf) then
|
||||
pcall(vim.api.nvim_buf_delete, scrollbar_state.buf, { force = true })
|
||||
end
|
||||
|
||||
scrollbar_state.win = nil
|
||||
scrollbar_state.buf = nil
|
||||
scrollbar_state.ever_shown = false
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -91,14 +91,16 @@ fn main() {
|
||||
&files,
|
||||
query,
|
||||
fff_nvim::file_picker::FuzzySearchOptions {
|
||||
max_results: 100,
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
reverse_order: false,
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: fff_nvim::types::PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
},
|
||||
},
|
||||
);
|
||||
match_count += results.total_matched;
|
||||
|
||||
@@ -87,14 +87,16 @@ fn test_search_memory_pattern(
|
||||
picker.get_files(),
|
||||
&query,
|
||||
fff_nvim::file_picker::FuzzySearchOptions {
|
||||
max_results: 50 + (i % 50),
|
||||
max_threads: 1 + (i % 4),
|
||||
current_file: None,
|
||||
reverse_order: false,
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: fff_nvim::types::PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 50 + (i % 50),
|
||||
},
|
||||
},
|
||||
);
|
||||
(search_result.items.len(), search_result.total_matched)
|
||||
|
||||
@@ -125,14 +125,16 @@ fn main() {
|
||||
&files,
|
||||
query,
|
||||
fff_nvim::file_picker::FuzzySearchOptions {
|
||||
max_results: 100,
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
reverse_order: false,
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: fff_nvim::types::PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -203,14 +203,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
picker.get_files(),
|
||||
query,
|
||||
fff_nvim::file_picker::FuzzySearchOptions {
|
||||
max_results,
|
||||
max_threads,
|
||||
current_file: None,
|
||||
reverse_order: false,
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: fff_nvim::types::PaginationArgs {
|
||||
offset: 0,
|
||||
limit: max_results,
|
||||
},
|
||||
},
|
||||
);
|
||||
let duration = search_start.elapsed();
|
||||
|
||||
@@ -160,14 +160,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
files,
|
||||
"rs",
|
||||
fff_nvim::file_picker::FuzzySearchOptions {
|
||||
max_results: 5,
|
||||
max_threads: 2,
|
||||
current_file: None,
|
||||
reverse_order: false,
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: fff_nvim::types::PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 5,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user