#![cfg_attr(not(feature = "local_fs"), allow(dead_code))] use fuzzy_match::{ contains_wildcards, match_indices_case_insensitive, match_wildcard_pattern_case_insensitive, FuzzyMatchResult, }; use std::sync::Arc; use std::{ collections::HashSet, path::{Path, PathBuf}, }; use warpui::{AppContext, Entity, ModelContext, SingletonEntity}; cfg_if::cfg_if! { if #[cfg(feature = "local_fs")] { use crate::workspace::ActiveSession; use command::blocking::Command; use repo_metadata::local_model::GetContentsArgs; use repo_metadata::wrapper_model::RepoMetadataEvent; use repo_metadata::RepoMetadataModel; use repo_metadata::repositories::DetectedRepositories; use std::cell::RefCell; use std::collections::HashMap; } } use super::search_item::FileSearchResult; /// Shared model for file search functionality across different UI components. /// This singleton provides common file discovery, fuzzy matching, and git integration. pub struct FileSearchModel { /// Cached flattened repo contents keyed by repo root path. /// Populated lazily on first query, invalidated when the file tree changes. #[cfg(feature = "local_fs")] repo_contents_cache: RefCell>>>, } impl FileSearchModel { #[cfg_attr(not(feature = "local_fs"), allow(unused_variables))] pub fn new(ctx: &mut ModelContext) -> Self { #[cfg(feature = "local_fs")] ctx.subscribe_to_model( &RepoMetadataModel::handle(ctx), |me, event, _ctx| match event { RepoMetadataEvent::FileTreeUpdated { ids } => { let mut cache = me.repo_contents_cache.borrow_mut(); for id in ids { if let repo_metadata::RepositoryIdentifier::Local(path) = id { if let Some(local) = path.to_local_path() { cache.remove(&local); } } } } RepoMetadataEvent::RepositoryRemoved { id } => { if let repo_metadata::RepositoryIdentifier::Local(path) = id { if let Some(local) = path.to_local_path() { me.repo_contents_cache.borrow_mut().remove(&local); } } } RepoMetadataEvent::RepositoryUpdated { id } => { if let repo_metadata::RepositoryIdentifier::Local(path) = id { if let Some(local) = path.to_local_path() { me.repo_contents_cache.borrow_mut().remove(&local); } } } RepoMetadataEvent::FileTreeEntryUpdated { .. } | RepoMetadataEvent::UpdatingRepositoryFailed { .. } | RepoMetadataEvent::IncrementalUpdateReady { .. } => {} }, ); Self { #[cfg(feature = "local_fs")] repo_contents_cache: RefCell::new(HashMap::new()), } } #[cfg(not(feature = "local_fs"))] pub fn repo_root(&self, _app: &AppContext) -> Option { None } #[cfg(feature = "local_fs")] pub fn repo_root(&self, app: &AppContext) -> Option { let active_window_id = app.windows().state().active_window; let current_dir = active_window_id .and_then(|window_id| ActiveSession::as_ref(app).path_if_local(window_id))?; DetectedRepositories::as_ref(app).get_root_for_path(current_dir) } #[cfg(not(feature = "local_fs"))] pub fn get_folder_contents(&self, _app: &AppContext) -> Vec { Vec::new() } /// Fetches files and folders from the filesystem at a given path. This uses the ordering generated by /// fs::read_dir, which is not guaranteed to be stable and in practice appears random. #[cfg(feature = "local_fs")] pub fn get_folder_contents(&self, app: &AppContext) -> Vec { let active_window_id = app.windows().state().active_window; let current_dir = match active_window_id .and_then(|window_id| ActiveSession::as_ref(app).path_if_local(window_id)) { Some(path) => path, None => return Vec::new(), }; let current_dir_string = current_dir.to_string_lossy().to_string(); match std::fs::read_dir(current_dir) { Ok(entries) => entries .filter_map(|entry| { entry.ok().map(|entry| { let path = entry.path(); FileSearchResult { path: path .strip_prefix(current_dir) .expect("path should be a descendant of current_dir") .to_string_lossy() .to_string(), project_directory: current_dir_string.clone(), is_directory: path.is_dir(), } }) }) .collect(), Err(err) => { log::warn!("Failed to read {current_dir_string}: {err:#}"); Vec::new() } } } /// Gets repository contents (files and directories) from the LocalRepoMetadataModel for the current working directory. /// Results are cached per repo root and invalidated when the file tree changes. #[cfg(feature = "local_fs")] pub fn get_repo_contents(&self, app: &AppContext) -> Arc> { let Some(repo_root) = self.repo_root(app) else { return Arc::new(Vec::new()); }; if let Some(cached) = self.repo_contents_cache.borrow().get(&repo_root) { return cached.clone(); } let repo_metadata = RepoMetadataModel::as_ref(app); let Some(id) = repo_metadata::RepositoryIdentifier::try_local(&repo_root) else { return Arc::new(Vec::new()); }; let contents = if repo_metadata.has_repository(&id, app) { self.get_contents_from_repo(&repo_root, repo_metadata, GetContentsArgs::default(), app) } else { Vec::new() }; let arc = Arc::new(contents); self.repo_contents_cache .borrow_mut() .insert(repo_root, arc.clone()); arc } /// Gets repository contents with git status information for prioritization. /// Reuses the cached repo contents from `get_repo_contents`. #[cfg(feature = "local_fs")] pub fn get_repo_contents_with_git_status( &self, app: &AppContext, ) -> (Arc>, HashSet) { let contents = self.get_repo_contents(app); let git_changed_files = self .repo_root(app) .and_then(|repo_root| self.get_git_changed_files(&repo_root).ok()) .unwrap_or_default(); (contents, git_changed_files) } /// Gets repository contents from the LocalRepoMetadataModel for the current working directory (WASM stub) #[cfg(not(feature = "local_fs"))] pub fn get_repo_contents(&self, _app: &AppContext) -> Arc> { Arc::new(Vec::new()) } /// Gets repository contents with git status information for prioritization (WASM stub) #[cfg(not(feature = "local_fs"))] pub fn get_repo_contents_with_git_status( &self, _app: &AppContext, ) -> (Arc>, HashSet) { (Arc::new(Vec::new()), HashSet::new()) } /// Helper method to get repository contents from a specific repository #[cfg(feature = "local_fs")] fn get_contents_from_repo( &self, repo_path: &Path, repo_metadata: &repo_metadata::wrapper_model::RepoMetadataModel, args: GetContentsArgs, app: &AppContext, ) -> Vec { // Canonicalize the repository path to handle symlinks consistently let Ok(canonical_repo_path) = dunce::canonicalize(repo_path) else { return Vec::new(); }; let Some(id) = repo_metadata::RepositoryIdentifier::try_local(repo_path) else { return Vec::new(); }; if let Some(contents) = repo_metadata.get_repo_contents(&id, args, app) { contents .iter() .filter_map(|content| { match content { repo_metadata::RepoContent::File(file_metadata) => { let file_local = file_metadata.path.to_local_path_lossy(); // Convert absolute path to relative path from the canonical repository root if let Ok(relative_path) = file_local.strip_prefix(&canonical_repo_path) { let path = relative_path.to_string_lossy().to_string(); Some(FileSearchResult { path, project_directory: canonical_repo_path .to_string_lossy() .to_string(), is_directory: false, }) } else { None } } repo_metadata::RepoContent::Directory(dir_entry) => { let dir_local = dir_entry.path.to_local_path_lossy(); // Convert absolute path to relative path from the canonical repository root if let Ok(relative_path) = dir_local.strip_prefix(&canonical_repo_path) { let mut path = relative_path.to_string_lossy().to_string(); // Add trailing slash for directories using platform-specific separator if !path.ends_with(std::path::MAIN_SEPARATOR) { path.push(std::path::MAIN_SEPARATOR); } Some(FileSearchResult { path, project_directory: canonical_repo_path .to_string_lossy() .to_string(), is_directory: true, }) } else { None } } } }) .collect() } else { Vec::new() } } /// Performs a fuzzy search on the given path with the query /// Prioritizes filename matches with 2x weight compared to path matches /// Supports space-separated queries - all parts must match for a result pub fn fuzzy_match_path(path: &str, query: &str) -> Option { if query.is_empty() { return Some(FuzzyMatchResult::no_match()); } // Split query by spaces to support multi-term searches let query_parts: Vec<&str> = query.split_whitespace().collect(); if query_parts.is_empty() { return Some(FuzzyMatchResult::no_match()); } // If there's only one query part, use the original logic if query_parts.len() == 1 { return Self::fuzzy_match_path_single(path, query_parts[0]); } // For multiple query parts, each part must match somewhere in the path let mut combined_score = 0i64; let mut all_matched_indices = Vec::new(); for query_part in &query_parts { if let Some(part_result) = Self::fuzzy_match_path_single(path, query_part) { combined_score += part_result.score; all_matched_indices.extend(part_result.matched_indices); } else { // If any part doesn't match, the entire query fails return None; } } // Remove duplicates and sort indices all_matched_indices.sort_unstable(); all_matched_indices.dedup(); Some(FuzzyMatchResult { score: combined_score, matched_indices: all_matched_indices, }) } /// Helper method for single-term fuzzy matching (supports wildcards) pub fn fuzzy_match_path_single(path: &str, query: &str) -> Option { if query.is_empty() { return Some(FuzzyMatchResult::no_match()); } // Check if query contains wildcards if contains_wildcards(query) { // Use wildcard matching for the entire path return match_wildcard_pattern_case_insensitive(path, query); } // Split the path into directory and filename let filename = path.split('/').next_back().unwrap_or(""); // Try to match the filename first let filename_match = match_indices_case_insensitive(filename, query); // Try to match the full path let path_match = match_indices_case_insensitive(path, query).map(|mut result| { result.score = Self::apply_path_proximity_ranking(path, &result.matched_indices, result.score); result }); match (filename_match, path_match) { (Some(filename_result), Some(path_result)) => { // Calculate filename offset in the full path let filename_start_offset = path.len() - filename.len(); // Check if filename had matches before consuming it let filename_has_matches = !filename_result.matched_indices.is_empty(); let filename_score = filename_result.score; // Check for exact filename match (without extension) let filename_without_ext = filename.split('.').next().unwrap_or(filename); let is_exact_filename_match = query.eq_ignore_ascii_case(filename_without_ext); // Adjust filename match indices to be relative to the full path let adjusted_filename_indices: Vec = filename_result .matched_indices .into_iter() .map(|idx| idx + filename_start_offset) .collect(); // Combine indices from both matches, removing duplicates let mut combined_indices = path_result.matched_indices.clone(); for idx in adjusted_filename_indices { if !combined_indices.contains(&idx) { combined_indices.push(idx); } } combined_indices.sort_unstable(); // Calculate combined score with exact filename match boost let mut combined_score = if filename_has_matches { filename_score * 2 + path_result.score } else { path_result.score }; // Apply extra boost for exact filename matches (without extension) if is_exact_filename_match { combined_score += 5000; // Large boost to prioritize exact matches } Some(FuzzyMatchResult { score: combined_score, matched_indices: combined_indices, }) } (Some(mut filename_result), None) => { // Only filename matched, adjust indices to full path let filename_start_offset = path.len() - filename.len(); filename_result.matched_indices = filename_result .matched_indices .into_iter() .map(|idx| idx + filename_start_offset) .collect(); // Check for exact filename match (without extension) let filename_without_ext = filename.split('.').next().unwrap_or(filename); let is_exact_filename_match = query.eq_ignore_ascii_case(filename_without_ext); // Apply 2x weight to filename-only matches filename_result.score *= 2; // Apply extra boost for exact filename matches (without extension) if is_exact_filename_match { filename_result.score += 5000; // Large boost to prioritize exact matches } Some(filename_result) } (None, Some(path_result)) => { // Only path matched (excluding filename) Some(path_result) } (None, None) => None, } } /// If `path` is absolute, strips the common prefix with `repo_root` (tried first) /// or `working_dir` (tried second) and returns the remaining relative path string. /// Returns `None` when the path is relative or neither prefix matches. pub fn strip_absolute_path_prefix( path: &str, repo_root: Option<&Path>, working_dir: Option<&Path>, ) -> Option { let query_path = Path::new(path); if !query_path.is_absolute() { return None; } if let Some(root) = repo_root { if let Ok(rel) = query_path.strip_prefix(root) { let s = rel.to_string_lossy().to_string(); if !s.is_empty() { return Some(s); } } } if let Some(cwd) = working_dir { if let Ok(rel) = query_path.strip_prefix(cwd) { let s = rel.to_string_lossy().to_string(); if !s.is_empty() { return Some(s); } } } None } /// Determines if a query is too broad and would match too many files /// causing performance issues. Returns true if we should skip the query. pub fn should_skip_overly_broad_query(query: &str) -> bool { let query = query.trim(); // Just a single wildcard if query == "*" || query == "?" { return true; } // Contains consecutive wildcards (very broad patterns) if query.contains("**") || query.contains("*?") || query.contains("?*") || query.contains("??") { return true; } false } /// Apply path proximity ranking to the score /// Parts of the path closer to the filename get higher scores pub fn apply_path_proximity_ranking( path: &str, matched_indices: &[usize], original_score: i64, ) -> i64 { if matched_indices.is_empty() { return original_score; } let path_chars: Vec = path.chars().collect(); let total_chars = path_chars.len(); // Calculate weighted proximity score based on distance from end of path let proximity_bonus: i64 = matched_indices .iter() .map(|&idx| { // Distance from the end (closer to filename = smaller distance) let distance_from_end = total_chars.saturating_sub(idx + 1); // Convert to a bonus (closer = higher bonus) let max_distance = total_chars; if max_distance > 0 { ((max_distance - distance_from_end) as f64 / max_distance as f64 * 10.0) as i64 } else { 0 } }) .sum(); original_score + proximity_bonus } /// Get git changed files (modified, added, renamed, etc.) for prioritization #[cfg(feature = "local_fs")] pub fn get_git_changed_files( &self, repo_path: &Path, ) -> Result, std::io::Error> { let mut changed_files = HashSet::new(); log::debug!("[GIT OPERATION] model.rs get_git_changed_files git status --porcelain"); // Run `git status --porcelain` to get changed files let output = Command::new("git") .args(["status", "--porcelain"]) .current_dir(repo_path) .output()?; if !output.status.success() { // Not a git repository or git command failed return Ok(changed_files); } let stdout = String::from_utf8_lossy(&output.stdout); for line in stdout.lines() { if line.len() >= 3 { // Git status --porcelain format: XY filename // Where X is index status, Y is working tree status let filename = &line[3..]; // Handle quoted filenames (for special characters) let clean_filename = if filename.starts_with('"') && filename.ends_with('"') { // Basic unquoting - in practice git escapes special chars &filename[1..filename.len() - 1] } else { filename }; changed_files.insert(clean_filename.to_string()); } } Ok(changed_files) } } impl SingletonEntity for FileSearchModel {} impl Entity for FileSearchModel { type Event = (); } #[cfg(test)] #[path = "model_tests.rs"] mod tests;