Files
galaxy/crates/repo_metadata/src/local_model.rs
T

1795 lines
73 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#![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<RepoContent<'a>>,
/// `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<StandardizedPath>,
},
/// 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<StandardizedPath>,
}
/// 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<StandardizedPath, IndexedRepoState>,
/// Stored context-discovery matches, independent from canonical tree materialization.
standing_results: HashMap<StandardizedPath, StandingQueryResults>,
/// Refcounts for lazily-loaded standalone paths tracked in the model.
lazy_loaded_paths: HashMap<StandardizedPath, usize>,
/// File system watcher for monitoring changes.
#[cfg(feature = "local_fs")]
watcher: Option<ModelHandle<BulkFilesystemWatcher>>,
/// 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<PathBuf>,
/// 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<PathBuf, HashSet<SymlinkTarget>>,
/// 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<StandardizedPath, RepoWatch>,
}
#[derive(Debug, Clone, Default)]
struct RepoUpdate {
added: Vec<PathBuf>,
deleted: Vec<PathBuf>,
moved: HashMap<PathBuf, PathBuf>,
}
#[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<String>,
},
/// 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<Arc<RepoContentFilter>>,
}
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<F>(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>) -> 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<Item = PathBuf>) {
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<Item = PathBuf>) {
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<StandardizedPath, RepoUpdate>,
) -> HashSet<PathBuf> {
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<Self>,
) {
if !self.emit_incremental_updates {
return;
}
let previously_watched = self.symlink_targets.keys().cloned().collect::<HashSet<_>>();
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(&current_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<Self>,
) {
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<PathBuf>,
ctx: &mut ModelContext<Self>,
) {
let desired_target_dirs = self.symlink_targets.keys().cloned().collect::<HashSet<_>>();
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<Self>,
) {
// Create a map to collect changes per repository
let mut repo_updates: HashMap<StandardizedPath, RepoUpdate> = 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<StandardizedPath> {
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<StandardizedPath> {
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> {
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<StandardizedPath> {
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<Self>,
) -> 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<PathBuf> = 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<Self>,
) -> 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<PathBuf> =
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<Self>,
) -> 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<Self>,
) {
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<Self>,
) -> 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<Self>,
) {
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<Self>,
) {
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<StandardizedPath> = 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<PathBuf> =
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<FileTreeMutation>,
StandingQueryResults,
Vec<StandardizedPath>,
) {
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<FileTreeMutation>,
lazy_load: bool,
emit_updates: bool,
) -> Option<RepoMetadataUpdate> {
let emit = emit_updates;
let mut remove_entries: Vec<StandardizedPath> = Vec::new();
let mut update_entries: Vec<FileTreeEntryUpdate> = 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<Repository>,
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<crate::entry::FileMetadata> = 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<Entry, _>, Vec<crate::entry::FileMetadata>, _, String, StandardizedPath, ModelHandle<Repository>, 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<RepoContents<'_>, 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<IndexedRepoState> {
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<IndexedRepoState> {
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>,
) {
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<RepoContent<'a>>,
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;