13 KiB
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, holdsHashMap<CanonicalizedPath, IndexedRepoState>+ an optionalBulkFilesystemWatcher. Subscribes toDetectedRepositoriesfor auto-indexing and the watcher for incremental updates.FileTreeState— holds aFileTreeEntry(the flattened map store), aVec<Gitignore>, and an optionalModelHandle<Repository>.FileTreeEntry(file_tree_store.rs) — wrapsFileTreeMapStore(parent→children + path→metadata hash maps) plus aroot_path: Arc<Path>.CanonicalizedPath(lib.rs) — aPathBufwrapper thatdunce::canonicalizes on construction. Used as the HashMap key for repositories.SessionId(app/src/terminal/model/session.rs) —u64wrapper identifying a terminal session, already used to distinguish SSH sessions.
Consumers in app/
FileTreeView(code/file_tree/view.rs) — stores aModelHandle<RepositoryMetadataModel>, subscribes to events, callsget_repository,repository_state,is_lazy_loaded_path,load_directory,index_lazy_loaded_path,remove_lazy_loaded_path.FileSearchModel(search/files/model.rs) — subscribes toRepositoryMetadataEvent, callshas_repository,get_repo_contents.SkillWatcher(ai/skills/file_watchers/skill_watcher.rs) — subscribes toRepositoryMetadataEvent, callsRepositoryMetadataModel::as_ref(ctx)for tree queries.
Proposed Changes
1. New types
RepositoryIdentifier
A discriminated identifier for repositories across local and remote environments.
/// 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.
#[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 +DetectedRepositoriessubscription.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, theimpl 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).
pub struct RemoteRepoMetadataModel {
repositories: HashMap<RemoteRepositoryIdentifier, IndexedRepoState>,
}
Events
Re-uses the same event enum shape but scoped to remote identifiers:
#[derive(Debug)]
pub enum RemoteRepositoryMetadataEvent {
RepositoryUpdated { id: RemoteRepositoryIdentifier },
RepositoryRemoved { id: RemoteRepositoryIdentifier },
FileTreeUpdated { ids: Vec<RemoteRepositoryIdentifier> },
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) -> boolrepository_state(&self, id: &RemoteRepositoryIdentifier) -> Option<&IndexedRepoState>get_repo_contents(&self, id: &RemoteRepositoryIdentifier, args: GetContentsArgs) -> Option<Vec<RepoContent<'_>>>
Write API (for future sync + test use)
insert_repository(&mut self, id: RemoteRepositoryIdentifier, state: FileTreeState, ctx: &mut ModelContext<Self>)— inserts/replaces state, emitsRepositoryUpdated.remove_repository(&mut self, id: &RemoteRepositoryIdentifier, ctx: &mut ModelContext<Self>)— removes state, emitsRepositoryRemoved.update_file_tree_entry(&mut self, id: &RemoteRepositoryIdentifier, entry: FileTreeEntry, ctx: &mut ModelContext<Self>)— replaces the entry within an existingFileTreeState, emitsFileTreeEntryUpdated. 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.
pub struct RepoMetadataModel {
local: ModelHandle<LocalRepoMetadataModel>,
remote: ModelHandle<RemoteRepoMetadataModel>,
}
Construction
impl RepoMetadataModel {
pub fn new(ctx: &mut ModelContext<Self>) -> 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
#[derive(Debug)]
pub enum RepoMetadataEvent {
RepositoryUpdated { id: RepositoryIdentifier },
RepositoryRemoved { id: RepositoryIdentifier },
FileTreeUpdated { ids: Vec<RepositoryIdentifier> },
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) -> boolrepository_state(&self, id: &RepositoryIdentifier, ctx: &AppContext) -> Option<&IndexedRepoState>get_repo_contents(&self, id: &RepositoryIdentifier, args: GetContentsArgs, ctx: &AppContext) -> Option<Vec<RepoContent<'_>>>Note: because the wrapper accesses sub-models throughModelHandle, the read APIs require anAppContextparameter to dereference the handle. Delegating viaas_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<Repository>, ctx: &mut ModelContext<Self>) -> Result<(), RepoMetadataError>index_lazy_loaded_path(&self, path: &Path, ctx: &mut ModelContext<Self>) -> Result<(), RepoMetadataError>load_directory(&self, repo_root: &Path, dir_path: &Path, ctx: &mut ModelContext<Self>) -> Result<(), RepoMetadataError>remove_lazy_loaded_path(&self, path: &Path, ctx: &mut ModelContext<Self>)remove_repository(&self, id: &RepositoryIdentifier, ctx: &mut ModelContext<Self>) -> Result<(), RepoMetadataError>— dispatches to the correct sub-model based on variant.is_lazy_loaded_path(&self, path: &Path, ctx: &AppContext) -> boolfind_repository_for_path(&self, path: &Path, ctx: &AppContext) -> Option<CanonicalizedPath>As remote equivalents are needed (e.g. triggering a remote directory load via the sync layer), they can be added to the wrapper withRepositoryIdentifier-based signatures.
Encapsulation
The wrapper does not expose .local() or .remote() accessors. All consumers interact exclusively through RepoMetadataModel's public API. This ensures:
- Consumers are decoupled from the local/remote split — they don't know or care which sub-model handles their request.
- Adding new environment variants (e.g. containers) doesn't require touching consumers.
- 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 tolocal_model.rs(containsLocalRepoMetadataModel).remote_model.rs(new, containsRemoteRepoMetadataModel).wrapper_model.rs(new, containsRepoMetadataModel).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)
- Add
RepositoryIdentifier,RemoteRepositoryIdentifier,RemoteRepoMetadataModel, andRepoMetadataModeltorepo_metadata. - Rename
RepositoryMetadataModel→LocalRepoMetadataModel. - Make
RepoMetadataModelthe new singleton; it creates theLocalRepoMetadataModelandRemoteRepoMetadataModelinternally. - Update
app/src/lib.rsto instantiateRepoMetadataModelinstead 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— changeModelHandle<RepositoryMetadataModel>→ModelHandle<RepoMetadataModel>. Subscribe toRepoMetadataEvent. For queries, constructRepositoryIdentifier::Local(canonicalized_path)and callwrapper.get_repository(id, ctx),wrapper.has_repository(id, ctx), etc. For local-only operations, callwrapper.index_lazy_loaded_path(path, ctx),wrapper.load_directory(root, dir, ctx), etc. directly on the wrapper.FileSearchModel— changeRepositoryMetadataModel::as_ref(app)→RepoMetadataModel::as_ref(app). ConstructRepositoryIdentifier::Local(...)for query calls. Event subscription migrates toRepoMetadataEvent.SkillWatcher— changeRepositoryMetadataModel::as_ref(ctx)→RepoMetadataModel::as_ref(ctx). ConstructRepositoryIdentifier::Local(...)for tree queries. Event subscription migrates. This phase is purely mechanical and doesn't change behavior — all identifiers areRepositoryIdentifier::Local(...)during this phase. A convenience constructor likeRepositoryIdentifier::local(path: impl TryInto<CanonicalizedPath>)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
RepoMetadataModelwrapper: unified query dispatching, event forwarding. - Existing
RepositoryMetadataModel(nowLocalRepoMetadataModel) tests remain unchanged. - Integration tests in
app/verify that consumer subscriptions and queries work through the wrapper.
Decisions
SessionIdlocation — MoveSessionIdtowarp_coresorepo_metadatacan depend on it directly without circular dependencies.- Event granularity — The wrapper emits only unified
RepoMetadataEvent. Consumers subscribe to the wrapper and filter byRepositoryIdentifiervariant if they only care about local or remote events. - 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.