# RepoMetadataModel Tech Spec ## Problem Statement `RepositoryMetadataModel` is a singleton that tracks repositories and their file tree state. It currently only supports local file trees backed by a filesystem watcher. To support remote development (SSH), we need a model that can also hold file tree state sourced from a remote server. The tech design ("Remote code model sync") proposes a generic wrapper `RepoMetadataModel` that dispatches to environment-specific sub-models. This spec details the implementation of that wrapper, the new `RemoteRepoMetadataModel` (client-side only, no syncing/indexing yet), and the consumer migration path. ## Current State ### Key types (all in `repo_metadata` crate) * **`RepositoryMetadataModel`** (`model.rs`) — singleton, holds `HashMap` + an optional `BulkFilesystemWatcher`. Subscribes to `DetectedRepositories` for auto-indexing and the watcher for incremental updates. * **`FileTreeState`** — holds a `FileTreeEntry` (the flattened map store), a `Vec`, and an optional `ModelHandle`. * **`FileTreeEntry`** (`file_tree_store.rs`) — wraps `FileTreeMapStore` (parent→children + path→metadata hash maps) plus a `root_path: Arc`. * **`CanonicalizedPath`** (`lib.rs`) — a `PathBuf` wrapper that `dunce::canonicalize`s on construction. Used as the HashMap key for repositories. * **`SessionId`** (`app/src/terminal/model/session.rs`) — `u64` wrapper identifying a terminal session, already used to distinguish SSH sessions. ### Consumers in `app/` * **`FileTreeView`** (`code/file_tree/view.rs`) — stores a `ModelHandle`, subscribes to events, calls `get_repository`, `repository_state`, `is_lazy_loaded_path`, `load_directory`, `index_lazy_loaded_path`, `remove_lazy_loaded_path`. * **`FileSearchModel`** (`search/files/model.rs`) — subscribes to `RepositoryMetadataEvent`, calls `has_repository`, `get_repo_contents`. * **`SkillWatcher`** (`ai/skills/file_watchers/skill_watcher.rs`) — subscribes to `RepositoryMetadataEvent`, calls `RepositoryMetadataModel::as_ref(ctx)` for tree queries. ## Proposed Changes ### 1. New types #### `RepositoryIdentifier` A discriminated identifier for repositories across local and remote environments. ```rust /// Identifies a repository across local and remote environments. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum RepositoryIdentifier { Local(CanonicalizedPath), Remote(RemoteRepositoryIdentifier), } ``` #### `RemoteRepositoryIdentifier` Pairs a session ID with the server-side path. Uses raw `PathBuf` because the path lives on the remote machine and cannot be canonicalized locally. ```rust #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct RemoteRepositoryIdentifier { pub session_id: SessionId, pub path: PathBuf, } ``` `SessionId` will be moved from `app/src/terminal/model/session.rs` to `warp_core` so that `repo_metadata` can depend on it directly without circular crate dependencies. ### 2. `LocalRepoMetadataModel` (rename of existing model) The existing `RepositoryMetadataModel` is renamed to `LocalRepoMetadataModel`. Its API is unchanged: * `new(ctx)` — sets up watcher + `DetectedRepositories` subscription. * `index_directory`, `index_lazy_loaded_path`, `load_directory`, `remove_lazy_loaded_path`, `remove_repository`. * `get_repository`, `repository_state`, `has_repository`, `is_lazy_loaded_path`, `get_repo_contents`. * Emits `RepositoryMetadataEvent` (unchanged). The rename is mechanical: update the struct name, the `impl Entity`, `impl SingletonEntity`, and all import sites. ### 3. `RemoteRepoMetadataModel` (new, client-side only) A model that holds file tree state for repositories on remote servers. In this initial phase it has **no syncing or indexing** — state is populated externally (e.g. by a future remote client model or via test helpers). ```rust pub struct RemoteRepoMetadataModel { repositories: HashMap, } ``` #### Events Re-uses the same event enum shape but scoped to remote identifiers: ```rust #[derive(Debug)] pub enum RemoteRepositoryMetadataEvent { RepositoryUpdated { id: RemoteRepositoryIdentifier }, RepositoryRemoved { id: RemoteRepositoryIdentifier }, FileTreeUpdated { ids: Vec }, FileTreeEntryUpdated { id: RemoteRepositoryIdentifier }, } ``` #### Read-only query API Matches the local model's query surface: * `get_repository(&self, id: &RemoteRepositoryIdentifier) -> Option<&FileTreeState>` * `has_repository(&self, id: &RemoteRepositoryIdentifier) -> bool` * `repository_state(&self, id: &RemoteRepositoryIdentifier) -> Option<&IndexedRepoState>` * `get_repo_contents(&self, id: &RemoteRepositoryIdentifier, args: GetContentsArgs) -> Option>>` #### Write API (for future sync + test use) * `insert_repository(&mut self, id: RemoteRepositoryIdentifier, state: FileTreeState, ctx: &mut ModelContext)` — inserts/replaces state, emits `RepositoryUpdated`. * `remove_repository(&mut self, id: &RemoteRepositoryIdentifier, ctx: &mut ModelContext)` — removes state, emits `RepositoryRemoved`. * `update_file_tree_entry(&mut self, id: &RemoteRepositoryIdentifier, entry: FileTreeEntry, ctx: &mut ModelContext)` — replaces the entry within an existing `FileTreeState`, emits `FileTreeEntryUpdated`. These will be the integration points for the future remote sync layer. ### 4. `RepoMetadataModel` wrapper A singleton that holds handles to both sub-models and provides a unified query API keyed by `RepositoryIdentifier`. ```rust pub struct RepoMetadataModel { local: ModelHandle, remote: ModelHandle, } ``` #### Construction ```rust impl RepoMetadataModel { pub fn new(ctx: &mut ModelContext) -> Self { let local = ctx.add_model(|ctx| LocalRepoMetadataModel::new(ctx)); let remote = ctx.add_model(|ctx| RemoteRepoMetadataModel::new(ctx)); // Forward events from both sub-models to a unified event stream. ctx.subscribe_to_model(&local, Self::forward_local_event); ctx.subscribe_to_model(&remote, Self::forward_remote_event); Self { local, remote } } } ``` #### Unified events ```rust #[derive(Debug)] pub enum RepoMetadataEvent { RepositoryUpdated { id: RepositoryIdentifier }, RepositoryRemoved { id: RepositoryIdentifier }, FileTreeUpdated { ids: Vec }, FileTreeEntryUpdated { id: RepositoryIdentifier }, UpdatingRepositoryFailed { id: RepositoryIdentifier }, } ``` The wrapper maps sub-model events into the unified enum. #### Unified query API Read operations are dispatched to the appropriate sub-model based on the `RepositoryIdentifier` variant: * `get_repository(&self, id: &RepositoryIdentifier, ctx: &AppContext) -> Option<&FileTreeState>` * `has_repository(&self, id: &RepositoryIdentifier, ctx: &AppContext) -> bool` * `repository_state(&self, id: &RepositoryIdentifier, ctx: &AppContext) -> Option<&IndexedRepoState>` * `get_repo_contents(&self, id: &RepositoryIdentifier, args: GetContentsArgs, ctx: &AppContext) -> Option>>` Note: because the wrapper accesses sub-models through `ModelHandle`, the read APIs require an `AppContext` parameter to dereference the handle. Delegating via `as_ref(ctx)` is simpler than caching and avoids duplication. #### Local-specific operations Operations that are inherently local (watcher management, lazy loading, indexing) are exposed directly on the wrapper, which delegates to `LocalRepoMetadataModel` internally via `self.local.update(ctx, ...)`. The sub-model handles are **not** exposed to consumers. * `index_directory(&self, repository: ModelHandle, ctx: &mut ModelContext) -> Result<(), RepoMetadataError>` * `index_lazy_loaded_path(&self, path: &Path, ctx: &mut ModelContext) -> Result<(), RepoMetadataError>` * `load_directory(&self, repo_root: &Path, dir_path: &Path, ctx: &mut ModelContext) -> Result<(), RepoMetadataError>` * `remove_lazy_loaded_path(&self, path: &Path, ctx: &mut ModelContext)` * `remove_repository(&self, id: &RepositoryIdentifier, ctx: &mut ModelContext) -> Result<(), RepoMetadataError>` — dispatches to the correct sub-model based on variant. * `is_lazy_loaded_path(&self, path: &Path, ctx: &AppContext) -> bool` * `find_repository_for_path(&self, path: &Path, ctx: &AppContext) -> Option` As remote equivalents are needed (e.g. triggering a remote directory load via the sync layer), they can be added to the wrapper with `RepositoryIdentifier`-based signatures. #### Encapsulation The wrapper does **not** expose `.local()` or `.remote()` accessors. All consumers interact exclusively through `RepoMetadataModel`'s public API. This ensures: 1. Consumers are decoupled from the local/remote split — they don't know or care which sub-model handles their request. 2. Adding new environment variants (e.g. containers) doesn't require touching consumers. 3. The wrapper can evolve its internal delegation strategy (e.g. caching, batching) without breaking callers. ### 5. Crate structure All new types live in the `repo_metadata` crate: * `lib.rs` — re-exports, `CanonicalizedPath`, `RepositoryIdentifier`, `RemoteRepositoryIdentifier`. * `model.rs` → renamed to `local_model.rs` (contains `LocalRepoMetadataModel`). * `remote_model.rs` (new, contains `RemoteRepoMetadataModel`). * `wrapper_model.rs` (new, contains `RepoMetadataModel`). * `file_tree_store.rs` — unchanged, shared by both models. ### 6. Consumer migration plan The migration can be done incrementally. The key invariant is that **existing local-only behavior is preserved** — the wrapper simply adds a remote dimension. #### Phase 1: Introduce types + wrapper (this spec) 1. Add `RepositoryIdentifier`, `RemoteRepositoryIdentifier`, `RemoteRepoMetadataModel`, and `RepoMetadataModel` to `repo_metadata`. 2. Rename `RepositoryMetadataModel` → `LocalRepoMetadataModel`. 3. Make `RepoMetadataModel` the new singleton; it creates the `LocalRepoMetadataModel` and `RemoteRepoMetadataModel` internally. 4. Update `app/src/lib.rs` to instantiate `RepoMetadataModel` instead of the old singleton. #### Phase 2: Migrate consumers to wrapper Consumers construct `RepositoryIdentifier::Local(...)` for their path-based lookups and call all operations through the wrapper's public API. No sub-model handles are accessed directly. * **`FileTreeView`** — change `ModelHandle` → `ModelHandle`. Subscribe to `RepoMetadataEvent`. For queries, construct `RepositoryIdentifier::Local(canonicalized_path)` and call `wrapper.get_repository(id, ctx)`, `wrapper.has_repository(id, ctx)`, etc. For local-only operations, call `wrapper.index_lazy_loaded_path(path, ctx)`, `wrapper.load_directory(root, dir, ctx)`, etc. directly on the wrapper. * **`FileSearchModel`** — change `RepositoryMetadataModel::as_ref(app)` → `RepoMetadataModel::as_ref(app)`. Construct `RepositoryIdentifier::Local(...)` for query calls. Event subscription migrates to `RepoMetadataEvent`. * **`SkillWatcher`** — change `RepositoryMetadataModel::as_ref(ctx)` → `RepoMetadataModel::as_ref(ctx)`. Construct `RepositoryIdentifier::Local(...)` for tree queries. Event subscription migrates. This phase is purely mechanical and doesn't change behavior — all identifiers are `RepositoryIdentifier::Local(...)` during this phase. A convenience constructor like `RepositoryIdentifier::local(path: impl TryInto)` reduces boilerplate at call sites. #### Phase 3: Wire remote file tree (future, out of scope) Connect the remote sync layer to `RemoteRepoMetadataModel::insert_repository`. Update `FileTreeView` to display remote repositories using `RepositoryIdentifier::Remote(...)`. This phase requires the remote client model and protobuf sync layer described in the parent tech design. ## Testing Strategy * Unit tests for `RemoteRepoMetadataModel`: insert/remove/query/event emission. * Unit tests for `RepoMetadataModel` wrapper: unified query dispatching, event forwarding. * Existing `RepositoryMetadataModel` (now `LocalRepoMetadataModel`) tests remain unchanged. * Integration tests in `app/` verify that consumer subscriptions and queries work through the wrapper. ## Decisions 1. **`SessionId` location** — Move `SessionId` to `warp_core` so `repo_metadata` can depend on it directly without circular dependencies. 2. **Event granularity** — The wrapper emits only unified `RepoMetadataEvent`. Consumers subscribe to the wrapper and filter by `RepositoryIdentifier` variant if they only care about local or remote events. 3. **Lifecycle of local-specific operations** — Local-only operations (e.g. `load_directory`) keep their current path-based signatures for now. Remote equivalents will be added to the wrapper once the remote client ↔ server sync layer is in place.