use std::{ collections::{HashMap, HashSet}, path::{Path, PathBuf}, sync::Arc, time::Duration, }; use itertools::Itertools; use repo_metadata::{BuildTreeError, DirectoryWatcher, Repository}; use thiserror::Error; cfg_if::cfg_if! { if #[cfg(feature = "local_fs")] { use chrono::Utc; use super::changed_files::ChangedFiles; use crate::index::path_passes_filters; use ignore::gitignore::Gitignore; use notify_debouncer_full::notify::{RecursiveMode, WatchFilter}; use galaxy_core::features::FeatureFlag; use watcher::{BulkFilesystemWatcher, BulkFilesystemWatcherEvent}; use galaxyui::r#async::Timer; use galaxy_core::{send_telemetry_from_ctx, report_if_error}; use crate::telemetry::AITelemetryEvent; use instant::Instant; use galaxy_core::channel::ChannelState; use galaxy_core::safe_warn; } } use galaxy_core::safe_anyhow; use galaxyui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity}; use super::{ codebase_index::{CodebaseIndexEvent, RetrievalID, SyncProgress}, fragment_metadata::FragmentMetadata, priority_queue::{BuildQueue, Priority}, snapshot::*, store_client::StoreClient, CodebaseIndex, EmbeddingConfig, Error as CodebaseIndexError, NodeHash, }; use crate::{ index::locations::CodeContextLocation, workspace::{WorkspaceMetadata, WorkspaceMetadataEvent}, }; /// The interval for debouncing filesystem events. const REPO_WATCHER_DEBOUNCE_DURATION: Duration = Duration::from_secs(10); /// The number of minutes between writing index snapshots. const REPO_SNAPSHOT_PERSISTENCE_MINUTES: u64 = 10; /// The interval for writing index snapshots. const REPO_SNAPSHOT_PERSISTENCE_INTERVAL: Duration = Duration::from_secs(60 * REPO_SNAPSHOT_PERSISTENCE_MINUTES); /// User-facing indexing completion status. pub enum CodebaseIndexFinishedStatus { Completed, Failed(CodebaseIndexingError), } #[derive(Error, Debug)] pub enum RetrieveFileError { #[error("Codebase index still indexing")] IndexSyncing, #[error("Codebase index failed: {0:#}")] IndexFailed(CodebaseIndexingError), #[error("Codebase index not found")] IndexNotFound, } pub enum CodebaseIndexManagerEvent { RetrievalRequestCompleted { retrieval_id: RetrievalID, fragments: Arc>, out_of_sync_delay: Option, }, RetrievalRequestFailed { retrieval_id: RetrievalID, error_message: String, }, SyncStateUpdated, IndexMetadataUpdated { root_path: PathBuf, event: WorkspaceMetadataEvent, }, RemoveExpiredIndexMetadata { expired_metadata: Arc>, }, NewIndexCreated, } /// User-facing indexing errors. #[derive(Error, Debug)] pub enum CodebaseIndexingError { #[error("Build tree error")] BuildTreeError, #[error("Repo size exceeded max file limit")] ExceededMaxFileLimit, #[error("Maximum directory depth exceeded")] MaxDepthExceeded, #[error("Failed to generate embeddings for some hashes:\n{0:#?}")] FailedToGenerateEmbeddings(Vec), #[error("Failed to sync intermediate nodes:\n{0:#?}")] FailedToSyncIntermediateNodes(Vec), #[error(transparent)] Other(#[from] anyhow::Error), } impl From<&CodebaseIndexError> for CodebaseIndexingError { fn from(value: &CodebaseIndexError) -> Self { match value { CodebaseIndexError::BuildTreeError(build_tree_error) => match build_tree_error { BuildTreeError::ExceededMaxFileLimit => Self::ExceededMaxFileLimit, BuildTreeError::MaxDepthExceeded => Self::MaxDepthExceeded, _ => Self::BuildTreeError, }, CodebaseIndexError::FailedToGenerateEmbeddings(failed_fragments) => { Self::FailedToGenerateEmbeddings(failed_fragments.clone()) } CodebaseIndexError::FailedToSyncIntermediateNodes(failed_hashes) => { Self::FailedToSyncIntermediateNodes(failed_hashes.clone()) } _ => Self::Other(anyhow::anyhow!(value.to_string())), } } } /// User-facing codebase index status. pub struct CodebaseIndexStatus { pub(super) has_pending: bool, pub(super) has_synced_version: bool, pub(super) last_sync_successful: Option, pub(super) sync_progress: Option, } impl CodebaseIndexStatus { pub fn has_pending(&self) -> bool { self.has_pending } pub fn has_synced_version(&self) -> bool { self.has_synced_version } pub fn last_sync_successful(&self) -> Option { self.last_sync_successful .as_ref() .map(|res| matches!(res, CodebaseIndexFinishedStatus::Completed)) } pub fn last_sync_result(&self) -> Option<&CodebaseIndexFinishedStatus> { self.last_sync_successful.as_ref() } pub fn sync_progress(&self) -> Option<&SyncProgress> { self.sync_progress.as_ref() } } pub enum BuildSource<'a> { FromPath(&'a Path), FromPersistedMetadata(WorkspaceMetadata), } /// Manager for the codebase index states across the app. pub struct CodebaseIndexManager { codebase_indices: HashMap>, store_client: Arc, #[cfg(feature = "local_fs")] watcher: ModelHandle, build_queue: BuildQueue, max_indices: Option, max_files_repo_limit: usize, embedding_generation_batch_size: usize, } impl CodebaseIndexManager { #[cfg_attr(not(feature = "local_fs"), allow(unused_variables))] pub fn new( persisted_index_metadata: Vec, max_index_count: Option, max_files_repo_limit: usize, embedding_generation_batch_size: usize, store_client: Arc, ctx: &mut ModelContext, ) -> Self { cfg_if::cfg_if! { if #[cfg(feature = "local_fs")] { let file_watcher = ctx.add_model(|ctx| BulkFilesystemWatcher::new(REPO_WATCHER_DEBOUNCE_DURATION, ctx)); ctx.subscribe_to_model(&file_watcher, Self::handle_watcher_event); } } log::debug!( "Received {:?} persisted codebase indices", persisted_index_metadata.len() ); #[cfg(feature = "local_fs")] report_if_error!(migrate_snapshots_to_secure_dir_if_needed()); let (invalid_metadata, valid_metadata) = split_snapshot_metadata_by_validity(persisted_index_metadata); ctx.emit(CodebaseIndexManagerEvent::RemoveExpiredIndexMetadata { expired_metadata: Arc::new( invalid_metadata .into_iter() .map(|metadata| metadata.path) .collect(), ), }); if let Some(snapshot_file_dir) = snapshot_dir() { clean_up_snapshot_files(&snapshot_file_dir, &valid_metadata); } // For the moment, we've decided to load all snapshots regardless of the index count. let build_queue = BuildQueue::new_with_persisted(valid_metadata); let mut me = Self { codebase_indices: HashMap::new(), store_client, #[cfg(feature = "local_fs")] watcher: file_watcher, build_queue, max_indices: max_index_count, max_files_repo_limit, embedding_generation_batch_size, }; // Start building the first index in the queue. if let Some(next_repo) = me.build_queue.pick_next_sync() { me.build_and_sync_codebase_index(BuildSource::FromPersistedMetadata(next_repo), ctx); } me } #[cfg(feature = "test-util")] pub fn new_for_test(store_client: Arc, ctx: &mut ModelContext) -> Self { #[cfg(feature = "local_fs")] let file_watcher = ctx.add_model(|_| BulkFilesystemWatcher::new_for_test()); Self { codebase_indices: HashMap::new(), store_client, #[cfg(feature = "local_fs")] watcher: file_watcher, build_queue: BuildQueue::empty(), max_indices: None, max_files_repo_limit: 0, embedding_generation_batch_size: 100, } } /// Check whether any of the codebases' root path was deleted and clean up its persisted /// artifacts. #[cfg(feature = "local_fs")] pub fn clean_up_deleted_indices(&mut self, ctx: &mut ModelContext) { let codebase_roots = self.codebase_indices.keys().cloned().collect_vec(); ctx.spawn( async move { // Check all codebase roots for deletion. codebase_roots .into_iter() .filter(|codebase_root_path| !codebase_root_path.exists()) .collect() }, |me, to_clean_up, ctx| { me.drop_indices(to_clean_up, ctx); }, ); } /// Cleans up all indexed codebases. fn drop_all_indices(&mut self, ctx: &mut ModelContext) { self.drop_indices(self.codebase_indices.keys().cloned().collect_vec(), ctx); // Replace the HashMap with the default (empty) one. // Unlike `.clear()` and `.drain()`, this releases the allocated memory. self.codebase_indices = HashMap::new(); } /// Checks if the codebase still exists in the filesystem. async fn should_clean_up_index(root_path: &Path) -> bool { let Ok(exists) = std::fs::exists(root_path) else { return true; }; !exists } /// Fully clears all persisted data related to the given indices and /// stops receiving watcher events for it. fn drop_indices(&mut self, to_drop: Vec, ctx: &mut ModelContext) { // Drop the in-memory indices and unregister the filewatcher. for codebase_root in &to_drop { self.drop_index_from_memory(codebase_root, ctx); } // Remove snapshots from disk. let to_drop_clone = to_drop.clone(); ctx.spawn( async move { Self::drop_index_snapshots(to_drop_clone).await }, |_, _, _| {}, ); // Remove metadata from SQLite. ctx.emit(CodebaseIndexManagerEvent::RemoveExpiredIndexMetadata { expired_metadata: Arc::new(to_drop), }); } /// Remove the gien index snapshots from disk. async fn drop_index_snapshots(to_drop: Vec) { if let Some(snapshot_dir) = snapshot_dir() { for codebase_root in &to_drop { Self::drop_index_snapshot(&snapshot_dir, codebase_root).await; } } } async fn drop_index_snapshot(snapshot_dir: &Path, codebase_root: &Path) { if let Err(err) = std::fs::remove_file(snapshot_path(snapshot_dir, codebase_root)) { log::warn!( "Failed to remove codebase index snapshot file for {codebase_root:?}: {err:#?}" ); } } /// Removes an index from in-memory data structures. fn drop_index_from_memory(&mut self, root_path: &Path, ctx: &mut ModelContext) { // Cancel any pending sync for this codebase if let Some(index) = self.codebase_indices.get(root_path) { index.update(ctx, |index, _| { index.abort_in_progress_sync(); }); } // Drop the in-memory index. self.codebase_indices.remove(root_path); // Stop the filewatcher from receiving events for this codebase. #[cfg(feature = "local_fs")] self.unwatch_path(root_path, ctx); } /// Fully clears all persisted data related to a single codebase index /// and stops receiving watcher events for it. pub fn drop_index(&mut self, root_path: PathBuf, ctx: &mut ModelContext) { let root_path = dunce::canonicalize(&root_path).unwrap_or(root_path); self.drop_index_from_memory(root_path.as_path(), ctx); // Remove snapshot from disk. let root_path_clone = root_path.clone(); ctx.spawn( async move { if let Some(snapshot_dir) = snapshot_dir() { Self::drop_index_snapshot(&snapshot_dir, &root_path_clone).await; } }, |_, _, _| {}, ); // Remove metadata from SQLite. ctx.emit(CodebaseIndexManagerEvent::RemoveExpiredIndexMetadata { expired_metadata: Arc::new(vec![root_path]), }); } #[cfg(feature = "local_fs")] fn group_file_events( &self, event: &BulkFilesystemWatcherEvent, ) -> HashMap { let mut added_or_updated = event.added_or_updated_set(); let mut deleted = event.deleted.clone(); // For now, treat a move as a deletion followed by an addition. // This means deletions must be processed before additions/updates. for (old_path, new_path) in event.moved.iter() { deleted.insert(old_path.to_path_buf()); added_or_updated.insert(new_path.to_path_buf()); } let mut updates_by_root: HashMap = HashMap::new(); for path in deleted { if let Some(root_path) = self.root_path_for_codebase(&path) { updates_by_root .entry(root_path) .or_default() .deletions .insert(path); } else { log::warn!( "Could not find index root for deleted file: {}", path.display() ); } } for path in added_or_updated { if let Some(root_path) = self.root_path_for_codebase(&path) { updates_by_root .entry(root_path) .or_default() .upsertions .insert(path); } else { log::warn!( "Could not find index root for updated file: {}", path.display() ); } } updates_by_root } #[cfg(feature = "local_fs")] fn incremental_update_codebase_index( &mut self, root_path: PathBuf, changed_files: ChangedFiles, ctx: &mut ModelContext, ) { if changed_files.is_empty() { return; } let Some(index_state) = self.codebase_indices.get(root_path.as_path()) else { log::warn!( "No prior index state for root path: {}", root_path.display() ); return; }; index_state.update(ctx, |codebase_index, ctx| { codebase_index.incremental_update(changed_files, self.store_client.clone(), false, ctx); }); } #[cfg(feature = "local_fs")] fn handle_watcher_event( &mut self, event: &BulkFilesystemWatcherEvent, ctx: &mut ModelContext, ) { let updates_by_root = self.group_file_events(event); for (root_path, changed_files) in updates_by_root { self.incremental_update_codebase_index(root_path, changed_files, ctx); } } pub fn handle_active_session_changed(&mut self, active_directory: &Path) { let Some(root_path) = self.root_path_for_codebase(active_directory) else { return; }; self.build_queue .update_path_priority(root_path, Priority::ActiveSession); } pub fn update_max_limits( &mut self, new_max_indices: Option, new_max_files_per_repo: usize, new_embedding_generation_batch_size: usize, _ctx: &mut ModelContext, ) { self.max_indices = new_max_indices; if self.max_files_repo_limit != new_max_files_per_repo { self.max_files_repo_limit = new_max_files_per_repo; #[cfg(feature = "local_fs")] for index in self.codebase_indices.values() { // If the max file repo limit changed, kick off a new full sync to retry indexing. if matches!( index .as_ref(_ctx) .codebase_index_status() .last_sync_result(), Some(CodebaseIndexFinishedStatus::Failed( CodebaseIndexingError::ExceededMaxFileLimit )) ) { index.update(_ctx, |code_index, ctx| { let _ = code_index.full_sync_index(self.max_files_repo_limit, ctx); }); } } } // Update the embedding generation batch size for existing indices if self.embedding_generation_batch_size != new_embedding_generation_batch_size { self.embedding_generation_batch_size = new_embedding_generation_batch_size; for index in self.codebase_indices.values() { index.update(_ctx, |code_index, _| { code_index.update_embedding_generation_batch_size( new_embedding_generation_batch_size, ); }); } } } /// Ensures the current number of indices is below the maximum. pub fn can_create_new_indices(&self) -> bool { self.max_indices .is_none_or(|max_indices| self.codebase_indices.len() < max_indices) } pub fn handle_session_bootstrapped(&mut self, working_directory: &Path) { let Some(root_path) = self.root_path_for_codebase(working_directory) else { return; }; self.build_queue .update_path_priority(root_path, Priority::OpenSession); } pub fn get_codebase_index_statuses<'a>( &'a self, app: &'a AppContext, ) -> impl Iterator { self.codebase_indices.iter().map(|(path, codebase_index)| { let index_state = codebase_index.as_ref(app); let status = index_state.codebase_index_status(); (path, status) }) } pub fn get_codebase_index_status_for_path<'a>( &'a self, root_path: &Path, app: &'a AppContext, ) -> Option { let root_path = dunce::canonicalize(root_path).unwrap_or_else(|_| root_path.to_path_buf()); self.codebase_indices.get(&root_path).map(|codebase_index| { let index_state = codebase_index.as_ref(app); index_state.codebase_index_status() }) } pub fn get_codebase_paths(&self) -> impl Iterator { self.codebase_indices.keys() } pub fn num_active_indices(&self) -> usize { self.codebase_indices.len() } pub fn index_directory(&mut self, directory: PathBuf, ctx: &mut ModelContext) { let directory = dunce::canonicalize(&directory).unwrap_or(directory); if !self.codebase_indices.contains_key(&directory) { self.build_and_sync_codebase_index(BuildSource::FromPath(&directory), ctx); // Starting a new codebase index should be considered into sync state updates. ctx.emit(CodebaseIndexManagerEvent::SyncStateUpdated); } } #[cfg(feature = "local_fs")] fn watch_path( &self, root_path: &Path, gitignores: Arc>, ctx: &mut ModelContext, ) { let watch_filter = WatchFilter::with_filter(Arc::new(move |path| { path_passes_filters(path, gitignores.as_slice()) })); self.watcher.update(ctx, |watcher, _ctx| { std::mem::drop(watcher.register_path( root_path, watch_filter, RecursiveMode::Recursive, )); }); } #[cfg(feature = "local_fs")] fn unwatch_path(&self, root_path: &Path, ctx: &mut ModelContext) { self.watcher.update(ctx, |watcher, _ctx| { std::mem::drop(watcher.unregister_path(root_path)); }); } #[cfg(feature = "local_fs")] fn unwatch_all_paths(&self, ctx: &mut ModelContext) { for path in self.get_codebase_paths() { self.unwatch_path(path, ctx); } } pub fn build_and_sync_codebase_index( &mut self, build_source: BuildSource, ctx: &mut ModelContext, ) { if !self.can_create_new_indices() { return; } let repo_path = match build_source { BuildSource::FromPath(path) => path, BuildSource::FromPersistedMetadata(ref metadata) => metadata.path.as_path(), }; let standardized_path = match galaxy_util::standardized_path::StandardizedPath::from_local_canonicalized( repo_path, ) { Ok(path) => path, Err(e) => { log::error!("Failed to canonicalize repository path: {e:?}"); return; } }; // Ensure the repository is registered with RepoWatcher. let handle = match DirectoryWatcher::handle(ctx).update(ctx, |repo_watcher, ctx| { repo_watcher.add_directory(standardized_path, ctx) }) { Ok(handle) => handle, Err(e) => { log::error!("Failed to start tracking repository: {e:?}"); return; } }; let canonical_key = dunce::canonicalize(repo_path).unwrap_or_else(|_| repo_path.to_path_buf()); let index = self .codebase_indices .entry(canonical_key) .or_insert_with(|| { let index = Self::build_and_sync_codebase_index_internal( self.store_client.clone(), handle, self.max_files_repo_limit, self.embedding_generation_batch_size, ctx, ); #[cfg(feature = "local_fs")] Self::schedule_next_snapshot_write(repo_path.to_path_buf(), ctx); index }) .clone(); if let BuildSource::FromPersistedMetadata(metadata) = build_source { index.update(ctx, |index, _| { index.update_timestamps_from_metadata(metadata); }); } } /// Checks whether a snapshot exists for the index and attempts to load it; /// otherwise, falls back to creating a brand-new index. fn build_and_sync_codebase_index_internal( store_client: Arc, repository: ModelHandle, max_files_repo_limit: usize, embedding_generation_batch_size: usize, ctx: &mut ModelContext, ) -> ModelHandle { let codebase_index = ctx.add_model(|ctx| { #[cfg(feature = "local_fs")] if FeatureFlag::CodebaseIndexPersistence.is_enabled() && repository .as_ref(ctx) .root_dir() .to_local_path() .is_some_and(|p| has_snapshot(&p)) { if let Some(snapshot_dir) = snapshot_dir() { let read_snapshot_start_time = Instant::now(); match read_snapshot( store_client.clone(), snapshot_dir.as_path(), repository.clone(), max_files_repo_limit, embedding_generation_batch_size, ctx, ) { Ok(snapshot_index) => { send_telemetry_from_ctx!( AITelemetryEvent::MerkleTreeSnapshotRebuildSuccess { duration: read_snapshot_start_time.elapsed() }, ctx ); return snapshot_index; } Err(err) => { send_telemetry_from_ctx!( AITelemetryEvent::MerkleTreeSnapshotRebuildFailed { error: err.to_string() }, ctx ); } } } } CodebaseIndex::new_from_scratch( repository, store_client, EmbeddingConfig::default(), max_files_repo_limit, embedding_generation_batch_size, ctx, ) }); ctx.subscribe_to_model(&codebase_index, Self::handle_codebase_index_event); codebase_index } fn handle_codebase_index_event( &mut self, event: &CodebaseIndexEvent, ctx: &mut ModelContext, ) { match event { CodebaseIndexEvent::RetrievalRequestFailed { retrieval_id, error, } => ctx.emit(CodebaseIndexManagerEvent::RetrievalRequestFailed { retrieval_id: retrieval_id.clone(), error_message: error.to_string(), }), CodebaseIndexEvent::RetrievalRequestCompleted { retrieval_id, fragments, out_of_sync_delay, } => ctx.emit(CodebaseIndexManagerEvent::RetrievalRequestCompleted { retrieval_id: retrieval_id.clone(), fragments: fragments.clone(), out_of_sync_delay: *out_of_sync_delay, }), CodebaseIndexEvent::SyncStateUpdated => { ctx.emit(CodebaseIndexManagerEvent::SyncStateUpdated) } CodebaseIndexEvent::IndexMetadataUpdated { root_path, event } => { ctx.emit(CodebaseIndexManagerEvent::IndexMetadataUpdated { root_path: root_path.to_path_buf(), event: *event, }) } #[cfg(feature = "local_fs")] CodebaseIndexEvent::GitignoresUpdated { repo_root_path, gitignores, } => { self.unwatch_path(repo_root_path, ctx); self.watch_path(repo_root_path, gitignores.clone(), ctx); } CodebaseIndexEvent::LocalIndexBuilt { repo_root_path } => { self.on_index_build_finished(repo_root_path, ctx); } #[cfg(feature = "local_fs")] CodebaseIndexEvent::InitialSyncCompleted { repo_path, has_pending_change, } => { if !has_pending_change { self.write_snapshot(repo_path, ctx); } } } } fn on_index_build_finished(&mut self, finished_repo: &Path, ctx: &mut ModelContext) { let Ok(_) = self.get_codebase_index_internal(finished_repo) else { return; }; if let Some(next_repo) = self.build_queue.pick_next_sync() { self.build_and_sync_codebase_index(BuildSource::FromPersistedMetadata(next_repo), ctx); } } /// Aborts any in-progress syncs and drops all codebase indices. pub fn reset_codebase_indexing(&mut self, ctx: &mut ModelContext) { for index in self.codebase_indices.values() { index.as_ref(ctx).abort_in_progress_sync(); } self.drop_all_indices(ctx); } pub fn root_path_for_codebase(&self, path: &Path) -> Option { self.get_codebase_index_internal(path) .map(|(_, path)| path) .ok() } fn get_codebase_index_internal( &self, path: &Path, ) -> anyhow::Result<(&ModelHandle, PathBuf)> { let mut path = dunce::canonicalize(path).unwrap_or_else(|_| path.to_owned()); loop { if let Some(outline) = self.codebase_indices.get(&path) { return Ok((outline, path)); } if !path.pop() { break; } } Err(safe_anyhow!( safe: ("Codebase index not found"), full: ("Codebase index for repo {path:?} not found") )) } /// Try to manually perform a full sync on the given codebase. This will fail if the codebase has a sync already in-progress. #[cfg(feature = "local_fs")] pub fn try_manual_resync_codebase(&self, repo_path: &Path, ctx: &mut ModelContext) { let Ok((codebase_index, _)) = self.get_codebase_index_internal(repo_path) else { return; }; codebase_index.update(ctx, |index, ctx| { let _ = index.full_sync_index(self.max_files_repo_limit, ctx); }) } #[cfg(not(feature = "local_fs"))] pub fn try_manual_resync_codebase(&self, _repo_path: &Path, _ctx: &mut ModelContext) {} pub fn retrieve_relevant_files( &self, query: String, repo_path: &Path, ctx: &mut ModelContext, ) -> Result { let Ok((codebase_index, _)) = self.get_codebase_index_internal(repo_path) else { return Err(RetrieveFileError::IndexNotFound); }; codebase_index.update(ctx, |codebase_index, ctx| { codebase_index.retrieve_relevant_files(query, self.store_client.clone(), ctx) }) } pub fn abort_retrieval_request( &self, repo_path: &Path, retrieval_id: RetrievalID, ctx: &mut ModelContext, ) -> Result<(), anyhow::Error> { let (codebase_index, _) = self.get_codebase_index_internal(repo_path)?; codebase_index.update(ctx, |codebase_index, _ctx| { codebase_index.abort_retrieval_request(retrieval_id); }); Ok(()) } #[cfg(feature = "local_fs")] pub fn write_snapshot(&mut self, working_directory: &Path, ctx: &mut ModelContext) { let Some(repo_path) = self.root_path_for_codebase(working_directory) else { safe_warn!( safe: ("No root codebase for path"), full: ("No root codebase for path {working_directory:?}") ); return; }; let Ok((codebase_index_model, _)) = self.get_codebase_index_internal(repo_path.as_path()) else { return; }; let codebase_index = codebase_index_model.as_ref(ctx); if !codebase_index.has_unsnapshotted_changes() { Self::schedule_next_snapshot_write(repo_path, ctx); return; } let snapshot_generation_time = Utc::now(); let serializable_index = match codebase_index.generate_snapshot() { Ok(index) => index, Err(err) => { log::warn!("Unable to generate snapshot: {err:?}"); Self::schedule_next_snapshot_write(repo_path, ctx); return; } }; let snapshot_dir = match snapshot_dir() { Some(dir) => dir, None => { log::warn!("No snapshot directory to write to"); Self::schedule_next_snapshot_write(repo_path, ctx); return; } }; let snapshot_path = snapshot_path(&snapshot_dir, repo_path.as_path()); // Update timestamp eagerly so concurrent calls to has_unsnapshotted_changes() // won't trigger a duplicate snapshot while the background write is in progress. codebase_index_model.update(ctx, |codebase_index, _ctx| { codebase_index.update_snapshot_ts(snapshot_generation_time) }); // Move the expensive serialization and file I/O to a background thread. ctx.spawn( async move { let result = async { let serialized = CodebaseIndex::serialize_snapshot(serializable_index).await?; async_fs::write(&snapshot_path, serialized).await?; anyhow::Ok(()) } .await; (repo_path, result) }, |_me, (repo_path, result), ctx| { if let Err(err) = result { if ChannelState::enable_debug_features() { log::error!("Unable to write snapshot for {repo_path:?}: {err:?}"); } else { log::warn!("Unable to write snapshot: {err:?}"); } } Self::schedule_next_snapshot_write(repo_path, ctx); }, ); } /// Schedules the next periodic snapshot write. #[cfg(feature = "local_fs")] fn schedule_next_snapshot_write(repo_path: PathBuf, ctx: &mut ModelContext) { ctx.spawn( async move { Timer::after(REPO_SNAPSHOT_PERSISTENCE_INTERVAL).await; let should_remove_index = Self::should_clean_up_index(&repo_path).await; (repo_path, should_remove_index) }, move |me, (repo_path, should_remove_index), ctx| { if should_remove_index { me.drop_index(repo_path, ctx); } else { me.write_snapshot(&repo_path, ctx); } }, ); } /// Triggers an incremental sync for the codebase at the given path. pub fn trigger_incremental_sync_for_path( &mut self, directory_path: &Path, ctx: &mut ModelContext, ) -> anyhow::Result<()> { // Find the root path for this directory's codebase let Some(repo_path) = self.root_path_for_codebase(directory_path) else { return Err(anyhow::anyhow!("Failed to find root path for directory")); }; // Check if there's an existing index for this repository let (codebase_index, _) = self.get_codebase_index_internal(repo_path.as_path())?; // Trigger an incremental sync by checking for file changes // This will detect any changes since the last sync and update the index accordingly codebase_index.update(ctx, |index, _ctx| { // Check if the index is in a state where it can perform incremental updates let status = index.codebase_index_status(); if status.has_pending { return; } log::debug!( "Triggering incremental sync for repo: {}", repo_path.display() ); // For now, we'll trigger a check that may lead to an incremental sync // The actual sync will only happen if the file watcher has detected changes // or if there are pending file changes that need to be processed // This is a lightweight operation that won't do unnecessary work #[cfg(feature = "local_fs")] index.flush_pending_file_changes(_ctx); }); Ok(()) } } impl Entity for CodebaseIndexManager { type Event = CodebaseIndexManagerEvent; } impl SingletonEntity for CodebaseIndexManager {}