diff --git a/README.md b/README.md index d825fdc33..98a7c34c0 100644 --- a/README.md +++ b/README.md @@ -374,6 +374,11 @@ require('fff').setup({ }, git = { status_text_color = false, -- true to color filenames by git status + recency = { + enabled = true, -- boost files from recent commits of the current branch + max_commits = 10, -- analyze the last N branch-specific commits + max_files_per_commit = 50, -- skip bulk commits touching more files than this + }, }, file_picker = { fuzzy_query_highlighting = false, -- true to highlight fuzzy query matches in file picker results @@ -468,6 +473,26 @@ Caveat: the chosen file replaces the buffer in the invoking window even if it's Sign-column indicators are on by default. To color filename text by git status, set `git.status_text_color = true` and adjust the `hl.git_*` groups. See `:help fff.nvim` for the full list. +### Git recency ranking + +Files that participated in recent commits of your current branch get a small additive score bonus: +1 point per commit within the analyzed window. A file touched by all of the last 10 commits gets +10; if your branch only has 2 commits, its files get at most +2. This surfaces the files you are actively working on — especially right after switching branches or pulling. + +- Only commits unique to the current branch are counted (against the merge bases with the repo's base branch: `origin/HEAD`, `main`/`master`, or your configured `init.defaultBranch`); on the base branch itself the last `max_commits` commits are used, even when it is ahead of its remote counterpart. +- Merge commits and bulk commits touching more than `git.recency.max_files_per_commit` files are ignored. +- Scores refresh automatically after commits, checkouts, and rebases; refreshes are skipped entirely while `HEAD` stays put, so it costs nothing during regular editing. + +```lua +require('fff').setup({ + git = { + recency = { + enabled = true, -- default: true + max_commits = 10, -- window of branch commits to analyze (also the max bonus) + max_files_per_commit = 50, -- skip merge/bulk commits touching more files + }, + }, +}) +``` + ### Float colors The picker maps its float content to `NormalFloat` (via `hl.normal`) and the border to `FloatBorder`. Default `FloatBorder` links to `NormalFloat`, so border and content share a background out of the box and the picker reads as a single popup. Override `hl.normal = 'Normal'` to make the picker blend with the editor instead. diff --git a/crates/fff-c/src/lib.rs b/crates/fff-c/src/lib.rs index 00280ccb2..5acbbcf5c 100644 --- a/crates/fff-c/src/lib.rs +++ b/crates/fff-c/src/lib.rs @@ -266,6 +266,7 @@ pub unsafe extern "C" fn fff_create_instance_with(opts: *const FffCreateOptions) follow_symlinks: opts.version >= 2 && opts.follow_symlinks, enable_fs_root_scanning: opts.enable_fs_root_scanning, enable_home_dir_scanning: opts.enable_home_dir_scanning, + git_recency: Default::default(), }, ) { return FffResult::err(&format!("Failed to init file picker: {}", e)); @@ -932,6 +933,10 @@ pub unsafe extern "C" fn fff_restart_index( } else { (false, true, true, FFFMode::default(), false, false, false) }; + let git_recency = guard + .as_ref() + .map(|p| p.git_recency_config()) + .unwrap_or_default(); drop(guard); @@ -948,6 +953,7 @@ pub unsafe extern "C" fn fff_restart_index( follow_symlinks, enable_fs_root_scanning: fs_root, enable_home_dir_scanning: home_dir, + git_recency, }, ) { Ok(()) => FffResult::ok_empty(), diff --git a/crates/fff-core/src/file_picker.rs b/crates/fff-core/src/file_picker.rs index bbb62096b..3f776a503 100644 --- a/crates/fff-core/src/file_picker.rs +++ b/crates/fff-core/src/file_picker.rs @@ -33,6 +33,7 @@ use crate::constants::{MAX_OVERFLOW_FILES, PATH_BUF_SIZE}; use crate::error::Error; use crate::frecency::FrecencyTracker; use crate::git::GitStatusCache; +use crate::git_recency::{self, GitRecencyConfig}; use crate::grep::{GrepResult, GrepSearchOptions, grep_search, multi_grep_search}; use crate::index::{BigramFilter, BigramOverlay}; use crate::query_tracker::QueryTracker; @@ -47,6 +48,7 @@ use crate::types::{ }; use crate::walk::WalkOutput; use crate::watch::BackgroundWatcher; +use ahash::AHashMap; use fff_query_parser::FFFQuery; use git2::{Repository, Status}; use rayon::prelude::*; @@ -195,8 +197,6 @@ impl FileSync { #[inline] fn find_file_index(&self, path: &Path, base_path: &Path) -> Option { - let arena = self.arena_base_ptr(); - // Strip base_path prefix to get the relative path. On Windows this // can fail for 8.3 short names or a different casing; fall back to // canonicalize-then-strip so watcher events still land on the right @@ -216,8 +216,13 @@ impl FileSync { }; // The dir table and stored file paths are '/'-canonical; fold the // native relative path so the byte-wise comparisons below match. - let rel_path_owned = crate::path_utils::to_canonical_slashes(&rel_path_owned).into_owned(); - let rel_path: &str = &rel_path_owned; + self.find_by_relative_path(&crate::path_utils::to_canonical_slashes(&rel_path_owned)) + } + + // Lookup for a base-relative, '/'-canonical path — the form paths are + // stored in, so no normalization is needed. + fn find_by_relative_path(&self, rel_path: &str) -> Option { + let arena = self.arena_base_ptr(); // Split into directory (with trailing '/') and filename. let parent_end = rel_path @@ -549,6 +554,9 @@ pub struct FilePickerOptions { /// Allow indexing the user's home directory. Off by default for the same /// reason as `enable_fs_root_scanning` pub enable_home_dir_scanning: bool, + /// Ranking boost for files that participated in recent commits of the + /// current branch. Enabled with default limits unless overridden. + pub git_recency: GitRecencyConfig, } impl Default for FilePickerOptions { @@ -563,6 +571,7 @@ impl Default for FilePickerOptions { follow_symlinks: false, enable_fs_root_scanning: false, enable_home_dir_scanning: false, + git_recency: GitRecencyConfig::default(), } } } @@ -586,6 +595,7 @@ pub struct FilePicker { follow_symlinks: bool, enable_fs_root_scanning: bool, enable_home_dir_scanning: bool, + git_recency_config: GitRecencyConfig, trace_span: tracing::Span, trace_id: String, } @@ -669,6 +679,10 @@ impl FilePicker { self.enable_home_dir_scanning } + pub fn git_recency_config(&self) -> GitRecencyConfig { + self.git_recency_config + } + pub fn trace_id(&self) -> &str { &self.trace_id } @@ -848,6 +862,8 @@ impl FilePicker { /// Always prefer new_with_shared_state for the consumer application, use this only if you know /// what you are doing. This won't spawn the backgraound watcher and won't walk the file tree. pub fn new(options: FilePickerOptions) -> Result { + crate::git::tune_libgit2_for_local_reads(); + let path = PathBuf::from(&options.base_path); if !path.exists() { error!("Base path does not exist: {}", options.base_path); @@ -903,6 +919,7 @@ impl FilePicker { follow_symlinks: options.follow_symlinks, enable_fs_root_scanning: options.enable_fs_root_scanning, enable_home_dir_scanning: options.enable_home_dir_scanning, + git_recency_config: options.git_recency, trace_span, trace_id, }) @@ -1030,6 +1047,15 @@ impl FilePicker { } } + if let Some(workdir) = self.sync_data.git_workdir.clone() + && let Ok(repo) = Repository::open(&workdir) + .inspect_err(|e| debug!(?e, ?workdir, "git recency: failed to open repo")) + { + let recency = + git_recency::compute_git_recency(&repo, &self.git_recency_config, &self.base_path); + self.apply_git_recency(recency.as_ref()); + } + self.signals.scanning.store(false, Ordering::Relaxed); Ok(()) } @@ -1535,6 +1561,32 @@ impl FilePicker { Ok(()) } + // Replaces every recency score with a freshly computed set. `None` zeroes + // them, so a vanished window (orphan HEAD, repo gone) leaves no stale boost. + pub(crate) fn apply_git_recency(&mut self, scores: Option<&AHashMap>) { + if !self.git_recency_config.enabled { + return; + } + + for file in self.sync_data.files.iter_mut() { + file.git_recency_score = 0; + } + + let Some(scores) = scores else { return }; + + let mut applied = 0usize; + for (relative_path, score) in scores { + if let Some(index) = self.sync_data.find_by_relative_path(relative_path) + && let Some((_, file)) = self.sync_data.get_file_mut(index) + { + file.git_recency_score = *score; + applied += 1; + } + } + + debug!(files_scored = applied, "Git recency scores applied"); + } + pub fn update_single_file_frecency( &mut self, file_path: impl AsRef, diff --git a/crates/fff-core/src/git.rs b/crates/fff-core/src/git.rs index 9514b628b..a2ddd649a 100644 --- a/crates/fff-core/src/git.rs +++ b/crates/fff-core/src/git.rs @@ -6,6 +6,22 @@ use std::{ path::{Path, PathBuf}, }; +/// Process-wide libgit2 tuning for read-heavy local use. By default libgit2 +/// never caches trees over 4KB (large repos re-inflate every directory tree on +/// every diff) and SHA-verifies each object read; measured on chromium this +/// takes a 10-commit recency walk from 1.4s down to 0.56s. +pub(crate) fn tune_libgit2_for_local_reads() { + static TUNE: std::sync::Once = std::sync::Once::new(); + TUNE.call_once(|| { + // Same tradeoff cargo makes: local objects are trusted, skip hashing. + git2::opts::strict_hash_verification(false); + // SAFETY: plain process-global size limit; total memory stays bounded + // by libgit2's 256MB odb cache. + let _ = + unsafe { git2::opts::set_cache_object_limit(git2::ObjectType::Tree, 8 * 1024 * 1024) }; + }); +} + pub(crate) fn default_status_options() -> StatusOptions { let mut opts = StatusOptions::new(); opts.include_untracked(true) @@ -52,7 +68,10 @@ impl GitStatusCache { } #[tracing::instrument(skip(repo, status_options))] - fn read_status_impl(repo: &Repository, status_options: &mut StatusOptions) -> Result { + pub(crate) fn read_status( + repo: &Repository, + status_options: &mut StatusOptions, + ) -> Result { let statuses = repo.statuses(Some(status_options))?; let Some(repo_path) = repo.workdir() else { return Ok(Self(AHashMap::new())); // repo is bare @@ -80,7 +99,7 @@ impl GitStatusCache { let git_workdir = git_workdir.as_ref()?; let repository = Repository::open(git_workdir).ok()?; - let status = Self::read_status_impl(&repository, status_options); + let status = Self::read_status(&repository, status_options); match status { Ok(status) => Some(status), @@ -123,7 +142,7 @@ impl GitStatusCache { status_options.pathspec(path.as_ref().strip_prefix(&workdir)?); } - let git_status_cache = Self::read_status_impl(repo, &mut status_options)?; + let git_status_cache = Self::read_status(repo, &mut status_options)?; Ok(git_status_cache) } } diff --git a/crates/fff-core/src/git_recency.rs b/crates/fff-core/src/git_recency.rs new file mode 100644 index 000000000..29a6332c8 --- /dev/null +++ b/crates/fff-core/src/git_recency.rs @@ -0,0 +1,179 @@ +use ahash::AHashMap; +use git2::{DiffOptions, Oid, Repository}; +use std::path::Path; + +#[derive(Debug, Clone, Copy)] +pub struct GitRecencyConfig { + pub enabled: bool, + pub max_commits: usize, + pub max_files_per_commit: usize, +} + +impl Default for GitRecencyConfig { + fn default() -> Self { + Self { + enabled: true, + max_commits: 10, + // Ignore commits that touched every single file in the repo + max_files_per_commit: 50, + } + } +} + +const MAX_COMMITS_HARD_CAP: usize = 128; + +// Computes per file recency bonuses +#[tracing::instrument(skip(repo), level = tracing::Level::DEBUG)] +pub(crate) fn compute_git_recency( + repo: &Repository, + config: &GitRecencyConfig, + base_path: &Path, +) -> Option> { + if !config.enabled || config.max_commits == 0 { + return None; + } + + // Unborn/orphan HEAD: there is no window to compute from. + let head_ref = repo.head().ok()?; + let head = head_ref.target()?; + let head_branch = head_ref.shorthand().ok().map(str::to_owned); + + let subdir = base_path_within_repo(repo, base_path); + let max_commits = config.max_commits.min(MAX_COMMITS_HARD_CAP); + + let mut revwalk = repo.revwalk().ok()?; + revwalk.push(head).ok()?; + + // only if we can resolve default branch (master, main) attempt to use the recency + if let Some((base_branch, base)) = resolve_base_branch(repo) + && head_branch.as_deref() != Some(base_branch.as_str()) + && let Ok(merge_base) = repo.merge_base(head, base) + && merge_base != head + { + let _ = revwalk.hide(merge_base); + } + + let mut scores: AHashMap = AHashMap::new(); + let mut qualifying = 0usize; + // Bounds total walked commits so histories full of skipped (merge/bulk) + // commits can't turn the walk into a full history scan. + let walk_budget = (max_commits * 5).max(64); + + for oid in revwalk.take(walk_budget) { + if qualifying >= max_commits { + break; + } + + let Ok(commit) = oid.and_then(|oid| repo.find_commit(oid)) else { + continue; + }; + + // Merge commits carry no authored changes; the merged commits are + // walked on their own anyway. + if commit.parent_count() > 1 { + continue; + } + + let Ok(tree) = commit.tree() else { continue }; + let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok()); + + // Pathspec prunes the tree recursion to the indexed subtree — libgit2 + // sets the tree iterators' start/end range from the pathspec prefix. + let mut diff_opts = DiffOptions::new(); + if let Some(subdir) = subdir.as_deref() { + diff_opts.pathspec(subdir); + } + let Ok(diff) = + repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), Some(&mut diff_opts)) + else { + continue; + }; + + let deltas = diff.deltas(); + if deltas.len() > config.max_files_per_commit { + continue; + } + + for delta in deltas { + let Some(path_bytes) = delta + .new_file() + .path_bytes() + .or_else(|| delta.old_file().path_bytes()) + else { + continue; + }; + + let repo_relative = String::from_utf8_lossy(path_bytes); + // Fold repo-relative down to base_path-relative when indexing a subdir + let relative_path = match subdir.as_deref() { + Some(subdir) => match repo_relative + .strip_prefix(subdir) + .and_then(|rest| rest.strip_prefix('/')) + { + Some(rest) => rest, + None => continue, + }, + None => repo_relative.as_ref(), + }; + + // get-before-insert keeps repeat participations allocation-free + if let Some(count) = scores.get_mut(relative_path) { + *count = count.saturating_add(1); + } else { + scores.insert(relative_path.to_owned(), 1); + } + } + + qualifying += 1; + } + + tracing::debug!( + files_scored = scores.len(), + commits_analyzed = qualifying, + "git recency computed" + ); + + Some(scores) +} + +fn base_path_within_repo(repo: &Repository, base_path: &Path) -> Option { + let workdir = crate::path_utils::normalize(repo.workdir()?.to_path_buf()); + let subdir = base_path.strip_prefix(workdir).ok()?; + let subdir = crate::path_utils::to_canonical_slashes(&subdir.to_string_lossy()).into_owned(); + (!subdir.is_empty()).then_some(subdir) +} + +// The branch feature work is measured against: `origin/HEAD`, else +// `init.defaultBranch` when configured, else `main`, else `master`. +fn resolve_base_branch(repo: &Repository) -> Option<(String, Oid)> { + let remote_head = repo + .find_reference("refs/remotes/origin/HEAD") + .ok() + .and_then(|r| { + r.symbolic_target() + .ok()?? + .strip_prefix("refs/remotes/origin/") + .map(str::to_owned) + }); + + let configured = repo + .config() + .and_then(|config| config.get_string("init.defaultBranch")) + .ok() + .filter(|name| !name.is_empty()); + + remote_head + .as_deref() + .into_iter() + .chain(configured.as_deref()) + .chain(["main", "master"]) + .find_map(|branch| { + Some((branch.to_owned(), { + // prefer remote branches + repo.resolve_reference_from_short_name(&format!("origin/{branch}")) + .or_else(|_| repo.resolve_reference_from_short_name(branch)) + .ok()? + .target() + }?)) + }) +} diff --git a/crates/fff-core/src/lib.rs b/crates/fff-core/src/lib.rs index ef5bdb10f..d963603ca 100644 --- a/crates/fff-core/src/lib.rs +++ b/crates/fff-core/src/lib.rs @@ -119,6 +119,9 @@ pub use dbs::*; /// Git status caching and repository detection utilities. pub mod git; +pub mod git_recency; +pub use git_recency::GitRecencyConfig; + /// Live grep search with regex, plain-text, and fuzzy matching modes. pub mod grep; pub use grep::*; diff --git a/crates/fff-core/src/score.rs b/crates/fff-core/src/score.rs index 8fe21ceb9..3dce9dcfa 100644 --- a/crates/fff-core/src/score.rs +++ b/crates/fff-core/src/score.rs @@ -516,6 +516,7 @@ pub(crate) fn fuzzy_match_and_score_dirs<'a>( special_filename_bonus: 0, frecency_boost, git_status_boost: 0, + git_recency_boost: 0, distance_penalty, current_file_penalty: 0, combo_match_boost: 0, @@ -721,6 +722,7 @@ fn match_and_score_in_arena<'a>( } else { 0 }; + let git_recency_boost = file.git_recency_score as i32; if context.current_file.is_some() || context.last_same_query_match.is_some() { file.write_dir_str(arena, &mut dir_buf); @@ -847,6 +849,7 @@ fn match_and_score_in_arena<'a>( let total = base_score .saturating_add(frecency_boost) .saturating_add(git_status_boost) + .saturating_add(git_recency_boost) .saturating_add(distance_penalty) .saturating_add(filename_bonus) .saturating_add(current_file_penalty) @@ -865,6 +868,7 @@ fn match_and_score_in_arena<'a>( }, frecency_boost, git_status_boost, + git_recency_boost, distance_penalty, combo_match_boost, path_alignment_bonus, @@ -925,11 +929,13 @@ fn score_filtered_by_frecency<'a>( } else { 0 }; + let git_recency_boost = file.git_recency_score as i32; let current_file_penalty = calculate_current_file_penalty(file, total_frecency_score, context, arena); let total = total_frecency_score .saturating_add(git_status_boost) + .saturating_add(git_recency_boost) .saturating_add(current_file_penalty); let score = Score { @@ -943,6 +949,7 @@ fn score_filtered_by_frecency<'a>( current_file_penalty, frecency_boost: total_frecency_score, git_status_boost, + git_recency_boost, exact_match: false, match_type: "frecency", }; @@ -1090,6 +1097,7 @@ mod tests { current_file_penalty: 0, frecency_boost: 0, git_status_boost: 0, + git_recency_boost: 0, exact_match: false, match_type: "test", combo_match_boost: 0, @@ -1527,6 +1535,91 @@ mod filename_bonus_tests { } } +#[cfg(test)] +mod git_recency_scoring_tests { + use super::*; + use crate::types::PaginationArgs; + use fff_query_parser::QueryParser; + + fn make_files(specs: &[(&str, i16)]) -> (Vec, ArenaPtr) { + let path_strings: Vec = specs.iter().map(|(p, _)| p.to_string()).collect(); + let items: Vec = specs + .iter() + .map(|(p, _)| { + let fname = p.rfind(std::path::is_separator).map(|i| i + 1).unwrap_or(0) as u16; + FileItem::new_raw(fname, 0, 0, None, false) + }) + .collect(); + let (store, strings) = + crate::simd_path::build_chunked_path_store_from_strings(&path_strings, &items); + let arena = store.as_arena_ptr(); + let mut result: Vec = items; + for (i, file) in result.iter_mut().enumerate() { + file.set_path(strings[i].clone()); + file.git_recency_score = specs[i].1; + } + std::mem::forget(store); + (result, arena) + } + + fn search(files: &[FileItem], query: &str, arena: ArenaPtr) -> Vec<(String, Score)> { + let parser = QueryParser::default(); + let parsed = parser.parse(query); + let ctx = ScoringContext { + query: &parsed, + max_threads: 1, + max_typos: 2, + current_file: None, + last_same_query_match: None, + project_path: None, + combo_boost_score_multiplier: 100, + min_combo_count: 3, + pagination: PaginationArgs { + offset: 0, + limit: 100, + }, + }; + let (items, scores, _) = + fuzzy_match_and_score_files(files, &ctx, files.len(), arena, arena); + items + .iter() + .zip(scores) + .map(|(f, s)| (f.relative_path(arena), s)) + .collect() + } + + #[test] + fn recency_boost_breaks_ties_between_equal_fuzzy_matches() { + // Dir names share no letters with the query so both paths fuzzy-match + // identically and only the recency boost separates them. + let (files, arena) = make_files(&[("src/xxx/handler.rs", 0), ("src/yyy/handler.rs", 5)]); + + let results = search(&files, "handler", arena); + + assert!(results.len() >= 2); + assert_eq!(results[0].0, "src/yyy/handler.rs"); + assert_eq!(results[0].1.git_recency_boost, 5); + assert_eq!(results[1].1.git_recency_boost, 0); + assert_eq!( + results[0].1.total - results[1].1.total, + 5, + "boost is additive: exactly +1 point per participating commit" + ); + } + + #[test] + fn recency_boost_ranks_files_in_frecency_only_mode() { + let (files, arena) = make_files(&[("cold.rs", 0), ("committed.rs", 7)]); + + let results = search(&files, "", arena); + + assert_eq!(results[0].0, "committed.rs"); + assert_eq!(results[0].1.git_recency_boost, 7); + assert_eq!(results[0].1.total, 7); + assert_eq!(results[0].1.match_type, "frecency"); + } +} + #[cfg(test)] mod typo_resistance_tests { use super::*; diff --git a/crates/fff-core/src/shared.rs b/crates/fff-core/src/shared.rs index 8ae735221..5a46e13d9 100644 --- a/crates/fff-core/src/shared.rs +++ b/crates/fff-core/src/shared.rs @@ -7,6 +7,7 @@ use crate::error::Error; use crate::file_picker::FilePicker; use crate::frecency::FrecencyTracker; use crate::git::GitStatusCache; +use crate::git_recency; use crate::query_tracker::QueryTracker; use crate::rescan_stats::{RescanCounters, RescanReason, RescanStats}; use crate::rescan_throttle::RescanThrottle; @@ -349,31 +350,42 @@ impl SharedFilePicker { /// Refresh git statuses for all indexed files #[tracing::instrument(level = "info", skip_all)] pub fn refresh_git_status(&self, shared_frecency: &SharedFrecency) -> Result { - use tracing::debug; - - let git_status = { - let git_root = { - let guard = self.read()?; - let Some(ref picker) = *guard else { - return Err(Error::FilePickerMissing); - }; - picker.git_root().map(|p| p.to_path_buf()) + let (git_root, recency_config, base_path) = { + // we do the libgit2 off lock cause it might take quite some time on very large repos + let guard = self.read()?; + let Some(ref picker) = *guard else { + return Err(Error::FilePickerMissing); }; + ( + picker.git_root().map(|p| p.to_path_buf()), + picker.git_recency_config(), + picker.base_path().to_path_buf(), + ) + }; - debug!(?git_root, "Refreshing git status for picker"); + let repo = git_root.as_deref().and_then(|root| { + wait_for_git_index_lock_release(root); + Repository::open(root) + .inspect_err(|e| tracing::error!(?e, "Failed to open repo for git refresh")) + .ok() + }); - if let Some(ref root) = git_root { - wait_for_git_index_lock_release(root); - } + let git_status = repo.as_ref().and_then(|repo| { + GitStatusCache::read_status(repo, &mut crate::git::default_status_options()) + .inspect_err(|e| tracing::error!(?e, "Failed to read git status")) + .ok() + }); - GitStatusCache::read_git_status( - git_root.as_deref(), - &mut crate::git::default_status_options(), - ) - }; + let recency = repo + .as_ref() + .and_then(|repo| git_recency::compute_git_recency(repo, &recency_config, &base_path)); let mut guard = self.write()?; let picker = guard.as_mut().ok_or(Error::FilePickerMissing)?; + // picker may have been swapped for another directory while we were off the lock + if picker.base_path() != base_path { + return Ok(0); + } let statuses_count = if let Some(git_status) = git_status { let count = git_status.statuses_len(); @@ -383,6 +395,8 @@ impl SharedFilePicker { 0 }; + picker.apply_git_recency(recency.as_ref()); + Ok(statuses_count) } diff --git a/crates/fff-core/src/types.rs b/crates/fff-core/src/types.rs index 72661c967..ec94e3b85 100644 --- a/crates/fff-core/src/types.rs +++ b/crates/fff-core/src/types.rs @@ -250,6 +250,7 @@ pub struct FileItem { pub modified: u64, pub access_frecency_score: i16, pub modification_frecency_score: i16, + pub git_recency_score: i16, pub git_status: Option, pub(crate) path: crate::simd_path::ChunkedString, pub(crate) parent_dir_index: u32, @@ -268,6 +269,7 @@ impl Clone for FileItem { modified: self.modified, access_frecency_score: self.access_frecency_score, modification_frecency_score: self.modification_frecency_score, + git_recency_score: self.git_recency_score, git_status: self.git_status, flags: AtomicU8::new(self.flags.load(Ordering::Relaxed)), // on clone we have to reset the content lock @@ -312,6 +314,7 @@ impl FileItem { modified, access_frecency_score: 0, modification_frecency_score: 0, + git_recency_score: 0, git_status, flags: AtomicU8::new(flags), #[cfg(not(target_os = "windows"))] @@ -813,6 +816,7 @@ pub struct Score { pub special_filename_bonus: i32, pub frecency_boost: i32, pub git_status_boost: i32, + pub git_recency_boost: i32, pub distance_penalty: i32, pub current_file_penalty: i32, pub combo_match_boost: i32, diff --git a/crates/fff-core/tests/git_recency_integration.rs b/crates/fff-core/tests/git_recency_integration.rs new file mode 100644 index 000000000..fc4f86807 --- /dev/null +++ b/crates/fff-core/tests/git_recency_integration.rs @@ -0,0 +1,175 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{Duration, Instant}; + +use fff_search::file_picker::FilePicker; +use fff_search::{FilePickerOptions, SharedFilePicker, SharedFrecency}; +use tempfile::TempDir; + +#[test] +fn collect_files_applies_recency_scores() { + let (_tmp, base) = init_repo(); + commit_file(&base, "hot.rs", "1", "c1"); + commit_file(&base, "cold.rs", "1", "c2"); + commit_file(&base, "hot.rs", "2", "c3"); + commit_file(&base, "src/hot_nested.rs", "1", "c4"); + + let mut picker = FilePicker::new(FilePickerOptions { + base_path: base.to_string_lossy().into_owned(), + watch: false, + ..Default::default() + }) + .unwrap(); + picker.collect_files().unwrap(); + + assert_eq!(recency_score(&picker, &base, "hot.rs"), 2); + assert_eq!(recency_score(&picker, &base, "cold.rs"), 1); + assert_eq!(recency_score(&picker, &base, "src/hot_nested.rs"), 1); +} + +/// Git reports repo-relative paths while the index is relative to base_path, +/// so a picker rooted below the repo root has to strip the subdirectory. +#[test] +fn picker_rooted_in_a_subdirectory_scores_by_index_relative_path() { + let (_tmp, repo) = init_repo(); + commit_file(&repo, "sub/hot.rs", "1", "c1"); + commit_file(&repo, "outside.rs", "1", "c2"); + commit_file(&repo, "sub/hot.rs", "2", "c3"); + commit_file(&repo, "sub/nested/cold.rs", "1", "c4"); + + let sub = repo.join("sub"); + let mut picker = FilePicker::new(FilePickerOptions { + base_path: sub.to_string_lossy().into_owned(), + watch: false, + ..Default::default() + }) + .unwrap(); + picker.collect_files().unwrap(); + + assert_eq!(recency_score(&picker, &sub, "hot.rs"), 2); + assert_eq!(recency_score(&picker, &sub, "nested/cold.rs"), 1); + assert!( + picker.get_file_by_path(repo.join("outside.rs")).is_none(), + "files above base_path are not indexed and must not be scored" + ); +} + +// End-to-end through the runtime path: the background scan populates scores +// via the git-status worker; refresh_git_status re-applies on git changes. +#[test] +fn refresh_git_status_tracks_commits_and_branch_switches() { + let (_tmp, base) = init_repo(); + commit_file(&base, "main_a.rs", "1", "m1"); + commit_file(&base, "main_b.rs", "1", "m2"); + // Present (untracked) at scan time so it is indexed without a watcher; + // committed only later on the feature branch. + fs::write(base.join("feat.rs"), "0").unwrap(); + + let shared = SharedFilePicker::default(); + let frecency = SharedFrecency::default(); + + FilePicker::new_with_shared_state( + shared.clone(), + frecency.clone(), + FilePickerOptions { + base_path: base.to_string_lossy().into_owned(), + watch: false, + ..Default::default() + }, + ) + .unwrap(); + + assert!( + shared.wait_for_scan(Duration::from_secs(20)), + "scan timed out" + ); + + // The git-status worker applies initial recency scores asynchronously. + wait_for_score(&shared, &base, "main_a.rs", 1); + wait_for_score(&shared, &base, "main_b.rs", 1); + + // New commits on a feature branch: only branch commits count and files + // that fell out of the window are reset. + git(&base, &["checkout", "-b", "feature"]); + commit_file(&base, "feat.rs", "1", "f1"); + commit_file(&base, "feat.rs", "2", "f2"); + + shared.refresh_git_status(&frecency).unwrap(); + + let guard = shared.read().unwrap(); + let picker = guard.as_ref().unwrap(); + assert_eq!(recency_score(picker, &base, "feat.rs"), 2); + assert_eq!( + recency_score(picker, &base, "main_a.rs"), + 0, + "base-branch files must be reset after switching to a feature branch" + ); + drop(guard); + + // A second refresh recomputes from scratch and lands on the same scores. + shared.refresh_git_status(&frecency).unwrap(); + let guard = shared.read().unwrap(); + let picker = guard.as_ref().unwrap(); + assert_eq!(recency_score(picker, &base, "feat.rs"), 2); +} + +fn wait_for_score(shared: &SharedFilePicker, base: &Path, rel: &str, expected: i16) { + let deadline = Instant::now() + Duration::from_secs(20); + loop { + { + let guard = shared.read().unwrap(); + if let Some(picker) = guard.as_ref() + && picker + .get_file_by_path(base.join(rel)) + .is_some_and(|f| f.git_recency_score == expected) + { + return; + } + } + assert!( + Instant::now() < deadline, + "timed out waiting for {rel} to reach recency score {expected}" + ); + std::thread::sleep(Duration::from_millis(25)); + } +} + +fn git(dir: &Path, args: &[&str]) { + let out = Command::new("git") + .args(args) + .current_dir(dir) + .env("GIT_AUTHOR_NAME", "t") + .env("GIT_AUTHOR_EMAIL", "t@t") + .env("GIT_COMMITTER_NAME", "t") + .env("GIT_COMMITTER_EMAIL", "t@t") + .output() + .unwrap(); + assert!( + out.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&out.stderr) + ); +} + +fn commit_file(dir: &Path, rel: &str, content: &str, message: &str) { + let path = dir.join(rel); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, content).unwrap(); + git(dir, &["add", "-A"]); + git(dir, &["commit", "-m", message, "--no-gpg-sign"]); +} + +fn init_repo() -> (TempDir, PathBuf) { + let tmp = TempDir::new().unwrap(); + let base = fff_search::path_utils::canonicalize(tmp.path()).unwrap(); + git(&base, &["init", "-b", "main"]); + (tmp, base) +} + +fn recency_score(picker: &FilePicker, base: &Path, rel: &str) -> i16 { + picker + .get_file_by_path(base.join(rel)) + .map(|f| f.git_recency_score) + .unwrap_or_else(|| panic!("{rel} not indexed")) +} diff --git a/crates/fff-mcp/src/main.rs b/crates/fff-mcp/src/main.rs index f2b715f63..c33a41189 100644 --- a/crates/fff-mcp/src/main.rs +++ b/crates/fff-mcp/src/main.rs @@ -307,6 +307,7 @@ async fn main() -> Result<(), Box> { follow_symlinks: args.follow_symlinks, enable_home_dir_scanning: args.enable_home_scan, enable_fs_root_scanning: args.enable_root_scan, + git_recency: Default::default(), }, ) .map_err(|e| format!("Failed to init file picker: {}", e))?; diff --git a/crates/fff-nvim/src/lib.rs b/crates/fff-nvim/src/lib.rs index 4ea4208af..64fe9c661 100644 --- a/crates/fff-nvim/src/lib.rs +++ b/crates/fff-nvim/src/lib.rs @@ -6,8 +6,8 @@ use fff::path_utils::expand_tilde; use fff::query_tracker::QueryTracker; use fff::{ DbHealthChecker, DirSearchConfig, Error, FFFMode, FFFQuery, FileSearchConfig, - FuzzySearchOptions, MixedSearchConfig, PaginationArgs, QueryParser, Score, SearchResult, - SharedFilePicker, SharedFrecency, SharedQueryTracker, + FuzzySearchOptions, GitRecencyConfig, MixedSearchConfig, PaginationArgs, QueryParser, Score, + SearchResult, SharedFilePicker, SharedFrecency, SharedQueryTracker, }; use mimalloc::MiMalloc; use mlua::prelude::*; @@ -68,6 +68,7 @@ struct PickerInitOpts { enable_fs_root_scanning: bool, enable_home_dir_scanning: bool, enable_filename_constraint: bool, + git_recency: Option, } impl PickerInitOpts { @@ -92,6 +93,9 @@ impl PickerInitOpts { enable_filename_constraint: t .get::>("enable_filename_constraint")? .unwrap_or(false), + git_recency: Self::git_recency_from_lua( + t.get::>("git_recency")?, + )?, }), other => Err(LuaError::RuntimeError(format!( "init opts must be a table, boolean, or nil — got {}", @@ -99,6 +103,45 @@ impl PickerInitOpts { ))), } } + + // Accepts a config table or a plain boolean (`recency = false` disables). + fn git_recency_from_lua(value: Option) -> LuaResult> { + let defaults = GitRecencyConfig::default(); + match value { + None | Some(mlua::Value::Nil) => Ok(None), + Some(mlua::Value::Boolean(enabled)) => Ok(Some(GitRecencyConfig { + enabled, + ..defaults + })), + Some(mlua::Value::Table(t)) => Ok(Some(GitRecencyConfig { + enabled: t + .get::>("enabled")? + .unwrap_or(defaults.enabled), + max_commits: Self::non_negative(&t, "max_commits")?.unwrap_or(defaults.max_commits), + max_files_per_commit: Self::non_negative(&t, "max_files_per_commit")? + .unwrap_or(defaults.max_files_per_commit), + })), + Some(other) => Err(LuaError::RuntimeError(format!( + "git_recency must be a table, boolean, or nil — got {}", + other.type_name() + ))), + } + } + + // Rejects invalid limits with the offending key named, instead of failing + // picker init with mlua's bare conversion error. + fn non_negative(t: &mlua::Table, key: &str) -> LuaResult> { + match t.get::>(key) { + Ok(None) => Ok(None), + Ok(Some(v)) if v >= 0 => Ok(Some(v as usize)), + Ok(Some(v)) => Err(LuaError::RuntimeError(format!( + "git_recency.{key} must be a non-negative integer — got {v}" + ))), + Err(e) => Err(LuaError::RuntimeError(format!( + "git_recency.{key} must be a non-negative integer — {e}" + ))), + } + } } pub fn init_file_picker( @@ -128,6 +171,7 @@ pub fn init_file_picker( follow_symlinks: opts.follow_symlinks, enable_fs_root_scanning: opts.enable_fs_root_scanning, enable_home_dir_scanning: opts.enable_home_dir_scanning, + git_recency: opts.git_recency.unwrap_or_default(), ..Default::default() }, ) @@ -175,7 +219,7 @@ pub fn restart_index_in_path( // Inherit current picker's scanning flags when caller didn't pass // explicit opts — otherwise a `:cd ~` after init would silently lose // the user's `enable_home_dir_scanning = true` setting. - let (follow_symlinks, fs_root, home_dir) = { + let (follow_symlinks, fs_root, home_dir, git_recency) = { let guard = match FILE_PICKER.read() { Ok(g) => g, Err(_) => return, @@ -193,11 +237,13 @@ pub fn restart_index_in_path( p.follows_symlinks() || opts.follow_symlinks, p.fs_root_scanning_enabled() || opts.enable_fs_root_scanning, p.home_dir_scanning_enabled() || opts.enable_home_dir_scanning, + opts.git_recency.unwrap_or_else(|| p.git_recency_config()), ), None => ( opts.follow_symlinks, opts.enable_fs_root_scanning, opts.enable_home_dir_scanning, + opts.git_recency.unwrap_or_default(), ), } }; @@ -220,6 +266,7 @@ pub fn restart_index_in_path( follow_symlinks, enable_fs_root_scanning: fs_root, enable_home_dir_scanning: home_dir, + git_recency, ..Default::default() }, ) { @@ -500,6 +547,7 @@ fn build_file_path_fallback(lua: &Lua, path: &Path, total_files: usize) -> LuaRe item.set("modification_frecency_score", 0i32)?; item.set("total_frecency_score", 0i32)?; item.set("git_status", "")?; + item.set("git_recency_score", 0i32)?; item.set("is_binary", false)?; let items_table = lua.create_table()?; @@ -513,9 +561,11 @@ fn build_file_path_fallback(lua: &Lua, path: &Path, total_files: usize) -> LuaRe score.set("special_filename_bonus", 0)?; score.set("frecency_boost", 0)?; score.set("git_status_boost", 0)?; + score.set("git_recency_boost", 0)?; score.set("distance_penalty", 0)?; score.set("current_file_penalty", 0)?; score.set("combo_match_boost", 0)?; + score.set("path_alignment_bonus", 0)?; score.set("exact_match", true)?; score.set("match_type", "path")?; diff --git a/crates/fff-nvim/src/lua_types.rs b/crates/fff-nvim/src/lua_types.rs index fa82fe82d..34b116c93 100644 --- a/crates/fff-nvim/src/lua_types.rs +++ b/crates/fff-nvim/src/lua_types.rs @@ -75,6 +75,7 @@ fn file_item_into_lua(item: &FileItem, lua: &Lua, picker: &FilePicker) -> LuaRes )?; table.set("total_frecency_score", item.total_frecency_score())?; table.set("git_status", format_git_status(item.git_status))?; + table.set("git_recency_score", item.git_recency_score)?; table.set("is_binary", item.is_binary())?; Ok(LuaValue::Table(table)) } @@ -100,6 +101,8 @@ fn score_into_lua(score: &Score, lua: &Lua) -> LuaResult { table.set("filename_bonus", score.filename_bonus)?; table.set("special_filename_bonus", score.special_filename_bonus)?; table.set("frecency_boost", score.frecency_boost)?; + table.set("git_status_boost", score.git_status_boost)?; + table.set("git_recency_boost", score.git_recency_boost)?; table.set("distance_penalty", score.distance_penalty)?; table.set("current_file_penalty", score.current_file_penalty)?; table.set("combo_match_boost", score.combo_match_boost)?; diff --git a/crates/fff-python/src/finder.rs b/crates/fff-python/src/finder.rs index 7d7857f19..96e57be16 100644 --- a/crates/fff-python/src/finder.rs +++ b/crates/fff-python/src/finder.rs @@ -252,6 +252,7 @@ impl FileFinder { follow_symlinks, enable_fs_root_scanning, enable_home_dir_scanning, + git_recency: Default::default(), }, ) .map_err(py_err) @@ -809,7 +810,16 @@ impl FileFinder { } let canonical = fff::path_utils::canonicalize(&new_path).map_err(py_err)?; - let (warmup_caches, content_indexing, watch, mode, fs_root, home_dir, follow_symlinks) = { + let ( + warmup_caches, + content_indexing, + watch, + mode, + fs_root, + home_dir, + follow_symlinks, + git_recency, + ) = { let guard = picker.read().map_err(py_err)?; if let Some(ref picker) = *guard { ( @@ -820,9 +830,20 @@ impl FileFinder { picker.fs_root_scanning_enabled(), picker.home_dir_scanning_enabled(), picker.follows_symlinks(), + picker.git_recency_config(), ) } else { - (false, true, true, FFFMode::default(), false, false, false) + ( + // just the defaults + false, + true, + true, + FFFMode::default(), + false, + false, + false, + Default::default(), + ) } }; @@ -843,6 +864,7 @@ impl FileFinder { follow_symlinks, enable_fs_root_scanning: fs_root, enable_home_dir_scanning: home_dir, + git_recency, }, ) .map_err(py_err) diff --git a/doc/fff.nvim.txt b/doc/fff.nvim.txt index 15dd35126..6bfce1570 100644 --- a/doc/fff.nvim.txt +++ b/doc/fff.nvim.txt @@ -261,6 +261,11 @@ Defaults are sensible. Override only what you care about. }, git = { status_text_color = false, -- true to color filenames by git status + recency = { + enabled = true, -- boost files from recent commits of the current branch + max_commits = 10, -- analyze the last N branch-specific commits + max_files_per_commit = 50, -- skip bulk commits touching more files than this + }, }, file_picker = { fuzzy_query_highlighting = false, -- true to highlight fuzzy query matches in file picker results @@ -369,6 +374,36 @@ set `git.status_text_color = true` and adjust the `hl.git_*` groups. See `:help fff.nvim` for the full list. +GIT RECENCY RANKING ~ + +Files that participated in recent commits of your current branch get a small +additive score bonus: +1 point per commit within the analyzed window. A file +touched by all of the last 10 commits gets +10; if your branch only has 2 +commits, its files get at most +2. This surfaces the files you are actively +working on — especially right after switching branches or pulling. + +- Only commits unique to the current branch are counted (against the merge + base with `origin/HEAD`/`main`/`master`); on the base branch itself the last + `max_commits` commits are used. +- Merge commits and bulk commits touching more than + `git.recency.max_files_per_commit` files are ignored. +- Scores refresh automatically after commits, checkouts, and rebases; + refreshes are skipped entirely while `HEAD` stays put, so it costs nothing + during regular editing. + +>lua + require('fff').setup({ + git = { + recency = { + enabled = true, -- default: true + max_commits = 10, -- window of branch commits to analyze (also the max bonus) + max_files_per_commit = 50, -- skip merge/bulk commits touching more files + }, + }, + }) +< + + FLOAT COLORS ~ The picker maps its float content to `NormalFloat` (via `hl.normal`) and the diff --git a/lua/fff/conf.lua b/lua/fff/conf.lua index 0b7ad0b06..1de583b27 100644 --- a/lua/fff/conf.lua +++ b/lua/fff/conf.lua @@ -62,6 +62,7 @@ local M = {} --- @field modes string[] --- @field trim_whitespace boolean --- @field location_format string +--- @field enable_filename_constraint boolean --- @alias FffSelectAction 'edit' | 'split' | 'vsplit' | 'tab' @@ -389,6 +390,13 @@ local function init() -- Git integration git = { status_text_color = false, -- Apply git status colors to filename text (default: false, only sign column) + -- Boost files that participated in recent commits of the current branch: + -- +1 score point per commit the file appeared in (max = max_commits) + recency = { + enabled = true, + max_commits = 10, -- Analyze the last N branch-specific commits + max_files_per_commit = 50, -- Skip bulk commits (merges, refactors) touching more files + }, }, debug = { enabled = false, -- Show file info panel in preview diff --git a/lua/fff/core.lua b/lua/fff/core.lua index e0dde3dae..79ff7565a 100644 --- a/lua/fff/core.lua +++ b/lua/fff/core.lua @@ -125,6 +125,7 @@ M.change_indexing_directory = function(new_path) enable_fs_root_scanning = config.enable_fs_root_scanning, enable_home_dir_scanning = config.enable_home_dir_scanning, enable_filename_constraint = config.grep and config.grep.enable_filename_constraint, + git_recency = config.git and config.git.recency, }) if not ok then vim.notify('Failed to change directory: ' .. err, vim.log.levels.ERROR) @@ -191,6 +192,7 @@ M.ensure_initialized = function() enable_fs_root_scanning = config.enable_fs_root_scanning, enable_home_dir_scanning = config.enable_home_dir_scanning, enable_filename_constraint = config.grep and config.grep.enable_filename_constraint, + git_recency = config.git and config.git.recency, }) if not ok then vim.notify('Failed to initialize file picker: ' .. tostring(result), vim.log.levels.ERROR) diff --git a/lua/fff/file_picker/file_info.lua b/lua/fff/file_picker/file_info.lua index 52eace9ac..bf5ff3d88 100644 --- a/lua/fff/file_picker/file_info.lua +++ b/lua/fff/file_picker/file_info.lua @@ -36,6 +36,8 @@ local M = {} ---@field filename_bonus integer ---@field special_filename_bonus integer ---@field frecency_boost integer +---@field git_status_boost integer +---@field git_recency_boost integer ---@field combo_match_boost integer ---@field distance_penalty integer ---@field current_file_penalty integer @@ -246,6 +248,7 @@ local function render_score_section(b, score, file, hls, width) { ' +name ' .. (score.filename_bonus or 0), bonus_hl(score.filename_bonus) }, { ' +special ' .. (score.special_filename_bonus or 0), bonus_hl(score.special_filename_bonus) }, { ' +frec ' .. signed(score.frecency_boost or 0), mod_hl(score.frecency_boost) }, + { ' +recent ' .. signed(score.git_recency_boost or 0), bonus_hl(score.git_recency_boost) }, { ' +combo ' .. signed(score.combo_match_boost or 0), bonus_hl(score.combo_match_boost) }, { ' penalty ' .. total_pen, total_pen > 0 and neg_hl or val_hl }, } diff --git a/tests/git_recency_spec.lua b/tests/git_recency_spec.lua new file mode 100644 index 000000000..d2059e103 --- /dev/null +++ b/tests/git_recency_spec.lua @@ -0,0 +1,108 @@ +---@diagnostic disable: undefined-field, missing-fields +local fff_rust = require('fff.rust') +local fuzzy = require('fff.fuzzy') + +local function git(dir, args) + local cmd = { 'git', '-C', dir, '-c', 'user.name=t', '-c', 'user.email=t@t' } + vim.list_extend(cmd, args) + local out = vim.fn.system(cmd) + assert(vim.v.shell_error == 0, table.concat(cmd, ' ') .. ' failed: ' .. out) +end + +describe('git recency scoring', function() + local tmp + + local function write(rel, content) + local path = tmp .. '/' .. rel + vim.fn.mkdir(vim.fn.fnamemodify(path, ':h'), 'p') + local f = assert(io.open(path, 'w')) + f:write(content) + f:close() + end + + local function commit(rel, content, msg) + write(rel, content) + git(tmp, { 'add', '-A' }) + git(tmp, { 'commit', '-m', msg, '--no-gpg-sign' }) + end + + --- Empty-query (frecency mode) search; returns the item and score for `name`. + local function find(name) + local result = fuzzy.fuzzy_search_files('', 4, nil, 100, 3, 0, 50) + for i, item in ipairs(result.items) do + if item.name == name then return item, result.scores[i] end + end + return nil, nil + end + + local function wait_for_recency(name, expected) + return vim.wait(15000, function() + local item = find(name) + return item ~= nil and item.git_recency_score == expected + end, 50) + end + + before_each(function() + pcall(fff_rust.stop_background_monitor) + pcall(fff_rust.cleanup_file_picker) + + tmp = vim.fn.tempname() + vim.fn.mkdir(tmp, 'p') + tmp = vim.fn.resolve(vim.fn.fnamemodify(tmp, ':p')):gsub('/+$', '') + git(tmp, { 'init', '-b', 'main' }) + commit('hot.lua', 'return 1', 'c1') + commit('cold.lua', 'return 1', 'c2') + commit('hot.lua', 'return 2', 'c3') + end) + + after_each(function() + pcall(fff_rust.stop_background_monitor) + pcall(fff_rust.cleanup_file_picker) + if tmp then vim.fn.delete(tmp, 'rf') end + end) + + it('boosts files by +1 per participating commit', function() + assert.is_true(fff_rust.init_file_picker(tmp)) + fff_rust.wait_for_initial_scan(30000) + + -- Recency scores are applied asynchronously by the git-status worker. + assert.is_true(wait_for_recency('hot.lua', 2), 'hot.lua never reached recency score 2') + + local hot_item, hot_score = find('hot.lua') + local cold_item, cold_score = find('cold.lua') + assert(hot_item and hot_score and cold_item and cold_score) + + assert.are.equal(2, hot_item.git_recency_score) + assert.are.equal(2, hot_score.git_recency_boost) + assert.are.equal(1, cold_item.git_recency_score) + assert.are.equal(1, cold_score.git_recency_boost) + assert.is_true(hot_score.total > cold_score.total, 'recent file must rank higher') + end) + + it('respects the git_recency config passed through init opts', function() + assert.is_true(fff_rust.init_file_picker(tmp, { git_recency = { max_commits = 1 } })) + fff_rust.wait_for_initial_scan(30000) + + -- Only the latest commit (touching hot.lua) is analyzed. + assert.is_true(wait_for_recency('hot.lua', 1), 'hot.lua never reached recency score 1') + local cold_item = find('cold.lua') + assert(cold_item) + assert.are.equal(0, cold_item.git_recency_score) + end) + + it('can be disabled entirely', function() + assert.is_true(fff_rust.init_file_picker(tmp, { git_recency = false })) + fff_rust.wait_for_initial_scan(30000) + + -- Dirty a committed file and refresh synchronously: the status flipping + -- to 'modified' proves a full status+recency pass ran with the feature off. + write('hot.lua', 'return 3') + fff_rust.refresh_git_status() + + local hot_item, hot_score = find('hot.lua') + assert(hot_item and hot_score) + assert.are.equal('modified', hot_item.git_status) + assert.are.equal(0, hot_item.git_recency_score) + assert.are.equal(0, hot_score.git_recency_boost) + end) +end) diff --git a/tests/screenshots/tests-picker_ui_snap.lua---debug---file_info_panel_bottom b/tests/screenshots/tests-picker_ui_snap.lua---debug---file_info_panel_bottom deleted file mode 100644 index ed19ef2b8..000000000 --- a/tests/screenshots/tests-picker_ui_snap.lua---debug---file_info_panel_bottom +++ /dev/null @@ -1,67 +0,0 @@ ---|---------|---------|---------|---------|---------|---------|---------|---------|---------|---------|---------|---------|---------|---------| -01| -02|~ -03|~ -04|~ ┌ FFFiles ────────────────────────────────────────────┬ src/main.rs ───────────────────────────────────────────┐ -05|~ │ │ Size 13 B Type rust │ -06|~ │ │ Git clean Opened never │ -07|~ │ table.tsx src/components │─ Score ────────────────────────────────────────────────│ -08|~ │ list.tsx src/components │ Total 60 fuzzy_filename Frecency acc 0 / mod 0 │ -09|~ │ dialog.tsx src/components │ base 52 +name 8 +special 0 +frec 0 +combo 0 penal…│ -10|~ │ button.tsx src/components │─ Path ─────────────────────────────────────────────────│ -11|~ │ api.rs src │ src/main.rs │ -12|~ │ license.md docs ├────────────────────────────────────────────────────────┤ -13|~ │ regression.rs tests │fn main() {} │ -14|~ │ menu.tsx src/components │ │ -15|~ │ changelog.md docs │ │ -16|~ │ contributing.md docs │ │ -17|~ │ integration.rs tests │ │ -18|~ │ input.tsx src/components │ │ -19|~ │ intro.md docs │ │ -20|~ │ main_test.rs tests │ │ -21|~ │ main_utils.rs src │ │ -22|~ │ main_runner.rs src │ │ -23|~ │ main_loop.rs src │ │ -24|~ │ main_helper.rs src │ │ -25|~ │ main.rs src │ │ -26|~ ├─────────────────────────────────────────────────────┤ │ -27|~ │> main 19/32 │ │ -28|~ └─────────────────────────────────────────────────────┴────────────────────────────────────────────────────────┘ -29|~ -30|~ -31|[No Name] 0,1 All -32|-- INSERT -- 1,7 All - ---|---------|---------|---------|---------|---------|---------|---------|---------|---------|---------|---------|---------|---------|---------| -01|00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -02|11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111 -03|11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111 -04|11111111111111123333333332222222222222222222222222222222222222222222223333333333333222222222222222222222222222222222222222222221111111111111 -05|11111111111111124422222222222222222222222222222222222222222222222222222555522666622222222222555522226666222222222222222222222221111111111111 -06|11111111111111124422222222222222222222222222222222222222222222222222222555222777772222222222555555224444422222222222222222222221111111111111 -07|11111111111111124422222222225555555555555522222222222222222222222222222288888222222222222222222222222222222222222222222222222221111111111111 -08|11111111111111124422222222255555555555555222222222222222222222222222222555552299299999999999999225555555522777777777777722222221111111111111 -09|111111111111111244222222222225555555555555522222222222222222222222222227777777:::::::::777777777777:::::::::77777777777777777721111111111111 -10|11111111111111124422222222222555555555555552222222222222222222222222222288882222222222222222222222222222222222222222222222222221111111111111 -11|11111111111111124422222225552222222222222222222222222222222222222222222;;;;;;;;;;;2222222222222222222222222222222222222222222221111111111111 -12|11111111111111124422222222222555522222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222221111111111111 -13|11111111111111124422222222222222555552222222222222222222222222222222222222222222222222222222222222222222222222222222222222222221111111111111 -14|11111111111111124422222222255555555555555222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -15|11111111111111124422222222222225555222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -16|11111111111111124422222222222222225555222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -17|11111111111111124422222222222222255555222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -18|11111111111111124422222222225555555555555522222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -19|11111111111111124422222222255552222222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -20|111111111111111244<<<<2222222225555522222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -21|111111111111111244<<<<2222222222555222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -22|111111111111111244<<<<2222222222255522222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -23|111111111111111244<<<<2222222225552222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -24|111111111111111244<<<<2222222222255522222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -25|1111111111111112==<<<<>>>>???>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>24444444444444444444444444444444444444444444444444444444421111111111111 -26|11111111111111122222222222222222222222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -27|11111111111111122222222222222222222222222222222222222222222222444442224444444444444444444444444444444444444444444444444444444421111111111111 -28|11111111111111122222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222221111111111111 -29|11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111 -30|11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111 -31|@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ -32|AAAAAAAAAAAABBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB diff --git a/tests/screenshots/tests-picker_ui_snap.lua---debug---file_info_panel_top b/tests/screenshots/tests-picker_ui_snap.lua---debug---file_info_panel_top deleted file mode 100644 index 22562963a..000000000 --- a/tests/screenshots/tests-picker_ui_snap.lua---debug---file_info_panel_top +++ /dev/null @@ -1,67 +0,0 @@ ---|---------|---------|---------|---------|---------|---------|---------|---------|---------|---------|---------|---------|---------|---------| -01| -02|~ -03|~ -04|~ ┌ FFFiles ────────────────────────────────────────────┬ src/main.rs ───────────────────────────────────────────┐ -05|~ │> main 19/32 │Size: 13 B │ Total Score: 60 │ -06|~ ├─────────────────────────────────────────────────────│Type: rust │ Match Type: fuzzy_filename │ -07|~ │ main.rs src │Git: clean │ Frecency Mod: 0, Acc: 0 │ -08|~ │ main_helper.rs src │Score Breakdown: base=52, name_bonus=8, special_bonus=0 │ -09|~ │ main_loop.rs src │Score Modifiers: frec_boost=0, dist_penalty=0, current_p│ -10|~ │ main_runner.rs src │ │ -11|~ │ main_utils.rs src │TIMINGS │ -12|~ │ main_test.rs tests │────────────────────────────────────────────────── │ -13|~ │ intro.md docs │Modified: 2026-05-21 11:31:39 │ -14|~ │ input.tsx src/components │Last Access: 2026-05-21 11:31:39 │ -15|~ │ integration.rs tests ├────────────────────────────────────────────────────────┤ -16|~ │ contributing.md docs │fn main() {} │ -17|~ │ changelog.md docs │ │ -18|~ │ menu.tsx src/components │ │ -19|~ │ regression.rs tests │ │ -20|~ │ license.md docs │ │ -21|~ │ api.rs src │ │ -22|~ │ button.tsx src/components │ │ -23|~ │ dialog.tsx src/components │ │ -24|~ │ list.tsx src/components │ │ -25|~ │ table.tsx src/components │ │ -26|~ │ │ │ -27|~ │ │ │ -28|~ └─────────────────────────────────────────────────────┴────────────────────────────────────────────────────────┘ -29|~ -30|~ -31|[No Name] 0,1 All -32|-- INSERT -- 1,7 All - ---|---------|---------|---------|---------|---------|---------|---------|---------|---------|---------|---------|---------|---------|---------| -01|00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -02|11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111 -03|11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111 -04|11111111111111123333333332222222222222222222222222222222222222222222223333333333333222222222222222222222222222222222222222222221111111111111 -05|11111111111111124444444444444444444444444444444444444444444444555554424444444444444444444444444444444444444444444444444444444421111111111111 -06|11111111111111122222222222222222222222222222222222222222222222222222224444444444444444444444444444444444444444444444444444444421111111111111 -07|11111111111111126677778888999888888888888888888888888888888888888888824444444444444444444444444444444444444444444444444444444421111111111111 -08|111111111111111255777744444444444:::44444444444444444444444444444444424444444444444444444444444444444444444444444444444444444421111111111111 -09|1111111111111112557777444444444:::4444444444444444444444444444444444424444444444444444444444444444444444444444444444444444444421111111111111 -10|111111111111111255777744444444444:::44444444444444444444444444444444424444444444444444444444444444444444444444444444444444444421111111111111 -11|11111111111111125577774444444444:::444444444444444444444444444444444424444444444444444444444444444444444444444444444444444444421111111111111 -12|1111111111111112557777444444444:::::44444444444444444444444444444444424444444444444444444444444444444444444444444444444444444421111111111111 -13|111111111111111255444444444::::4444444444444444444444444444444444444424444444444444444444444444444444444444444444444444444444421111111111111 -14|1111111111111112554444444444::::::::::::::44444444444444444444444444424444444444444444444444444444444444444444444444444444444421111111111111 -15|111111111111111255444444444444444:::::444444444444444444444444444444422222222222222222222222222222222222222222222222222222222221111111111111 -16|1111111111111112554444444444444444::::444444444444444444444444444444424444444444444444444444444444444444444444444444444444444421111111111111 -17|1111111111111112554444444444444::::444444444444444444444444444444444425555555555555555555555555555555555555555555555555555555521111111111111 -18|111111111111111255444444444::::::::::::::444444444444444444444444444425555555555555555555555555555555555555555555555555555555521111111111111 -19|11111111111111125544444444444444:::::4444444444444444444444444444444425555555555555555555555555555555555555555555555555555555521111111111111 -20|11111111111111125544444444444::::44444444444444444444444444444444444425555555555555555555555555555555555555555555555555555555521111111111111 -21|1111111111111112554444444:::4444444444444444444444444444444444444444425555555555555555555555555555555555555555555555555555555521111111111111 -22|11111111111111125544444444444::::::::::::::4444444444444444444444444425555555555555555555555555555555555555555555555555555555521111111111111 -23|11111111111111125544444444444::::::::::::::4444444444444444444444444425555555555555555555555555555555555555555555555555555555521111111111111 -24|111111111111111255444444444::::::::::::::444444444444444444444444444425555555555555555555555555555555555555555555555555555555521111111111111 -25|1111111111111112554444444444::::::::::::::44444444444444444444444444425555555555555555555555555555555555555555555555555555555521111111111111 -26|11111111111111125555555555555555555555555555555555555555555555555555525555555555555555555555555555555555555555555555555555555521111111111111 -27|11111111111111125555555555555555555555555555555555555555555555555555525555555555555555555555555555555555555555555555555555555521111111111111 -28|11111111111111122222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222221111111111111 -29|11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111 -30|11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111 -31|;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -32|<<<<<<<<<<<<================================================================================================================================ diff --git a/tests/screenshots/tests-picker_ui_snap.lua---debug_narrow---file_info_panel_bottom b/tests/screenshots/tests-picker_ui_snap.lua---debug_narrow---file_info_panel_bottom index c66c54f8d..9bbad275e 100644 --- a/tests/screenshots/tests-picker_ui_snap.lua---debug_narrow---file_info_panel_bottom +++ b/tests/screenshots/tests-picker_ui_snap.lua---debug_narrow---file_info_panel_bottom @@ -6,8 +6,8 @@ 05|~ │ │ Size 13 B Type rust │ 06|~ │ │ Git clean Opened never │ 07|~ │ api.rs src │─ Score ────────────────────────────────────────────────│ -08|~ │ table.tsx src/components │ Total 56 fuzzy_filename Frecency acc 0 / mod 0 │ -09|~ │ list.tsx src/components │ base 48 +name 8 +special 0 +frec 0 +combo 0 penal…│ +08|~ │ table.tsx src/components │ Total 57 fuzzy_filename Frecency acc 0 / mod 0 │ +09|~ │ list.tsx src/components │ base 48 +name 8 +special 0 +frec 0 +recent +1 +co…│ 10|~ │ dialog.tsx src/components │─ Path ─────────────────────────────────────────────────│ 11|~ │ button.tsx src/components │ src/main.rs │ 12|~ │ license.md docs ├────────────────────────────────────────────────────────┤ @@ -41,7 +41,7 @@ 06|11111111111111124422222222222222222222222222222222222222222222222222222555222777772222222222555555224444422222222222222222222221111111111111 07|11111111111111124422222225552222222222222222222222222222222222222222222288888222222222222222222222222222222222222222222222222221111111111111 08|11111111111111124422222222225555555555555522222222222222222222222222222555552299299999999999999225555555522777777777777722222221111111111111 -09|111111111111111244222222222555555555555552222222222222222222222222222227777777:::::::::777777777777:::::::::77777777777777777721111111111111 +09|111111111111111244222222222555555555555552222222222222222222222222222227777777:::::::::777777777777:::::::::::::::::::::77777721111111111111 10|11111111111111124422222222222555555555555552222222222222222222222222222288882222222222222222222222222222222222222222222222222221111111111111 11|11111111111111124422222222222555555555555552222222222222222222222222222;;;;;;;;;;;2222222222222222222222222222222222222222222221111111111111 12|11111111111111124422222222222555522222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222221111111111111 diff --git a/tests/screenshots/tests-picker_ui_snap.lua---debug_narrow---file_info_panel_top b/tests/screenshots/tests-picker_ui_snap.lua---debug_narrow---file_info_panel_top index c0837f473..37111c865 100644 --- a/tests/screenshots/tests-picker_ui_snap.lua---debug_narrow---file_info_panel_top +++ b/tests/screenshots/tests-picker_ui_snap.lua---debug_narrow---file_info_panel_top @@ -6,8 +6,8 @@ 05|~ │> main 19/32 │ Size 13 B Type rust │ 06|~ ├─────────────────────────────────────────────────────┤ Git clean Opened never │ 07|~ │ main.rs src │─ Score ────────────────────────────────────────────────│ -08|~ │ main_helper.rs src │ Total 56 fuzzy_filename Frecency acc 0 / mod 0 │ -09|~ │ main_loop.rs src │ base 48 +name 8 +special 0 +frec 0 +combo 0 penal…│ +08|~ │ main_helper.rs src │ Total 57 fuzzy_filename Frecency acc 0 / mod 0 │ +09|~ │ main_loop.rs src │ base 48 +name 8 +special 0 +frec 0 +recent +1 +co…│ 10|~ │ main_runner.rs src │─ Path ─────────────────────────────────────────────────│ 11|~ │ main_utils.rs src │ src/main.rs │ 12|~ │ main_test.rs tests ├────────────────────────────────────────────────────────┤ @@ -41,7 +41,7 @@ 06|11111111111111122222222222222222222222222222222222222222222222222222222555222777772222222222555555224444422222222222222222222221111111111111 07|1111111111111112889999::::;;;::::::::::::::::::::::::::::::::::::::::222<<<<<222222222222222222222222222222222222222222222222221111111111111 08|111111111111111244999922222222222555222222222222222222222222222222222225555522==2==============225555555522777777777777722222221111111111111 -09|111111111111111244999922222222255522222222222222222222222222222222222227777777>>>>>>>>>777777777777>>>>>>>>>77777777777777777721111111111111 +09|111111111111111244999922222222255522222222222222222222222222222222222227777777>>>>>>>>>777777777777>>>>>>>>>>>>>>>>>>>>>77777721111111111111 10|111111111111111244999922222222222555222222222222222222222222222222222222<<<<2222222222222222222222222222222222222222222222222221111111111111 11|11111111111111124499992222222222555222222222222222222222222222222222222???????????2222222222222222222222222222222222222222222221111111111111 12|11111111111111124499992222222225555522222222222222222222222222222222222222222222222222222222222222222222222222222222222222222221111111111111 diff --git a/tests/screenshots/tests-picker_ui_snap.lua---debug_wide---file_info_panel_bottom b/tests/screenshots/tests-picker_ui_snap.lua---debug_wide---file_info_panel_bottom index 83427fbc4..755b81435 100644 --- a/tests/screenshots/tests-picker_ui_snap.lua---debug_wide---file_info_panel_bottom +++ b/tests/screenshots/tests-picker_ui_snap.lua---debug_wide---file_info_panel_bottom @@ -8,8 +8,8 @@ 07|~ │ │ Size 13 B Type rust │ 08|~ │ │ Git clean Opened never │ 09|~ │ │─ Score ────────────────────────────────────────────────────────────────────────────────────────│ -10|~ │ │ Total 56 fuzzy_filename Frecency acc 0 / mod 0 │ -11|~ │ │ base 48 +name 8 +special 0 +frec 0 +combo 0 penalty 0 │ +10|~ │ │ Total 57 fuzzy_filename Frecency acc 0 / mod 0 │ +11|~ │ │ base 48 +name 8 +special 0 +frec 0 +recent +1 +combo 0 penalty 0 │ 12|~ │ │─ Path ─────────────────────────────────────────────────────────────────────────────────────────│ 13|~ │ │ src/main.rs │ 14|~ │ ├────────────────────────────────────────────────────────────────────────────────────────────────┤ @@ -59,7 +59,7 @@ 08|111111111111111111111111124422222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222255522277777222222222255555522444442222222222222222222222222222222222222222222222222222222222222211111111111111111111111 09|111111111111111111111111124422222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222228888822222222222222222222222222222222222222222222222222222222222222222222222222222222222222222211111111111111111111111 10|111111111111111111111111124422222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222255555229929999999999999922555555552277777777777772222222222222222222222222222222222222222222222211111111111111111111111 -11|11111111111111111111111112442222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222227777777:::::::::777777777777:::::::::7777777777777777777772222222222222222222222222222222222222211111111111111111111111 +11|11111111111111111111111112442222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222227777777:::::::::777777777777:::::::::::::::::::::7777777777777777777772222222222222222222222222211111111111111111111111 12|111111111111111111111111124422222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222228888222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222211111111111111111111111 13|1111111111111111111111111244222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222;;;;;;;;;;;222222222222222222222222222222222222222222222222222222222222222222222222222222222222211111111111111111111111 14|111111111111111111111111124422222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222211111111111111111111111 diff --git a/tests/screenshots/tests-picker_ui_snap.lua---debug_wide---file_info_panel_top b/tests/screenshots/tests-picker_ui_snap.lua---debug_wide---file_info_panel_top index e7c83255d..891218476 100644 --- a/tests/screenshots/tests-picker_ui_snap.lua---debug_wide---file_info_panel_top +++ b/tests/screenshots/tests-picker_ui_snap.lua---debug_wide---file_info_panel_top @@ -8,8 +8,8 @@ 07|~ │> main 19/32 │ Size 13 B Type rust │ 08|~ ├─────────────────────────────────────────────────────────────────────────────────────────────┤ Git clean Opened never │ 09|~ │ main.rs src │─ Score ────────────────────────────────────────────────────────────────────────────────────────│ -10|~ │ main_helper.rs src │ Total 56 fuzzy_filename Frecency acc 0 / mod 0 │ -11|~ │ main_loop.rs src │ base 48 +name 8 +special 0 +frec 0 +combo 0 penalty 0 │ +10|~ │ main_helper.rs src │ Total 57 fuzzy_filename Frecency acc 0 / mod 0 │ +11|~ │ main_loop.rs src │ base 48 +name 8 +special 0 +frec 0 +recent +1 +combo 0 penalty 0 │ 12|~ │ main_runner.rs src │─ Path ─────────────────────────────────────────────────────────────────────────────────────────│ 13|~ │ main_utils.rs src │ src/main.rs │ 14|~ │ main_test.rs tests ├────────────────────────────────────────────────────────────────────────────────────────────────┤ @@ -59,7 +59,7 @@ 08|111111111111111111111111122222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222255522277777222222222255555522444442222222222222222222222222222222222222222222222222222222222222211111111111111111111111 09|11111111111111111111111112889999::::;;;::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::222<<<<<22222222222222222222222222222222222222222222222222222222222222222222222222222222222222222211111111111111111111111 10|11111111111111111111111112449999222222222225552222222222222222222222222222222222222222222222222222222222222222222222222225555522==2==============22555555552277777777777772222222222222222222222222222222222222222222222211111111111111111111111 -11|11111111111111111111111112449999222222222555222222222222222222222222222222222222222222222222222222222222222222222222222227777777>>>>>>>>>777777777777>>>>>>>>>7777777777777777777772222222222222222222222222222222222222211111111111111111111111 +11|11111111111111111111111112449999222222222555222222222222222222222222222222222222222222222222222222222222222222222222222227777777>>>>>>>>>777777777777>>>>>>>>>>>>>>>>>>>>>7777777777777777777772222222222222222222222222211111111111111111111111 12|11111111111111111111111112449999222222222225552222222222222222222222222222222222222222222222222222222222222222222222222222<<<<222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222211111111111111111111111 13|1111111111111111111111111244999922222222225552222222222222222222222222222222222222222222222222222222222222222222222222222???????????222222222222222222222222222222222222222222222222222222222222222222222222222222222222211111111111111111111111 14|111111111111111111111111124499992222222225555522222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222211111111111111111111111