//! Unified repository metadata model. //! //! [`RepoMetadataModel`] is the singleton entry point for all repository metadata //! queries. It holds handles to [`LocalRepoMetadataModel`] and //! [`RemoteRepoMetadataModel`] and dispatches operations based on //! [`RepositoryIdentifier`]. #[cfg(feature = "local_fs")] use std::path::Path; use galaxy_core::HostId; use galaxy_util::standardized_path::StandardizedPath; use galaxyui::{AppContext, ModelContext, ModelHandle, SingletonEntity}; use crate::file_tree_store::FileTreeState; use crate::file_tree_update::RepoMetadataUpdate; use crate::local_model::{ GetContentsArgs, IndexedRepoState, LocalRepoMetadataModel, RepoContent, RepositoryMetadataEvent, }; use crate::remote_model::{RemoteRepoMetadataModel, RemoteRepositoryMetadataEvent}; use crate::repository_identifier::{RemoteRepositoryIdentifier, RepositoryIdentifier}; use crate::RepoMetadataError; /// Unified events emitted by the [`RepoMetadataModel`] wrapper. /// /// These are mapped from the sub-model events into a common enum keyed by /// [`RepositoryIdentifier`]. #[derive(Debug)] pub enum RepoMetadataEvent { /// A repository was added or updated. RepositoryUpdated { id: RepositoryIdentifier }, /// A repository was removed. RepositoryRemoved { id: RepositoryIdentifier }, /// File trees for repositories were updated. FileTreeUpdated { ids: Vec }, /// A file tree entry was updated. FileTreeEntryUpdated { id: RepositoryIdentifier }, /// Updating a repository failed. UpdatingRepositoryFailed { id: RepositoryIdentifier }, /// An incremental file tree update is ready to be sent to the remote /// client. Only emitted when the local model has /// `emit_incremental_updates` enabled. IncrementalUpdateReady { update: RepoMetadataUpdate }, } /// Singleton wrapper that provides a unified API over local and remote /// repository metadata models. /// /// All consumers should interact with this type rather than accessing the /// sub-models directly. The wrapper does **not** expose `.local()` or /// `.remote()` accessors — encapsulation ensures consumers are decoupled /// from the local/remote split. pub struct RepoMetadataModel { local: ModelHandle, remote: ModelHandle, } impl RepoMetadataModel { /// Creates a new `RepoMetadataModel`, instantiating both sub-models and /// subscribing to their events for forwarding. pub fn new(ctx: &mut ModelContext) -> Self { let local = ctx.add_model(LocalRepoMetadataModel::new); let remote = ctx.add_model(RemoteRepoMetadataModel::new); ctx.subscribe_to_model(&local, Self::forward_local_event); ctx.subscribe_to_model(&remote, Self::forward_remote_event); Self { local, remote } } /// Creates a new `RepoMetadataModel` with incremental update emission /// enabled on the local sub-model. Used by the remote server. pub fn new_with_incremental_updates(ctx: &mut ModelContext) -> Self { let local = ctx.add_model(|ctx| { let mut model = LocalRepoMetadataModel::new(ctx); model.set_emit_incremental_updates(true); model }); let remote = ctx.add_model(RemoteRepoMetadataModel::new); ctx.subscribe_to_model(&local, Self::forward_local_event); ctx.subscribe_to_model(&remote, Self::forward_remote_event); Self { local, remote } } // ── Event forwarding ───────────────────────────────────────────── fn forward_local_event( &mut self, event: &RepositoryMetadataEvent, ctx: &mut ModelContext, ) { let unified = match event { RepositoryMetadataEvent::RepositoryUpdated { path } => { RepoMetadataEvent::RepositoryUpdated { id: RepositoryIdentifier::local(path.clone()), } } RepositoryMetadataEvent::RepositoryRemoved { path } => { RepoMetadataEvent::RepositoryRemoved { id: RepositoryIdentifier::local(path.clone()), } } RepositoryMetadataEvent::FileTreeUpdated { paths } => { RepoMetadataEvent::FileTreeUpdated { ids: paths .iter() .map(|p| RepositoryIdentifier::local(p.clone())) .collect(), } } RepositoryMetadataEvent::FileTreeEntryUpdated { path } => { RepoMetadataEvent::FileTreeEntryUpdated { id: RepositoryIdentifier::local(path.clone()), } } RepositoryMetadataEvent::UpdatingRepositoryFailed { path } => { RepoMetadataEvent::UpdatingRepositoryFailed { id: RepositoryIdentifier::local(path.clone()), } } RepositoryMetadataEvent::IncrementalUpdateReady { update } => { RepoMetadataEvent::IncrementalUpdateReady { update: update.clone(), } } }; ctx.emit(unified); } fn forward_remote_event( &mut self, event: &RemoteRepositoryMetadataEvent, ctx: &mut ModelContext, ) { let unified = match event { RemoteRepositoryMetadataEvent::RepositoryUpdated { id } => { RepoMetadataEvent::RepositoryUpdated { id: RepositoryIdentifier::Remote(id.clone()), } } RemoteRepositoryMetadataEvent::RepositoryRemoved { id } => { RepoMetadataEvent::RepositoryRemoved { id: RepositoryIdentifier::Remote(id.clone()), } } RemoteRepositoryMetadataEvent::FileTreeUpdated { ids } => { RepoMetadataEvent::FileTreeUpdated { ids: ids .iter() .cloned() .map(RepositoryIdentifier::Remote) .collect(), } } RemoteRepositoryMetadataEvent::FileTreeEntryUpdated { id } => { RepoMetadataEvent::FileTreeEntryUpdated { id: RepositoryIdentifier::Remote(id.clone()), } } }; ctx.emit(unified); } // ── Unified query API ──────────────────────────────────────────── /// Returns the [`FileTreeState`] for a repository identified by `id`. pub fn get_repository<'a>( &self, id: &RepositoryIdentifier, ctx: &'a AppContext, ) -> Option<&'a FileTreeState> { match id { RepositoryIdentifier::Local(path) => self.local.as_ref(ctx).get_repository(path), RepositoryIdentifier::Remote(remote_id) => { self.remote.as_ref(ctx).get_repository(remote_id) } } } /// Returns whether the given repository is indexed. pub fn has_repository(&self, id: &RepositoryIdentifier, ctx: &AppContext) -> bool { match id { RepositoryIdentifier::Local(path) => self.local.as_ref(ctx).has_repository(path), RepositoryIdentifier::Remote(remote_id) => { self.remote.as_ref(ctx).has_repository(remote_id) } } } /// Returns the current [`IndexedRepoState`] for a repository. pub fn repository_state<'a>( &self, id: &RepositoryIdentifier, ctx: &'a AppContext, ) -> Option<&'a IndexedRepoState> { match id { RepositoryIdentifier::Local(path) => self.local.as_ref(ctx).repository_state(path), RepositoryIdentifier::Remote(remote_id) => { self.remote.as_ref(ctx).repository_state(remote_id) } } } /// Returns repository contents for the specified repository. pub fn get_repo_contents<'a>( &self, id: &RepositoryIdentifier, args: GetContentsArgs, ctx: &'a AppContext, ) -> Option>> { match id { RepositoryIdentifier::Local(path) => { self.local.as_ref(ctx).get_repo_contents(path, args) } RepositoryIdentifier::Remote(remote_id) => { self.remote.as_ref(ctx).get_repo_contents(remote_id, args) } } } /// Finds the repository root that contains the given local path. #[cfg(feature = "local_fs")] pub fn find_repository_for_path( &self, path: &Path, ctx: &AppContext, ) -> Option { self.local.as_ref(ctx).find_repository_for_path(path) } // ── Local-specific operations ──────────────────────────────────── // These delegate to the local sub-model. Remote equivalents will be // added once the remote client ↔ server sync layer is in place. /// Indexes a local repository from the given repository handle. #[cfg(feature = "local_fs")] pub fn index_directory( &self, repository: ModelHandle, ctx: &mut ModelContext, ) -> Result<(), RepoMetadataError> { self.local .update(ctx, |local, ctx| local.index_directory(repository, ctx)) } /// Lazily indexes a local standalone path with only the first level of children. #[cfg(feature = "local_fs")] pub fn index_lazy_loaded_path( &self, path: &StandardizedPath, ctx: &mut ModelContext, ) -> Result<(), RepoMetadataError> { let path = path.clone(); self.local .update(ctx, |local, ctx| local.index_lazy_loaded_path(&path, ctx)) } /// Loads a specific directory inside an already-tracked local tree. #[cfg(feature = "local_fs")] pub fn load_directory( &self, repo_root: &StandardizedPath, dir_path: &StandardizedPath, ctx: &mut ModelContext, ) -> Result<(), RepoMetadataError> { let repo_root = repo_root.clone(); let dir_path = dir_path.clone(); self.local.update(ctx, |local, ctx| { local.load_directory(&repo_root, &dir_path, ctx) }) } /// Removes a lazily-loaded local standalone path from tracking. #[cfg(feature = "local_fs")] pub fn remove_lazy_loaded_path(&self, path: &StandardizedPath, ctx: &mut ModelContext) { let path = path.clone(); self.local .update(ctx, |local, ctx| local.remove_lazy_loaded_path(&path, ctx)); } // ── Remote-specific operations ───────────────────────────────── // These delegate to the remote sub-model and are called by the // RemoteServerManager event subscription in the app layer. /// Inserts or replaces a remote repository from a snapshot push event. pub fn insert_remote_snapshot( &self, host_id: HostId, update: &RepoMetadataUpdate, ctx: &mut ModelContext, ) { self.remote.update(ctx, |remote, ctx| { remote.insert_from_snapshot(host_id, update, ctx); }); } /// Applies an incremental remote repo metadata update. pub fn apply_remote_incremental_update( &self, host_id: &HostId, update: &RepoMetadataUpdate, ctx: &mut ModelContext, ) { let host_id = host_id.clone(); self.remote.update(ctx, |remote, ctx| { remote.apply_incremental_update(&host_id, update, ctx); }); } /// Removes all remote repositories for the given host (e.g. on disconnect). pub fn remove_remote_repositories_for_host( &self, host_id: &HostId, ctx: &mut ModelContext, ) { let host_id = host_id.clone(); self.remote.update(ctx, |remote, ctx| { remote.remove_repositories_for_host(&host_id, ctx); }); } /// Removes a repository (local or remote) from tracking. pub fn remove_repository( &self, id: &RepositoryIdentifier, ctx: &mut ModelContext, ) -> Result<(), RepoMetadataError> { match id { RepositoryIdentifier::Local(path) => { let path = path.clone(); self.local .update(ctx, |local, ctx| local.remove_repository(&path, ctx)) } RepositoryIdentifier::Remote(remote_id) => { let remote_id = remote_id.clone(); self.remote .update(ctx, |remote, ctx| remote.remove_repository(&remote_id, ctx)); Ok(()) } } } /// Returns all tracked remote repository identifiers. pub fn remote_repository_ids<'a>( &self, ctx: &'a AppContext, ) -> impl Iterator { self.remote.as_ref(ctx).remote_repository_ids() } /// Returns whether the given local path is tracked as a lazily-loaded standalone path. pub fn is_lazy_loaded_path(&self, path: &StandardizedPath, ctx: &AppContext) -> bool { self.local.as_ref(ctx).is_lazy_loaded_path(path) } } impl galaxyui::Entity for RepoMetadataModel { type Event = RepoMetadataEvent; } impl SingletonEntity for RepoMetadataModel {} #[cfg(any(test, feature = "test-util"))] impl RepoMetadataModel { /// Inserts repository state directly into the local sub-model for testing. pub fn insert_test_state( &self, repo_path: StandardizedPath, state: FileTreeState, ctx: &mut ModelContext, ) { self.local.update(ctx, |local, _ctx| { local.insert_test_state(repo_path, state); }); } }