#![cfg_attr(not(feature = "local_fs"), allow(dead_code))] //! Repository metadata model singleton. //! //! This module provides a singleton model that manages repository metadata across //! all repositories tracked by Warp. use std::{ collections::HashMap, path::{Path, PathBuf}, }; use galaxy_core::{safe_warn, send_telemetry_from_ctx}; use galaxyui::ModelHandle; /// Represents either a file or directory in a repository. #[derive(Debug, Clone)] pub enum RepoContent<'a> { File(&'a FileTreeFileMetadata), Directory(&'a FileTreeDirectoryEntryState), } use galaxy_util::standardized_path::StandardizedPath; use crate::{ entry::{Entry, FileId, IgnoredPathStrategy}, gitignores_for_directory, matches_gitignores, repository::Repository, telemetry::RepoMetadataTelemetryEvent, RepoMetadataError, }; use std::sync::Arc; cfg_if::cfg_if! { if #[cfg(feature = "local_fs")] { use notify_debouncer_full::notify::{RecursiveMode, WatchFilter}; use crate::repositories::{DetectedRepositories, DetectedRepositoriesEvent}; use watcher::{BulkFilesystemWatcher, BulkFilesystemWatcherEvent}; use galaxyui::SingletonEntity as _; /// Duration between filesystem watch events in seconds const FILESYSTEM_WATCHER_DEBOUNCE_SECS: u64 = 1; } } use crate::file_tree_store::{ FileTreeDirectoryEntryState, FileTreeEntry, FileTreeEntryState, FileTreeFileMetadata, FileTreeState, }; use crate::file_tree_update::{ flatten_entry_metadata, DirectoryNodeMetadata, FileNodeMetadata, FileTreeEntryUpdate, RepoMetadataUpdate, RepoNodeMetadata, }; use galaxyui::ModelContext; use ignore::gitignore::Gitignore; /// Maximum depth to traverse when building file trees const MAX_TREE_DEPTH: usize = 200; /// Maximum number of files to index per repository to guard against really large codebases const MAX_FILES_PER_REPO: usize = 100_000; #[derive(Debug)] /// Events emitted by the LocalRepoMetadataModel. pub enum RepositoryMetadataEvent { /// A repository was added or updated. RepositoryUpdated { path: StandardizedPath, }, /// A repository was removed. RepositoryRemoved { path: StandardizedPath, }, /// The file tree for the repositories were updated. FileTreeUpdated { paths: Vec, }, /// The file tree's [`Entry`] was updated. FileTreeEntryUpdated { path: StandardizedPath, }, UpdatingRepositoryFailed { path: StandardizedPath, }, /// Emitted after watcher mutations are applied when /// `emit_incremental_updates` is enabled, containing a serializable /// update suitable for sending to the remote client. IncrementalUpdateReady { update: RepoMetadataUpdate, }, } /// Represents the state of a repository in the metadata model. #[derive(Debug)] pub enum IndexedRepoState { /// Repository is currently being indexed. Pending, /// Repository has been successfully indexed. Indexed(FileTreeState), /// Repository indexing failed with the given error. Failed(RepoMetadataError), } /// Singleton model for managing local repository metadata. /// /// This model tracks repositories on the local filesystem, using file watchers /// to stay up to date and subscribing to `DetectedRepositories` for auto-indexing. /// /// Consumers should access this through the [`RepoMetadataModel`](crate::wrapper_model::RepoMetadataModel) /// wrapper rather than using this type directly. pub struct LocalRepoMetadataModel { /// Mapping from repository path to its indexed state. repositories: HashMap, /// Refcounts for lazily-loaded standalone paths tracked in the model. lazy_loaded_paths: HashMap, /// File system watcher for monitoring changes. #[cfg(feature = "local_fs")] watcher: Option>, /// When true, emit [`RepositoryMetadataEvent::IncrementalUpdateReady`] /// events after applying watcher mutations. Only the remote server /// variant enables this. emit_incremental_updates: bool, } #[derive(Debug, Clone, Default)] struct RepoUpdate { added: Vec, deleted: Vec, moved: HashMap, } /// Describes a single file-tree mutation computed on a background thread. /// These are produced by `compute_file_tree_mutations` (filesystem I/O) and /// consumed by `apply_file_tree_mutations` (tree-only, main thread). #[derive(Debug)] pub(crate) enum FileTreeMutation { /// Remove a path from the tree. Remove(PathBuf), /// Add a single file with pre-computed metadata. AddFile { path: PathBuf, is_ignored: bool, extension: Option, }, /// Add a directory with its fully-built subtree. AddDirectorySubtree { dir_path: PathBuf, subtree: Entry }, /// Fallback: add a bare (unloaded) directory entry when `build_tree` fails. AddEmptyDirectory { path: PathBuf, is_ignored: bool }, } /// A filter function for filtering repo contents during traversal. type RepoContentFilter = dyn for<'a> Fn(&RepoContent<'a>) -> bool + Send + Sync; pub struct GetContentsArgs { pub include_folders: bool, pub include_ignored: bool, /// Optional filter applied during traversal to skip entries early. /// Return `true` to include the entry, `false` to skip it. pub filter: Option>, } impl Default for GetContentsArgs { fn default() -> Self { Self { include_folders: true, include_ignored: false, filter: None, } } } impl GetContentsArgs { pub fn include_ignored(mut self) -> Self { self.include_ignored = true; self } pub fn exclude_folders(mut self) -> Self { self.include_folders = false; self } /// Sets a filter closure to be applied during traversal. /// Only entries for which the filter returns `true` will be included. pub fn with_filter(self, filter: F) -> Self where F: for<'a> Fn(&RepoContent<'a>) -> bool + Send + Sync + 'static, { Self { include_folders: self.include_folders, include_ignored: self.include_ignored, filter: Some(Arc::new(filter)), } } } impl LocalRepoMetadataModel { /// Creates a new LocalRepoMetadataModel. #[cfg_attr(not(feature = "local_fs"), allow(unused_variables), allow(unused_mut))] pub fn new(ctx: &mut ModelContext) -> Self { let mut model = Self { repositories: HashMap::new(), lazy_loaded_paths: HashMap::new(), #[cfg(feature = "local_fs")] watcher: None, emit_incremental_updates: false, }; cfg_if::cfg_if! { if #[cfg(feature = "local_fs")] { let watcher = ctx.add_model(|ctx| { BulkFilesystemWatcher::new( std::time::Duration::from_secs(FILESYSTEM_WATCHER_DEBOUNCE_SECS), ctx, ) }); ctx.subscribe_to_model(&watcher, Self::handle_watcher_event); model.watcher = Some(watcher); ctx.subscribe_to_model(&DetectedRepositories::handle(ctx), |me, event, ctx| { let DetectedRepositoriesEvent::DetectedGitRepo { repository, .. } = event; let repo_path = repository.as_ref(ctx).root_dir().clone(); if let Err(e) = me.index_directory(repository.clone(), ctx) { log::warn!( "Failed to index directory {repo_path}: {e}" ); } }); } } model } /// Enables or disables emission of /// [`RepositoryMetadataEvent::IncrementalUpdateReady`] events after /// applying watcher mutations. Only the remote server variant should /// enable this. pub fn set_emit_incremental_updates(&mut self, enabled: bool) { self.emit_incremental_updates = enabled; } /// Handles events from the BulkFilesystemWatcher. #[cfg(feature = "local_fs")] fn handle_watcher_event( &mut self, event: &BulkFilesystemWatcherEvent, ctx: &mut ModelContext, ) { // Create a map to collect changes per repository let mut repo_updates: HashMap = HashMap::new(); // Process added or updated files for path in event.added_or_updated_iter() { if let Some(repo_path) = self.find_repository_for_path(path) { let repo_update = repo_updates.entry(repo_path).or_default(); repo_update.added.push(path.to_path_buf()); } } // Process deleted files for path in &event.deleted { if let Some(repo_path) = self.find_repository_for_path_string(path.to_string_lossy().as_ref()) { let repo_update = repo_updates.entry(repo_path).or_default(); repo_update.deleted.push(path.to_path_buf()); } else { log::warn!("Deleted file not found in any repo: {path:?} not found in any repo"); } } // Process moved files for (to_path, from_path) in &event.moved { if let Some(repo_path) = self.find_repository_for_path(to_path) { let repo_update = repo_updates.entry(repo_path).or_default(); repo_update .moved .insert(to_path.to_path_buf(), from_path.to_path_buf()); } } // Collect all paths that have been updated and emit an event. ctx.emit(RepositoryMetadataEvent::FileTreeUpdated { paths: repo_updates.keys().cloned().collect(), }); // Apply updates to each affected repository asynchronously. // Phase 1 (background thread): compute lightweight mutations via filesystem I/O. // Phase 2 (main thread callback): apply mutations directly to the tree — no clone needed. for (repo_path, repo_scoped_update) in repo_updates { if let Some(IndexedRepoState::Indexed(state)) = self.repositories.get_mut(&repo_path) { let repo_path_clone = repo_path.clone(); let gitignores_clone = state.gitignores.clone(); let lazy_load = self.lazy_loaded_paths.contains_key(&repo_path); ctx.spawn( async move { let mutations = Self::compute_file_tree_mutations( &repo_scoped_update, &gitignores_clone, ) .await; (mutations, repo_path_clone, lazy_load) }, |model, (mutations, repo_path, lazy_load), ctx| { if let Some(IndexedRepoState::Indexed(state)) = model.repositories.get_mut(&repo_path) { let update = Self::apply_file_tree_mutations( &mut state.entry, mutations, lazy_load, model.emit_incremental_updates, ); ctx.emit(RepositoryMetadataEvent::FileTreeEntryUpdated { path: repo_path, }); if let Some(update) = update { ctx.emit(RepositoryMetadataEvent::IncrementalUpdateReady { update, }); } } }, ); } } } #[cfg(feature = "local_fs")] fn find_repository_for_path_string(&self, path_str: &str) -> Option { self.repositories .iter() .filter(|(repo_path, state)| { let repo_path_str = repo_path.as_str(); path_str.starts_with(repo_path_str) && matches!(state, IndexedRepoState::Indexed(_)) }) .max_by_key(|(repo_path, _)| repo_path.as_str().len()) .map(|(repo_path, _)| repo_path.clone()) } #[cfg(feature = "local_fs")] pub fn find_repository_for_path(&self, path: &Path) -> Option { match StandardizedPath::from_local_canonicalized(path) { Ok(std_path) => self.find_repository_for_path_string(std_path.as_str()), Err(_) => None, } } /// Adds or updates a repository's file tree state. fn add_repository_internal( &mut self, repo_path: StandardizedPath, state: FileTreeState, ctx: &mut ModelContext, ) -> Result<(), RepoMetadataError> { let local_path = repo_path .to_local_path() .ok_or_else(|| RepoMetadataError::PathEncodingMismatch(repo_path.clone()))?; // Validate the repository path exists if !local_path.exists() { return Err(RepoMetadataError::RepoNotFound(repo_path.to_string())); } if !local_path.is_dir() { return Err(RepoMetadataError::InvalidPath( "Repository path must be a directory".to_string(), )); } // Register this path with the watcher if we have one #[cfg(feature = "local_fs")] { if let Some(ref watcher) = self.watcher { let watch_path = local_path.clone(); watcher.update(ctx, |watcher, _ctx| { use crate::entry::should_ignore_git_path; let watch_filter = WatchFilter::with_filter(Arc::new(move |watch_path| { !should_ignore_git_path(watch_path) })); std::mem::drop(watcher.register_path( &watch_path, watch_filter, RecursiveMode::Recursive, )); }); } } // Insert the repository state into the map let repo_path_for_event = repo_path.clone(); self.repositories .insert(repo_path, IndexedRepoState::Indexed(state)); ctx.emit(RepositoryMetadataEvent::RepositoryUpdated { path: repo_path_for_event, }); Ok(()) } /// Removes a repository from tracking. pub fn remove_repository( &mut self, repo_path: &StandardizedPath, ctx: &mut ModelContext, ) -> Result<(), RepoMetadataError> { if self.repositories.remove(repo_path).is_some() { // Unregister from watcher #[cfg(feature = "local_fs")] { if let Some(ref watcher) = self.watcher { if let Some(local_path) = repo_path.to_local_path() { watcher.update(ctx, |watcher, _ctx| { std::mem::drop(watcher.unregister_path(&local_path)); }); } } } ctx.emit(RepositoryMetadataEvent::RepositoryRemoved { path: repo_path.clone(), }); Ok(()) } else { Err(RepoMetadataError::RepoNotFound(repo_path.to_string())) } } pub fn get_repository(&self, repo_path: &StandardizedPath) -> Option<&FileTreeState> { match self.repositories.get(repo_path)? { IndexedRepoState::Indexed(state) => Some(state), IndexedRepoState::Pending => None, IndexedRepoState::Failed(_) => None, } } /// Returns the current [`IndexedRepoState`] for the specified repository or `None` if the /// repository is not being tracked. pub fn repository_state(&self, repo_path: &StandardizedPath) -> Option<&IndexedRepoState> { self.repositories.get(repo_path) } /// Checks if a repository is being tracked and indexed. pub fn has_repository(&self, repo_path: &StandardizedPath) -> bool { matches!( self.repositories.get(repo_path), Some(IndexedRepoState::Indexed(_)) ) } /// Returns whether the given path is tracked as a lazily-loaded standalone path. pub fn is_lazy_loaded_path(&self, path: &StandardizedPath) -> bool { self.lazy_loaded_paths.contains_key(path) } /// Lazily indexes a standalone path with only the first level of children. /// Registers the path with the file watcher for live updates. /// No-ops if the path is already tracked. #[cfg(feature = "local_fs")] pub fn index_lazy_loaded_path( &mut self, path: &StandardizedPath, ctx: &mut ModelContext, ) -> Result<(), RepoMetadataError> { // Already tracked as a lazy-loaded path — increase the refcount and keep the // existing watcher/model entry alive. if let Some(refcount) = self.lazy_loaded_paths.get_mut(path) { *refcount += 1; return Ok(()); } // Already tracked as a real repo — don't overwrite it. if matches!( self.repositories.get(path), Some(IndexedRepoState::Indexed(_) | IndexedRepoState::Pending) ) { return Ok(()); } let local_path = path .to_local_path() .ok_or_else(|| RepoMetadataError::PathEncodingMismatch(path.clone()))?; if !local_path.exists() { return Err(RepoMetadataError::RepoNotFound(path.to_string())); } if !local_path.is_dir() { return Err(RepoMetadataError::InvalidPath( "Path must be a directory".to_string(), )); } // Build first-level-only tree. let mut files = Vec::new(); let mut file_limit = MAX_FILES_PER_REPO; let root_entry = Entry::build_tree( &local_path, &mut files, &mut vec![], Some(&mut file_limit), 1, // max_depth — only first level 0, &IgnoredPathStrategy::Include, ) .map_err(RepoMetadataError::BuildTree)?; let state = FileTreeState::new_lazy_loaded(root_entry); self.add_repository_internal(path.clone(), state, ctx)?; self.lazy_loaded_paths.insert(path.clone(), 1); Ok(()) } /// Removes a lazily-loaded standalone path from tracking and unregisters the file watcher. #[cfg(feature = "local_fs")] pub fn remove_lazy_loaded_path( &mut self, path: &StandardizedPath, ctx: &mut ModelContext, ) { let Some(refcount) = self.lazy_loaded_paths.get_mut(path) else { return; }; if *refcount > 1 { *refcount -= 1; return; } self.lazy_loaded_paths.remove(path); // remove_repository unregisters the watcher and emits RepositoryRemoved. let _ = self.remove_repository(path, ctx); } /// Loads a specific directory inside an already-tracked tree. /// Emits `FileTreeEntryUpdated` so subscribers can sync. #[cfg(feature = "local_fs")] pub fn load_directory( &mut self, repo_root: &StandardizedPath, dir_path: &StandardizedPath, ctx: &mut ModelContext, ) -> Result<(), RepoMetadataError> { let Some(IndexedRepoState::Indexed(state)) = self.repositories.get_mut(repo_root) else { return Err(RepoMetadataError::RepoNotFound(repo_root.to_string())); }; let mut gitignores = state.gitignores.clone(); state .entry .load_at_path(dir_path, &mut gitignores) .map_err(RepoMetadataError::BuildTree)?; ctx.emit(RepositoryMetadataEvent::FileTreeEntryUpdated { path: repo_root.clone(), }); Ok(()) } /// Checks whether the parent directory of `path` is loaded in the given entry. fn is_parent_loaded_in_entry(entry: &FileTreeEntry, path: &StandardizedPath) -> bool { let Some(parent) = path.parent() else { return true; }; entry.get(&parent).is_some_and(|state| state.loaded()) } /// Phase 1: Computes file-tree mutations on a background thread. /// /// Performs all filesystem I/O (`exists()`, `is_dir()`, `build_tree()`, /// gitignore checks) and returns a lightweight list of mutations that can /// be applied to the tree on the main thread without cloning it. async fn compute_file_tree_mutations( update: &RepoUpdate, gitignores: &[Gitignore], ) -> Vec { let mut mutations = Vec::new(); // Removals for deleted and moved-from paths for path_to_remove in update.deleted.iter().chain(update.moved.values()) { mutations.push(FileTreeMutation::Remove(path_to_remove.clone())); } // Additions for new and moved-to paths for path_to_add in update.added.iter().chain(update.moved.keys()) { if !path_to_add.exists() { continue; } let is_ignored = Self::path_is_ignored(path_to_add, gitignores); if path_to_add.is_dir() { let mut files = Vec::new(); let mut gitignores = gitignores.to_owned(); let mut file_limit = MAX_FILES_PER_REPO; match Entry::build_tree( path_to_add, &mut files, &mut gitignores, Some(&mut file_limit), MAX_TREE_DEPTH, 0, &IgnoredPathStrategy::IncludeLazy, ) { Ok(subtree) => { mutations.push(FileTreeMutation::AddDirectorySubtree { dir_path: path_to_add.clone(), subtree, }); } Err(e) => { log::warn!("Failed to build subtree for directory {path_to_add:?}: {e:?}"); mutations.push(FileTreeMutation::AddEmptyDirectory { path: path_to_add.clone(), is_ignored, }); } } } else { let extension = path_to_add .extension() .and_then(|ext| ext.to_str().map(|s| s.to_owned())); mutations.push(FileTreeMutation::AddFile { path: path_to_add.clone(), is_ignored, extension, }); } } mutations } /// Phase 2: Applies pre-computed mutations to the file tree on the main thread. /// /// No filesystem I/O — only tree-structure operations. When `lazy_load` is /// true, additions are skipped if the parent directory has not been expanded. /// /// When `emit_updates` is true, /// from the mutations that were actually applied (filtering out any skipped /// by `lazy_load`), suitable for sending to the remote client. When false, /// no update tracking is performed and the function returns `None`. pub(crate) fn apply_file_tree_mutations( root_entry: &mut FileTreeEntry, mutations: Vec, lazy_load: bool, emit_updates: bool, ) -> Option { let emit = emit_updates; let mut remove_entries: Vec = Vec::new(); let mut update_entries: Vec = Vec::new(); for mutation in mutations { match mutation { FileTreeMutation::Remove(ref path) => { let Some(std_path) = StandardizedPath::try_from_local(path).ok() else { continue; }; root_entry.remove(&std_path); if emit { remove_entries.push(std_path); } } FileTreeMutation::AddFile { ref path, is_ignored, ref extension, } => { let Some(std_path) = StandardizedPath::try_from_local(path).ok() else { continue; }; if lazy_load && !Self::is_parent_loaded_in_entry(root_entry, &std_path) { continue; } let Some(parent) = std_path.parent() else { continue; }; Self::ensure_parent_directories_exist(root_entry, &parent); let Some(parent_dir) = root_entry.find_parent_directory(&std_path) else { continue; }; // If the file already exists in the tree, just update its ignored flag // to preserve the existing FileId. if let Some(entry) = root_entry.get_mut(&std_path) { entry.set_ignored(is_ignored); } else { let file_state = FileTreeEntryState::File(FileTreeFileMetadata { path: Arc::new(std_path.clone()), file_id: FileId::new(), extension: extension.clone(), ignored: is_ignored, }); root_entry.insert_child_state(&parent_dir, file_state); } if emit { update_entries.push(FileTreeEntryUpdate { parent_path_to_replace: parent.clone(), subtree_metadata: vec![RepoNodeMetadata::File(FileNodeMetadata { path: std_path, extension: extension.clone(), ignored: is_ignored, })], }); } } FileTreeMutation::AddDirectorySubtree { ref dir_path, ref subtree, } => { let Some(std_dir) = StandardizedPath::try_from_local(dir_path).ok() else { continue; }; if lazy_load && !Self::is_parent_loaded_in_entry(root_entry, &std_dir) { continue; } if let Some(parent) = std_dir.parent() { Self::ensure_parent_directories_exist(root_entry, &parent); } if let Some(parent_path) = root_entry.find_parent_directory(&std_dir) { if let Some(FileTreeEntryState::Directory(directory)) = root_entry.get_mut(&parent_path) { directory.loaded = true; } root_entry.remove(subtree.path()); root_entry.insert_entry_at_path( Arc::new(subtree.path().clone()), subtree.clone(), ); if emit { let parent_std = std_dir.parent().unwrap_or(std_dir.clone()); let metadata = flatten_entry_metadata(subtree); update_entries.push(FileTreeEntryUpdate { parent_path_to_replace: parent_std, subtree_metadata: metadata, }); } } } FileTreeMutation::AddEmptyDirectory { ref path, is_ignored, } => { let Some(std_path) = StandardizedPath::try_from_local(path).ok() else { continue; }; if lazy_load && !Self::is_parent_loaded_in_entry(root_entry, &std_path) { continue; } let Some(parent) = std_path.parent() else { continue; }; Self::ensure_parent_directories_exist(root_entry, &parent); let Some(parent_dir) = root_entry.find_parent_directory(&std_path) else { continue; }; let dir_state = FileTreeEntryState::Directory(FileTreeDirectoryEntryState { path: Arc::new(std_path.clone()), ignored: is_ignored, loaded: false, }); root_entry.insert_child_state(&parent_dir, dir_state); if emit { update_entries.push(FileTreeEntryUpdate { parent_path_to_replace: parent.clone(), subtree_metadata: vec![RepoNodeMetadata::Directory( DirectoryNodeMetadata { path: std_path, ignored: is_ignored, loaded: false, }, )], }); } } } } if !emit { return None; } Some(RepoMetadataUpdate { repo_path: root_entry.root_directory().as_ref().clone(), remove_entries, update_entries, }) } /// Delegates to [`FileTreeEntry::ensure_parent_directories_exist`]. fn ensure_parent_directories_exist( root_entry: &mut FileTreeEntry, target_parent: &StandardizedPath, ) { root_entry.ensure_parent_directories_exist(target_parent); } /// Checks if a path matches any of the gitignore patterns fn path_is_ignored(path: &Path, gitignores: &[Gitignore]) -> bool { // Check if any component of the path is .git if path .components() .any(|component| component.as_os_str() == ".git") { return true; } // Check if path matches any gitignore patterns let is_dir = path.is_dir(); matches_gitignores(path, is_dir, gitignores, true) } /// Indexes a repository from the given repository handle. pub fn index_directory( &mut self, repository: ModelHandle, ctx: &mut ModelContext<'_, Self>, ) -> Result<(), RepoMetadataError> { let std_path = repository.as_ref(ctx).root_dir().clone(); let local_path = std_path .to_local_path() .ok_or_else(|| RepoMetadataError::PathEncodingMismatch(std_path.clone()))?; // Validate the repository path exists and is a directory if !local_path.exists() { return Err(RepoMetadataError::RepoNotFound(std_path.to_string())); } if !local_path.is_dir() { return Err(RepoMetadataError::InvalidPath( "Repository path must be a directory".to_string(), )); } let repo_path_str = std_path.to_string(); // Check if the repository is already indexed or currently being indexed. // Allow re-indexing if the existing entry was a lazily-loaded path placeholder. match self.repositories.get(&std_path) { Some(IndexedRepoState::Indexed(_)) if !self.lazy_loaded_paths.contains_key(&std_path) => { log::debug!("Repository already indexed: {std_path}"); return Ok(()); } Some(IndexedRepoState::Indexed(_)) => { // Was a lazy-loaded path – allow upgrading to a real repo. log::info!("Upgrading lazy-loaded path to git repo: {repo_path_str}"); self.lazy_loaded_paths.remove(&std_path); } Some(IndexedRepoState::Pending) => { log::debug!("Repository already being indexed: {repo_path_str}"); return Ok(()); } Some(IndexedRepoState::Failed(error)) => { log::debug!( "Repository indexing previously failed: {repo_path_str}, error: {error}" ); log::info!("Retrying indexing for previously failed repository: {repo_path_str}"); // Continue to retry indexing } None => { // Repository is not indexed and not pending, proceed with indexing } } // Collect gitignore files from the repository let gitignores = gitignores_for_directory(&local_path); // Mark the repository as pending to prevent duplicate work self.repositories .insert(std_path.clone(), IndexedRepoState::Pending); // Use the provided repository handle instead of creating a new one let repository_handle = repository; // Build the complete file tree for the repository asynchronously let repo_path_for_build = local_path; let gitignores_for_build = gitignores.clone(); let repo_path_str_for_log = std_path.to_string(); let std_path_for_completion = std_path; let repository_handle_for_completion = repository_handle.clone(); ctx.spawn( async move { let mut files: Vec = Vec::new(); let mut gitignores_for_build = gitignores_for_build; let mut file_limit = MAX_FILES_PER_REPO; let build_result = Entry::build_tree( &repo_path_for_build, &mut files, &mut gitignores_for_build, Some(&mut file_limit), MAX_TREE_DEPTH, // max_depth 0, // current_depth &IgnoredPathStrategy::IncludeLazy, ); ( build_result, files, gitignores_for_build, repo_path_str_for_log, std_path_for_completion, repository_handle_for_completion, ) }, move |model: &mut LocalRepoMetadataModel, ( build_result, files, gitignores_for_build, repo_path_str, std_repo_path, repository_handle, ): (Result, Vec, _, String, StandardizedPath, ModelHandle), ctx| { match build_result { Ok(root_entry) => { let state = FileTreeState::new(root_entry, gitignores_for_build, Some(repository_handle)); if let Err(e) = model.add_repository_internal(std_repo_path.clone(), state, ctx) { log::warn!("Failed to add repository {repo_path_str}: {e:?}"); // On failure, mark the repository as failed model .repositories .insert(std_repo_path, IndexedRepoState::Failed(e)); } else { log::info!( "Successfully indexed repository: {} with {} files", repo_path_str, files.len() ); } } Err(e) => { safe_warn!( safe: ("Failed to build file tree for repository: {e:?}"), full: ("Failed to build file tree for repository {repo_path_str}: {e:?}") ); send_telemetry_from_ctx!(RepoMetadataTelemetryEvent::BuildTreeFailed { error: format!("{e:#}") }, ctx); ctx.emit(RepositoryMetadataEvent::UpdatingRepositoryFailed { path: std_repo_path.clone() }); model.repositories.insert( std_repo_path, IndexedRepoState::Failed(RepoMetadataError::BuildTree(e)), ); } } }, ); Ok(()) } /// Returns repository contents (files and optionally directories) in a given repository. pub fn get_repo_contents( &self, repo_path: &StandardizedPath, args: GetContentsArgs, ) -> Option>> { let state = match self.repositories.get(repo_path)? { IndexedRepoState::Indexed(state) => state, IndexedRepoState::Pending => return None, IndexedRepoState::Failed(_) => return None, }; let mut contents = Vec::new(); collect_contents_recursive( &state.entry, state.entry.root_directory(), &mut contents, &args, ); Some(contents) } } impl galaxyui::Entity for LocalRepoMetadataModel { type Event = RepositoryMetadataEvent; } /// Helper function to recursively collect contents (files and optionally directories) from an Entry tree. pub(crate) fn collect_contents_recursive<'a>( entry: &'a FileTreeEntry, current_path: &'a StandardizedPath, contents: &mut Vec>, args: &GetContentsArgs, ) { if !args.include_ignored && entry.ignored(current_path) { return; } match entry.get(current_path) { Some(FileTreeEntryState::File(metadata)) => { let content = RepoContent::File(metadata); if args.filter.as_ref().is_none_or(|f| f(&content)) { contents.push(content); } } Some(FileTreeEntryState::Directory(dir)) => { if args.include_folders { let content = RepoContent::Directory(dir); if args.filter.as_ref().is_none_or(|f| f(&content)) { contents.push(content); } } for child in entry.child_paths(current_path) { collect_contents_recursive(entry, child, contents, args); } } None => {} } } // Test helpers #[cfg(any(test, feature = "test-util"))] impl LocalRepoMetadataModel { /// Insert a repository state directly for testing purposes. pub fn insert_test_state(&mut self, repo_path: StandardizedPath, state: FileTreeState) { self.repositories .insert(repo_path, IndexedRepoState::Indexed(state)); } } #[cfg(test)] #[path = "local_model_test.rs"] mod tests;