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

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