feat: Pagination (#194)

closes https://github.com/dmtrKovalenko/fff.nvim/issues/83
This commit is contained in:
Dmitriy Kovalenko
2025-12-15 16:37:37 -08:00
committed by GitHub
parent c477f12487
commit 7fd361a369
20 changed files with 1059 additions and 464 deletions
Generated
+115
View File
@@ -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"
+1
View File
@@ -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"
+2
View File
@@ -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
View File
@@ -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);
+23 -96
View File
@@ -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
View File
@@ -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
-- {
+2
View File
@@ -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',
+27 -10
View File
@@ -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
View File
@@ -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,
+9 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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");
}
}
+7 -2
View File
@@ -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)]
+104
View File
@@ -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
+4 -2
View File
@@ -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;
+4 -2
View File
@@ -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)
+4 -2
View File
@@ -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,
},
},
);
+4 -2
View File
@@ -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();
+4 -2
View File
@@ -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,
},
},
);