From 192b11b32339e51bd7470a2c892dad766d51082d Mon Sep 17 00:00:00 2001 From: Dmitriy Kovalenko Date: Mon, 3 Nov 2025 11:34:29 -0800 Subject: [PATCH] feat: Backend for combo match --- Cargo.lock | 80 ++++++- Cargo.toml | 8 + README.md | 26 +-- benches/query_tracker_bench.rs | 297 +++++++++++++++++++++++++ lua/fff/combo_renderer.lua | 202 +++++++++++++++++ lua/fff/conf.lua | 9 +- lua/fff/core.lua | 8 +- lua/fff/file_picker/init.lua | 40 ++-- lua/fff/fuzzy.lua | 6 + lua/fff/main.lua | 10 +- lua/fff/picker_ui.lua | 321 ++++++++++++++++++++------ lua/fff/rust/file_picker.rs | 39 +++- lua/fff/rust/lib.rs | 170 ++++++++++++-- lua/fff/rust/{tracing.rs => log.rs} | 0 lua/fff/rust/query_tracker.rs | 334 ++++++++++++++++++++++++++++ lua/fff/rust/score.rs | 48 +++- lua/fff/rust/types.rs | 10 +- src/bin/bench_search_only.rs | 16 +- src/bin/jemalloc_profile.rs | 14 +- src/bin/search_profiler.rs | 17 +- src/bin/test_memory_leak.rs | 14 +- src/bin/test_watcher.rs | 15 +- 22 files changed, 1520 insertions(+), 164 deletions(-) create mode 100644 benches/query_tracker_bench.rs create mode 100644 lua/fff/combo_renderer.lua rename lua/fff/rust/{tracing.rs => log.rs} (100%) create mode 100644 lua/fff/rust/query_tracker.rs diff --git a/Cargo.lock b/Cargo.lock index 669997b3..ff536a39 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,19 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.3", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -373,6 +386,7 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" name = "fff_nvim" version = "0.1.0" dependencies = [ + "ahash", "blake3", "chrono", "criterion", @@ -389,7 +403,10 @@ dependencies = [ "once_cell", "openssl", "pathdiff", + "rand", "rayon", + "serde", + "smartstring", "tempfile", "thiserror 2.0.12", "tracing", @@ -439,6 +456,17 @@ dependencies = [ "libc", ] +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + [[package]] name = "getrandom" version = "0.3.3" @@ -739,7 +767,7 @@ version = "0.1.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a" dependencies = [ - "getrandom", + "getrandom 0.3.3", "libc", ] @@ -1268,6 +1296,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.95" @@ -1298,6 +1335,18 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", "rand_core", ] @@ -1306,6 +1355,9 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] [[package]] name = "rayon" @@ -1485,12 +1537,30 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smartstring" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" +dependencies = [ + "autocfg", + "serde", + "static_assertions", + "version_check", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "syn" version = "2.0.104" @@ -1535,7 +1605,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.3.3", "once_cell", "rustix", "windows-sys 0.59.0", @@ -1749,6 +1819,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index 60ea3704..bbdc4291 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,12 @@ path = "src/bin/search_profiler.rs" name = "bench_search_only" path = "src/bin/bench_search_only.rs" +[[bench]] +name = "query_tracker_bench" +harness = false + [dependencies] +ahash = "0.8" blake3 = "1.8.2" chrono = { version = "0.4", features = ["serde"] } ctrlc = "3.4.2" @@ -40,6 +45,8 @@ once_cell = "1.20.2" openssl = { version = "0.10", features = ["vendored"] } pathdiff = "0.2.1" rayon = "1.8.0" +serde = { version = "1.0", features = ["derive"] } +smartstring = { version = "1.0.1", features = ["serde"] } thiserror = "2.0.10" tracing = "0.1" tracing-appender = "0.2" @@ -47,6 +54,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } [dev-dependencies] criterion = { version = "0.5", features = ["html_reports"] } +rand = { version = "0.8", features = ["small_rng"] } tempfile = "3.8" [[bench]] diff --git a/README.md b/README.md index cdb9e1c8..d4d34092 100644 --- a/README.md +++ b/README.md @@ -82,8 +82,8 @@ vim.pack.add({ 'https://github.com/dmtrKovalenko/fff.nvim' }) nvim.create_autocmd('PackChanged', { callback = function(event) - if event.data.updated then - require('fff.download').download_or_build_binary() + if event.data.updated then + require('fff.download').download_or_build_binary() end end, }) @@ -145,11 +145,14 @@ require('fff').setup({ select_split = '', select_vsplit = '', select_tab = '', + -- you can assign multiple keys to any action move_up = { '', '' }, move_down = { '', '' }, preview_scroll_up = '', preview_scroll_down = '', toggle_debug = '', + -- goes to the previous query in history + cycle_previous_query = '', }, hl = { border = 'FloatBorder', @@ -162,10 +165,18 @@ require('fff').setup({ frecency = 'Number', debug = 'Comment', }, + -- Store file open frecency frecency = { enabled = true, db_path = vim.fn.stdpath('cache') .. '/fff_nvim', }, + -- Store successfully opened queries with respective matches + history = { + enabled = true, + db_path = vim.fn.stdpath('data') .. '/fff_queries', + min_combo_count = 3, -- file will get a boost if it was selected 3 in a row times per specific query + combo_boost_score_multiplier = 100, -- Score multiplier for combo matches + }, debug = { enabled = false, -- Set to true to show scores in the UI show_scores = false, @@ -203,17 +214,6 @@ FFF.nvim provides several commands for interacting with the file picker: - `:FFFDebug [on|off|toggle]` - Toggle debug scores display - `:FFFOpenLog` - Open the FFF log file in a new tab -#### Multiple Key Bindings - -You can assign multiple key combinations to the same action: - -```lua -keymaps = { - move_up = { '', '', '' }, -- Three ways to move up - close = { '', '' }, -- Two ways to close - select = '', -- Single binding still works -} -``` #### Multiline Paste Support diff --git a/benches/query_tracker_bench.rs b/benches/query_tracker_bench.rs new file mode 100644 index 00000000..c4ce174c --- /dev/null +++ b/benches/query_tracker_bench.rs @@ -0,0 +1,297 @@ +use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main}; +use fff_nvim::query_tracker::{QueryMatchEntry, QueryTracker}; +use rand::distributions::Alphanumeric; +use rand::prelude::*; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn generate_random_string(len: usize) -> String { + thread_rng() + .sample_iter(&Alphanumeric) + .take(len) + .map(char::from) + .collect() +} + +fn generate_test_data(num_entries: usize) -> Vec { + let mut rng = thread_rng(); + let mut entries = Vec::with_capacity(num_entries); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + // Generate some common queries that will be reused + let common_queries = vec![ + "main", + "test", + "config", + "utils", + "lib", + "mod", + "index", + "init", + "server", + "client", + "api", + "service", + "controller", + "model", + "view", + "component", + "handler", + "middleware", + "router", + "database", + "auth", + ]; + + // Generate some common project paths + let project_paths = vec![ + "/home/user/project1", + "/home/user/project2", + "/home/user/web-app", + "/home/user/cli-tool", + "/home/user/library", + ]; + + for _ in 0..num_entries { + let query = if rng.gen_bool(0.7) { + // 70% chance to use common query + common_queries.choose(&mut rng).unwrap().to_string() + } else { + // 30% chance to use random query + generate_random_string(rng.gen_range(3..15)) + }; + + let project_path = project_paths.choose(&mut rng).unwrap(); + let file_name = format!( + "{}.{}", + generate_random_string(rng.gen_range(5..20)), + if rng.gen_bool(0.5) { "rs" } else { "js" } + ); + let file_path = PathBuf::from(format!("{}/src/{}", project_path, file_name)); + + let entry = QueryMatchEntry { + query: query.into(), + project_path: PathBuf::from(project_path), + file_path, + open_count: rng.gen_range(1..10), + last_opened: now - rng.gen_range(0..30 * 24 * 3600), // Random time within last 30 days + }; + + entries.push(entry); + } + + entries +} + +fn setup_tracker_with_data(entries: &[QueryMatchEntry]) -> (QueryTracker, PathBuf) { + use std::time::{SystemTime, UNIX_EPOCH}; + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_dir = + std::env::temp_dir().join(format!("fff_bench_{}_{}", timestamp, rand::random::())); + let mut tracker = QueryTracker::new(temp_dir.to_str().unwrap(), true).unwrap(); + + // Insert all test data + for entry in entries { + for _ in 0..entry.open_count { + tracker + .track_query_completion(&entry.query, &entry.project_path, &entry.file_path) + .unwrap(); + } + } + + (tracker, temp_dir) +} + +fn cleanup_tracker_dir(dir: PathBuf) { + if dir.exists() { + let _ = std::fs::remove_dir_all(dir); + } +} + +fn bench_track_query_completion(c: &mut Criterion) { + let mut group = c.benchmark_group("track_query_completion"); + + for size in &[100, 1000, 10000] { + let entries = generate_test_data(*size); + let (mut tracker, temp_dir) = setup_tracker_with_data(&entries[..*size / 2]); // Pre-populate with half + + group.bench_with_input(BenchmarkId::new("entries", size), size, |b, _| { + let mut rng = thread_rng(); + b.iter(|| { + let entry = entries.choose(&mut rng).unwrap(); + black_box( + tracker + .track_query_completion( + black_box(&entry.query), + black_box(&entry.project_path), + black_box(&entry.file_path), + ) + .unwrap(), + ); + }); + }); + + drop(tracker); + cleanup_tracker_dir(temp_dir); + } + + group.finish(); +} + +fn bench_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"); + + for size in &[1000, 10000] { + let entries = generate_test_data(*size); + let (mut tracker, temp_dir) = setup_tracker_with_data(&entries); + + group.bench_with_input(BenchmarkId::new("mixed_operations", size), size, |b, _| { + let mut rng = thread_rng(); + b.iter(|| { + let entry = entries.choose(&mut rng).unwrap(); + + // Simulate realistic usage: 70% lookups, 25% tracking, 5% history + match rng.gen_range(0..100) { + 0..70 => { + // Query boost lookup (most common operation) + 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); + } + 70..95 => { + // Track completion (when user opens file) + black_box( + tracker + .track_query_completion( + black_box(&entry.query), + black_box(&entry.project_path), + black_box(&entry.file_path), + ) + .unwrap(), + ); + } + 95..100 => { + // Get history (least common) + let history = black_box( + tracker + .get_query_history(black_box(&entry.project_path), black_box(20)) + .unwrap(), + ); + black_box(history); + } + _ => unreachable!(), + } + }); + }); + + drop(tracker); + cleanup_tracker_dir(temp_dir); + } + + group.finish(); +} + +criterion_group!( + benches, + bench_track_query_completion, + bench_get_query_boost, + bench_get_query_history, + bench_cleanup_old_entries, + bench_realistic_workload +); +criterion_main!(benches); diff --git a/lua/fff/combo_renderer.lua b/lua/fff/combo_renderer.lua new file mode 100644 index 00000000..b1f8da6d --- /dev/null +++ b/lua/fff/combo_renderer.lua @@ -0,0 +1,202 @@ +local M = {} + +local overlay_state = { + left_buf = nil, + left_win = nil, + right_buf = nil, + right_win = nil, + ns_id = nil, + -- Cache last position to avoid unnecessary updates + last_row = nil, + last_col = nil, + last_border_hl = nil, +} + +local LEFT_OVERLAY_CONTENT = '├────' +local RIGHT_OVERLAY_CONTENT = '─┤' +local LEFT_OVERLAY_WIDTH = vim.fn.strdisplaywidth(LEFT_OVERLAY_CONTENT) +local LEFT_HEADER_PADDING = LEFT_OVERLAY_WIDTH - 2 +local RIGHT_OVERLAY_WIDTH = vim.fn.strdisplaywidth(RIGHT_OVERLAY_CONTENT) + +local COMBO_TEXT_FORMAT = 'Last Match (×%d combo) ' +local LAST_MATCH_TEXT_FORMAT = 'Last Match ' + +function M.init(ns_id) overlay_state.ns_id = ns_id end + +local function detect_combo_item(items, file_picker, combo_boost_score_multiplier) + if not items or #items == 0 then return nil, 0 end + + local first_score = file_picker.get_file_score(1) + local last_score = file_picker.get_file_score(#items) + + if first_score.combo_match_boost > combo_boost_score_multiplier then + return 1, first_score.combo_match_boost / combo_boost_score_multiplier + elseif last_score.combo_match_boost > combo_boost_score_multiplier then + return #items, last_score.combo_match_boost / combo_boost_score_multiplier + end + + return nil, 0 +end + +local function create_header_text(combo_count, win_width, disable_combo_display) + local combo_text = nil + if disable_combo_display then + combo_text = LAST_MATCH_TEXT_FORMAT + else + combo_text = string.format(COMBO_TEXT_FORMAT, combo_count) + end + + local text_len = vim.fn.strdisplaywidth(combo_text) + local available_for_content = win_width - LEFT_HEADER_PADDING - RIGHT_OVERLAY_WIDTH + local remaining_dashes = math.max(0, available_for_content - text_len) + + return string.rep(' ', LEFT_HEADER_PADDING) .. combo_text .. string.rep('─', remaining_dashes), text_len +end + +local function apply_header_highlights(buf, ns_id, line_idx, text_len, border_hl) + vim.api.nvim_buf_add_highlight(buf, ns_id, border_hl, line_idx - 1, 0, -1) + vim.api.nvim_buf_add_highlight( + buf, + ns_id, + 'Number', + line_idx - 1, + LEFT_HEADER_PADDING, + LEFT_HEADER_PADDING + text_len + ) +end + +local function get_or_create_overlay_buf(state_key) + if not overlay_state[state_key] or not vim.api.nvim_buf_is_valid(overlay_state[state_key]) then + overlay_state[state_key] = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_option(overlay_state[state_key], 'bufhidden', 'wipe') + end + return overlay_state[state_key] +end + +local function update_overlay_content(buf, content, border_hl) + -- Batch all buffer operations together for performance + vim.api.nvim_buf_set_option(buf, 'modifiable', true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { content }) + vim.api.nvim_buf_clear_namespace(buf, overlay_state.ns_id, 0, -1) + vim.api.nvim_buf_add_highlight(buf, overlay_state.ns_id, border_hl, 0, 0, -1) + vim.api.nvim_buf_set_option(buf, 'modifiable', false) +end + +local function position_overlay_window(state_key, buf, width, row, col) + local win_config = { + relative = 'editor', + width = width, + height = 1, + row = row, + col = col, + style = 'minimal', + focusable = false, + zindex = 250, + } + + if overlay_state[state_key] and vim.api.nvim_win_is_valid(overlay_state[state_key]) then + vim.api.nvim_win_set_config(overlay_state[state_key], win_config) + else + overlay_state[state_key] = vim.api.nvim_open_win(buf, false, win_config) + end + + vim.api.nvim_win_set_option(overlay_state[state_key], 'winhl', 'Normal:Normal') +end + +local function update_overlays(list_win, combo_header_line, border_hl) + local list_config = vim.api.nvim_win_get_config(list_win) + local combo_header_row = list_config.row + combo_header_line + + -- Skip update if position and highlight haven't changed + if + overlay_state.last_row == combo_header_row + and overlay_state.last_col == list_config.col + and overlay_state.last_border_hl == border_hl + and overlay_state.left_win + and vim.api.nvim_win_is_valid(overlay_state.left_win) + and overlay_state.right_win + and vim.api.nvim_win_is_valid(overlay_state.right_win) + then + return + end + + -- Cache current values + overlay_state.last_row = combo_header_row + overlay_state.last_col = list_config.col + overlay_state.last_border_hl = border_hl + + -- Update both overlays in a batch to minimize API calls + local left_buf = get_or_create_overlay_buf('left_buf') + local right_buf = get_or_create_overlay_buf('right_buf') + + -- Update content for both buffers + update_overlay_content(left_buf, LEFT_OVERLAY_CONTENT, border_hl) + update_overlay_content(right_buf, RIGHT_OVERLAY_CONTENT, border_hl) + + -- Position both windows + position_overlay_window('left_win', left_buf, LEFT_OVERLAY_WIDTH, combo_header_row, list_config.col) + position_overlay_window( + 'right_win', + right_buf, + RIGHT_OVERLAY_WIDTH, + combo_header_row, + list_config.col + list_config.width + ) +end + +local function clear_overlays_internal() + if overlay_state.left_win and vim.api.nvim_win_is_valid(overlay_state.left_win) then + vim.api.nvim_win_close(overlay_state.left_win, true) + overlay_state.left_win = nil + end + + if overlay_state.right_win and vim.api.nvim_win_is_valid(overlay_state.right_win) then + vim.api.nvim_win_close(overlay_state.right_win, true) + overlay_state.right_win = nil + end + + -- Clear cache + overlay_state.last_row = nil + overlay_state.last_col = nil + overlay_state.last_border_hl = nil +end + +function M.detect_and_prepare(items, file_picker, win_width, combo_boost_score_multiplier, disable_combo_display) + local combo_item_index, combo_count = detect_combo_item(items, file_picker, combo_boost_score_multiplier) + + if not combo_item_index then return false, nil, 0, nil end + + local header_line, text_len = create_header_text(combo_count, win_width, disable_combo_display) + return true, header_line, text_len, combo_item_index +end + +function M.render_highlights_and_overlays( + combo_item_index, + text_len, + list_buf, + list_win, + ns_id, + border_hl, + item_to_lines +) + if not combo_item_index then + clear_overlays_internal() + return + end + + local combo_item_lines = item_to_lines[combo_item_index] + if not combo_item_lines then + clear_overlays_internal() + return + end + + local combo_header_line_idx = combo_item_lines.first + apply_header_highlights(list_buf, ns_id, combo_header_line_idx, text_len, border_hl) + update_overlays(list_win, combo_header_line_idx, border_hl) +end + +function M.get_overlay_widths() return LEFT_OVERLAY_WIDTH, RIGHT_OVERLAY_WIDTH end + +function M.cleanup() clear_overlays_internal() end + +return M diff --git a/lua/fff/conf.lua b/lua/fff/conf.lua index c11d90fa..ae69af02 100644 --- a/lua/fff/conf.lua +++ b/lua/fff/conf.lua @@ -1,6 +1,6 @@ local M = {} ----@class fff.conf.State +--@class fff.conf.State local state = { ---@type table | nil config = nil, @@ -140,6 +140,7 @@ local function init() preview_scroll_up = '', preview_scroll_down = '', toggle_debug = '', + cycle_previous_query = '', }, hl = { border = 'FloatBorder', @@ -156,6 +157,12 @@ local function init() enabled = true, db_path = vim.fn.stdpath('cache') .. '/fff_nvim', }, + history = { + enabled = true, + db_path = vim.fn.stdpath('data') .. '/fff_queries', + min_combo_count = 3, -- Minimum selections before combo boost applies (3 = boost starts on 3rd selection) + combo_boost_score_multiplier = 100, -- Score multiplier for combo matches (files repeatedly opened with same query) + }, debug = { enabled = false, -- Set to true to show scores in the UI show_scores = false, diff --git a/lua/fff/core.lua b/lua/fff/core.lua index 02b100f1..0d47d798 100644 --- a/lua/fff/core.lua +++ b/lua/fff/core.lua @@ -82,9 +82,11 @@ M.ensure_initialized = function() end end - local db_path = config.frecency.db_path or (vim.fn.stdpath('cache') .. '/fff_nvim') - local ok, result = pcall(fuzzy.init_db, db_path, true) - if not ok then vim.notify('Failed to initialize frecency database: ' .. result, vim.log.levels.WARN) end + local frecency_db_path = config.frecency.db_path or (vim.fn.stdpath('cache') .. '/fff_frecency') + local history_db_path = config.history.db_path or (vim.fn.stdpath('data') .. '/fff_history') + + local ok, result = pcall(fuzzy.init_db, frecency_db_path, history_db_path, true) + if not ok then vim.notify('Failed to databases: ' .. result, vim.log.levels.WARN) end ok, result = pcall(fuzzy.init_file_picker, config.base_path) if not ok then diff --git a/lua/fff/file_picker/init.lua b/lua/fff/file_picker/init.lua index 76c75776..f363039c 100644 --- a/lua/fff/file_picker/init.lua +++ b/lua/fff/file_picker/init.lua @@ -11,17 +11,7 @@ M.state = { } function M.setup() - local db_path = vim.fn.stdpath('cache') .. '/fff_nvim' - local ok, result = pcall(fuzzy.init_db, db_path, true) - if not ok then vim.notify('Failed to initialize frecency database: ' .. result, vim.log.levels.WARN) end - local config = require('fff.conf').get() - ok, result = pcall(fuzzy.init_file_picker, config.base_path) - if not ok then - vim.notify('Failed to initialize file picker: ' .. result, vim.log.levels.ERROR) - return false - end - M.state.initialized = true M.state.base_path = config.base_path @@ -43,20 +33,34 @@ end --- Search files with fuzzy matching using blink.cmp's advanced algorithm --- @param query string Search query ---- @param max_results number Maximum number of results (optional) ---- @param max_threads number Maximum number of threads (optional) +--- @param max_results number|nil Maximum number of results (optional) +--- @param max_threads number|nil Maximum number of threads (optional) --- @param current_file string|nil Path to current file to deprioritize (optional) --- @param reverse_order boolean Reverse order of results +--- @param min_combo_count_override number|nil Optional override for min_combo_count (nil uses config) --- @return table List of matching files -function M.search_files(query, max_results, max_threads, current_file, reverse_order) +function M.search_files(query, current_file, max_results, max_threads, reverse_order, min_combo_count_override) local config = require('fff.conf').get() if not M.state.initialized then return {} end - max_results = max_results or config.max_results - max_threads = max_threads or config.max_threads - - local ok, search_result = - pcall(fuzzy.fuzzy_search_files, query, max_results, max_threads, current_file, reverse_order) + 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 + + -- 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 ok, search_result = pcall( + fuzzy.fuzzy_search_files, + query, + max_results, + max_threads, + current_file, + reverse_order, + combo_boost_score_multiplier, + min_combo_count + ) if not ok then vim.notify('Failed to search files: ' .. tostring(search_result), vim.log.levels.ERROR) return {} diff --git a/lua/fff/fuzzy.lua b/lua/fff/fuzzy.lua index aa8f85ca..fba32f97 100644 --- a/lua/fff/fuzzy.lua +++ b/lua/fff/fuzzy.lua @@ -33,4 +33,10 @@ M.cleanup_file_picker = rust_module.cleanup_file_picker M.init_tracing = rust_module.init_tracing M.wait_for_initial_scan = rust_module.wait_for_initial_scan +-- Query tracking functions +M.init_query_db = rust_module.init_query_db +M.destroy_query_db = rust_module.destroy_query_db +M.track_query_completion = rust_module.track_query_completion +M.get_historical_query = rust_module.get_historical_query + return M diff --git a/lua/fff/main.lua b/lua/fff/main.lua index 1662db1f..eb355363 100644 --- a/lua/fff/main.lua +++ b/lua/fff/main.lua @@ -15,7 +15,7 @@ function M.find_files() if picker_ok then picker_ui.open() else - vim.notify('Failed to load picker UI', vim.log.levels.ERROR) + vim.notify('Failed to load picker UI: ' .. picker_ui, vim.log.levels.ERROR) end end @@ -53,8 +53,12 @@ end --- @return table List of matching files function M.search(query, max_results) local fuzzy = require('fff.core').ensure_initialized() - max_results = max_results or require('fff.config').get().max_results - local ok, search_result = pcall(fuzzy.fuzzy_search_files, query, max_results, nil, nil) + local config = require('fff.conf').get() + max_results = max_results or config.max_results + local combo_boost_score_multiplier = config.history and config.history.combo_boost_score_multiplier or 100 + local min_combo_count = config.history and config.history.min_combo_count or 3 + local ok, search_result = + pcall(fuzzy.fuzzy_search_files, query, max_results, nil, nil, false, combo_boost_score_multiplier, min_combo_count) if ok and search_result.items then return search_result.items end return {} end diff --git a/lua/fff/picker_ui.lua b/lua/fff/picker_ui.lua index b5809ee9..8ef813ec 100644 --- a/lua/fff/picker_ui.lua +++ b/lua/fff/picker_ui.lua @@ -7,6 +7,7 @@ local icons = require('fff.file_picker.icons') local git_utils = require('fff.git_utils') local utils = require('fff.utils') local location_utils = require('fff.location_utils') +local combo_renderer = require('fff.combo_renderer') local function get_prompt_position() local config = M.state.config @@ -254,6 +255,10 @@ M.state = { item_line_map = {}, location = nil, -- Current location from search results + -- History cycling 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) + config = nil, ns_id = nil, @@ -270,7 +275,10 @@ M.state = { function M.create_ui() local config = M.state.config - if not M.state.ns_id then M.state.ns_id = vim.api.nvim_create_namespace('fff_picker_status') end + if not M.state.ns_id then + M.state.ns_id = vim.api.nvim_create_namespace('fff_picker_status') + combo_renderer.init(M.state.ns_id) + end local debug_enabled_in_preview = M.enabled_preview() and config and config.debug and config.debug.show_scores @@ -613,6 +621,7 @@ function M.setup_keymaps() set_keymap('i', keymaps.preview_scroll_up, M.scroll_preview_up, input_opts) set_keymap('i', keymaps.preview_scroll_down, M.scroll_preview_down, input_opts) set_keymap('i', keymaps.toggle_debug, M.toggle_debug, input_opts) + set_keymap('i', keymaps.cycle_previous_query, M.recall_query_from_history, input_opts) local list_opts = { buffer = M.state.list_buf, noremap = true, silent = true } @@ -765,12 +774,19 @@ function M.update_results_sync() dynamic_max_results = M.state.config.max_results or 100 end + -- 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( M.state.query, + M.state.current_file_cache, dynamic_max_results, M.state.config.max_threads, - M.state.current_file_cache, - prompt_position == 'bottom' + prompt_position == 'bottom', + min_combo_override ) -- Get location from search results @@ -874,6 +890,18 @@ local function format_file_display(item, max_width) return filename, display_path end +--- Calculate number of rows an item will occupy when rendered +--- @param item_index number Index of the item (1-based) +--- @param has_combo boolean Whether any combo boost exists +--- @param combo_item_index number|nil Index of the combo-boosted item +--- @return number Number of rows (1 or 2 currently) +local function get_item_row_count(item_index, has_combo, combo_item_index) + if has_combo and item_index == combo_item_index then + return 2 -- Combo header line + content line + end + return 1 -- Just content line +end + function M.render_list() if not M.state.active then return end @@ -883,33 +911,67 @@ function M.render_list() local debug_enabled = config and config.debug and config.debug.show_scores local win_height = vim.api.nvim_win_get_height(M.state.list_win) local win_width = vim.api.nvim_win_get_width(M.state.list_win) - local display_count = math.min(#items, win_height) local empty_lines_needed = 0 - local prompt_position = get_prompt_position() - local cursor_line = 0 + local combo_boost_score_multiplier = config.history and config.history.combo_boost_score_multiplier or 100 + local has_combo, combo_header_line, combo_header_text_len, combo_item_index = combo_renderer.detect_and_prepare( + items, + file_picker, + win_width, + combo_boost_score_multiplier, + -- disable rendering of combos if cycling through history or user wants to always show the last match + M.state.next_search_force_combo_boost or config.history.min_combo_count == 0 + ) + M.state.next_search_force_combo_boost = false -- effectively reset if set by the history recall + + -- Calculate how many items fit (accounting for multi-row items) + local display_count = 0 + local accumulated_rows = 0 if #items > 0 then - if prompt_position == 'bottom' then - empty_lines_needed = win_height - display_count - cursor_line = empty_lines_needed + M.state.cursor - else - cursor_line = M.state.cursor + display_count = 1 -- Always show at least first item, even if it exceeds win_height + accumulated_rows = get_item_row_count(1, has_combo, combo_item_index) + + for i = 2, #items do + local item_rows = get_item_row_count(i, has_combo, combo_item_index) + if accumulated_rows + item_rows > win_height then + break -- Next item won't fit + end + accumulated_rows = accumulated_rows + item_rows + display_count = i end - cursor_line = math.max(1, math.min(cursor_line, win_height)) end - local padded_lines = {} - if prompt_position == 'bottom' then - for _ = 1, empty_lines_needed do - table.insert(padded_lines, string.rep(' ', win_width + 5)) - end + local prompt_position = get_prompt_position() + + -- Calculate which items to display based on prompt position + local display_start = 1 + local display_end = display_count + + 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 + end + + local padded_lines = {} local icon_data = {} local path_data = {} + local item_to_lines = {} -- Maps item index to its line indices {first_line, last_line} - for i = 1, display_count do + for i = display_start, display_end do local item = items[i] + local item_start_line = #padded_lines + 1 + + -- For combo items, insert header first + if has_combo and combo_item_index and i == combo_item_index then table.insert(padded_lines, combo_header_line) end local icon, icon_hl_group = icons.get_icon_display(item.name, item.extension, false) icon_data[i] = { icon, icon_hl_group } @@ -945,6 +1007,43 @@ function M.render_list() local line_len = vim.fn.strdisplaywidth(line) local padding = math.max(0, win_width - line_len + 5) table.insert(padded_lines, line .. string.rep(' ', padding)) + + -- Record line range for this item + local item_end_line = #padded_lines + item_to_lines[i] = { + first = item_start_line, + last = item_end_line, + } + end + + -- Handle bottom positioning: add empty lines at the top + local empty_line_offset = 0 + if prompt_position == 'bottom' then + local total_content_lines = #padded_lines + empty_lines_needed = math.max(0, win_height - total_content_lines) + + if empty_lines_needed > 0 then + -- Insert empty lines at the beginning + for i = empty_lines_needed, 1, -1 do + table.insert(padded_lines, 1, string.rep(' ', win_width + 5)) + end + empty_line_offset = empty_lines_needed + + -- Adjust item_to_lines mapping + for i = display_start, display_end do + if item_to_lines[i] then + item_to_lines[i].first = item_to_lines[i].first + empty_line_offset + item_to_lines[i].last = item_to_lines[i].last + empty_line_offset + end + end + end + end + + -- Calculate cursor line based on current item + local cursor_line = 0 + if #items > 0 and M.state.cursor >= 1 and M.state.cursor <= #items then + local cursor_item = item_to_lines[M.state.cursor] + if cursor_item then cursor_line = cursor_item.last end end vim.api.nvim_buf_set_option(M.state.list_buf, 'modifiable', true) @@ -953,36 +1052,35 @@ function M.render_list() vim.api.nvim_buf_clear_namespace(M.state.list_buf, M.state.ns_id, 0, -1) + -- Set cursor position if #items > 0 and cursor_line > 0 and cursor_line <= win_height then vim.api.nvim_win_set_cursor(M.state.list_win, { cursor_line, 0 }) + end - -- Cursor line highlighting - vim.api.nvim_buf_add_highlight( - M.state.list_buf, - M.state.ns_id, - M.state.config.hl.active_file, - cursor_line - 1, - 0, - -1 - ) - - -- Fill remaining width for cursor line - local current_line = padded_lines[cursor_line] or '' - local line_len = vim.fn.strdisplaywidth(current_line) - local remaining_width = math.max(0, win_width - line_len) - - if remaining_width > 0 then - vim.api.nvim_buf_set_extmark(M.state.list_buf, M.state.ns_id, cursor_line - 1, -1, { - virt_text = { { string.rep(' ', remaining_width), M.state.config.hl.active_file } }, - virt_text_pos = 'eol', - }) - end - - for i = 1, display_count do + -- Apply highlighting to all items + if #items > 0 then + 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 + + 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 - local line_idx = empty_lines_needed + i - local is_cursor_line = line_idx == cursor_line + -- 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 @@ -1034,36 +1132,56 @@ function M.render_list() end if is_current_file then - if not is_cursor_line 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 - local virt_text_hl = is_cursor_line and M.state.config.hl.active_file or 'Comment' + 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 border_char = ' ' - local border_hl = nil - if item.git_status and git_utils.should_show_border(item.git_status) then - border_char = git_utils.get_border_char(item.git_status) - if is_cursor_line then - border_hl = git_utils.get_border_highlight_selected(item.git_status) + 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 = M.state.config.hl.active_file + end else border_hl = git_utils.get_border_highlight(item.git_status) end - end - local final_border_hl = border_hl ~= '' and border_hl - or (is_cursor_line and M.state.config.hl.active_file or '') - - if final_border_hl ~= '' or is_cursor_line 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 = final_border_hl ~= '' and final_border_hl or M.state.config.hl.active_file, + sign_text = ' ', + sign_hl_group = M.state.config.hl.active_file, priority = 1000, }) end @@ -1080,7 +1198,19 @@ function M.render_list() ) end end + + ::continue:: end + + combo_renderer.render_highlights_and_overlays( + combo_item_index, + combo_header_text_len, + M.state.list_buf, + M.state.list_win, + M.state.ns_id, + M.state.config.hl.border, + item_to_lines + ) end end @@ -1284,6 +1414,54 @@ function M.scroll_preview_down() preview.scroll(scroll_lines) end +--- Reset history cycling state +function M.reset_history_state() + M.state.history_offset = nil + M.state.updating_from_history = false +end + +--- Recall query from history with temporary min_combo_count=0 +function M.recall_query_from_history() + if not M.state.active then return end + + -- Initialize offset on first press + if M.state.history_offset == nil then + M.state.history_offset = 0 + else + -- Increment offset for next query + M.state.history_offset = M.state.history_offset + 1 + end + + -- Fetch query at current offset from Rust + local fuzzy = require('fff.core').ensure_initialized() + local ok, query = pcall(fuzzy.get_historical_query, M.state.history_offset) + + if not ok or not query then + -- Reached end of history, wrap to beginning + M.state.history_offset = 0 + ok, query = pcall(fuzzy.get_historical_query, 0) + + if not ok or not query then + -- No history available at all + vim.notify('No query history available', vim.log.levels.INFO) + M.state.history_offset = nil + return + end + end + + M.state.next_search_force_combo_boost = true + + -- this is going to trigger the on_input_change handler with the normal search and render flow + vim.api.nvim_buf_set_lines(M.state.input_buf, 0, -1, false, { M.state.config.prompt .. query }) + + -- Position cursor at end + vim.schedule(function() + if M.state.active and M.state.input_win and vim.api.nvim_win_is_valid(M.state.input_win) then + vim.api.nvim_win_set_cursor(M.state.input_win, { 1, #M.state.config.prompt + #query }) + end + end) +end + --- Find the first visible window with a normal file buffer --- @return number|nil Window ID of the first suitable window, or nil if none found local function find_suitable_window() @@ -1333,6 +1511,7 @@ function M.select(action) local relative_path = vim.fn.fnamemodify(item.path, ':.') local location = M.state.location -- Capture location before closing + local query = M.state.query -- Capture query before closing for tracking vim.cmd('stopinsert') M.close() @@ -1357,10 +1536,19 @@ function M.select(action) vim.cmd('tabedit ' .. vim.fn.fnameescape(relative_path)) end - if location then - -- Use vim.schedule to ensure the file is fully loaded before jumping - vim.schedule(function() location_utils.jump_to_location(location) end) - end + -- Derive side effects on vim schedule to ensure they run after the file is opened + vim.schedule(function() + if location then location_utils.jump_to_location(location) end + + if query and query ~= '' then + local config = conf.get() + if config.history and config.history.enabled then + local fff = require('fff.core').ensure_initialized() + -- Track in background thread (non-blocking, handled by Rust) + pcall(fff.track_query_completion, query, item.path) + end + end + end) end function M.close() @@ -1369,6 +1557,8 @@ function M.close() vim.cmd('stopinsert') M.state.active = false + combo_renderer.cleanup() + local windows = { M.state.input_win, M.state.list_win, @@ -1421,7 +1611,7 @@ function M.close() M.state.last_preview_location = nil M.state.current_file_cache = nil M.state.location = nil - + M.reset_history_state() -- Clean up picker focus autocmds pcall(vim.api.nvim_del_augroup_by_name, 'fff_picker_focus') end @@ -1537,10 +1727,7 @@ function M.open_with_callback(query, callback, opts) if not merged_config then return false end local current_file_cache = get_current_file_cache(base_path) - - local max_results = merged_config.max_results or 100 - local max_threads = merged_config.max_threads or 4 - local results = file_picker.search_files(query, max_results, max_threads, current_file_cache, false) + local results = file_picker.search_files(query, nil, nil, current_file_cache, nil) local metadata = file_picker.get_search_metadata() local location = file_picker.get_search_location() diff --git a/lua/fff/rust/file_picker.rs b/lua/fff/rust/file_picker.rs index 2e402921..834c4cbb 100644 --- a/lua/fff/rust/file_picker.rs +++ b/lua/fff/rust/file_picker.rs @@ -3,6 +3,7 @@ use crate::error::Error; use crate::frecency::FrecencyTracker; use crate::git::GitStatusCache; use crate::location::parse_location; +use crate::query_tracker::QueryMatchEntry; use crate::score::match_and_score_files; use crate::types::{FileItem, ScoringContext, SearchResult}; use git2::{Repository, Status, StatusOptions}; @@ -18,6 +19,18 @@ use tracing::{Level, debug, error, info, warn}; 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, +} + #[derive(Debug, Clone)] struct FileSync { pub files: Vec, @@ -123,6 +136,10 @@ impl std::fmt::Debug for FilePicker { } impl FilePicker { + pub fn base_path(&self) -> &Path { + &self.base_path + } + pub fn git_root(&self) -> Option<&Path> { self.sync_data.git_workdir.as_deref() } @@ -162,17 +179,14 @@ impl FilePicker { pub fn fuzzy_search<'a>( files: &'a [FileItem], query: &'a str, - max_results: usize, - max_threads: usize, - current_file: Option<&'a str>, - reverse_order: bool, + options: FuzzySearchOptions<'a>, ) -> SearchResult<'a> { - let max_threads = max_threads.max(1); + let max_threads = options.max_threads.max(1); debug!( ?query, - ?max_results, + max_results = ?options.max_results, ?max_threads, - ?current_file, + current_file = ?options.current_file, "Fuzzy search", ); @@ -183,14 +197,19 @@ impl FilePicker { let max_typos = (query.len() as u16 / 4).clamp(2, 6); let context = ScoringContext { query, + project_path: options.project_path, max_typos, max_threads, - current_file, - max_results, - reverse_order, + current_file: options.current_file, + 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, }; let time = std::time::Instant::now(); + let (items, scores, total_matched) = match_and_score_files(files, &context); debug!( diff --git a/lua/fff/rust/lib.rs b/lua/fff/rust/lib.rs index bacc286b..c9e8a0a9 100644 --- a/lua/fff/rust/lib.rs +++ b/lua/fff/rust/lib.rs @@ -1,6 +1,7 @@ use crate::error::Error; -use crate::file_picker::FilePicker; +use crate::file_picker::{FilePicker, FuzzySearchOptions}; use crate::frecency::FrecencyTracker; +use crate::query_tracker::QueryTracker; use mlua::prelude::*; use once_cell::sync::Lazy; use std::path::PathBuf; @@ -13,10 +14,11 @@ pub mod file_picker; mod frecency; pub mod git; mod location; +mod log; mod path_utils; +pub mod query_tracker; pub mod score; -pub mod sort_buffer; -mod tracing; +mod sort_buffer; pub mod types; use mimalloc::MiMalloc; @@ -25,22 +27,47 @@ static GLOBAL: MiMalloc = MiMalloc; pub static FRECENCY: Lazy>> = Lazy::new(|| RwLock::new(None)); pub static FILE_PICKER: Lazy>> = Lazy::new(|| RwLock::new(None)); +pub static QUERY_TRACKER: Lazy>> = Lazy::new(|| RwLock::new(None)); -pub fn init_db(_: &Lua, (db_path, use_unsafe_no_lock): (String, bool)) -> LuaResult { +pub fn init_db( + _: &Lua, + (frecency_db_path, history_db_path, use_unsafe_no_lock): (String, String, bool), +) -> LuaResult { let mut frecency = FRECENCY.write().map_err(|_| Error::AcquireFrecencyLock)?; if frecency.is_some() { - return Ok(false); + *frecency = None; + } + *frecency = Some(FrecencyTracker::new(&frecency_db_path, use_unsafe_no_lock)?); + tracing::info!("Frecency database initialized at {}", frecency_db_path); + + let mut query_tracker = QUERY_TRACKER + .write() + .map_err(|_| Error::AcquireFrecencyLock)?; + if query_tracker.is_some() { + *query_tracker = None; } - *frecency = Some(FrecencyTracker::new(&db_path, use_unsafe_no_lock)?); + + let tracker = QueryTracker::new(&history_db_path, use_unsafe_no_lock)?; + *query_tracker = Some(tracker); + tracing::info!("Query tracker database initialized at {}", history_db_path); + Ok(true) } -pub fn destroy_db(_: &Lua, _: ()) -> LuaResult { +pub fn destroy_frecency_db(_: &Lua, _: ()) -> LuaResult { let mut frecency = FRECENCY.write().map_err(|_| Error::AcquireFrecencyLock)?; *frecency = None; Ok(true) } +pub fn destroy_query_db(_: &Lua, _: ()) -> LuaResult { + let mut query_tracker = QUERY_TRACKER + .write() + .map_err(|_| Error::AcquireFrecencyLock)?; + *query_tracker = None; + Ok(true) +} + pub fn init_file_picker(_: &Lua, base_path: String) -> LuaResult { let mut file_picker = FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)?; if file_picker.is_some() { @@ -96,25 +123,60 @@ pub fn scan_files(_: &Lua, _: ()) -> LuaResult<()> { pub fn fuzzy_search_files( lua: &Lua, - (query, max_results, max_threads, current_file, order_reverse): ( - String, - usize, - usize, - Option, - bool, - ), + ( + query, + max_results, + max_threads, + current_file, + order_reverse, + combo_boost_score_multiplier, + min_combo_count, + ): (String, usize, usize, Option, bool, i32, Option), ) -> LuaResult { let Some(ref mut picker) = *FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)? else { return Err(Error::FilePickerMissing)?; }; + let base_path = picker.base_path(); + let min_combo_count = min_combo_count.unwrap_or(3); + + let last_same_query_entry = { + let query_tracker = QUERY_TRACKER + .read() + .map_err(|_| Error::AcquireFrecencyLock)?; + + if query_tracker.as_ref().is_none() { + tracing::warn!("Query tracker not initialized"); + } + + query_tracker + .as_ref() + .map(|tracker| tracker.get_last_query_entry(&query, base_path, min_combo_count)) + .transpose()? + .flatten() + }; + + tracing::debug!( + ?last_same_query_entry, + ?base_path, + ?query, + ?min_combo_count, + "Last same query entry" + ); + let results = FilePicker::fuzzy_search( picker.get_files(), &query, - max_results, - max_threads, - current_file.as_deref(), - order_reverse, + 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, + }, ); results.into_lua(lua) @@ -202,6 +264,61 @@ pub fn cancel_scan(_: &Lua, _: ()) -> LuaResult { Ok(true) } +pub fn track_query_completion(_: &Lua, (query, file_path): (String, String)) -> LuaResult { + // Get the project path before spawning thread + let project_path = { + let Some(ref picker) = *FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)? else { + return Ok(false); + }; + picker.base_path().to_path_buf() + }; + + // Canonicalize the file path before spawning thread + let file_path = match PathBuf::from(&file_path).canonicalize() { + Ok(path) => path, + Err(e) => { + tracing::warn!(?file_path, error = ?e, "Failed to canonicalize file path for tracking"); + return Ok(false); + } + }; + + // Spawn background thread to do the actual tracking (expensive DB write) + std::thread::spawn(move || { + if let Ok(Some(tracker)) = QUERY_TRACKER.write().as_deref_mut() + && let Err(e) = tracker.track_query_completion(&query, &project_path, &file_path) + { + tracing::error!( + query = %query, + file = %file_path.display(), + error = ?e, + "Failed to track query completion" + ); + } + }); + + Ok(true) +} + +pub fn get_historical_query(_: &Lua, offset: usize) -> LuaResult> { + let project_path = { + let Some(ref picker) = *FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)? else { + return Ok(None); + }; + picker.base_path().to_path_buf() + }; + + let Some(ref tracker) = *QUERY_TRACKER + .read() + .map_err(|_| Error::AcquireFrecencyLock)? + else { + return Ok(None); + }; + + tracker + .get_historical_query(&project_path, offset) + .map_err(Into::into) +} + pub fn wait_for_initial_scan(_: &Lua, timeout_ms: Option) -> LuaResult { let file_picker = FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)?; let picker = file_picker @@ -234,14 +351,17 @@ pub fn init_tracing( _: &Lua, (log_file_path, log_level): (String, Option), ) -> LuaResult { - crate::tracing::init_tracing(&log_file_path, log_level.as_deref()) + crate::log::init_tracing(&log_file_path, log_level.as_deref()) .map_err(|e| LuaError::RuntimeError(format!("Failed to initialize tracing: {}", e))) } fn create_exports(lua: &Lua) -> LuaResult { let exports = lua.create_table()?; exports.set("init_db", lua.create_function(init_db)?)?; - exports.set("destroy_db", lua.create_function(destroy_db)?)?; + exports.set( + "destroy_frecency_db", + lua.create_function(destroy_frecency_db)?, + )?; exports.set("init_file_picker", lua.create_function(init_file_picker)?)?; exports.set( "restart_index_in_path", @@ -272,6 +392,16 @@ fn create_exports(lua: &Lua) -> LuaResult { "cleanup_file_picker", lua.create_function(cleanup_file_picker)?, )?; + exports.set("destroy_query_db", lua.create_function(destroy_query_db)?)?; + exports.set( + "track_query_completion", + lua.create_function(track_query_completion)?, + )?; + exports.set( + "get_historical_query", + lua.create_function(get_historical_query)?, + )?; + Ok(exports) } diff --git a/lua/fff/rust/tracing.rs b/lua/fff/rust/log.rs similarity index 100% rename from lua/fff/rust/tracing.rs rename to lua/fff/rust/log.rs diff --git a/lua/fff/rust/query_tracker.rs b/lua/fff/rust/query_tracker.rs new file mode 100644 index 00000000..651b047e --- /dev/null +++ b/lua/fff/rust/query_tracker.rs @@ -0,0 +1,334 @@ +use crate::error::Error; +use heed::types::Bytes; +use heed::{Database, Env, EnvOpenOptions}; +use heed::{EnvFlags, types::SerdeBincode}; +use serde::{Deserialize, Serialize}; +use std::collections::VecDeque; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +const MAX_HISTORY_ENTRIES: usize = 128; + +/// Simplified QueryFileEntry without redundant fields +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct QueryMatchEntry { + pub file_path: PathBuf, // File that was actually opened + pub open_count: u32, // Number of times opened with this query + pub last_opened: u64, // Unix timestamp +} + +/// Entry for query history tracking +#[derive(Debug, Serialize, Deserialize, Clone)] +struct HistoryEntry { + query: String, + timestamp: u64, +} + +#[derive(Debug)] +pub struct QueryTracker { + env: Env, + // Database for (project_path, query) -> QueryMatchEntry mappings + query_file_db: Database>, + // Database for project_path -> VecDeque mappings + query_history_db: Database>>, +} + +impl QueryTracker { + pub fn new(db_path: &str, use_unsafe_no_lock: bool) -> Result { + fs::create_dir_all(db_path).map_err(Error::CreateDir)?; + let env = unsafe { + let mut opts = EnvOpenOptions::new(); + opts.max_dbs(16); // Allow up to 16 databases per environment + if use_unsafe_no_lock { + opts.flags(EnvFlags::NO_LOCK | EnvFlags::NO_SYNC | EnvFlags::NO_META_SYNC); + } + opts.open(db_path).map_err(Error::EnvOpen)? + }; + + env.clear_stale_readers() + .map_err(Error::DbClearStaleReaders)?; + + let mut wtxn = env.write_txn().map_err(Error::DbStartWriteTxn)?; + + // Create two named databases + let query_file_db = env + .create_database(&mut wtxn, Some("query_file_associations")) + .map_err(Error::DbCreate)?; + let query_history_db = env + .create_database(&mut wtxn, Some("query_history")) + .map_err(Error::DbCreate)?; + + wtxn.commit().map_err(Error::DbCommit)?; + + Ok(QueryTracker { + env, + query_file_db, + query_history_db, + }) + } + + fn get_now(&self) -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + } + + fn create_query_key(project_path: &Path, query: &str) -> Result<[u8; 32], Error> { + let project_str = project_path + .to_str() + .ok_or_else(|| Error::InvalidPath(project_path.to_path_buf()))?; + + let mut hasher = blake3::Hasher::default(); + hasher.update(project_str.as_bytes()); + hasher.update(b"::"); + hasher.update(query.as_bytes()); + + Ok(*hasher.finalize().as_bytes()) + } + + fn create_project_key(project_path: &Path) -> Result<[u8; 32], Error> { + let project_str = project_path + .to_str() + .ok_or_else(|| Error::InvalidPath(project_path.to_path_buf()))?; + + Ok(*blake3::hash(project_str.as_bytes()).as_bytes()) + } + + pub fn track_query_completion( + &mut self, + query: &str, + project_path: &Path, + file_path: &Path, + ) -> Result<(), Error> { + let now = self.get_now(); + let file_path_buf = file_path.to_path_buf(); + + let query_key = Self::create_query_key(project_path, query)?; + let mut wtxn = self.env.write_txn().map_err(Error::DbStartWriteTxn)?; + + let mut entry = self + .query_file_db + .get(&wtxn, &query_key) + .map_err(Error::DbRead)? + .unwrap_or_else(|| QueryMatchEntry { + file_path: file_path_buf.clone(), + open_count: 0, + last_opened: now, + }); + + if entry.file_path == file_path_buf { + tracing::debug!( + ?query, + ?file_path, + "Query completed for same file as last time" + ); + + // Same file - just increment count + entry.open_count += 1; + } else { + tracing::debug!( + ?query, + ?file_path, + "Query completed for different file than last time" + ); + + // Different file - replace and reset count to 1 + entry.file_path = file_path_buf; + entry.open_count = 1; + } + + entry.last_opened = now; + + self.query_file_db + .put(&mut wtxn, &query_key, &entry) + .map_err(Error::DbWrite)?; + + // Update query history database + let project_key = Self::create_project_key(project_path)?; + let mut history = self + .query_history_db + .get(&wtxn, &project_key) + .map_err(Error::DbRead)? + .unwrap_or_default(); + + let history_entry = HistoryEntry { + query: query.to_string(), + timestamp: now, + }; + history.push_back(history_entry); + while history.len() > MAX_HISTORY_ENTRIES { + history.pop_front(); + } + + self.query_history_db + .put(&mut wtxn, &project_key, &history) + .map_err(Error::DbWrite)?; + + wtxn.commit().map_err(Error::DbCommit)?; + + tracing::debug!(?query, ?file_path, "Tracked query completion"); + Ok(()) + } + + pub fn get_last_query_entry( + &self, + query: &str, + project_path: &Path, + min_combo_count: u32, + ) -> Result, Error> { + let query_key = Self::create_query_key(project_path, query)?; + tracing::debug!(?query_key, "HASH"); + let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?; + + let last_match = self + .query_file_db + .get(&rtxn, &query_key) + .map_err(Error::DbRead)?; + + Ok(last_match.filter(|entry| entry.open_count >= min_combo_count)) + } + + pub fn get_last_query_path( + &self, + query: &str, + project_path: &Path, + file_path: &Path, + combo_boost: i32, + ) -> Result { + let query_key = Self::create_query_key(project_path, query)?; + tracing::debug!(?query_key, "HASH"); + let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?; + + match self + .query_file_db + .get(&rtxn, &query_key) + .map_err(Error::DbRead)? + { + Some(entry) => { + // Check if the file path matches and return boost + if entry.file_path == file_path && entry.open_count >= 2 { + Ok(combo_boost) + } else { + Ok(0) + } + } + None => Ok(0), // Query not found + } + } + + /// Get query from history at a specific offset + /// offset=0 returns most recent query, offset=1 returns 2nd most recent, etc. + /// Returns None if offset exceeds history length + pub fn get_historical_query( + &self, + project_path: &Path, + offset: usize, + ) -> Result, Error> { + let project_key = Self::create_project_key(project_path)?; + let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?; + + let mut history = self + .query_history_db + .get(&rtxn, &project_key) + .map_err(Error::DbRead)? + .unwrap_or_default(); + + // history is FIFO, last element is most recent + if history.len() > offset { + let index = history.len() - 1 - offset; + let record = history.remove(index); + + Ok(record.map(|r| r.query)) + } else { + Ok(None) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + + #[test] + fn test_query_tracking() { + let temp_dir = env::temp_dir().join("fff_test_query_tracking_new"); + let _ = std::fs::remove_dir_all(&temp_dir); + + let mut tracker = QueryTracker::new(temp_dir.to_str().unwrap(), true).unwrap(); + + let project_path = PathBuf::from("/test/project"); + let file_path = PathBuf::from("/test/project/src/main.rs"); + + // First completion + tracker + .track_query_completion("main", &project_path, &file_path) + .unwrap(); + let boost = tracker + .get_last_query_path("main", &project_path, &file_path, 10000) + .unwrap(); + assert_eq!(boost, 0, "First completion should not boost"); + + // Second completion - should boost now + tracker + .track_query_completion("main", &project_path, &file_path) + .unwrap(); + let boost = tracker + .get_last_query_path("main", &project_path, &file_path, 10000) + .unwrap(); + assert_eq!(boost, 10000, "Second completion should boost"); + + // Different file for same query - should reset count and no boost + let other_file = PathBuf::from("/test/project/src/lib.rs"); + tracker + .track_query_completion("main", &project_path, &other_file) + .unwrap(); + let boost = tracker + .get_last_query_path("main", &project_path, &other_file, 10000) + .unwrap(); + assert_eq!(boost, 0, "Different file should reset boost"); + + // Original file should no longer get boost (replaced by new file) + let boost = tracker + .get_last_query_path("main", &project_path, &file_path, 10000) + .unwrap(); + assert_eq!(boost, 0, "Original file should not boost after replacement"); + + let _ = std::fs::remove_dir_all(&temp_dir); + } + + #[test] + fn test_hashing_functions() { + let project_path = PathBuf::from("/test/project"); + + // Test project key hashing + let key1 = QueryTracker::create_project_key(&project_path).unwrap(); + let key2 = QueryTracker::create_project_key(&project_path).unwrap(); + assert_eq!(key1, key2, "Same project should hash to same key"); + + // Test query key hashing + let query_key1 = QueryTracker::create_query_key(&project_path, "test").unwrap(); + let query_key2 = QueryTracker::create_query_key(&project_path, "test").unwrap(); + assert_eq!( + query_key1, query_key2, + "Same project+query should hash to same key" + ); + + // Different queries should hash differently + let query_key3 = QueryTracker::create_query_key(&project_path, "different").unwrap(); + assert_ne!( + query_key1, query_key3, + "Different queries should hash to different keys" + ); + + // Different projects should hash differently + let other_project = PathBuf::from("/other/project"); + let query_key4 = QueryTracker::create_query_key(&other_project, "test").unwrap(); + assert_ne!( + query_key1, query_key4, + "Different projects should hash to different keys" + ); + } +} diff --git a/lua/fff/rust/score.rs b/lua/fff/rust/score.rs index 82ef5676..3c6d6b8e 100644 --- a/lua/fff/rust/score.rs +++ b/lua/fff/rust/score.rs @@ -1,5 +1,3 @@ -use std::path::MAIN_SEPARATOR; - use crate::{ git::is_modified_status, path_utils::calculate_distance_penalty, @@ -8,6 +6,7 @@ use crate::{ }; use neo_frizbee::Scoring; use rayon::prelude::*; +use std::path::MAIN_SEPARATOR; pub fn match_and_score_files<'a>( files: &'a [FileItem], @@ -145,15 +144,31 @@ pub fn match_and_score_files<'a>( }; let current_file_penalty = calculate_current_file_penalty(file, base_score, context); - if current_file_penalty < 0 { - tracing::debug!(file =?file.relative_path, ?current_file_penalty, "Applied penalty"); - } + + let combo_match_boost = { + let last_same_query_match = context + .last_same_query_match + .filter(|m| m.file_path.as_os_str() == file.path.as_os_str()); + + match last_same_query_match { + // if we request a combo match without a boost we have to render it anyway + Some(_) if context.min_combo_count == 0 => 1000, + Some(combo_match) if combo_match.open_count >= context.min_combo_count => { + combo_match.open_count as i32 * context.combo_boost_score_multiplier + } + // until we hit the combo count threshold, we add a smaller boost because it + // makes sense and makes the search more efficient + Some(combo_match) => combo_match.open_count as i32 * 5, + _ => 0, + } + }; let total = base_score .saturating_add(frecency_boost) .saturating_add(distance_penalty) .saturating_add(filename_bonus) - .saturating_add(current_file_penalty); + .saturating_add(current_file_penalty) + .saturating_add(combo_match_boost); let score = Score { total, @@ -167,6 +182,7 @@ pub fn match_and_score_files<'a>( }, frecency_boost, distance_penalty, + combo_match_boost, exact_match: path_match.exact || filename_match.is_some_and(|m| m.exact), match_type: match filename_match { Some(filename_match) if filename_match.exact => "exact_filename", @@ -227,6 +243,7 @@ fn score_all_by_frecency<'a>( filename_bonus: 0, distance_penalty: 0, special_filename_bonus: 0, + combo_match_boost: 0, current_file_penalty, frecency_boost: total_frecency_score, exact_match: false, @@ -255,8 +272,6 @@ fn calculate_current_file_penalty( Some(status) if is_modified_status(status) => base_score / 2, _ => base_score, }; - - tracing::debug!(file =?file.relative_path, current=?context.current_file, ?penalty, "Calculating current file penalty"); } penalty @@ -357,6 +372,7 @@ mod tests { frecency_boost: 0, exact_match: false, match_type: "test", + combo_match_boost: 0, }; (file, score_obj) } @@ -390,6 +406,10 @@ mod tests { 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, }; // Test with partial sort (threshold = 3 * 2 = 6, our len is 10 > 6) @@ -431,6 +451,10 @@ mod tests { 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, }; let (items, scores, _) = sort_and_truncate(results, &context); @@ -465,6 +489,10 @@ mod tests { 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, }; // threshold = 2 * 2 = 4, len = 3 < 4, so regular sort @@ -500,6 +528,10 @@ mod tests { 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); diff --git a/lua/fff/rust/types.rs b/lua/fff/rust/types.rs index cceffd7f..7dcaa9fc 100644 --- a/lua/fff/rust/types.rs +++ b/lua/fff/rust/types.rs @@ -1,7 +1,7 @@ use mlua::prelude::*; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; -use crate::{git::format_git_status, location::Location}; +use crate::{git::format_git_status, location::Location, query_tracker::QueryMatchEntry}; #[derive(Debug, Clone)] pub struct FileItem { @@ -27,6 +27,7 @@ pub struct Score { pub frecency_boost: i32, pub distance_penalty: i32, pub current_file_penalty: i32, + pub combo_match_boost: i32, pub exact_match: bool, pub match_type: &'static str, } @@ -34,11 +35,15 @@ pub struct Score { #[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, } #[derive(Debug, Clone, Default)] @@ -79,6 +84,7 @@ impl IntoLua for Score { table.set("frecency_boost", self.frecency_boost)?; table.set("distance_penalty", self.distance_penalty)?; table.set("current_file_penalty", self.current_file_penalty)?; + table.set("combo_match_boost", self.combo_match_boost)?; table.set("match_type", self.match_type)?; table.set("exact_match", self.exact_match)?; Ok(LuaValue::Table(table)) diff --git a/src/bin/bench_search_only.rs b/src/bin/bench_search_only.rs index 516124c0..01d47f16 100644 --- a/src/bin/bench_search_only.rs +++ b/src/bin/bench_search_only.rs @@ -88,10 +88,18 @@ fn main() { for _ in 0..iterations { let results = FilePicker::fuzzy_search( - &files, query, 100, // max_results - 4, // max_threads - None, // current_file - false, // reverse_order + &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, + }, ); match_count += results.total_matched; } diff --git a/src/bin/jemalloc_profile.rs b/src/bin/jemalloc_profile.rs index 77004b64..1787a872 100644 --- a/src/bin/jemalloc_profile.rs +++ b/src/bin/jemalloc_profile.rs @@ -86,10 +86,16 @@ fn test_search_memory_pattern( let search_result = FilePicker::fuzzy_search( picker.get_files(), &query, - 50 + (i % 50), // Vary result count - 1 + (i % 4), // Vary thread count - None, - false, // prompt_position not relevant for test + fff_nvim::file_picker::FuzzySearchOptions { + max_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, + }, ); (search_result.items.len(), search_result.total_matched) } else { diff --git a/src/bin/search_profiler.rs b/src/bin/search_profiler.rs index 3fbea433..e1d8cd29 100644 --- a/src/bin/search_profiler.rs +++ b/src/bin/search_profiler.rs @@ -122,11 +122,20 @@ fn main() { for _ in 0..iterations { let results = FilePicker::fuzzy_search( - &files, query, 100, // max_results - 4, // max_threads - None, // current_file - false, // reverse_order + &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, + }, ); + match_count += results.total_matched; } diff --git a/src/bin/test_memory_leak.rs b/src/bin/test_memory_leak.rs index b9e46ebd..75585dac 100644 --- a/src/bin/test_memory_leak.rs +++ b/src/bin/test_memory_leak.rs @@ -202,10 +202,16 @@ fn main() -> Result<(), Box> { let search_result = FilePicker::fuzzy_search( picker.get_files(), query, - max_results, - max_threads, - None, - false, // prompt_position not relevant for test + fff_nvim::file_picker::FuzzySearchOptions { + max_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, + }, ); let duration = search_start.elapsed(); (search_result.items.len(), duration) diff --git a/src/bin/test_watcher.rs b/src/bin/test_watcher.rs index c5e7fa84..da7233a9 100644 --- a/src/bin/test_watcher.rs +++ b/src/bin/test_watcher.rs @@ -156,7 +156,20 @@ fn main() -> Result<(), Box> { let timestamp = chrono::Local::now().format("%H:%M:%S"); let file_picker = FILE_PICKER.read().unwrap(); let files = file_picker.as_ref().unwrap().get_files(); - let search_results = FilePicker::fuzzy_search(files, "rs", 5, 2, None, false); + let search_results = FilePicker::fuzzy_search( + files, + "rs", + fff_nvim::file_picker::FuzzySearchOptions { + max_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, + }, + ); println!( "🔍 [{}] Search test 'rs': {} matches",