From 25822ca88f0b07590e5c86370f73c48291c06a16 Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Fri, 24 Jul 2026 16:51:14 +0800 Subject: [PATCH] feat(search): invalidate file index cache on git file changes - Add an `invalidate_file_index_cache` command backed by a dedicated `FilePathIndexCache` (search/file/index_cache.rs), replacing the ad-hoc prune/insert helpers on the plain HashMap. - Emit git-watch file-change events (event_emitter) and, on the frontend, invalidate the affected project's fuzzy file index via a new `fileIndexInvalidation` hook wired through GitStatusContext, so stale entries don't linger after files change on disk. - Add `invalidateFileIndexCache` / `shouldPrewarmFileIndex` fileSearch helpers. Adds unit tests for the invalidation hook and the fileSearch helpers. --- .../crates/git/src/watch/event_emitter.rs | 4 +- src-tauri/crates/search/src/file.rs | 345 +++++------- .../crates/search/src/file/index_cache.rs | 506 ++++++++++++++++++ .../crates/search/src/tests/file_tests.rs | 92 ++-- src-tauri/src/commands/handler_list.inc | 1 + .../GitStatusContext/GitStatusProvider.tsx | 14 + .../hooks/__tests__/TEST_CASES.md | 34 ++ .../__tests__/fileIndexInvalidation.test.ts | 75 +++ .../hooks/fileIndexInvalidation.ts | 52 ++ .../hooks/useGitEventListeners.ts | 60 ++- src/util/platform/tauri/fileSearch.test.ts | 14 + src/util/platform/tauri/fileSearch.ts | 53 +- 12 files changed, 969 insertions(+), 281 deletions(-) create mode 100644 src-tauri/crates/search/src/file/index_cache.rs create mode 100644 src/contexts/git/GitStatusContext/hooks/__tests__/TEST_CASES.md create mode 100644 src/contexts/git/GitStatusContext/hooks/__tests__/fileIndexInvalidation.test.ts create mode 100644 src/contexts/git/GitStatusContext/hooks/fileIndexInvalidation.ts create mode 100644 src/util/platform/tauri/fileSearch.test.ts diff --git a/src-tauri/crates/git/src/watch/event_emitter.rs b/src-tauri/crates/git/src/watch/event_emitter.rs index 20d7d8376..2d9a0adfe 100644 --- a/src-tauri/crates/git/src/watch/event_emitter.rs +++ b/src-tauri/crates/git/src/watch/event_emitter.rs @@ -43,13 +43,15 @@ impl EventEmitter { }); let payload = json!({ + "type": "repo:changed", "repo_id": repo_id, "change_type": change_type_str, "affected_count": affected_count, "timestamp": Self::current_timestamp_ms(), }); - let _ = self.app_handle.emit("repo:changed", payload); + let _ = self.app_handle.emit("repo:changed", payload.clone()); + crate::hooks::websocket_broadcast(payload.to_string()); } /// Emit file changed event (for Filesync channel - individual file changes) diff --git a/src-tauri/crates/search/src/file.rs b/src-tauri/crates/search/src/file.rs index 62279d27d..e8276ef93 100644 --- a/src-tauri/crates/search/src/file.rs +++ b/src-tauri/crates/search/src/file.rs @@ -15,12 +15,16 @@ use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern}; use nucleo_matcher::{Config, Matcher, Utf32Str}; use rayon::prelude::*; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; use std::path::PathBuf; -use std::sync::{Arc, Mutex}; -use std::time::Instant; +use std::sync::{Arc, LazyLock}; +use std::time::{Duration, Instant}; use tracing::{debug, info, warn}; +#[path = "file/index_cache.rs"] +mod index_cache; + +use index_cache::FilePathIndexCache; + // ============================================ // Types // ============================================ @@ -62,86 +66,42 @@ struct FileEntry { is_dir: bool, } -struct FileIndex { - entries: Arc>, - _root_path: String, - indexed_at: std::time::SystemTime, - estimated_bytes: usize, -} - -/// Cache TTL — 5 minutes. The old 30 s TTL caused a cold re-walk every time -/// the user paused for half a minute between @ searches. -const CACHE_TTL_SECS: u64 = 300; +/// File changes invalidate indexes through the repository watcher. This slow +/// safety TTL only recovers from a missed watcher event; it is not a polling +/// cadence and does not create background work by itself. +const CACHE_SAFETY_TTL: Duration = Duration::from_secs(60 * 60); const MAX_CACHED_FILE_INDEXES: usize = 4; const MAX_FILE_INDEX_BYTES: usize = 32 * 1024 * 1024; const MAX_FILE_INDEX_CACHE_BYTES: usize = 64 * 1024 * 1024; -static FILE_INDEX_CACHE: std::sync::LazyLock>>> = - std::sync::LazyLock::new(|| Arc::new(Mutex::new(HashMap::new()))); - -fn prune_file_index_cache(cache: &mut HashMap) { - cache.retain(|_, index| { - index - .indexed_at - .elapsed() - .map(|elapsed| elapsed.as_secs() < CACHE_TTL_SECS) - .unwrap_or(false) - }); - - let mut total_bytes = cache - .values() - .map(|index| index.estimated_bytes) - .sum::(); - while cache.len() > MAX_CACHED_FILE_INDEXES || total_bytes > MAX_FILE_INDEX_CACHE_BYTES { - let Some(oldest_key) = cache - .iter() - .min_by_key(|(_, index)| index.indexed_at) - .map(|(root_path, _)| root_path.clone()) - else { - break; - }; - if let Some(removed) = cache.remove(&oldest_key) { - total_bytes = total_bytes.saturating_sub(removed.estimated_bytes); - } - } -} - -fn estimate_file_index_bytes(entries: &[FileEntry]) -> usize { - std::mem::size_of_val(entries) - + entries - .iter() - .map(|entry| entry.path.len() + entry.filename.len()) - .sum::() -} - -fn insert_file_index_cache_entry( - root_path: String, - entries: Arc>, - indexed_at: std::time::SystemTime, -) { - let estimated_bytes = estimate_file_index_bytes(&entries); - if estimated_bytes > MAX_FILE_INDEX_BYTES { - debug!( - root_path = %root_path, - entries = entries.len(), - estimated_bytes, - "search::file: index exceeds per-repository cache budget; using it for this request only" - ); - return; - } - - let mut cache = FILE_INDEX_CACHE.lock().unwrap(); - prune_file_index_cache(&mut cache); - cache.insert( - root_path.clone(), - FileIndex { - entries, - _root_path: root_path, - indexed_at, - estimated_bytes, - }, - ); - prune_file_index_cache(&mut cache); +static FILE_INDEX_CACHE: LazyLock = LazyLock::new(|| { + FilePathIndexCache::new( + CACHE_SAFETY_TTL, + MAX_CACHED_FILE_INDEXES, + MAX_FILE_INDEX_BYTES, + MAX_FILE_INDEX_CACHE_BYTES, + ) +}); + +const DEFAULT_EXCLUDED_DIRS: &[&str] = &[ + "node_modules", + ".git", + "dist", + "build", + ".next", + "target", + ".cache", + "coverage", + "__pycache__", + ".venv", + "venv", +]; + +fn default_excluded_dirs() -> Vec { + DEFAULT_EXCLUDED_DIRS + .iter() + .map(|directory| (*directory).to_string()) + .collect() } // ============================================ @@ -173,17 +133,29 @@ fn build_file_index(root_path: &str, exclude_dirs: &[String]) -> Vec // Skip excluded directories at the walker level so we never descend // into node_modules, .git, etc. This is orders of magnitude faster // than post-filtering. + let root = std::path::PathBuf::from(root_path); + let filter_root = root.clone(); builder.filter_entry(move |entry| { if entry.file_type().is_some_and(|ft| ft.is_dir()) { let name = entry.file_name().to_string_lossy(); if exclude_set.contains(name.as_ref()) { return false; } + + // ORG2 runtime worktrees contain full repository copies and their + // generated artifacts. They are implementation state, not distinct + // user files, so descending into them multiplies every index walk. + if let Ok(relative) = entry.path().strip_prefix(&filter_root) { + if relative == std::path::Path::new(".worktrees") + || relative == std::path::Path::new(".orgii/worktrees") + { + return false; + } + } } true }); - let root = std::path::Path::new(root_path); let walker = builder.build(); let entries: Vec = walker @@ -224,42 +196,21 @@ fn build_file_index(root_path: &str, exclude_dirs: &[String]) -> Vec /// **never** during the expensive `build_file_index` walk. This means /// concurrent searches for different repos proceed in parallel, and a /// slow index build for repo A won't block a cached lookup for repo B. -fn get_file_index(root_path: &str, exclude_dirs: &[String]) -> Arc> { - // 1. Quick check under the lock — return cached entries if fresh. - { - let mut cache = FILE_INDEX_CACHE.lock().unwrap(); - prune_file_index_cache(&mut cache); - if let Some(index) = cache.get(root_path) { - if let Ok(elapsed) = index.indexed_at.elapsed() { - if elapsed.as_secs() < CACHE_TTL_SECS { - return Arc::clone(&index.entries); - } - } - } - } // ← lock released here - - // 2. Validate the path before spending time walking it. - // Protects against bad descriptors after rapid repo switches. +fn get_file_index(root_path: &str, exclude_dirs: &[String]) -> Result, String> { + // Validate the path before spending time walking it. Protects against bad + // descriptors after rapid repo switches. let root = std::path::Path::new(root_path); if !root.exists() || !root.is_dir() { warn!( root_path = %root_path, "search::file: root path invalid or gone; skipping index" ); - return Arc::new(Vec::new()); + return Ok(Arc::from(Vec::::new())); } - // 3. Build index WITHOUT holding the lock. - let entries = Arc::new(build_file_index(root_path, exclude_dirs)); - - // 4. Re-acquire lock to store. - insert_file_index_cache_entry( - root_path.to_string(), - Arc::clone(&entries), - std::time::SystemTime::now(), - ); - - entries + FILE_INDEX_CACHE.get_or_build(root_path, exclude_dirs, || { + build_file_index(root_path, exclude_dirs) + }) } // ============================================ @@ -271,29 +222,25 @@ fn score_entry( entry: &FileEntry, pattern: &Pattern, matcher: &mut Matcher, -) -> Option<(FileEntry, i64)> { - // Buffer for UTF-32 conversion - let mut buf = Vec::new(); + buf: &mut Vec, +) -> Option { + buf.clear(); // Convert filename to Utf32Str for nucleo - let filename_utf32 = Utf32Str::new(&entry.filename, &mut buf); + let filename_utf32 = Utf32Str::new(&entry.filename, buf); // Try matching against filename first (higher priority) if let Some(score) = pattern.score(filename_utf32, matcher) { // Boost filename matches significantly let boosted_score = (score as i64) * 2; - return Some((entry.clone(), boosted_score)); + return Some(boosted_score); } // Clear buffer and try matching against full path buf.clear(); - let path_utf32 = Utf32Str::new(&entry.path, &mut buf); - - if let Some(score) = pattern.score(path_utf32, matcher) { - return Some((entry.clone(), score as i64)); - } + let path_utf32 = Utf32Str::new(&entry.path, buf); - None + pattern.score(path_utf32, matcher).map(i64::from) } /// Perform fuzzy search on the file index @@ -325,9 +272,11 @@ fn fuzzy_search( ); // Use parallel processing for large indices - let results: Vec<(FileEntry, i64)> = entries + let mut scored_results: Vec<(usize, i64)> = entries .par_iter() + .enumerate() .filter(|entry| { + let entry = entry.1; // Filter by extension if specified if let Some(extensions) = file_extensions { if !entry.is_dir { @@ -339,19 +288,33 @@ fn fuzzy_search( } true }) - .filter_map(|entry| { - // Each thread gets its own matcher - let mut matcher = Matcher::new(Config::DEFAULT); - score_entry(entry, &pattern, &mut matcher) - }) + .map_init( + || (Matcher::new(Config::DEFAULT), Vec::new()), + |(matcher, buf), (index, entry)| { + score_entry(entry, &pattern, matcher, buf).map(|score| (index, score)) + }, + ) + .filter_map(|result| result) .collect(); - // Sort by score descending and take top results - let mut sorted_results = results; - sorted_results.sort_by_key(|result| std::cmp::Reverse(result.1)); - sorted_results.truncate(max_results); + if max_results == 0 { + return Vec::new(); + } + + // Partition first so only the requested top-K needs a full sort. + let compare_rank = |left: &(usize, i64), right: &(usize, i64)| { + right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0)) + }; + if scored_results.len() > max_results { + scored_results.select_nth_unstable_by(max_results, compare_rank); + scored_results.truncate(max_results); + } + scored_results.sort_unstable_by(compare_rank); - sorted_results + scored_results + .into_iter() + .map(|(index, score)| (entries[index].clone(), score)) + .collect() } // ============================================ @@ -371,30 +334,23 @@ pub async fn search_files_fuzzy(options: SearchOptions) -> Result = Vec::new(); @@ -450,33 +406,16 @@ pub async fn index_project_files( } // Default exclusions - let default_excludes = vec![ - "node_modules".to_string(), - ".git".to_string(), - "dist".to_string(), - "build".to_string(), - ".next".to_string(), - "target".to_string(), - ]; + let default_excludes = default_excluded_dirs(); let exclude_dirs = exclude_dirs.unwrap_or(default_excludes); - // Clear existing cache for this path - { - let mut cache = FILE_INDEX_CACHE.lock().unwrap(); - cache.remove(&root_path); - } - - // Build fresh index - let entries = Arc::new(build_file_index(&root_path, &exclude_dirs)); + // Invalidate every exclusion-policy variant for this root. A build + // that started before this force request cannot repopulate the cache. + FILE_INDEX_CACHE.invalidate_root(&root_path); + let entries = get_file_index(&root_path, &exclude_dirs)?; let count = entries.len(); - insert_file_index_cache_entry( - root_path, - Arc::clone(&entries), - std::time::SystemTime::now(), - ); - let duration = start.elapsed(); info!(entries = count, ?duration, "search::file: indexed entries"); @@ -502,50 +441,12 @@ pub async fn prewarm_file_index(root_path: String) -> Result { )); } - // Check if already cached and fresh — skip the walk entirely. - { - let mut cache = FILE_INDEX_CACHE.lock().unwrap(); - prune_file_index_cache(&mut cache); - if let Some(index) = cache.get(&root_path) { - if let Ok(elapsed) = index.indexed_at.elapsed() { - if elapsed.as_secs() < CACHE_TTL_SECS { - debug!( - entries = index.entries.len(), - age_secs = elapsed.as_secs_f64(), - "search::file: prewarm skipped; cache still fresh" - ); - return Ok(index.entries.len()); - } - } - } - } - debug!(root_path = %root_path, "search::file: prewarming index"); - let default_excludes = vec![ - "node_modules".to_string(), - ".git".to_string(), - "dist".to_string(), - "build".to_string(), - ".next".to_string(), - "target".to_string(), - ".cache".to_string(), - "coverage".to_string(), - "__pycache__".to_string(), - ".venv".to_string(), - "venv".to_string(), - ]; - - // Build WITHOUT holding the lock. - let entries = Arc::new(build_file_index(&root_path, &default_excludes)); + let default_excludes = default_excluded_dirs(); + let entries = get_file_index(&root_path, &default_excludes)?; let count = entries.len(); - insert_file_index_cache_entry( - root_path, - Arc::clone(&entries), - std::time::SystemTime::now(), - ); - info!(entries = count, "search::file: prewarm complete"); Ok(count) }) @@ -556,11 +457,20 @@ pub async fn prewarm_file_index(root_path: String) -> Result { /// Clear the file index cache #[tauri::command] pub fn clear_file_index_cache() { - let mut cache = FILE_INDEX_CACHE.lock().unwrap(); - cache.clear(); + FILE_INDEX_CACHE.clear(); info!("search::file: cache cleared"); } +/// Invalidate cached file indexes for one workspace root. +/// +/// This command performs no scan. The next foreground prewarm or search builds +/// a fresh index, and any older in-flight generation is discarded. +#[tauri::command] +pub fn invalidate_file_index_cache(root_path: String) { + FILE_INDEX_CACHE.invalidate_root(&root_path); + debug!(root_path = %root_path, "search::file: root cache invalidated"); +} + /// Find files by extension in a directory /// Returns list of file paths matching any of the given extensions #[tauri::command] @@ -584,21 +494,8 @@ pub async fn find_files_by_extension( } // Directories to skip entirely (the walker will NOT descend into them). - let exclude_set: std::collections::HashSet = [ - "node_modules", - ".git", - "dist", - "build", - ".next", - "target", - ".cache", - "__pycache__", - ".venv", - "venv", - ] - .iter() - .map(|s| s.to_string()) - .collect(); + let exclude_set: std::collections::HashSet = + default_excluded_dirs().into_iter().collect(); let mut builder = WalkBuilder::new(&directory); diff --git a/src-tauri/crates/search/src/file/index_cache.rs b/src-tauri/crates/search/src/file/index_cache.rs new file mode 100644 index 000000000..6113e49ea --- /dev/null +++ b/src-tauri/crates/search/src/file/index_cache.rs @@ -0,0 +1,506 @@ +use super::FileEntry; +use std::collections::HashMap; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::{Duration, Instant}; + +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +struct FileIndexKey { + root_path: String, + exclude_dirs: Vec, +} + +impl FileIndexKey { + fn new(root_path: &str, exclude_dirs: &[String]) -> Self { + let mut exclude_dirs = exclude_dirs.to_vec(); + exclude_dirs.sort_unstable(); + exclude_dirs.dedup(); + Self { + root_path: root_path.to_string(), + exclude_dirs, + } + } +} + +struct CachedIndex { + entries: Arc<[FileEntry]>, + indexed_at: Instant, + last_accessed_at: Instant, + estimated_bytes: usize, +} + +#[derive(Default)] +struct BuildFlight { + result: Mutex>, String>>>, + completed: Condvar, +} + +impl BuildFlight { + fn wait(&self) -> Result>, String> { + let mut result = self.result.lock().unwrap(); + while result.is_none() { + result = self.completed.wait(result).unwrap(); + } + result.clone().unwrap() + } + + fn finish(&self, result: Result>, String>) { + *self.result.lock().unwrap() = Some(result); + self.completed.notify_all(); + } +} + +struct CacheSlot { + generation: u64, + cached: Option, + in_flight: Option>, + last_accessed_at: Instant, +} + +impl CacheSlot { + fn new() -> Self { + Self { + generation: 0, + cached: None, + in_flight: None, + last_accessed_at: Instant::now(), + } + } +} + +#[derive(Default)] +struct CacheState { + slots: HashMap, +} + +enum CacheAction { + Return(Arc<[FileEntry]>), + Wait(Arc), + Build { + flight: Arc, + generation: u64, + }, +} + +/// Coordinates file-path indexes for every open workspace. +/// +/// A slot is keyed by both workspace root and exclusion policy. Equivalent +/// callers share one build. Invalidating a root bumps its generation so a +/// build that started before a file change can never repopulate the cache. +pub(super) struct FilePathIndexCache { + state: Mutex, + safety_ttl: Duration, + max_cached_indexes: usize, + max_index_bytes: usize, + max_total_bytes: usize, +} + +impl FilePathIndexCache { + pub(super) fn new( + safety_ttl: Duration, + max_cached_indexes: usize, + max_index_bytes: usize, + max_total_bytes: usize, + ) -> Self { + Self { + state: Mutex::new(CacheState::default()), + safety_ttl, + max_cached_indexes, + max_index_bytes, + max_total_bytes, + } + } + + pub(super) fn get_or_build( + &self, + root_path: &str, + exclude_dirs: &[String], + build: F, + ) -> Result, String> + where + F: Fn() -> Vec, + { + let key = FileIndexKey::new(root_path, exclude_dirs); + + loop { + let action = { + let mut state = self.state.lock().unwrap(); + self.prune_locked(&mut state); + + let slot = state + .slots + .entry(key.clone()) + .or_insert_with(CacheSlot::new); + slot.last_accessed_at = Instant::now(); + + if let Some(cached) = slot.cached.as_mut() { + if cached.indexed_at.elapsed() < self.safety_ttl { + cached.last_accessed_at = Instant::now(); + CacheAction::Return(Arc::clone(&cached.entries)) + } else if let Some(flight) = slot.in_flight.as_ref() { + CacheAction::Wait(Arc::clone(flight)) + } else { + slot.cached = None; + let flight = Arc::new(BuildFlight::default()); + slot.in_flight = Some(Arc::clone(&flight)); + CacheAction::Build { + flight, + generation: slot.generation, + } + } + } else if let Some(flight) = slot.in_flight.as_ref() { + CacheAction::Wait(Arc::clone(flight)) + } else { + let flight = Arc::new(BuildFlight::default()); + slot.in_flight = Some(Arc::clone(&flight)); + CacheAction::Build { + flight, + generation: slot.generation, + } + } + }; + + match action { + CacheAction::Return(entries) => return Ok(entries), + CacheAction::Wait(flight) => { + if let Some(entries) = flight.wait()? { + return Ok(entries); + } + } + CacheAction::Build { flight, generation } => { + let build_result = catch_unwind(AssertUnwindSafe(&build)); + let entries = match build_result { + Ok(entries) => Arc::<[FileEntry]>::from(entries), + Err(_) => { + let error = format!("File index build panicked for {root_path}"); + self.finish_failed_build(&key, &flight, error.clone()); + return Err(error); + } + }; + let estimated_bytes = estimate_file_index_bytes(&entries); + + let accepted = { + let mut state = self.state.lock().unwrap(); + let Some(slot) = state.slots.get_mut(&key) else { + flight.finish(Ok(None)); + continue; + }; + + let owns_flight = slot + .in_flight + .as_ref() + .is_some_and(|current| Arc::ptr_eq(current, &flight)); + if owns_flight { + slot.in_flight = None; + } + + if owns_flight && slot.generation == generation { + let now = Instant::now(); + if estimated_bytes <= self.max_index_bytes { + slot.cached = Some(CachedIndex { + entries: Arc::clone(&entries), + indexed_at: now, + last_accessed_at: now, + estimated_bytes, + }); + } + slot.last_accessed_at = now; + self.prune_locked(&mut state); + true + } else { + false + } + }; + + flight.finish(Ok(accepted.then(|| Arc::clone(&entries)))); + if accepted { + return Ok(entries); + } + // A file change or explicit clear superseded this build. + // Loop so the caller receives a generation-current index. + } + } + } + } + + pub(super) fn invalidate_root(&self, root_path: &str) { + let mut state = self.state.lock().unwrap(); + for (key, slot) in &mut state.slots { + if key.root_path == root_path { + slot.generation = slot.generation.wrapping_add(1); + slot.cached = None; + slot.last_accessed_at = Instant::now(); + } + } + state + .slots + .retain(|_, slot| slot.cached.is_some() || slot.in_flight.is_some()); + } + + pub(super) fn clear(&self) { + let mut state = self.state.lock().unwrap(); + for slot in state.slots.values_mut() { + slot.generation = slot.generation.wrapping_add(1); + slot.cached = None; + } + state.slots.retain(|_, slot| slot.in_flight.is_some()); + } + + fn finish_failed_build(&self, key: &FileIndexKey, flight: &Arc, error: String) { + let mut state = self.state.lock().unwrap(); + if let Some(slot) = state.slots.get_mut(key) { + if slot + .in_flight + .as_ref() + .is_some_and(|current| Arc::ptr_eq(current, flight)) + { + slot.in_flight = None; + } + } + state + .slots + .retain(|_, slot| slot.cached.is_some() || slot.in_flight.is_some()); + drop(state); + flight.finish(Err(error)); + } + + fn prune_locked(&self, state: &mut CacheState) { + state.slots.retain(|_, slot| { + slot.in_flight.is_some() + || slot + .cached + .as_ref() + .is_some_and(|cached| cached.indexed_at.elapsed() < self.safety_ttl) + }); + + let mut total_bytes = state + .slots + .values() + .filter_map(|slot| slot.cached.as_ref()) + .map(|cached| cached.estimated_bytes) + .sum::(); + while state + .slots + .values() + .filter(|slot| slot.cached.is_some()) + .count() + > self.max_cached_indexes + || total_bytes > self.max_total_bytes + { + let Some(oldest_key) = state + .slots + .iter() + .filter(|(_, slot)| slot.in_flight.is_none() && slot.cached.is_some()) + .min_by_key(|(_, slot)| slot.last_accessed_at) + .map(|(key, _)| key.clone()) + else { + break; + }; + if let Some(removed) = state.slots.remove(&oldest_key) { + total_bytes = total_bytes.saturating_sub( + removed + .cached + .as_ref() + .map(|cached| cached.estimated_bytes) + .unwrap_or_default(), + ); + } + } + } +} + +fn estimate_file_index_bytes(entries: &[FileEntry]) -> usize { + std::mem::size_of_val(entries) + + entries + .iter() + .map(|entry| entry.path.len() + entry.filename.len()) + .sum::() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Barrier; + use std::thread; + + fn entry(name: &str) -> FileEntry { + FileEntry { + path: format!("/repo/{name}"), + filename: name.to_string(), + is_dir: false, + } + } + + fn test_cache(max_cached_indexes: usize) -> FilePathIndexCache { + FilePathIndexCache::new( + Duration::from_secs(60), + max_cached_indexes, + 32 * 1024 * 1024, + 64 * 1024 * 1024, + ) + } + + #[test] + fn equivalent_concurrent_requests_share_one_build() { + let cache = Arc::new(test_cache(4)); + let build_count = Arc::new(AtomicUsize::new(0)); + let start = Arc::new(Barrier::new(8)); + + let threads: Vec<_> = (0..8) + .map(|_| { + let cache = Arc::clone(&cache); + let build_count = Arc::clone(&build_count); + let start = Arc::clone(&start); + thread::spawn(move || { + start.wait(); + cache + .get_or_build("/repo", &["target".to_string()], || { + build_count.fetch_add(1, Ordering::SeqCst); + thread::sleep(Duration::from_millis(40)); + vec![entry("main.rs")] + }) + .unwrap() + }) + }) + .collect(); + + for handle in threads { + assert_eq!(handle.join().unwrap().len(), 1); + } + assert_eq!(build_count.load(Ordering::SeqCst), 1); + } + + #[test] + fn invalidation_discards_an_in_flight_generation() { + let cache = Arc::new(test_cache(4)); + let build_count = Arc::new(AtomicUsize::new(0)); + let first_started = Arc::new(Barrier::new(2)); + let resume_first = Arc::new(Barrier::new(2)); + + let worker = { + let cache = Arc::clone(&cache); + let build_count = Arc::clone(&build_count); + let first_started = Arc::clone(&first_started); + let resume_first = Arc::clone(&resume_first); + thread::spawn(move || { + cache + .get_or_build("/repo", &[], || { + let build_number = build_count.fetch_add(1, Ordering::SeqCst); + if build_number == 0 { + first_started.wait(); + resume_first.wait(); + } + vec![entry("main.rs")] + }) + .unwrap() + }) + }; + + first_started.wait(); + cache.invalidate_root("/repo"); + resume_first.wait(); + + assert_eq!(worker.join().unwrap().len(), 1); + assert_eq!(build_count.load(Ordering::SeqCst), 2); + } + + #[test] + fn exclusion_policy_is_part_of_the_cache_key() { + let cache = test_cache(4); + let build_count = AtomicUsize::new(0); + + cache + .get_or_build("/repo", &["target".to_string()], || { + build_count.fetch_add(1, Ordering::SeqCst); + vec![entry("first")] + }) + .unwrap(); + cache + .get_or_build("/repo", &["node_modules".to_string()], || { + build_count.fetch_add(1, Ordering::SeqCst); + vec![entry("second")] + }) + .unwrap(); + + assert_eq!(build_count.load(Ordering::SeqCst), 2); + } + + #[test] + fn failed_owner_releases_waiters_and_allows_recovery() { + let cache = test_cache(4); + let failed = cache.get_or_build("/repo", &[], || panic!("boom")); + assert!(failed.is_err()); + + let recovered = cache + .get_or_build("/repo", &[], || vec![entry("recovered")]) + .unwrap(); + assert_eq!(recovered[0].filename, "recovered"); + } + + #[test] + fn cache_prunes_to_global_byte_budget() { + let cache = FilePathIndexCache::new(Duration::from_secs(60), 4, usize::MAX, 100); + + for index in 0..3 { + cache + .get_or_build(&format!("/repo-{index}"), &[], || { + vec![entry(&"x".repeat(40))] + }) + .unwrap(); + } + + let state = cache.state.lock().unwrap(); + let cached_count = state + .slots + .values() + .filter(|slot| slot.cached.is_some()) + .count(); + assert!(cached_count < 3); + assert!( + state + .slots + .values() + .filter_map(|slot| slot.cached.as_ref()) + .map(|cached| cached.estimated_bytes) + .sum::() + <= 100 + ); + } + + #[test] + fn oversized_index_is_shared_but_not_retained() { + let cache = Arc::new(FilePathIndexCache::new( + Duration::from_secs(60), + 4, + 1, + usize::MAX, + )); + let build_count = Arc::new(AtomicUsize::new(0)); + let start = Arc::new(Barrier::new(4)); + + let threads: Vec<_> = (0..4) + .map(|_| { + let cache = Arc::clone(&cache); + let build_count = Arc::clone(&build_count); + let start = Arc::clone(&start); + thread::spawn(move || { + start.wait(); + cache + .get_or_build("/repo", &[], || { + build_count.fetch_add(1, Ordering::SeqCst); + thread::sleep(Duration::from_millis(40)); + vec![entry("large")] + }) + .unwrap() + }) + }) + .collect(); + + for handle in threads { + assert_eq!(handle.join().unwrap().len(), 1); + } + assert_eq!(build_count.load(Ordering::SeqCst), 1); + assert!(cache.state.lock().unwrap().slots.is_empty()); + } +} diff --git a/src-tauri/crates/search/src/tests/file_tests.rs b/src-tauri/crates/search/src/tests/file_tests.rs index 991865e19..4e43596f7 100644 --- a/src-tauri/crates/search/src/tests/file_tests.rs +++ b/src-tauri/crates/search/src/tests/file_tests.rs @@ -1,47 +1,6 @@ -use std::collections::HashMap; -use std::sync::Arc; +use app_utils::testing::temp_dir_with_files; -use crate::file::{ - estimate_file_index_bytes, fuzzy_search, prune_file_index_cache, FileEntry, FileIndex, -}; - -#[test] -fn file_index_size_estimate_includes_paths_and_entry_storage() { - let entries = vec![FileEntry { - path: "C:/repo/src/main.rs".to_string(), - filename: "main.rs".to_string(), - is_dir: false, - }]; - - assert!( - estimate_file_index_bytes(&entries) - >= std::mem::size_of::() + entries[0].path.len() + entries[0].filename.len() - ); -} - -#[test] -fn file_index_cache_prunes_to_global_byte_budget() { - let now = std::time::SystemTime::now(); - let mut cache = HashMap::new(); - for index in 0..3 { - cache.insert( - format!("repo-{index}"), - FileIndex { - entries: Arc::new(Vec::new()), - _root_path: format!("repo-{index}"), - indexed_at: now - .checked_sub(std::time::Duration::from_secs(3 - index)) - .expect("test timestamp should be representable"), - estimated_bytes: 24 * 1024 * 1024, - }, - ); - } - - prune_file_index_cache(&mut cache); - - assert_eq!(cache.len(), 2); - assert!(!cache.contains_key("repo-0")); -} +use crate::file::{build_file_index, default_excluded_dirs, fuzzy_search, FileEntry}; #[test] fn test_fuzzy_matching() { @@ -70,3 +29,50 @@ fn test_fuzzy_matching() { // "btn" should match "Button" better than others assert_eq!(results[0].0.filename, "Button.tsx"); } + +#[test] +fn file_index_skips_runtime_worktrees_but_keeps_user_orgii_files() { + let (_dir, root) = temp_dir_with_files(&[ + ("src/main.rs", "fn main() {}"), + (".env", "SECRET=not-a-real-secret"), + (".orgii/skills/example/SKILL.md", "# Example"), + (".orgii/worktrees/session-a/generated.rs", "generated"), + (".worktrees/session-b/generated.rs", "generated"), + ]); + + let entries = build_file_index(root.to_str().unwrap(), &default_excluded_dirs()); + let paths: Vec<_> = entries + .iter() + .filter_map(|entry| { + std::path::Path::new(&entry.path) + .strip_prefix(&root) + .ok() + .map(|path| path.to_string_lossy().to_string()) + }) + .collect(); + + assert!(paths.contains(&"src/main.rs".to_string())); + assert!(paths.contains(&".env".to_string())); + assert!(paths.contains(&".orgii/skills/example/SKILL.md".to_string())); + assert!(!paths + .iter() + .any(|path| path.starts_with(".orgii/worktrees"))); + assert!(!paths.iter().any(|path| path.starts_with(".worktrees"))); +} + +#[test] +fn fuzzy_search_honors_zero_and_top_k_limits() { + let entries: Vec<_> = (0..100) + .map(|index| FileEntry { + path: format!("src/component-{index}.tsx"), + filename: format!("component-{index}.tsx"), + is_dir: false, + }) + .collect(); + + assert!(fuzzy_search(&entries, "component", 0, None).is_empty()); + + let results = fuzzy_search(&entries, "component", 5, None); + assert_eq!(results.len(), 5); + assert!(results.windows(2).all(|pair| pair[0].1 >= pair[1].1)); +} diff --git a/src-tauri/src/commands/handler_list.inc b/src-tauri/src/commands/handler_list.inc index 97ea4390f..2795fe770 100644 --- a/src-tauri/src/commands/handler_list.inc +++ b/src-tauri/src/commands/handler_list.inc @@ -212,6 +212,7 @@ search::file::search_files_fuzzy, search::file::index_project_files, search::file::prewarm_file_index, search::file::clear_file_index_cache, +search::file::invalidate_file_index_cache, search::file::find_files_by_extension, // Code search commands search::code::commands::search_code_regex, diff --git a/src/contexts/git/GitStatusContext/GitStatusProvider.tsx b/src/contexts/git/GitStatusContext/GitStatusProvider.tsx index 091552808..e063a0304 100644 --- a/src/contexts/git/GitStatusContext/GitStatusProvider.tsx +++ b/src/contexts/git/GitStatusContext/GitStatusProvider.tsx @@ -141,6 +141,19 @@ export const GitStatusProvider: React.FC<{ children: React.ReactNode }> = ({ return currentRepo?.path || currentRepo?.fs_uri; }, [currentRepo]); + const resolveRepoPath = useCallback( + (repoId: string): string | undefined => { + const repo = repoMap.get(repoId); + if (repo?.path || repo?.fs_uri) return repo.path || repo.fs_uri; + + const folder = workspaceFolders.find( + (candidate) => candidate.id === repoId || candidate.repoId === repoId + ); + return folder?.path; + }, + [repoMap, workspaceFolders] + ); + // ============================================ // Watcher Registration Hook // ============================================ @@ -233,6 +246,7 @@ export const GitStatusProvider: React.FC<{ children: React.ReactNode }> = ({ setGitStatusAtom, setGitSuggestedActionAtom, setGitOperation, + resolveRepoPath, }); // ============================================ diff --git a/src/contexts/git/GitStatusContext/hooks/__tests__/TEST_CASES.md b/src/contexts/git/GitStatusContext/hooks/__tests__/TEST_CASES.md new file mode 100644 index 000000000..28ead01a5 --- /dev/null +++ b/src/contexts/git/GitStatusContext/hooks/__tests__/TEST_CASES.md @@ -0,0 +1,34 @@ +# File index invalidation test cases + +## Preconditions + +- Git watcher events identify a repository with `repo_id`. +- File-path index invalidation is a cheap state transition; it does not scan. +- Content-only modifications do not change the indexed path set. + +## Happy path + +- Created, deleted, renamed, or unknown file events schedule invalidation. +- Aggregate `repo:changed` events invalidate only when `change_type` is `files`. +- Multiple events for one repository inside 250 ms produce one invalidation. +- Simultaneous events for different repositories invalidate each root once. + +## Edge cases + +- `modified` events are ignored because filenames and paths are unchanged. +- Disposing the listener clears pending invalidations. +- Empty paths are not scheduled. + +## Error path + +- A rejected Tauri invalidation request is reported through the supplied error callback and does not create an unhandled rejection. + +## Accessibility + +- Not applicable: this lifecycle change has no rendered UI or input behavior. + +## Acceptance criteria + +- No timer causes indexing or repeated background scans. +- A watcher burst produces at most one cheap invalidation call per root. +- Listener teardown leaves no pending timer. diff --git a/src/contexts/git/GitStatusContext/hooks/__tests__/fileIndexInvalidation.test.ts b/src/contexts/git/GitStatusContext/hooks/__tests__/fileIndexInvalidation.test.ts new file mode 100644 index 000000000..b1c28a4c6 --- /dev/null +++ b/src/contexts/git/GitStatusContext/hooks/__tests__/fileIndexInvalidation.test.ts @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + createFileIndexInvalidationScheduler, + fileChangeInvalidatesPathIndex, + repoChangeInvalidatesPathIndex, +} from "../fileIndexInvalidation"; + +describe("file index invalidation", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("coalesces a burst into one invalidation per workspace root", async () => { + const invalidate = vi.fn().mockResolvedValue(undefined); + const scheduler = createFileIndexInvalidationScheduler(invalidate, 250); + + scheduler.schedule("/repo-a"); + scheduler.schedule("/repo-a"); + scheduler.schedule("/repo-b"); + vi.advanceTimersByTime(249); + expect(invalidate).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + await Promise.resolve(); + expect(invalidate).toHaveBeenCalledTimes(2); + expect(invalidate).toHaveBeenCalledWith("/repo-a"); + expect(invalidate).toHaveBeenCalledWith("/repo-b"); + }); + + it("drops pending work after disposal", () => { + const invalidate = vi.fn().mockResolvedValue(undefined); + const scheduler = createFileIndexInvalidationScheduler(invalidate, 250); + + scheduler.schedule("/repo-a"); + scheduler.dispose(); + vi.advanceTimersByTime(500); + + expect(invalidate).not.toHaveBeenCalled(); + }); + + it("reports asynchronous invalidation failures", async () => { + const error = new Error("IPC unavailable"); + const onError = vi.fn(); + const scheduler = createFileIndexInvalidationScheduler( + vi.fn().mockRejectedValue(error), + 1, + onError + ); + + scheduler.schedule("/repo-a"); + await vi.advanceTimersByTimeAsync(1); + + expect(onError).toHaveBeenCalledWith(error); + }); + + it("ignores content-only modifications", () => { + expect(fileChangeInvalidatesPathIndex("modified")).toBe(false); + expect(fileChangeInvalidatesPathIndex("created")).toBe(true); + expect(fileChangeInvalidatesPathIndex("deleted")).toBe(true); + expect(fileChangeInvalidatesPathIndex("renamed")).toBe(true); + expect(fileChangeInvalidatesPathIndex(undefined)).toBe(true); + }); + + it("only invalidates for aggregate filesystem changes", () => { + expect(repoChangeInvalidatesPathIndex("files")).toBe(true); + expect(repoChangeInvalidatesPathIndex("git_meta")).toBe(false); + expect(repoChangeInvalidatesPathIndex("branch")).toBe(false); + expect(repoChangeInvalidatesPathIndex(undefined)).toBe(false); + }); +}); diff --git a/src/contexts/git/GitStatusContext/hooks/fileIndexInvalidation.ts b/src/contexts/git/GitStatusContext/hooks/fileIndexInvalidation.ts new file mode 100644 index 000000000..b15584fd4 --- /dev/null +++ b/src/contexts/git/GitStatusContext/hooks/fileIndexInvalidation.ts @@ -0,0 +1,52 @@ +export interface FileIndexInvalidationScheduler { + schedule(rootPath: string): void; + dispose(): void; +} + +/** + * Coalesces file-create/delete/rename bursts into one invalidation per root. + * Invalidating is deliberately cheap: it marks state stale but never scans. + */ +export function createFileIndexInvalidationScheduler( + invalidate: (rootPath: string) => Promise, + delayMs = 250, + onError: (error: unknown) => void = () => undefined +): FileIndexInvalidationScheduler { + const pendingRoots = new Set(); + let timer: ReturnType | null = null; + let disposed = false; + + const flush = () => { + timer = null; + const roots = [...pendingRoots]; + pendingRoots.clear(); + + for (const rootPath of roots) { + void invalidate(rootPath).catch(onError); + } + }; + + return { + schedule(rootPath) { + if (disposed || !rootPath) return; + pendingRoots.add(rootPath); + if (timer) return; + timer = setTimeout(flush, delayMs); + }, + dispose() { + disposed = true; + pendingRoots.clear(); + if (timer) clearTimeout(timer); + timer = null; + }, + }; +} + +/** Content-only modifications do not change a file-path index. */ +export function fileChangeInvalidatesPathIndex(kind: unknown): boolean { + return typeof kind !== "string" || kind !== "modified"; +} + +export function repoChangeInvalidatesPathIndex(changeType: unknown): boolean { + return changeType === "files"; +} diff --git a/src/contexts/git/GitStatusContext/hooks/useGitEventListeners.ts b/src/contexts/git/GitStatusContext/hooks/useGitEventListeners.ts index 45677b146..3650411d2 100644 --- a/src/contexts/git/GitStatusContext/hooks/useGitEventListeners.ts +++ b/src/contexts/git/GitStatusContext/hooks/useGitEventListeners.ts @@ -18,8 +18,14 @@ import type { } from "@src/types/session/steps"; import { decodeOctalPath } from "@src/util/file/pathUtils"; import { computeSuggestedAction } from "@src/util/git/computeSuggestedAction"; +import { invalidateFileIndexCache } from "@src/util/platform/tauri/fileSearch"; import type { GitStatusRefs } from "../types"; +import { + createFileIndexInvalidationScheduler, + fileChangeInvalidatesPathIndex, + repoChangeInvalidatesPathIndex, +} from "./fileIndexInvalidation"; const log = createLogger("GitStatusContext"); @@ -38,6 +44,7 @@ interface UseGitEventListenersOptions { details: string; timestamp: number; }) => void; + resolveRepoPath: (repoId: string) => string | undefined; } export function useGitEventListeners({ @@ -48,6 +55,7 @@ export function useGitEventListeners({ setGitStatusAtom, setGitSuggestedActionAtom, setGitOperation, + resolveRepoPath, }: UseGitEventListenersOptions): void { const { currentRepoIdRef, gitStatusRef } = refs; @@ -60,6 +68,7 @@ export function useGitEventListeners({ const setGitStatusAtomRef = useRef(setGitStatusAtom); const setGitSuggestedActionAtomRef = useRef(setGitSuggestedActionAtom); const setGitOperationRef = useRef(setGitOperation); + const resolveRepoPathRef = useRef(resolveRepoPath); useEffect(() => { setGitStatusRef.current = setGitStatus; }, [setGitStatus]); @@ -75,11 +84,20 @@ export function useGitEventListeners({ useEffect(() => { setGitOperationRef.current = setGitOperation; }, [setGitOperation]); + useEffect(() => { + resolveRepoPathRef.current = resolveRepoPath; + }, [resolveRepoPath]); useEffect(() => { if (!selectedRepoId) return; const cleanupFns: (() => void)[] = []; + const fileIndexInvalidation = createFileIndexInvalidationScheduler( + invalidateFileIndexCache, + 250, + (error) => + log.warn("[GitStatusContext] File index invalidation failed:", error) + ); const setupListeners = () => { try { @@ -209,9 +227,44 @@ export function useGitEventListeners({ }); cleanupFns.push(unsubscribeStatus); - // Listen to file:changed for file changes - const unsubscribeChanged = ws.on("file:changed", (_data) => { - // Status will arrive via repo:status_updated event from debouncer + // The repository watcher emits this aggregate event for every real + // filesystem burst. It is the canonical path-index invalidation source. + const unsubscribeRepoChanged = ws.on("repo:changed", (data) => { + const event = data as { + repo_id?: string; + change_type?: string; + }; + if ( + !event.repo_id || + !repoChangeInvalidatesPathIndex(event.change_type) + ) { + return; + } + + const rootPath = resolveRepoPathRef.current(event.repo_id); + if (rootPath) fileIndexInvalidation.schedule(rootPath); + }); + cleanupFns.push(unsubscribeRepoChanged); + + // Retain compatibility with granular file events from future or + // alternate watcher producers. + const unsubscribeChanged = ws.on("file:changed", (data) => { + const event = data as { + repo_id?: string; + kind?: string; + files?: Array<{ kind?: string }>; + }; + if (!event.repo_id) return; + + const containsPathChange = event.files + ? event.files.some((file) => + fileChangeInvalidatesPathIndex(file.kind) + ) + : fileChangeInvalidatesPathIndex(event.kind); + if (!containsPathChange) return; + + const rootPath = resolveRepoPathRef.current(event.repo_id); + if (rootPath) fileIndexInvalidation.schedule(rootPath); }); cleanupFns.push(unsubscribeChanged); @@ -247,6 +300,7 @@ export function useGitEventListeners({ setupListeners(); return () => { + fileIndexInvalidation.dispose(); cleanupFns.forEach((fn) => fn()); }; }, [ diff --git a/src/util/platform/tauri/fileSearch.test.ts b/src/util/platform/tauri/fileSearch.test.ts new file mode 100644 index 000000000..464f2d985 --- /dev/null +++ b/src/util/platform/tauri/fileSearch.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; + +import { shouldPrewarmFileIndex } from "./fileSearch"; + +describe("shouldPrewarmFileIndex", () => { + it("allows visible and non-DOM callers", () => { + expect(shouldPrewarmFileIndex("visible")).toBe(true); + expect(shouldPrewarmFileIndex(undefined)).toBe(true); + }); + + it("skips proactive work for hidden windows", () => { + expect(shouldPrewarmFileIndex("hidden")).toBe(false); + }); +}); diff --git a/src/util/platform/tauri/fileSearch.ts b/src/util/platform/tauri/fileSearch.ts index dad6127b0..10802d6a6 100644 --- a/src/util/platform/tauri/fileSearch.ts +++ b/src/util/platform/tauri/fileSearch.ts @@ -16,6 +16,7 @@ import type { SearchResultItem } from "@src/scaffold/ContextMenu/types"; import { ensureTauriReady, invokeTauri, isTauriReady } from "./init"; const log = createLogger("FileSearch"); +const prewarmRequests = new Map>(); // ============================================ // Types @@ -137,17 +138,32 @@ export async function indexProjectFiles( */ export async function prewarmFileIndex(rootPath: string): Promise { if (!isTauriReady()) return 0; - - try { - const count = await invokeTauri("prewarm_file_index", { - rootPath, + if (!shouldPrewarmFileIndex(globalThis.document?.visibilityState)) return 0; + + const existingRequest = prewarmRequests.get(rootPath); + if (existingRequest) return existingRequest; + + const request = invokeTauri("prewarm_file_index", { rootPath }) + .catch((error) => { + // Non-fatal — search will still work, just cold on first use. + log.warn("[FileSearch] Prewarm failed (non-fatal):", error); + return 0; + }) + .finally(() => { + if (prewarmRequests.get(rootPath) === request) { + prewarmRequests.delete(rootPath); + } }); - return count; - } catch (error) { - // Non-fatal — search will still work, just cold on first use. - log.warn("[FileSearch] Prewarm failed (non-fatal):", error); - return 0; - } + + prewarmRequests.set(rootPath, request); + return request; +} + +/** Hidden windows do not spend CPU pre-walking projects. */ +export function shouldPrewarmFileIndex( + visibilityState: DocumentVisibilityState | undefined +): boolean { + return visibilityState !== "hidden"; } /** @@ -168,6 +184,23 @@ export async function clearFileIndexCache(): Promise { } } +/** + * Mark one workspace's file-path index stale without starting a scan. + * The next foreground prewarm or search rebuilds it on demand. + */ +export async function invalidateFileIndexCache( + rootPath: string +): Promise { + if (!isTauriReady()) return; + + try { + await invokeTauri("invalidate_file_index_cache", { rootPath }); + } catch (error) { + log.error("[FileSearch] Failed to invalidate cache:", error); + throw error; + } +} + // ============================================ // Helper Functions // ============================================