#![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; #[cfg(feature = "local_fs")] use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::Arc; use futures::future::{self, BoxFuture, FutureExt as _}; use galaxy_core::{safe_warn, send_telemetry_from_ctx}; use galaxy_util::sync::Condition; use galaxyui_core::ModelHandle; /// Represents either a file or directory in a repository. #[derive(Debug, Clone)] pub enum RepoContent<'a> { File(&'a FileTreeFileMetadata), Directory(&'a FileTreeDirectoryEntryState), } /// The result of [`LocalRepoMetadataModel::get_repo_contents`]. /// /// The number of returned entries is capped at [`MAX_REPO_CONTENTS_RESULTS`]. /// When the repository contains more matching entries than that cap, the /// returned `contents` are a partial prefix and `truncated` is `true`. #[derive(Debug, Default)] pub struct RepoContents<'a> { /// The collected repository contents, capped at [`MAX_REPO_CONTENTS_RESULTS`]. pub contents: Vec>, /// `true` if traversal stopped early because the maximum result size was /// reached, meaning more matching entries exist than were returned. pub truncated: bool, } use galaxy_util::standardized_path::StandardizedPath; use crate::entry::{ BudgetExceededBehavior, BuildTreeError, BuildTreeOptions, Entry, FileId, IgnoredPathStrategy, }; use crate::repository::Repository; use crate::standing_queries::{ StandingQueryDefinitions, StandingQueryResults, StandingQueryResultsDelta, }; use crate::telemetry::RepoMetadataTelemetryEvent; use crate::{gitignores_for_directory, matches_gitignores, RepoMetadataError}; cfg_if::cfg_if! { if #[cfg(feature = "local_fs")] { use notify_debouncer_full::notify::RecursiveMode; use crate::entry::repo_watch_filter; use crate::repositories::{DetectedRepositories, DetectedRepositoriesEvent}; use crate::watcher::DirectoryWatcher; use watcher::{BulkFilesystemWatcher, BulkFilesystemWatcherEvent}; use galaxyui_core::SingletonEntity as _; /// Duration between filesystem watch events in seconds const FILESYSTEM_WATCHER_DEBOUNCE_SECS: u64 = 1; } } use galaxyui_core::ModelContext; use ignore::gitignore::Gitignore; use crate::file_tree_store::{ FileTreeDirectoryEntryState, FileTreeEntry, FileTreeEntryState, FileTreeFileMetadata, FileTreeState, }; use crate::file_tree_update::{ flatten_entry_metadata, DirectoryNodeMetadata, FileNodeMetadata, FileTreeEntryUpdate, MetadataUpdateType, RepoMetadataUpdate, RepoNodeMetadata, }; /// Maximum depth to traverse when building file trees const MAX_TREE_DEPTH: usize = 200; /// Maximum number of non-ignored files to index eagerly per repository. /// /// This is a high safety ceiling, not the common case: gitignored directories /// are lazy placeholders and never consume this budget, so only repositories /// with an enormous number of *tracked* files reach it. When the budget is /// exhausted the builder stops descending breadth-first and leaves the /// remaining directories as unloaded placeholders (lazy-loaded on demand) /// rather than failing or collapsing the tree to a single level. const MAX_FILES_PER_REPO: usize = 200_000; /// Maximum number of results to return from get_repo_contents to prevent accidentally /// materializing the entire repository const MAX_REPO_CONTENTS_RESULTS: usize = 100; #[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, /// Specifies whether this event contains a precise delta or requires a conservative /// refresh because the entry was replaced without one. update_type: MetadataUpdateType, }, /// The paths retained for standing queries changed. StandingQueryResultsUpdated { path: StandardizedPath, delta: StandingQueryResultsDelta, }, 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(Condition), /// Repository has been successfully indexed. Indexed(FileTreeState), /// Repository indexing failed with the given error. Failed(RepoMetadataError), } impl IndexedRepoState { pub fn pending() -> Self { Self::Pending(Condition::new()) } pub fn wait_until_indexed(&self) -> BoxFuture<'static, ()> { match self { Self::Indexed(_) | Self::Failed(_) => future::ready(()).boxed(), Self::Pending(condition) => { let condition = condition.clone(); async move { condition.wait().await; } .boxed() } } } } impl IndexedRepoState { pub(crate) fn complete_if_pending(&self) { if let Self::Pending(condition) = self { condition.set(); } } } /// How a repository's ROOT directory is registered with the filesystem watcher. /// /// This is orthogonal to the set of on-demand per-directory watches a repo may /// also hold (see [`RepoWatch::extra_dirs`]): a recursive root can now carry /// extra non-recursive watches too. // // Without `local_fs` there is no watcher, so `NonRecursive` is never // constructed; the variant still exists to keep the watch-mode model intact. #[cfg_attr(not(feature = "local_fs"), allow(dead_code))] #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RootWatchMode { /// A single recursive watch on the root covers the whole subtree. Used for /// git repos (which rely on gitignore pruning in the watch descend filter) /// and, on macOS/Windows, for lazy non-git roots where a recursive OS watch /// is cheap. Recursive, /// The root is watched non-recursively; each loaded directory gets its own /// non-recursive watch, so the number of watches scales with what the user /// expands rather than the whole subtree. Used for lazy (non-git) roots on /// Linux, where per-directory inotify watches are otherwise prohibitively /// expensive. NonRecursive, } /// Tracks how a repository is registered with the filesystem watcher. /// /// `root_mode` describes how the ROOT directory is registered. `extra_dirs` /// holds the on-demand NON-recursive watches we register on individual /// subdirectories that the root watch does not cover: every loaded subdir under /// a non-recursive root, or expanded gitignored dirs under a recursive root on /// Linux. The root itself is never stored here — it is unregistered directly by /// its repo path on teardown. In practice `extra_dirs` is only populated on /// Linux, since other backends deliver gitignored events through the recursive /// root watch. #[cfg(feature = "local_fs")] #[derive(Debug)] struct RepoWatch { root_mode: RootWatchMode, extra_dirs: HashSet, } /// 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, /// Stored context-discovery matches, independent from canonical tree materialization. standing_results: 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, /// Paths that must be loaded even when gitignored or beyond the tree's size /// limit. For example, a consumer can register `.foo/bar` so ignored /// `.foo`, `.foo/bar`, and descendants of `.foo/bar` are loaded into the /// tree. force_included_paths: Vec, /// Configured standing-query matchers. standing_query_definitions: StandingQueryDefinitions, /// Resolved symlink target directories for direct children of ignored-path interests. /// /// One resolved target may be referenced by multiple lexical symlinks, so retain every /// alias rather than using a one-to-one bidirectional map. #[cfg(feature = "local_fs")] symlink_targets: HashMap>, /// How each tracked repository is registered with the filesystem watcher, /// including any on-demand per-directory watches recorded for teardown. See /// [`RepoWatch`]. #[cfg(feature = "local_fs")] repo_watches: HashMap, } #[derive(Debug, Clone, Default)] struct RepoUpdate { added: Vec, deleted: Vec, moved: HashMap, } #[cfg(feature = "local_fs")] #[derive(Debug, Clone, PartialEq, Eq, Hash)] struct SymlinkTarget { repo_path: StandardizedPath, current_path: PathBuf, } /// 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 }, /// Add a bare (unloaded) directory placeholder, materialized on demand when /// the user expands it. Used for newly created directories under lazy roots /// and as a fallback when `build_tree` fails. AddUnloadedDirectory { 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(), standing_results: HashMap::new(), lazy_loaded_paths: HashMap::new(), #[cfg(feature = "local_fs")] watcher: None, emit_incremental_updates: false, force_included_paths: Vec::new(), standing_query_definitions: StandingQueryDefinitions::default(), #[cfg(feature = "local_fs")] symlink_targets: HashMap::new(), #[cfg(feature = "local_fs")] repo_watches: HashMap::new(), }; 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, |me, _, event, ctx| me.handle_watcher_event(event, ctx)); 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; } /// Registers paths that must be loaded even when gitignored or beyond the /// tree's size limit. /// /// This stays intentionally generic: consumers own the meaning of the paths, /// while repo metadata only uses them to decide which ignored subtrees should /// be represented eagerly instead of as lazy placeholders. pub fn register_force_included_paths(&mut self, paths: impl IntoIterator) { for path in paths { assert!( !path.is_absolute(), "force-included paths must be repository-relative" ); if !self .force_included_paths .iter() .any(|existing| existing == &path) { self.force_included_paths.push(path); } } } pub fn set_project_skill_provider_paths(&mut self, paths: impl IntoIterator) { self.standing_query_definitions .set_project_skill_provider_paths(paths); } /// Adds synthetic lexical repository updates for changes beneath resolved symlink targets. /// /// Directory symlinks are intentionally absent from the canonical tree, so target changes /// must be replayed through their lexical paths to refresh standing-query results. #[cfg(feature = "local_fs")] fn add_symlink_target_updates( &self, event: &BulkFilesystemWatcherEvent, repo_updates: &mut HashMap, ) -> HashSet { let mut matched_paths = HashSet::new(); let mut append_updates = |path: &Path| { for (original_path, targets) in &self.symlink_targets { if path != original_path && !path.starts_with(original_path) { continue; } matched_paths.insert(path.to_path_buf()); for target in targets { let update = repo_updates.entry(target.repo_path.clone()).or_default(); if !update.deleted.contains(&target.current_path) { update.deleted.push(target.current_path.clone()); } if target.current_path.is_dir() && !update.added.contains(&target.current_path) { update.added.push(target.current_path.clone()); } } } }; for path in event.added_or_updated_iter() { append_updates(path); } for path in &event.deleted { append_updates(path); } for (to_path, from_path) in &event.moved { append_updates(from_path); append_updates(to_path); } matched_paths } /// Synchronizes producer-side target watches for symlinked children of ignored-path interests. #[cfg(feature = "local_fs")] fn refresh_symlink_targets( &mut self, repo_path: &StandardizedPath, ctx: &mut ModelContext, ) { if !self.emit_incremental_updates { return; } let previously_watched = self.symlink_targets.keys().cloned().collect::>(); self.remove_symlink_targets_for_repo(repo_path); let Some(local_repo_path) = repo_path.to_local_path() else { return; }; for interest in &self.force_included_paths { let Ok(entries) = std::fs::read_dir(local_repo_path.join(interest)) else { continue; }; for entry in entries.flatten() { let current_path = entry.path(); if !current_path.is_symlink() || !current_path.is_dir() { continue; } let Ok(original_path) = dunce::canonicalize(¤t_path) else { continue; }; self.symlink_targets .entry(original_path) .or_default() .insert(SymlinkTarget { repo_path: repo_path.clone(), current_path, }); } } self.sync_symlink_target_watches(previously_watched, ctx); } #[cfg(feature = "local_fs")] fn remove_symlink_targets_for_repo(&mut self, repo_path: &StandardizedPath) { for targets in self.symlink_targets.values_mut() { targets.retain(|target| &target.repo_path != repo_path); } self.symlink_targets .retain(|_, targets| !targets.is_empty()); } #[cfg(feature = "local_fs")] fn clear_symlink_targets_for_repo( &mut self, repo_path: &StandardizedPath, ctx: &mut ModelContext, ) { let previously_watched = self.symlink_targets.keys().cloned().collect(); self.remove_symlink_targets_for_repo(repo_path); self.sync_symlink_target_watches(previously_watched, ctx); } #[cfg(feature = "local_fs")] fn sync_symlink_target_watches( &self, previously_watched: HashSet, ctx: &mut ModelContext, ) { let desired_target_dirs = self.symlink_targets.keys().cloned().collect::>(); if let Some(ref watcher) = self.watcher { for target_dir in desired_target_dirs.difference(&previously_watched) { watcher.update(ctx, |watcher, _ctx| { std::mem::drop(watcher.register_path( target_dir, repo_watch_filter(Vec::new(), Vec::new()), RecursiveMode::NonRecursive, )); }); } for target_dir in previously_watched.difference(&desired_target_dirs) { watcher.update(ctx, |watcher, _ctx| { std::mem::drop(watcher.unregister_path(target_dir)); }); } } } /// 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(); let symlink_target_paths = self.add_symlink_target_updates(event, &mut repo_updates); // Process added or updated files for path in event.added_or_updated_iter() { if let Some(repo_path) = self.find_repository_for_watcher_entry_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 if !symlink_target_paths.contains(path) { 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_watcher_entry_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 force_included_paths = self.force_included_paths.clone(); let standing_query_definitions = self.standing_query_definitions.clone(); let lazy_load = self.lazy_loaded_paths.contains_key(&repo_path); ctx.spawn( async move { let (mutations, standing_results, removed_roots) = Self::compute_file_tree_mutations( &repo_scoped_update, &gitignores_clone, &force_included_paths, &standing_query_definitions, lazy_load, ) .await; ( mutations, standing_results, removed_roots, repo_path_clone, lazy_load, ) }, |model, (mutations, discovered_results, removed_roots, repo_path, lazy_load), ctx| { if let Some(IndexedRepoState::Indexed(state)) = model.repositories.get_mut(&repo_path) { let mut update = Self::apply_file_tree_mutations( &mut state.entry, mutations, lazy_load, true, ) .expect("update tracking was enabled"); let standing_delta = model .standing_results .entry(repo_path.clone()) .or_default() .replace_subtrees(&removed_roots, discovered_results); model.refresh_symlink_targets(&repo_path, ctx); update.standing_results_delta = standing_delta.clone(); ctx.emit(RepositoryMetadataEvent::FileTreeEntryUpdated { path: repo_path.clone(), update_type: MetadataUpdateType::IncrementalUpdate(update.clone()), }); if !standing_delta.is_empty() { ctx.emit(RepositoryMetadataEvent::StandingQueryResultsUpdated { path: repo_path.clone(), delta: standing_delta, }); } if model.emit_incremental_updates { ctx.emit(RepositoryMetadataEvent::IncrementalUpdateReady { update, }); } } // Drop per-directory watches for any directory that was // deleted or moved away (along with their tracked // descendants). Without this their stale `extra_dirs` // entries would make `watch_subdir` skip re-watching if a // directory is later recreated at the same path. for removed in &removed_roots { model.unwatch_removed_subtree(&repo_path, removed, ctx); } }, ); } } } #[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")] fn find_repository_for_standardized_path( &self, path: &StandardizedPath, ) -> Option { self.repositories .iter() .filter(|(repo_path, state)| { path.starts_with(repo_path) && matches!(state, IndexedRepoState::Indexed(_)) }) .max_by_key(|(repo_path, _)| repo_path.as_str().len()) .map(|(repo_path, _)| repo_path.clone()) } #[cfg(feature = "local_fs")] fn find_repository_for_watcher_entry_path(&self, path: &Path) -> Option { StandardizedPath::try_from_local(path) .ok() .and_then(|path| self.find_repository_for_standardized_path(&path)) .or_else(|| self.find_repository_for_path(path)) } #[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_standardized_path(&std_path), Err(_) => None, } } /// Adds or updates a repository's file tree state. /// /// `root_mode` controls how the root is registered with the filesystem /// watcher: [`RootWatchMode::Recursive`] registers a single recursive watch /// (git repos, and lazy roots off Linux), while /// [`RootWatchMode::NonRecursive`] registers the root non-recursively so /// expanded subdirectories can be watched individually. #[cfg_attr(not(feature = "local_fs"), allow(unused_variables))] fn add_repository_internal( &mut self, repo_path: StandardizedPath, state: FileTreeState, root_mode: RootWatchMode, 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(), )); } // Record how this root is watched and register it with the watcher (if // any). #[cfg(feature = "local_fs")] { let recursive_mode = match root_mode { RootWatchMode::Recursive => RecursiveMode::Recursive, RootWatchMode::NonRecursive => RecursiveMode::NonRecursive, }; // Replace any prior registration, dropping its root watch and stale // per-directory watches before re-registering (e.g. a lazy // non-recursive root upgraded to a recursive git repo, whose old // per-dir watches would otherwise duplicate the recursive coverage). let previous = self.repo_watches.insert( repo_path.clone(), RepoWatch { root_mode, extra_dirs: HashSet::new(), }, ); if let Some(ref watcher) = self.watcher { let watch_path = local_path.clone(); // Build the gitignore set (root + global) and force-included // path list so the descend filter prunes gitignored subtrees // while still watching registered force-included paths (e.g. // skills). let gitignores = crate::gitignores_for_directory(&watch_path); let force_included_paths = self.force_included_paths.clone(); let had_previous = previous.is_some(); let previous_extra: Vec = previous .map(|prev| { prev.extra_dirs .iter() .filter_map(|dir| dir.to_local_path()) .collect() }) .unwrap_or_default(); watcher.update(ctx, |watcher, _ctx| { if had_previous { std::mem::drop(watcher.unregister_path(&watch_path)); } for dir in &previous_extra { std::mem::drop(watcher.unregister_path(dir)); } std::mem::drop(watcher.register_path( &watch_path, repo_watch_filter(gitignores, force_included_paths), recursive_mode, )); }); } } // Insert the repository state into the map let repo_path_for_event = repo_path.clone(); self.replace_repository_state(repo_path, IndexedRepoState::Indexed(state)); #[cfg(feature = "local_fs")] self.refresh_symlink_targets(&repo_path_for_event, ctx); 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.remove_repository_state(repo_path).is_some() { self.standing_results.remove(repo_path); #[cfg(feature = "local_fs")] self.clear_symlink_targets_for_repo(repo_path, ctx); // Drop the recorded watch entry and unregister from the watcher. #[cfg(feature = "local_fs")] { let removed = self.repo_watches.remove(repo_path); if let Some(ref watcher) = self.watcher { // Uniform teardown: the root watch lives on the repo path, // plus any on-demand per-directory watches in `extra_dirs`. let mut paths_to_unregister: Vec = repo_path.to_local_path().into_iter().collect(); if let Some(removed) = removed { paths_to_unregister.extend( removed .extra_dirs .iter() .filter_map(|dir| dir.to_local_path()), ); } watcher.update(ctx, |watcher, _ctx| { for path in &paths_to_unregister { std::mem::drop(watcher.unregister_path(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, } } pub fn standing_query_results( &self, repo_path: &StandardizedPath, ) -> Option<&StandingQueryResults> { self.standing_results.get(repo_path) } /// 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 while collecting standing results across // descendants that are not materialized in the lazy file tree. let mut files = Vec::new(); let mut file_limit = MAX_FILES_PER_REPO; let mut standing_results = StandingQueryResults::default(); let root_entry = Entry::build_tree_with_standing_queries( &local_path, &mut files, &mut vec![], Some(&mut file_limit), BuildTreeOptions { max_depth: 1, // Only first level. current_depth: 0, ignored_path_strategy: &IgnoredPathStrategy::Include, force_included_paths: &self.force_included_paths, budget_exceeded_behavior: BudgetExceededBehavior::StopAndLazyLoad, }, &mut standing_results, &self.standing_query_definitions, ) .map_err(RepoMetadataError::BuildTree)?; let state = FileTreeState::new_lazy_loaded(root_entry); self.standing_results.insert(path.clone(), standing_results); // On Linux, watch lazy (non-git) roots non-recursively to avoid // registering an inotify watch for every directory in the subtree. // Subdirectories get their own non-recursive watch as they are expanded // (see `load_directory`). macOS/Windows watch a whole tree with a single // OS handle, so recursive watching stays cheap there. let root_mode = if cfg!(target_os = "linux") { RootWatchMode::NonRecursive } else { RootWatchMode::Recursive }; self.add_repository_internal(path.clone(), state, root_mode, 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)?; // Start watching the directory we just expanded so its direct children // stay fresh. For a non-recursive root this covers every expanded // subdir; for a recursive root it covers gitignored dirs pruned from the // root watch on Linux. No-op when the root watch already covers it. self.watch_subdir(repo_root, dir_path, ctx); ctx.emit(RepositoryMetadataEvent::FileTreeEntryUpdated { path: repo_root.clone(), update_type: MetadataUpdateType::FullReplace, }); Ok(()) } /// Registers an on-demand non-recursive watch on `dir_path` when the root /// watch does not already cover it, recording it in `extra_dirs` so it can /// be unregistered on teardown. No-ops if `repo_root` is not tracked, the /// directory is already watched, or the root watch already covers it. /// /// Decision by root mode: /// - [`RootWatchMode::NonRecursive`]: nothing under the root is covered, so /// every loaded subdir gets its own watch. /// - [`RootWatchMode::Recursive`]: the root watch already covers non-pruned /// subtrees; only gitignored dirs are pruned, and only on Linux (other /// backends ignore the descend filter and still deliver their events), so /// we watch those to keep expanded gitignored folders fresh. #[cfg(feature = "local_fs")] fn watch_subdir( &mut self, repo_root: &StandardizedPath, dir_path: &StandardizedPath, ctx: &mut ModelContext, ) { let Some(repo_watch) = self.repo_watches.get(repo_root) else { return; }; if repo_watch.extra_dirs.contains(dir_path) { // Already watching this directory. return; } let root_mode = repo_watch.root_mode; let should_watch = match root_mode { RootWatchMode::NonRecursive => true, RootWatchMode::Recursive => { cfg!(target_os = "linux") && self.dir_pruned_from_root_watch(repo_root, dir_path) } }; if !should_watch { return; } let Some(local_path) = dir_path.to_local_path() else { return; }; let gitignores = crate::gitignores_for_directory(&local_path); let force_included_paths = self.force_included_paths.clone(); if let Some(repo_watch) = self.repo_watches.get_mut(repo_root) { repo_watch.extra_dirs.insert(dir_path.clone()); } if let Some(ref watcher) = self.watcher { watcher.update(ctx, |watcher, _ctx| { std::mem::drop(watcher.register_path( &local_path, repo_watch_filter(gitignores, force_included_paths), RecursiveMode::NonRecursive, )); }); } } /// Drops any on-demand per-directory watches at or under `removed_path` when /// a directory is deleted or moved away. Each stale path is unregistered from /// the watcher and removed from `extra_dirs`. Without this, a directory /// recreated at the same path would be skipped by [`watch_subdir`] (which /// sees the stale `extra_dirs` entry) and never receive a fresh watch. #[cfg(feature = "local_fs")] fn unwatch_removed_subtree( &mut self, repo_root: &StandardizedPath, removed_path: &StandardizedPath, ctx: &mut ModelContext, ) { let Some(repo_watch) = self.repo_watches.get_mut(repo_root) else { return; }; // The removed directory plus any tracked descendants are now stale. // `starts_with` is component-aware and matches the path itself, so this // covers both an exact removed dir and everything expanded beneath it. let stale: Vec = repo_watch .extra_dirs .iter() .filter(|dir| dir.starts_with(removed_path)) .cloned() .collect(); if stale.is_empty() { return; } for dir in &stale { repo_watch.extra_dirs.remove(dir); } if let Some(ref watcher) = self.watcher { let local_paths: Vec = stale.iter().filter_map(|dir| dir.to_local_path()).collect(); watcher.update(ctx, |watcher, _ctx| { for path in &local_paths { std::mem::drop(watcher.unregister_path(path)); } }); } } /// Returns whether `dir_path` would be pruned from `repo_root`'s recursive /// watch by the gitignore descend filter (i.e. it is gitignored relative to /// the repo root). Used to decide whether an expanded directory needs its /// own watch. #[cfg(feature = "local_fs")] fn dir_pruned_from_root_watch( &self, repo_root: &StandardizedPath, dir_path: &StandardizedPath, ) -> bool { let Some(IndexedRepoState::Indexed(state)) = self.repositories.get(repo_root) else { return false; }; let Some(local) = dir_path.to_local_path() else { return false; }; Self::path_is_ignored(&local, &state.gitignores) } /// 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. /// /// When `lazy_load` is true (lazy non-git roots), newly added directories /// are emitted as unloaded placeholders rather than fully-materialized /// subtrees, matching the lazy tree model; the directory is materialized /// (and watched) on demand when the user expands it via `load_directory`. async fn compute_file_tree_mutations( update: &RepoUpdate, gitignores: &[Gitignore], force_included_paths: &[PathBuf], standing_query_definitions: &StandingQueryDefinitions, lazy_load: bool, ) -> ( Vec, StandingQueryResults, Vec, ) { let mut mutations = Vec::new(); let mut standing_results = StandingQueryResults::default(); let mut removed_roots = 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())); if let Ok(path) = StandardizedPath::try_from_local(path_to_remove) { removed_roots.push(path); } // A removed direct provider child may have been a symlinked skill directory, // which is intentionally absent from the canonical tree and standing file matches. standing_results.record_direct_project_skill_provider_child_change( path_to_remove, standing_query_definitions, ); } // 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() { if lazy_load { // Lazy (non-git) roots are not materialized when a directory // is created; insert it as an unloaded placeholder and build // the subtree on demand when the user expands it (see // `load_directory`). mutations.push(FileTreeMutation::AddUnloadedDirectory { path: path_to_add.clone(), is_ignored, }); continue; } let mut files = Vec::new(); let mut gitignores = gitignores.to_owned(); let mut file_limit = MAX_FILES_PER_REPO; match Entry::build_tree_with_standing_queries( path_to_add, &mut files, &mut gitignores, Some(&mut file_limit), BuildTreeOptions { max_depth: MAX_TREE_DEPTH, current_depth: 0, ignored_path_strategy: &IgnoredPathStrategy::IncludeLazy, force_included_paths, budget_exceeded_behavior: BudgetExceededBehavior::StopAndLazyLoad, }, &mut standing_results, standing_query_definitions, ) { Ok(subtree) => { mutations.push(FileTreeMutation::AddDirectorySubtree { dir_path: path_to_add.clone(), subtree, }); } Err(BuildTreeError::Symlink) => { // Directory symlinks are intentionally absent from the canonical tree. // Re-hydrate only when the changed entry itself can introduce a // symlinked skill; ordinary descendants should not wake consumers. standing_results.record_direct_project_skill_provider_child_change( path_to_add, standing_query_definitions, ); standing_results.record_followed_project_skill_directory( path_to_add, standing_query_definitions, ); } Err(e) => { log::warn!("Failed to build subtree for directory {path_to_add:?}: {e:?}"); mutations.push(FileTreeMutation::AddUnloadedDirectory { path: path_to_add.clone(), is_ignored, }); } } } else { standing_results.record_path(path_to_add, false, standing_query_definitions); 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, standing_results, removed_roots) } /// 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, returns a [`RepoMetadataUpdate`] built from the mutations /// that were actually applied (filtering out any skipped by `lazy_load`), suitable for /// consumers and 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::AddUnloadedDirectory { 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, standing_results_delta: StandingQueryResultsDelta::default(), }) } /// 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) } /// Fully indexes a local directory after registering it with the directory watcher. #[cfg(feature = "local_fs")] pub fn index_directory_path( &mut self, path: &StandardizedPath, ctx: &mut ModelContext<'_, Self>, ) -> Result<(), RepoMetadataError> { let path = path.clone(); let repository = DirectoryWatcher::handle(ctx) .update(ctx, |watcher, ctx| watcher.add_directory(path, ctx))?; self.index_directory(repository, ctx) } /// 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 fully indexed directory. log::info!( "Upgrading lazy-loaded path to fully indexed directory: {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.replace_repository_state(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 force_included_paths = self.force_included_paths.clone(); let standing_query_definitions = self.standing_query_definitions.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 standing_results = StandingQueryResults::default(); // Budget for non-ignored files. When it is exhausted the builder // stops descending breadth-first and leaves the remaining // directories as unloaded placeholders (lazy-loaded on demand) // instead of failing the whole build. Gitignored subtrees stay // lazy and registered force-included paths are always loaded; // both are handled inside the builder. let mut file_limit = MAX_FILES_PER_REPO; let build_result = Entry::build_tree_with_standing_queries( &repo_path_for_build, &mut files, &mut gitignores_for_build, Some(&mut file_limit), BuildTreeOptions { max_depth: MAX_TREE_DEPTH, current_depth: 0, ignored_path_strategy: &IgnoredPathStrategy::IncludeLazy, force_included_paths: &force_included_paths, budget_exceeded_behavior: BudgetExceededBehavior::StopAndLazyLoad, }, &mut standing_results, &standing_query_definitions, ); // A fully-exhausted budget means the repo was too large to index // eagerly: the tree is partial (with a lazy-loaded remainder) // but still browsable and searchable as far as it goes. let indexed_with_limit = file_limit == 0; ( build_result, files, gitignores_for_build, repo_path_str_for_log, std_path_for_completion, repository_handle_for_completion, indexed_with_limit, standing_results, ) }, move |model: &mut LocalRepoMetadataModel, ( build_result, files, gitignores_for_build, repo_path_str, std_repo_path, repository_handle, indexed_with_limit, standing_results, ): (Result, Vec, _, String, StandardizedPath, ModelHandle, bool, StandingQueryResults), ctx| { match build_result { Ok(root_entry) => { model .standing_results .insert(std_repo_path.clone(), standing_results); 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, RootWatchMode::Recursive, ctx, ) { log::warn!("Failed to add repository {repo_path_str}: {e:?}"); // On failure, mark the repository as failed so waiters are notified. model.mark_repository_failed(std_repo_path, e, ctx); } else if indexed_with_limit { safe_warn!( safe: ("Repository exceeded max file budget; indexed with partial coverage"), full: ("Repository {repo_path_str} exceeded the max file budget ({MAX_FILES_PER_REPO}); indexed breadth-first up to the budget — remaining directories load on expand") ); send_telemetry_from_ctx!(RepoMetadataTelemetryEvent::BuildTreeFailed { error: format!("{:#}", BuildTreeError::ExceededMaxFileLimit) }, ctx); } 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); model.mark_repository_failed( std_repo_path, RepoMetadataError::BuildTree(e), ctx, ); } } }, ); Ok(()) } /// Returns repository contents (files and optionally directories) in a given repository. /// /// At most [`MAX_REPO_CONTENTS_RESULTS`] entries are returned. When the /// repository contains more matching entries, the result is truncated to /// that cap and [`RepoContents::truncated`] is set to `true`. /// /// Returns an error if the repository is not indexed, indexing is pending, or indexing failed. pub fn get_repo_contents( &self, repo_path: &StandardizedPath, args: GetContentsArgs, ) -> Result, RepoMetadataError> { let state = match self.repositories.get(repo_path) { Some(IndexedRepoState::Indexed(state)) => state, Some(IndexedRepoState::Pending(_)) => { return Err(RepoMetadataError::RepositoryIndexingPending); } Some(IndexedRepoState::Failed(_)) => { return Err(RepoMetadataError::RepositoryIndexingFailed); } None => { return Err(RepoMetadataError::RepositoryNotIndexed); } }; let mut contents = Vec::new(); let truncated = collect_contents_recursive( &state.entry, state.entry.root_directory(), &mut contents, &args, ); Ok(RepoContents { contents, truncated, }) } /// Change the indexing state of `repo_path` to `state`. /// /// All changes to the state **must** go through this method so that /// waiters are properly notified. fn replace_repository_state( &mut self, repo_path: StandardizedPath, state: IndexedRepoState, ) -> Option { let previous = self.repositories.insert(repo_path, state); if let Some(previous) = &previous { previous.complete_if_pending(); } previous } /// Drop the indexing state for `repo_path`, notifying any waiters. fn remove_repository_state( &mut self, repo_path: &StandardizedPath, ) -> Option { let previous = self.repositories.remove(repo_path); if let Some(previous) = &previous { previous.complete_if_pending(); } previous } /// Mark indexing as failed for `repo_path` and emit an `UpdatingRepositoryFailed` event. fn mark_repository_failed( &mut self, repo_path: StandardizedPath, error: RepoMetadataError, ctx: &mut ModelContext, ) { self.standing_results.remove(&repo_path); #[cfg(feature = "local_fs")] self.clear_symlink_targets_for_repo(&repo_path, ctx); self.replace_repository_state(repo_path.clone(), IndexedRepoState::Failed(error)); ctx.emit(RepositoryMetadataEvent::UpdatingRepositoryFailed { path: repo_path }); } /// Returns a future that resolves once repository indexing reaches a terminal state. /// /// Callers should check [`Self::repository_state`] after awaiting this future to see whether /// indexing succeeded or failed. pub fn repository_indexed(&self, repo_path: &StandardizedPath) -> BoxFuture<'static, ()> { match self.repositories.get(repo_path) { Some(state) => state.wait_until_indexed(), None => future::ready(()).boxed(), } } } impl galaxyui_core::Entity for LocalRepoMetadataModel { type Event = RepositoryMetadataEvent; } /// Helper function to recursively collect contents (files and optionally directories) from an Entry tree. /// /// Collects at most [`MAX_REPO_CONTENTS_RESULTS`] entries into `contents`. /// Returns `true` if traversal stopped early because that cap was reached, /// indicating the collected `contents` are truncated and more matching entries /// exist. pub(crate) fn collect_contents_recursive<'a>( entry: &'a FileTreeEntry, current_path: &'a StandardizedPath, contents: &mut Vec>, args: &GetContentsArgs, ) -> bool { if !args.include_ignored && entry.ignored(current_path) { return false; } 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)) { // Stop before exceeding the cap, reporting that results are truncated. if contents.len() >= MAX_REPO_CONTENTS_RESULTS { return true; } 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)) { // Stop before exceeding the cap, reporting that results are truncated. if contents.len() >= MAX_REPO_CONTENTS_RESULTS { return true; } contents.push(content); } } for child in entry.child_paths(current_path) { if collect_contents_recursive(entry, child, contents, args) { return true; } } } None => {} } false } // 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.replace_repository_state(repo_path, IndexedRepoState::Indexed(state)); } pub fn insert_test_standing_results( &mut self, repo_path: StandardizedPath, standing_results: StandingQueryResults, ) { self.standing_results.insert(repo_path, standing_results); } } #[cfg(test)] #[path = "local_model_tests.rs"] mod tests;