diff --git a/lua/fff/file_picker/init.lua b/lua/fff/file_picker/init.lua index d40c1d29..80fde5d7 100644 --- a/lua/fff/file_picker/init.lua +++ b/lua/fff/file_picker/init.lua @@ -137,10 +137,10 @@ end --- Record file access for frecency tracking --- @param file_path string Path to the file that was accessed -function M.access_file(file_path) +function M.track_access(file_path) if not M.state.initialized then return end - local ok, result = pcall(fuzzy.access_file, file_path) + local ok, result = pcall(fuzzy.track_access, file_path) if not ok then vim.notify('Failed to record file access: ' .. result, vim.log.levels.WARN) end end diff --git a/lua/fff/fuzzy.lua b/lua/fff/fuzzy.lua index 7705d454..2d994918 100644 --- a/lua/fff/fuzzy.lua +++ b/lua/fff/fuzzy.lua @@ -19,7 +19,7 @@ M.restart_index_in_path = rust_module.restart_index_in_path M.scan_files = rust_module.scan_files M.get_cached_files = rust_module.get_cached_files M.fuzzy_search_files = rust_module.fuzzy_search_files -M.access_file = rust_module.access_file +M.track_access = rust_module.track_access M.add_file = rust_module.add_file M.remove_file = rust_module.remove_file M.cancel_scan = rust_module.cancel_scan diff --git a/lua/fff/main.lua b/lua/fff/main.lua index 51ab36f9..20c1067f 100644 --- a/lua/fff/main.lua +++ b/lua/fff/main.lua @@ -130,23 +130,26 @@ function M.setup_global_autocmds() local group = vim.api.nvim_create_augroup('fff_file_tracking', { clear = true }) if M.config.frecency.enabled then - vim.api.nvim_create_autocmd({ 'BufRead', 'BufNewFile' }, { + vim.api.nvim_create_autocmd({ 'BufReadPost' }, { group = group, + desc = 'Track file access for FFF frecency', callback = function(args) local file_path = args.file + if not (file_path and file_path ~= '' and not vim.startswith(file_path, 'term://')) then return end + + vim.uv.fs_stat(file_path, function(err, stat) + if err or not stat then return end + + vim.uv.fs_realpath(file_path, function(rp_err, real_path) + if rp_err or not real_path then return end + local ok, track_err = pcall(fuzzy.track_access, real_path) - if file_path and file_path ~= '' and not vim.startswith(file_path, 'term://') then - -- never block the UI - vim.schedule(function() - local stat = vim.uv.fs_stat(file_path) - if stat and stat.type == 'file' then - local relative_path = vim.fn.fnamemodify(file_path, ':.') - pcall(fuzzy.access_file, relative_path) + if not ok then + vim.notify('FFF: Failed to track file access: ' .. tostring(track_err), vim.log.levels.ERROR) end end) - end + end) end, - desc = 'Track file access for FFF frecency', }) end diff --git a/lua/fff/picker_ui.lua b/lua/fff/picker_ui.lua index 413da014..e2399ea5 100644 --- a/lua/fff/picker_ui.lua +++ b/lua/fff/picker_ui.lua @@ -505,11 +505,11 @@ function M.render_list() if total_frecency > 0 and debug_enabled then local indicator = '' - if mod_frecency >= 6 then -- High modification frecency (recently modified git file) + if mod_frecency >= 8 then -- High modification frecency (recently modified git file) indicator = '🔥' -- Fire for recently modified - elseif access_frecency >= 4 then -- High access frecency (recently accessed) + elseif access_frecency >= 8 then -- High access frecency (recently accessed) indicator = '⭐' -- Star for frequently accessed - elseif total_frecency >= 3 then -- Medium total frecency + elseif total_frecency >= 4 then -- Medium total frecency indicator = '✨' -- Sparkle for moderate activity elseif total_frecency >= 1 then -- Low frecency indicator = '•' -- Dot for minimal activity @@ -856,8 +856,6 @@ function M.select(action) action = action or 'edit' local relative_path = vim.fn.fnamemodify(item.path, ':.') - file_picker.access_file(relative_path) - vim.cmd('stopinsert') M.close() diff --git a/lua/fff/rust/file_key.rs b/lua/fff/rust/file_key.rs deleted file mode 100644 index e4abc0b4..00000000 --- a/lua/fff/rust/file_key.rs +++ /dev/null @@ -1,22 +0,0 @@ -#[derive(Debug, Clone)] -pub struct FileKey { - pub path: String, -} - -impl FileKey { - pub fn new(path: String) -> Self { - Self { path } - } - - pub fn into_path_buf(self) -> std::path::PathBuf { - std::path::PathBuf::from(self.path) - } - - pub fn as_path(&self) -> &std::path::Path { - std::path::Path::new(&self.path) - } - - pub fn into_string(self) -> String { - self.path - } -} diff --git a/lua/fff/rust/file_picker.rs b/lua/fff/rust/file_picker.rs index c75095dc..a3a2496f 100644 --- a/lua/fff/rust/file_picker.rs +++ b/lua/fff/rust/file_picker.rs @@ -1,8 +1,7 @@ use crate::background_watcher::BackgroundWatcher; use crate::error::Error; -use crate::file_key::FileKey; use crate::frecency::FrecencyTracker; -use crate::git::{format_git_status, GitStatusCache}; +use crate::git::GitStatusCache; use crate::score::match_and_score_files; use crate::types::{FileItem, ScoringContext, SearchResult}; use git2::{Repository, Status, StatusOptions}; @@ -78,10 +77,9 @@ impl FileItem { } pub fn update_frecency_scores(&mut self, tracker: &FrecencyTracker) -> Result<(), Error> { - let file_key = FileKey::from(&*self); - self.access_frecency_score = tracker.get_access_score(&file_key); + self.access_frecency_score = tracker.get_access_score(&self.path); self.modification_frecency_score = - tracker.get_modification_score(self.modified, format_git_status(self.git_status)); + tracker.get_modification_score(self.modified, self.git_status); self.total_frecency_score = self.access_frecency_score + self.modification_frecency_score; Ok(()) @@ -98,14 +96,6 @@ impl FileItem { } } -impl From<&FileItem> for FileKey { - fn from(file: &FileItem) -> Self { - FileKey { - path: file.relative_path.clone(), - } - } -} - pub struct FilePicker { base_path: PathBuf, sync_data: FileSync, @@ -572,19 +562,19 @@ fn scan_filesystem( })?; let frecency = FRECENCY.read().map_err(|_| Error::AcquireFrecencyLock)?; - if let Some(git_cache) = &git_cache { - files - .par_iter_mut() - .try_for_each(|file| -> Result<(), Error> { + files + .par_iter_mut() + .try_for_each(|file| -> Result<(), Error> { + if let Some(git_cache) = &git_cache { file.git_status = git_cache.lookup_status(&file.path); + } - if let Some(frecency) = frecency.as_ref() { - file.update_frecency_scores(frecency)?; - } + if let Some(frecency) = frecency.as_ref() { + file.update_frecency_scores(frecency)?; + } - Ok(()) - })?; - } + Ok(()) + })?; let total_time = scan_start.elapsed(); info!( diff --git a/lua/fff/rust/frecency.rs b/lua/fff/rust/frecency.rs index 78b625d9..a1ac1dab 100644 --- a/lua/fff/rust/frecency.rs +++ b/lua/fff/rust/frecency.rs @@ -1,13 +1,12 @@ -use crate::error::Error; -use crate::file_key::FileKey; +use crate::{error::Error, git::is_modified_status}; use heed::{ types::{Bytes, SerdeBincode}, EnvFlags, }; use heed::{Database, Env, EnvOpenOptions}; -use std::collections::VecDeque; use std::fs; use std::time::{SystemTime, UNIX_EPOCH}; +use std::{collections::VecDeque, path::Path}; const DECAY_CONSTANT: f64 = 0.0693; // ln(2)/10 for 10-day half-life const SECONDS_PER_DAY: f64 = 86400.0; @@ -19,9 +18,9 @@ pub struct FrecencyTracker { db: Database>>, } -const ACCESS_THRESHOLDS: [(i64, u64); 5] = [ - (12, 60 * 2), // 2 minutes - (6, 60 * 10), // 10 minutes +const MODIFICATION_THRESHOLDS: [(i64, u64); 5] = [ + (16, 60 * 2), // 2 minutes + (8, 60 * 15), // 15 minutes (4, 60 * 60), // 1 hour (2, 60 * 60 * 24), // 1 day (1, 60 * 60 * 24 * 7), // 1 week @@ -52,9 +51,10 @@ impl FrecencyTracker { }) } - fn get_accesses(&self, file_key: &FileKey) -> Result>, Error> { + fn get_accesses(&self, path: &Path) -> Result>, Error> { let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?; - let key_hash = Self::path_to_hash_bytes(&file_key.path); + + let key_hash = Self::path_to_hash_bytes(path)?; self.db.get(&rtxn, &key_hash).map_err(Error::DbRead) } @@ -65,15 +65,19 @@ impl FrecencyTracker { .as_secs() } - fn path_to_hash_bytes(path: &str) -> [u8; 32] { - *blake3::hash(path.as_bytes()).as_bytes() + fn path_to_hash_bytes(path: &Path) -> Result<[u8; 32], Error> { + let Some(key) = path.to_str() else { + return Err(Error::InvalidPath(path.to_path_buf())); + }; + + Ok(*blake3::hash(key.as_bytes()).as_bytes()) } - pub fn track_access(&self, file_key: &FileKey) -> Result<(), Error> { + pub fn track_access(&self, path: &Path) -> Result<(), Error> { let mut wtxn = self.env.write_txn().map_err(Error::DbStartWriteTxn)?; - let key_hash = Self::path_to_hash_bytes(&file_key.path); - let mut accesses = self.get_accesses(file_key)?.unwrap_or_default(); + let key_hash = Self::path_to_hash_bytes(path)?; + let mut accesses = self.get_accesses(path)?.unwrap_or_default(); let now = self.get_now(); let cutoff_time = now.saturating_sub((MAX_HISTORY_DAYS * SECONDS_PER_DAY) as u64); @@ -86,6 +90,8 @@ impl FrecencyTracker { } accesses.push_back(now); + tracing::debug!(?path, accesses = accesses.len(), "Tracking access"); + self.db .put(&mut wtxn, &key_hash, &accesses) .map_err(Error::DbWrite)?; @@ -95,10 +101,12 @@ impl FrecencyTracker { Ok(()) } - pub fn get_access_score(&self, file_key: &FileKey) -> i64 { + pub fn get_access_score(&self, file_path: &Path) -> i64 { + tracing::debug!(?file_path, "Calculating access score"); let accesses = self - .get_accesses(file_key) - .unwrap_or(None) + .get_accesses(file_path) + .ok() + .flatten() .unwrap_or_default(); if accesses.is_empty() { @@ -129,23 +137,38 @@ impl FrecencyTracker { normalized_frecency.round() as i64 } - /// Calculate modification frecency score (0-12 points, git-aware) - pub fn get_modification_score(&self, modified_time: u64, git_status: &str) -> i64 { - let git_shows_changes = matches!( - git_status, - "modified" | "staged_modified" | "untracked" | "staged_new" - ); - - if !git_shows_changes { - return 0; // No modification score for clean/unchanged files + /// Calculating modification score but only if the file is modified in the current git dir + pub fn get_modification_score( + &self, + modified_time: u64, + git_status: Option, + ) -> i64 { + let is_modified_git_status = git_status.is_some_and(is_modified_status); + if !is_modified_git_status { + return 0; } let now = self.get_now(); let duration_since = now.saturating_sub(modified_time); - for (base_points, threshold_seconds) in ACCESS_THRESHOLDS { - if duration_since <= threshold_seconds { - return base_points * 2; + for i in 0..MODIFICATION_THRESHOLDS.len() { + let (current_points, current_threshold) = MODIFICATION_THRESHOLDS[i]; + + if duration_since <= current_threshold { + if i == 0 || duration_since == current_threshold { + return current_points; + } + + let (prev_points, prev_threshold) = MODIFICATION_THRESHOLDS[i - 1]; + + let time_range = current_threshold - prev_threshold; + let time_offset = duration_since - prev_threshold; + let points_diff = prev_points - current_points; + + let interpolated_score = + prev_points - (points_diff * time_offset as i64) / time_range as i64; + + return interpolated_score; } } @@ -221,4 +244,48 @@ mod tests { old_score ); } + + #[test] + fn test_modification_score_interpolation() { + let temp_dir = std::env::temp_dir().join("fff_test_interpolation"); + let _ = std::fs::remove_dir_all(&temp_dir); + let tracker = FrecencyTracker::new(temp_dir.to_str().unwrap(), true).unwrap(); + + let current_time = tracker.get_now(); + let git_status = Some(git2::Status::WT_MODIFIED); + + // At 5 minutes: should interpolate between 16 and 8 points + let five_minutes_ago = current_time - (5 * 60); + let score = tracker.get_modification_score(five_minutes_ago, git_status); + + // Expected: 16 - (8 * 3 / 13) = 16 - 1 = 15 points + // (time_offset = 5-2 = 3, time_range = 15-2 = 13, points_diff = 16-8 = 8) + assert_eq!(score, 15, "5 minutes should interpolate to 15 points"); + + let two_minutes_ago = current_time - (2 * 60); + let score = tracker.get_modification_score(two_minutes_ago, git_status); + assert_eq!(score, 16, "2 minutes should be exactly 16 points"); + + let fifteen_minutes_ago = current_time - (15 * 60); + let score = tracker.get_modification_score(fifteen_minutes_ago, git_status); + assert_eq!(score, 8, "15 minutes should be exactly 8 points"); + + // At 12 hours: should interpolate between 4 and 2 points + let twelve_hours_ago = current_time - (12 * 60 * 60); + let score = tracker.get_modification_score(twelve_hours_ago, git_status); + // Expected: 4 - (2 * 11 / 23) = 4 - 0 = 4 points (integer division) + // (time_offset = 12-1 = 11 hours, time_range = 24-1 = 23 hours, points_diff = 4-2 = 2) + assert_eq!(score, 4, "12 hours should interpolate to 4 points"); + + // at 18 hours for more significant interpolation + let eighteen_hours_ago = current_time - (18 * 60 * 60); + let score = tracker.get_modification_score(eighteen_hours_ago, git_status); + // Expected: 4 - (2 * 17 / 23) = 4 - 1 = 3 points + assert_eq!(score, 3, "18 hours should interpolate to 3 points"); + + let score = tracker.get_modification_score(five_minutes_ago, None); + assert_eq!(score, 0, "No git status should return 0"); + + let _ = std::fs::remove_dir_all(&temp_dir); + } } diff --git a/lua/fff/rust/lib.rs b/lua/fff/rust/lib.rs index 993597e7..7735fc2d 100644 --- a/lua/fff/rust/lib.rs +++ b/lua/fff/rust/lib.rs @@ -1,15 +1,14 @@ use crate::error::Error; -use crate::file_key::FileKey; use crate::file_picker::FilePicker; use crate::frecency::FrecencyTracker; use mlua::prelude::*; use once_cell::sync::Lazy; +use std::path::PathBuf; use std::sync::RwLock; use std::time::Duration; mod background_watcher; mod error; -mod file_key; pub mod file_picker; mod frecency; pub mod git; @@ -112,7 +111,7 @@ pub fn fuzzy_search_files( results.into_lua(lua) } -pub fn access_file(_: &Lua, file_path: String) -> LuaResult { +pub fn track_access(_: &Lua, file_path: String) -> LuaResult { let Some(ref frecency) = *FRECENCY.read().map_err(|_| Error::AcquireFrecencyLock)? else { return Ok(false); }; @@ -120,10 +119,9 @@ pub fn access_file(_: &Lua, file_path: String) -> LuaResult { return Err(Error::FilePickerMissing)?; }; - let file_key = FileKey::new(file_path); - frecency.track_access(&file_key)?; + let file_path = PathBuf::from(&file_path).canonicalize()?; + frecency.track_access(file_path.as_path())?; - let file_path = file_key.into_path_buf(); picker.update_single_file_frecency(&file_path, frecency)?; Ok(true) @@ -242,7 +240,7 @@ fn create_exports(lua: &Lua) -> LuaResult { "fuzzy_search_files", lua.create_function(fuzzy_search_files)?, )?; - exports.set("access_file", lua.create_function(access_file)?)?; + exports.set("track_access", lua.create_function(track_access)?)?; exports.set("cancel_scan", lua.create_function(cancel_scan)?)?; exports.set("get_scan_progress", lua.create_function(get_scan_progress)?)?; exports.set(