Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
# Client-Side Wiring for Remote File Tree — Tech Spec
|
||||
|
||||
Linear: [APP-3788](https://linear.app/warpdotdev/issue/APP-3788)
|
||||
|
||||
## 1. Problem
|
||||
|
||||
The remote server file tree protocol (proto schema, server handlers, client API, Rust↔Proto conversion, incremental sync) is implemented. However, nothing on the client side triggers these flows. When a user SSH's into a remote host and `cd`s around, the Project Explorer shows "not supported in remote sessions."
|
||||
|
||||
We need to wire three things:
|
||||
1. SSH `cd` → `navigate_to_directory` request to the remote server
|
||||
2. Server push events (`RepoMetadataSnapshot`, `RepoMetadataUpdatePush`) → populate `RemoteRepoMetadataModel`
|
||||
3. File tree view renders from `RemoteRepoMetadataModel` for SSH sessions
|
||||
|
||||
## 2. Relevant Code
|
||||
|
||||
### Remote server client & manager
|
||||
- `crates/remote_server/src/client.rs:156` — `navigate_to_directory()` async request method
|
||||
- `crates/remote_server/src/client.rs:202` — `send_request()` that all request methods delegate to
|
||||
- `crates/remote_server/src/client.rs:46` — `ClientEvent` enum (`Disconnected`, `RepoMetadataSnapshotReceived`, `RepoMetadataUpdated`)
|
||||
- `crates/remote_server/src/client.rs:109` — `new()` returns `(Self, async_channel::Receiver<ClientEvent>)` — unified event channel for push events and disconnect
|
||||
- `app/src/remote_server/manager.rs:91` — `RemoteServerManager` singleton, maps sessions → hosts → clients
|
||||
- `app/src/remote_server/manager.rs:262` — `client_for_session()` lookup
|
||||
- `app/src/remote_server/manager.rs:181` — event channel drain loop (currently only handles `Disconnected`, TODO for forwarding push events)
|
||||
|
||||
### Repo metadata models
|
||||
- `crates/repo_metadata/src/remote_model.rs:41` — `RemoteRepoMetadataModel` with `insert_repository()`, `apply_incremental_update()`
|
||||
- `crates/repo_metadata/src/wrapper_model.rs:52` — `RepoMetadataModel` wrapper singleton
|
||||
- `crates/repo_metadata/src/repository_identifier.rs:51` — `RemoteRepositoryIdentifier { session_id, path }`
|
||||
|
||||
### Proto conversion
|
||||
- `crates/remote_server/src/repo_metadata_proto.rs` — `proto_snapshot_to_update()` converts `RepoMetadataSnapshot` → `RepoMetadataUpdate`; `proto_to_repo_metadata_update()` converts `RepoMetadataUpdatePush` → `RepoMetadataUpdate`; `From` impls for Rust → Proto direction
|
||||
|
||||
### File tree view & workspace
|
||||
- `app/src/code/file_tree/view.rs:235` — `FileTreeView` struct with `root_directories`, `displayed_directories`, `repository_metadata_model`
|
||||
- `app/src/code/file_tree/view.rs:660` — `set_root_directories()` converts `PathBuf` via `try_from_local` (fails for remote paths)
|
||||
- `app/src/code/file_tree/view.rs:702` — `update_directory_contents()` looks up `DetectedRepositories` + local model only
|
||||
- `app/src/code/file_tree/view.rs:351` — `handle_repository_metadata_event()` matches only `RepositoryIdentifier::Local(..)`
|
||||
- `app/src/workspace/view.rs:13377` — `update_active_session()` sets `CodingPanelEnablementState::RemoteSession` for SSH
|
||||
- `app/src/workspace/view.rs:11964` — `refresh_working_directories_for_pane_group()` collects CWDs via `pwd_if_local()`
|
||||
- `app/src/coding_panel_enablement_state.rs:1` — `CodingPanelEnablementState` enum
|
||||
- `app/src/terminal/view.rs:20438` — `pwd()` returns raw CWD (works for remote sessions)
|
||||
- `app/src/terminal/view.rs:20445` — `pwd_if_local()` returns `None` for remote sessions
|
||||
- `app/src/pane_group/working_directories.rs:737` — `normalize_cwd()` calls `dunce::canonicalize` (fails for remote paths)
|
||||
|
||||
### LSP push event pattern (reference)
|
||||
- `crates/lsp/src/model.rs:268` — `spawn_stream_local` drains the LSP server notification channel on the main thread, calling `handle_server_notification` for each event
|
||||
- `crates/lsp/src/model.rs:540` — `handle_server_notification` dispatches notifications by type, updates model state, and emits domain events via `ctx.emit()`
|
||||
- `crates/lsp/src/manager.rs:191` — `LspManagerModel` subscribes to `LspServerModel` events and re-emits them as `LspManagerModelEvent`s for downstream consumers
|
||||
|
||||
## 3. Current State
|
||||
|
||||
### CWD tracking pipeline
|
||||
`terminal_view_working_directories()` calls `pwd_if_local()`, which returns `None` for SSH sessions. The raw CWD *is* available via `pwd()` (reads `BlockMetadata::current_working_directory`), but it's never used for remote sessions. The workspace's `refresh_working_directories_for_pane_group` consequently filters remote sessions out entirely.
|
||||
|
||||
### File tree enablement
|
||||
`update_active_session()` sets `CodingPanelEnablementState::RemoteSession` when `is_remote == true`. `FileTreeView::render()` shows "The Project Explorer requires access to your local workspace, which isn't supported in remote sessions." when enablement is `RemoteSession` and `displayed_directories` is empty.
|
||||
|
||||
### RemoteServerManager
|
||||
Singleton that maps sessions → hosts → `RemoteServerClient` handles. Exposes `client_for_session(session_id)`. The event channel from `RemoteServerClient::new()` is drained in a background loop that currently only handles `Disconnected` — `RepoMetadataSnapshotReceived` and `RepoMetadataUpdated` events have a TODO to forward them.
|
||||
|
||||
### RemoteRepoMetadataModel
|
||||
Has `insert_repository()`, `apply_incremental_update()`, and `update_file_tree_entry()` write APIs. Accessible through the `RepoMetadataModel` wrapper singleton which forwards events as `RepoMetadataEvent` with `RepositoryIdentifier::Remote(..)`. Currently never populated.
|
||||
|
||||
### Server push flow
|
||||
The remote server proactively pushes repo metadata after `NavigatedToDirectory`:
|
||||
- For non-git directories: server responds with `{ indexed_path, is_git: false }`, then pushes a `RepoMetadataSnapshot` with the lazy tree data.
|
||||
- For git directories: server responds with `{ indexed_path, is_git: true }`, then pushes a `RepoMetadataSnapshot` once full git indexing completes.
|
||||
- Incremental updates are pushed as `RepoMetadataUpdatePush` on filesystem watcher changes.
|
||||
|
||||
The client parses these in `push_message_to_event()` and delivers them as `ClientEvent::RepoMetadataSnapshotReceived` / `ClientEvent::RepoMetadataUpdated` through the event channel. The `RemoteServerManager` drain loop currently ignores these events.
|
||||
|
||||
### FileTreeView local-only assumptions
|
||||
`set_root_directories()` converts `PathBuf → StandardizedPath` via `try_from_local` (calls `dunce::canonicalize`, fails for non-local paths). `update_directory_contents()` looks up `DetectedRepositories` (local singleton) and calls `load_directory` (local filesystem I/O). `handle_repository_metadata_event` matches only `RepositoryIdentifier::Local(..)` variants and ignores all `Remote(..)` variants.
|
||||
|
||||
## 4. Proposed Changes
|
||||
|
||||
### Pre-requisite: `RemoteRepositoryIdentifier` keyed by `HostId`
|
||||
|
||||
Currently `RemoteRepositoryIdentifier` is `(SessionId, StandardizedPath)`. Multiple SSH sessions to the same host share one remote server, so keying by session would duplicate repo metadata N times.
|
||||
|
||||
**Move `HostId`** from `crates/remote_server/src/host_id.rs` to `crates/warp_core/src/host_id.rs` (same pattern as `SessionId` in `warp_core/src/session_id.rs`). Re-export from `remote_server` for backward compatibility.
|
||||
|
||||
**Update `RemoteRepositoryIdentifier`**:
|
||||
|
||||
```rust
|
||||
pub struct RemoteRepositoryIdentifier {
|
||||
pub host_id: HostId,
|
||||
pub path: StandardizedPath,
|
||||
}
|
||||
```
|
||||
|
||||
Blast radius is small — `RemoteRepositoryIdentifier` is only used within `repo_metadata` today (repository_identifier.rs, remote_model.rs, wrapper_model.rs).
|
||||
|
||||
### 4.1. Feature flag gating
|
||||
|
||||
All remote file tree behavior must be gated behind `FeatureFlag::SshRemoteServer`. When the flag is disabled:
|
||||
- `update_active_session()` should NOT call `navigate_to_directory` for remote sessions
|
||||
- The file tree view should continue to render the existing "not supported in remote sessions" disabled state
|
||||
- Push events from the remote server are still forwarded (the server runs regardless of the flag), but `RemoteRepoMetadataModel` should no-op if the flag is off
|
||||
|
||||
The flag check should live at the entry points (workspace `update_active_session` and `FileTreeView::render`) rather than deep in the manager or model, so the plumbing is ready for immediate use once the flag is enabled.
|
||||
|
||||
### 4.2. Wire SSH `cd` to `navigate_to_directory`
|
||||
|
||||
**RemoteServerManager** stays as a thin connection manager. Add:
|
||||
|
||||
```rust
|
||||
pub fn navigate_to_directory(
|
||||
&mut self,
|
||||
session_id: SessionId,
|
||||
path: String,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
// 1. Look up client + host_id for this session
|
||||
// 2. Clone the Arc<RemoteServerClient> and spawn on background executor
|
||||
// 3. Call client.navigate_to_directory(path).await
|
||||
// 4. On success, spawner.spawn() back to main thread and emit:
|
||||
// RemoteServerManagerEvent::NavigatedToDirectory {
|
||||
// host_id, indexed_path, is_git
|
||||
// }
|
||||
}
|
||||
```
|
||||
|
||||
The manager does NOT store state or decide next actions — that's `RemoteRepoMetadataModel`'s job. The server will proactively push `RepoMetadataSnapshot` after responding to `NavigatedToDirectory`, so the client does not need a separate fetch request.
|
||||
|
||||
**Caller**: The workspace's `update_active_session()` flow. When the active terminal is remote and has a CWD (via `terminal.pwd()`), call `navigate_to_directory` on the manager instead of skipping.
|
||||
|
||||
### 4.3. Forward push events from `RemoteServerManager` (following LSP pattern)
|
||||
|
||||
The LSP codebase provides a clean pattern for handling server push messages:
|
||||
1. `LspServerModel::start()` uses `spawn_stream_local` to drain the notification channel on the main thread
|
||||
2. Each notification is dispatched to `handle_server_notification`, which updates model state and emits domain events via `ctx.emit()`
|
||||
3. `LspManagerModel` subscribes to these events and re-emits them as higher-level manager events
|
||||
|
||||
We apply the same pattern to `RemoteServerManager`:
|
||||
|
||||
**Extend the event drain loop** in `connect_session()` (currently at `app/src/remote_server/manager.rs:181`). Instead of ignoring push events, forward them as `RemoteServerManagerEvent` variants:
|
||||
|
||||
```rust
|
||||
// In the event drain loop (currently the while let Ok(event) block):
|
||||
while let Ok(event) = event_rx.recv().await {
|
||||
match event {
|
||||
ClientEvent::Disconnected => break,
|
||||
ClientEvent::RepoMetadataSnapshotReceived { update } => {
|
||||
let _ = spawner.spawn(move |_me, ctx| {
|
||||
ctx.emit(RemoteServerManagerEvent::RepoMetadataSnapshot {
|
||||
host_id: host_id.clone(),
|
||||
update,
|
||||
});
|
||||
}).await;
|
||||
}
|
||||
ClientEvent::RepoMetadataUpdated { update } => {
|
||||
let _ = spawner.spawn(move |_me, ctx| {
|
||||
ctx.emit(RemoteServerManagerEvent::RepoMetadataUpdated {
|
||||
host_id: host_id.clone(),
|
||||
update,
|
||||
});
|
||||
}).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Add new event variants** to `RemoteServerManagerEvent`:
|
||||
|
||||
```rust
|
||||
pub enum RemoteServerManagerEvent {
|
||||
// ... existing variants ...
|
||||
|
||||
/// A full or lazy-loaded repo metadata snapshot was pushed by the server.
|
||||
RepoMetadataSnapshot {
|
||||
host_id: HostId,
|
||||
update: repo_metadata::RepoMetadataUpdate,
|
||||
},
|
||||
/// An incremental repo metadata update was pushed by the server.
|
||||
RepoMetadataUpdated {
|
||||
host_id: HostId,
|
||||
update: repo_metadata::RepoMetadataUpdate,
|
||||
},
|
||||
/// Response to a navigate_to_directory request.
|
||||
NavigatedToDirectory {
|
||||
host_id: HostId,
|
||||
indexed_path: String,
|
||||
is_git: bool,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Note on `host_id` availability**: The event drain loop starts while the session is still in `Initializing` state (before the initialize handshake returns the `host_id`). Push events will only arrive after the handshake completes and `NavigatedToDirectory` is sent, so by that point the session is `Connected` and the `host_id` is known. The drain loop should capture the `host_id` from the `mark_session_connected` transition (e.g., via a shared `watch` channel or by looking it up from session state when emitting).
|
||||
|
||||
### 4.4. `RemoteRepoMetadataModel` subscribes to manager events
|
||||
|
||||
The remote model subscribes to `RemoteServerManagerEvent` and reacts to push events:
|
||||
|
||||
#### On `RemoteServerManagerEvent::RepoMetadataSnapshot { host_id, update }`
|
||||
Call `self.insert_repository(host_id, update)` to populate the initial tree state.
|
||||
|
||||
#### On `RemoteServerManagerEvent::RepoMetadataUpdated { host_id, update }`
|
||||
Call `self.apply_incremental_update(host_id, update)` to apply watcher-driven changes.
|
||||
|
||||
#### On `RemoteServerManagerEvent::HostDisconnected { host_id }`
|
||||
Clean up remote repositories for that host.
|
||||
|
||||
The remote model no longer needs direct access to `RemoteServerClient` — all data arrives through the event channel. This keeps the model decoupled from connection management.
|
||||
|
||||
### 4.5. Update file tree view for remote repositories
|
||||
|
||||
#### 4.5a. Enablement state
|
||||
Keep `CodingPanelEnablementState::RemoteSession`. When `FeatureFlag::SshRemoteServer` is enabled, change the file tree view's `render()` to check if remote root directories exist before showing the error. If `displayed_directories` is non-empty (remote roots present), render the tree normally regardless of `RemoteSession` enablement. When the flag is disabled, always render the existing disabled state for remote sessions.
|
||||
|
||||
#### 4.5b. Separate entry point for remote roots
|
||||
The local pipeline (`PathBuf → normalize_cwd → WorkingDirectoriesModel → set_root_directories → try_from_local`) fails for remote paths at `dunce::canonicalize` and `try_from_local`. Rather than migrating that pipeline, add a separate entry point:
|
||||
|
||||
```rust
|
||||
impl FileTreeView {
|
||||
/// Sets root directories from a remote server.
|
||||
/// Bypasses the local WorkingDirectoriesModel pipeline entirely.
|
||||
pub fn set_remote_root_directories(
|
||||
&mut self,
|
||||
roots: Vec<(HostId, StandardizedPath)>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
Remote paths come from `NavigatedToDirectoryResponse.indexed_path` (a `String`), which converts directly to `StandardizedPath::try_new()` with no I/O.
|
||||
|
||||
Each `RootDirectory` gets an optional `RepositoryIdentifier` field so the view knows whether to query the local or remote model when loading contents.
|
||||
|
||||
Do NOT migrate `WorkingDirectoriesModel` or `normalize_cwd` to `StandardizedPath` — that's a much larger change with no immediate value for this feature.
|
||||
|
||||
#### 4.5c. Remote directory contents
|
||||
`update_directory_contents()` currently looks up `DetectedRepositories` and calls `load_directory` (local-only). For remote roots:
|
||||
- Look up `RepositoryIdentifier::Remote(RemoteRepositoryIdentifier { host_id, path })` in `RepoMetadataModel`
|
||||
- Use the returned `FileTreeState.entry` directly as the root directory's entry
|
||||
- Skip lazy loading / `DetectedRepositories` lookup entirely — the remote server handles indexing
|
||||
|
||||
#### 4.5d. Handle remote `RepoMetadataEvent`s
|
||||
`handle_repository_metadata_event` currently only matches `RepositoryIdentifier::Local(..)` and ignores remote variants. Add handling for `RepositoryIdentifier::Remote(..)` in:
|
||||
- `RepositoryUpdated` — triggers `update_directory_contents` for matching remote roots
|
||||
- `FileTreeEntryUpdated` — refreshes the cached `FileTreeEntry` and calls `rebuild_flattened_items`
|
||||
|
||||
## 5. End-to-End Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant TerminalView
|
||||
participant Workspace
|
||||
participant RSManager as RemoteServerManager
|
||||
participant RSClient as RemoteServerClient
|
||||
participant RemoteServer
|
||||
participant RemoteModel as RemoteRepoMetadataModel
|
||||
participant FileTree as FileTreeView
|
||||
|
||||
User->>TerminalView: cd /home/user/project
|
||||
TerminalView->>Workspace: AppStateChanged (pwd updated)
|
||||
Workspace->>Workspace: update_active_session()
|
||||
Workspace->>RSManager: navigate_to_directory(session_id, "/home/user/project")
|
||||
RSManager->>RSClient: navigate_to_directory("/home/user/project")
|
||||
RSClient->>RemoteServer: NavigatedToDirectory { path }
|
||||
RemoteServer-->>RSClient: NavigatedToDirectoryResponse { indexed_path, is_git }
|
||||
RSManager->>RSManager: emit NavigatedToDirectory event
|
||||
|
||||
Note over RemoteServer: Server proactively pushes snapshot
|
||||
alt is_git = false (lazy tree)
|
||||
RemoteServer->>RSClient: RepoMetadataSnapshot (push, lazy tree)
|
||||
RSClient->>RSManager: ClientEvent::RepoMetadataSnapshotReceived
|
||||
RSManager->>RSManager: emit RepoMetadataSnapshot event
|
||||
RSManager->>RemoteModel: (via subscription)
|
||||
RemoteModel->>RemoteModel: insert_repository(host_id, path, state)
|
||||
RemoteModel->>FileTree: RepoMetadataEvent::RepositoryUpdated { Remote(..) }
|
||||
else is_git = true (full git index)
|
||||
RemoteServer->>RSClient: RepoMetadataSnapshot (push, after git indexing)
|
||||
RSClient->>RSManager: ClientEvent::RepoMetadataSnapshotReceived
|
||||
RSManager->>RSManager: emit RepoMetadataSnapshot event
|
||||
RSManager->>RemoteModel: (via subscription)
|
||||
RemoteModel->>RemoteModel: insert_repository(host_id, path, state)
|
||||
RemoteModel->>FileTree: RepoMetadataEvent::RepositoryUpdated { Remote(..) }
|
||||
end
|
||||
|
||||
FileTree->>FileTree: set_remote_root_directories + update_directory_contents
|
||||
FileTree->>User: renders file tree
|
||||
```
|
||||
|
||||
After initial population, incremental updates flow as:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant RemoteServer
|
||||
participant RSClient as RemoteServerClient
|
||||
participant RSManager as RemoteServerManager
|
||||
participant RemoteModel as RemoteRepoMetadataModel
|
||||
participant FileTree as FileTreeView
|
||||
|
||||
RemoteServer->>RSClient: RepoMetadataUpdatePush (empty request_id)
|
||||
RSClient->>RSManager: ClientEvent::RepoMetadataUpdated
|
||||
RSManager->>RSManager: emit RepoMetadataUpdated event
|
||||
RSManager->>RemoteModel: (via subscription)
|
||||
RemoteModel->>RemoteModel: apply_incremental_update()
|
||||
RemoteModel->>FileTree: RepoMetadataEvent::FileTreeEntryUpdated { Remote(..) }
|
||||
FileTree->>FileTree: rebuild_flattened_items + notify
|
||||
```
|
||||
|
||||
## 6. Risks and Mitigations
|
||||
|
||||
**Risk**: Multiple rapid `cd` commands could fire overlapping `navigate_to_directory` requests. The same path could be navigated to before the first response arrives.
|
||||
**Mitigation**: The manager should debounce or dedup: if a navigation is already in-flight for the same session, skip or cancel the previous one. The remote model can also idempotently handle duplicate `insert_repository` calls.
|
||||
|
||||
**Risk**: The remote server disconnects between `NavigatedToDirectoryResponse` and the `RepoMetadataSnapshot` push, leaving the remote model without tree data.
|
||||
**Mitigation**: On `RemoteServerManagerEvent::HostDisconnected`, clear all remote repositories for that host. The file tree view will fall back to the "not supported" message.
|
||||
|
||||
**Risk**: `FileTreeView` rendering code is heavily `#[cfg(feature = "local_fs")]`-gated. Remote rendering needs to work on all platforms including WASM (where `local_fs` is disabled).
|
||||
**Mitigation**: The remote root directory pipeline (`set_remote_root_directories`, remote `update_directory_contents` branch) should NOT be behind `#[cfg(feature = "local_fs")]` since it performs no local I/O.
|
||||
|
||||
**Risk**: The event drain loop starts before the initialize handshake completes, so `host_id` is not yet available when push events arrive.
|
||||
**Mitigation**: Push events only arrive after `NavigatedToDirectory` is sent, which happens after the session reaches `Connected` state. The drain loop can look up the `host_id` from session state or receive it via a shared channel after the handshake.
|
||||
|
||||
## 7. Testing and Validation
|
||||
|
||||
- **Unit tests for `RemoteRepoMetadataModel` event handling**: Mock `RemoteServerManagerEvent::RepoMetadataSnapshot` / `RepoMetadataUpdated` events and verify the model calls `insert_repository()` / `apply_incremental_update()` correctly.
|
||||
- **Unit tests for `FileTreeView` with remote roots**: Construct a `RemoteRepoMetadataModel` with test data, call `set_remote_root_directories`, verify the view queries the correct model and renders entries.
|
||||
- **Integration test**: End-to-end flow from `navigate_to_directory` through push event delivery to file tree rendering, using the existing in-memory client/server test harness from `client_tests.rs`.
|
||||
- **Manual testing**: SSH into a remote host, `cd` around, verify the Project Explorer populates with the remote file tree and updates incrementally on filesystem changes.
|
||||
|
||||
## 8. Follow-ups
|
||||
|
||||
- **Remote `load_directory`**: When a user expands a collapsed directory in the remote file tree, the client needs to send a request to the server for that subtree. Today `load_directory_from_model` is synchronous (local I/O). The remote case requires an async round-trip with a loading spinner. The `loaded: false` field on `FileTreeDirectoryEntryState` can drive this.
|
||||
- **File tree cleanup on session close**: When all sessions to a host are closed and the remote server is torn down, clean up remote repos from `RemoteRepoMetadataModel`.
|
||||
- **`WorkingDirectoriesModel` StandardizedPath migration**: The current `PathBuf`-based working directories pipeline could be migrated to `StandardizedPath` for consistency, but this is a larger refactor with no immediate functional benefit.
|
||||
- **Remote file search**: `FileSearchModel` currently only queries local repos. Extending it to search remote repos requires a separate remote search protocol.
|
||||
@@ -0,0 +1,305 @@
|
||||
# Remote Server File Tree Protocol — Tech Spec
|
||||
|
||||
Linear: [APP-3788](https://linear.app/warpdotdev/issue/APP-3788)
|
||||
|
||||
## Problem
|
||||
|
||||
The remote server binary (`crates/remote_server`) currently only handles `Initialize`/`InitializeResponse`. We need to:
|
||||
1. Boot repo metadata models on the server so it can index directories and keep file trees up to date
|
||||
2. Let the client tell the server which directories to index (via `NavigatedToDirectory`)
|
||||
3. Let the client fetch the initial tree and receive subsequent incremental updates as push messages
|
||||
|
||||
## Current State
|
||||
|
||||
### Remote server (`crates/remote_server`)
|
||||
- `ServerModel` singleton handles stdin/stdout protobuf I/O
|
||||
- `run()` boots a headless warpui app with only `ServerModel`
|
||||
- Proto schema has only `Initialize`/`InitializeResponse`
|
||||
|
||||
### repo_metadata crate
|
||||
- `LocalRepoMetadataModel` — indexes repos, subscribes to `DetectedRepositories` for auto-indexing, has `emit_incremental_updates: bool` field and emits `IncrementalUpdateReady` when enabled
|
||||
- `DetectedRepositories` singleton — runs async git detection via `detect_possible_git_repo()`, emits `DetectedGitRepo` events. Uses `DirectoryWatcher` to register watch directories.
|
||||
- `DirectoryWatcher` singleton — manages filesystem watchers and routes changes to `Repository` subscribers via a `TaskQueue`
|
||||
- `LocalRepoMetadataModel` also supports lazy-loaded non-git directories via `index_lazy_loaded_path()` (first-level-only tree, `loaded: false` on subdirectories)
|
||||
- The incremental update types (`RepoMetadataUpdate`, `FileTreeEntryUpdate`, etc.) already exist in `file_tree_update.rs`
|
||||
|
||||
### Key insight on two separate watchers
|
||||
`DirectoryWatcher` and `LocalRepoMetadataModel` each own their own `BulkFilesystemWatcher`. `DirectoryWatcher`'s watcher feeds the `Repository` model (git status, etc.), while `LocalRepoMetadataModel`'s watcher feeds the file tree. Both need to be running on the server.
|
||||
|
||||
## Proposed Changes
|
||||
|
||||
### 1. Proto schema additions (`remote_server.proto`)
|
||||
|
||||
Names and fields mirror the Rust types in `repo_metadata/src/file_tree_update.rs` 1:1 for trivial conversion.
|
||||
|
||||
```proto
|
||||
// ── Shared file tree sub-messages ─────────────────────────────────
|
||||
// Mirror the Rust types in repo_metadata/src/file_tree_update.rs.
|
||||
|
||||
message RepoNodeMetadata {
|
||||
oneof node {
|
||||
DirectoryNodeMetadata directory = 1;
|
||||
FileNodeMetadata file = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message DirectoryNodeMetadata {
|
||||
string path = 1;
|
||||
bool ignored = 2;
|
||||
bool loaded = 3;
|
||||
}
|
||||
|
||||
message FileNodeMetadata {
|
||||
string path = 1;
|
||||
optional string extension = 2;
|
||||
bool ignored = 3;
|
||||
}
|
||||
|
||||
// Mirrors FileTreeEntryUpdate in Rust.
|
||||
message FileTreeEntryUpdate {
|
||||
string parent_path_to_replace = 1;
|
||||
repeated RepoNodeMetadata subtree_metadata = 2;
|
||||
}
|
||||
|
||||
// ── Client → server ───────────────────────────────────────────────
|
||||
|
||||
// "I navigated to this directory, please index it."
|
||||
message NavigatedToDirectory {
|
||||
string path = 1;
|
||||
}
|
||||
|
||||
// Response after the server has run git detection on the requested path.
|
||||
//
|
||||
// - is_git = true: A git repo was found. indexed_path is the repo root.
|
||||
// Full indexing runs in the background; the client should
|
||||
// wait for RepositoryIndexedPush before calling FetchFileTree.
|
||||
// - is_git = false: No git repo. The directory was lazily indexed at first
|
||||
// level. indexed_path is the standardized input path.
|
||||
// The client can call FetchFileTree immediately.
|
||||
message NavigatedToDirectoryResponse {
|
||||
string indexed_path = 1;
|
||||
bool is_git = 2;
|
||||
}
|
||||
|
||||
// "Give me the current tree for this repo."
|
||||
message FetchFileTree {
|
||||
string repo_path = 1;
|
||||
}
|
||||
|
||||
// Sent as one or more responses for the same request_id.
|
||||
// Client accumulates entries until sync_complete = true.
|
||||
message FetchFileTreeResponse {
|
||||
string repo_path = 1;
|
||||
repeated FileTreeEntryUpdate entries = 2;
|
||||
bool sync_complete = 3;
|
||||
}
|
||||
|
||||
// ── Server → client push (empty request_id) ───────────────────────
|
||||
|
||||
// Mirrors RepoMetadataUpdate in Rust.
|
||||
message FileTreeUpdatePush {
|
||||
string repo_path = 1;
|
||||
repeated string remove_entries = 2;
|
||||
repeated FileTreeEntryUpdate update_entries = 3;
|
||||
}
|
||||
|
||||
// A repository finished full indexing and is ready for FetchFileTree.
|
||||
message RepositoryIndexedPush {
|
||||
string repo_path = 1;
|
||||
}
|
||||
```
|
||||
|
||||
Updated envelopes:
|
||||
|
||||
```proto
|
||||
message ClientMessage {
|
||||
string request_id = 1;
|
||||
oneof message {
|
||||
Initialize initialize = 2;
|
||||
NavigatedToDirectory navigated_to_directory = 3;
|
||||
FetchFileTree fetch_file_tree = 4;
|
||||
}
|
||||
}
|
||||
|
||||
message ServerMessage {
|
||||
string request_id = 1;
|
||||
oneof message {
|
||||
InitializeResponse initialize_response = 2;
|
||||
ErrorResponse error = 3;
|
||||
NavigatedToDirectoryResponse navigated_to_directory_response = 4;
|
||||
FetchFileTreeResponse fetch_file_tree_response = 5;
|
||||
FileTreeUpdatePush file_tree_update = 6;
|
||||
RepositoryIndexedPush repository_indexed = 7;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Push messages use an empty `request_id` to distinguish them from request/response pairs.
|
||||
|
||||
### 2. Server-side model bootstrap
|
||||
|
||||
Update `remote_server::run()` to register the repo metadata singletons:
|
||||
|
||||
```rust
|
||||
AppBuilder::new_headless(...).run(|ctx| {
|
||||
ctx.add_singleton_model(DirectoryWatcher::new);
|
||||
ctx.add_singleton_model(DetectedRepositories::default_entity);
|
||||
ctx.add_singleton_model(|ctx| {
|
||||
let mut model = LocalRepoMetadataModel::new(ctx);
|
||||
model.set_emit_incremental_updates(true);
|
||||
model
|
||||
});
|
||||
ctx.add_singleton_model(ServerModel::new);
|
||||
});
|
||||
```
|
||||
|
||||
This automatically wires up the existing `DetectedRepositories` → `LocalRepoMetadataModel` subscription and the watcher → `LocalRepoMetadataModel` update pipeline.
|
||||
|
||||
New API needed: `LocalRepoMetadataModel::set_emit_incremental_updates(&mut self, enabled: bool)` (or a builder-style constructor parameter).
|
||||
|
||||
### 3. Server-side message handling
|
||||
|
||||
#### `NavigatedToDirectory`
|
||||
|
||||
When the server receives `NavigatedToDirectory { path }`:
|
||||
|
||||
1. Await `detect_possible_git_repo(path)` — this checks the in-memory cache first (instant if already known), otherwise walks up the directory tree checking for `.git` (fast filesystem metadata, not full indexing)
|
||||
2. If a git repo was found (`Some(git_root)`):
|
||||
- Full indexing was already triggered by the `DetectedGitRepo` → `LocalRepoMetadataModel` subscription inside `detect_possible_git_repo`
|
||||
- Respond with `{ indexed_path: git_root, is_git: true }`
|
||||
- Client waits for `RepositoryIndexedPush` before calling `FetchFileTree`
|
||||
3. If no git repo (`None`):
|
||||
- Call `index_lazy_loaded_path(path)` for first-level-only data
|
||||
- Respond with `{ indexed_path: standardized_path, is_git: false }`
|
||||
- Client can call `FetchFileTree` immediately
|
||||
|
||||
#### `FetchFileTree`
|
||||
|
||||
When the server receives `FetchFileTree { repo_path }`:
|
||||
|
||||
1. Look up the repository in `LocalRepoMetadataModel` via `get_repository(&repo_path)`
|
||||
2. If `Indexed`: serialize the full `FileTreeEntry` as one or more `FetchFileTreeResponse` chunks (see section on streaming pagination below)
|
||||
3. If `Pending`: return `ErrorResponse` — the client retries after receiving `RepositoryIndexedPush`
|
||||
4. If `Failed` or not found: return `ErrorResponse`
|
||||
|
||||
Serialization: Walk the `FileTreeEntry`'s `state_map` and `parent_to_child_map` to produce `FileTreeEntryUpdate` entries. This is the same shape as `RepoMetadataUpdate` but for the full tree.
|
||||
|
||||
#### Incremental update push
|
||||
|
||||
The `ServerModel` subscribes to `LocalRepoMetadataModel`'s `IncrementalUpdateReady` events. On receiving the event:
|
||||
|
||||
1. Convert the `RepoMetadataUpdate` to `FileTreeUpdatePush` proto
|
||||
2. Send as a `ServerMessage` with empty `request_id`
|
||||
|
||||
### 4. Conversion layer: Rust types ↔ Proto
|
||||
|
||||
Add a new module `crates/remote_server/src/file_tree_proto.rs` with:
|
||||
|
||||
- `RepoMetadataUpdate` → `FileTreeUpdatePush` proto
|
||||
- `FileTreeEntry` → `FetchFileTreeResponse` proto (full tree serialization with chunking)
|
||||
- Proto `FetchFileTreeResponse` / `FileTreeUpdatePush` → `RepoMetadataUpdate` for client-side application
|
||||
|
||||
These conversions are straightforward because the Rust types in `file_tree_update.rs` were designed to mirror the proto schema 1:1.
|
||||
|
||||
### 5. Client-side changes
|
||||
|
||||
#### `RemoteServerClient` additions
|
||||
|
||||
Add methods to the client:
|
||||
|
||||
- `navigate_to_directory(&self, path: String) -> Result<NavigatedToDirectoryResponse>`
|
||||
- `fetch_file_tree(&self, repo_path: String) -> Result<FetchFileTreeResponse>` (accumulates chunked responses)
|
||||
- Handle push messages (`FileTreeUpdatePush`, `RepositoryIndexedPush`) in the client's reader loop and emit them as client events
|
||||
|
||||
#### `RemoteServerClient` event handling
|
||||
|
||||
The client's reader task receives `ServerMessage`s. For push messages (empty `request_id`), route to event emission instead of completing a pending request:
|
||||
|
||||
```rust
|
||||
RemoteServerClientEvent::FileTreeUpdated { update: RepoMetadataUpdate }
|
||||
RemoteServerClientEvent::RepositoryIndexed { repo_path: String }
|
||||
```
|
||||
|
||||
The downstream consumer (future file tree view integration) subscribes to these events and calls `RemoteRepoMetadataModel::apply_incremental_update()` for `FileTreeUpdated`, and `RemoteRepoMetadataModel::insert_repository()` for the initial tree after calling `fetch_file_tree`.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### 1. NavigatedToDirectory: await git detection, then branch
|
||||
|
||||
The local `FileTreeView::update_directory_contents` (`view.rs:703`) uses a two-pronged approach: check for a git repo first, fall back to lazy-loading if none is found. The remote server mirrors this but runs git detection synchronously within the request handling so the client gets a definitive answer in one round trip:
|
||||
|
||||
1. Server awaits `detect_possible_git_repo(path)` — checks in-memory cache first (instant for known repos), otherwise walks up the directory tree (fast filesystem metadata checks, not full indexing)
|
||||
2. If git repo found: respond with `{ indexed_path: git_root, is_git: true }`. Full indexing was already triggered by `DetectedGitRepo` → `LocalRepoMetadataModel`. Client waits for `RepositoryIndexedPush` before `FetchFileTree`.
|
||||
3. If no git repo: server calls `index_lazy_loaded_path(path)` for first-level data, responds with `{ indexed_path: path, is_git: false }`. Client calls `FetchFileTree` immediately.
|
||||
|
||||
This avoids the unnecessary eager lazy-load for git repos (which would be thrown away when full indexing completes) and gives the client clear instructions in a single response.
|
||||
|
||||
### 2. Initial tree fetch: server-controlled streaming pagination
|
||||
|
||||
Pagination is controlled by the server based on actual response size, not tree depth (a flat repo could have huge amounts of data at each level). The protocol:
|
||||
|
||||
1. Server serializes the tree top-to-bottom (breadth-first or depth-first pre-order)
|
||||
2. Each `FetchFileTreeResponse` chunk contains a batch of entries plus a `bool sync_complete` flag
|
||||
3. The server segments by a target byte budget per chunk (e.g. 256KB)
|
||||
4. The client renders progressively as chunks arrive, and knows the full tree is loaded when `sync_complete = true`
|
||||
|
||||
Multiple `FetchFileTreeResponse` messages are sent for the same `request_id`. The client accumulates them and applies each chunk to the `RemoteRepoMetadataModel` as it arrives.
|
||||
|
||||
## End-to-End Flow
|
||||
|
||||
### Case A: Directory is inside a git repo
|
||||
|
||||
```
|
||||
Client Server
|
||||
│ │
|
||||
User navigates to │ NavigatedToDirectory { path } │
|
||||
/home/user/project/src │ ────────────────────────────────────────> │
|
||||
│ │── await detect_possible_git_repo(path)
|
||||
│ │ → found git root /home/user/project
|
||||
│ │ (full indexing triggered in bg)
|
||||
│ Response { indexed_path: .../project, │
|
||||
│ is_git: true } │
|
||||
│ <──────────────────────────────────────── │
|
||||
│ │
|
||||
Client waits... │ ... full repo indexing completes ... │
|
||||
│ │
|
||||
│ RepositoryIndexedPush { repo_path } │
|
||||
│ <──────────────────────────────────────── │
|
||||
│ │
|
||||
Now fetch full tree │ FetchFileTree { repo_path } │
|
||||
│ ────────────────────────────────────────> │
|
||||
│ │
|
||||
│ FetchFileTreeResponse { ... true } │ ← full tree (chunked)
|
||||
│ <──────────────────────────────────────── │
|
||||
│ │
|
||||
│ ... file watcher detects changes ... │
|
||||
│ │
|
||||
│ FileTreeUpdatePush { incremental } │ ← push, empty request_id
|
||||
│ <──────────────────────────────────────── │
|
||||
```
|
||||
|
||||
### Case B: Directory is NOT a git repo
|
||||
|
||||
```
|
||||
Client Server
|
||||
│ │
|
||||
User navigates to │ NavigatedToDirectory { path } │
|
||||
/tmp/some-dir │ ────────────────────────────────────────> │
|
||||
│ │── await detect_possible_git_repo → None
|
||||
│ │── index_lazy_loaded_path(path)
|
||||
│ Response { indexed_path: /tmp/some-dir, │
|
||||
│ is_git: false } │
|
||||
│ <──────────────────────────────────────── │
|
||||
│ │
|
||||
Fetch immediately │ FetchFileTree { repo_path } │
|
||||
│ ────────────────────────────────────────> │
|
||||
│ │
|
||||
│ FetchFileTreeResponse { ... true } │ ← first-level tree
|
||||
│ <──────────────────────────────────────── │
|
||||
```
|
||||
|
||||
## Follow-ups (out of scope)
|
||||
|
||||
- Wire the client events to `RemoteRepoMetadataModel` and `FileTreeView`
|
||||
- `LoadDirectory` request for expanding collapsed directories over the network
|
||||
- Subscription management (unsubscribe from updates when file tree is closed)
|
||||
@@ -0,0 +1,349 @@
|
||||
# Incremental Repo Metadata Syncing — Tech Spec
|
||||
|
||||
## Problem
|
||||
|
||||
The `LocalRepoMetadataModel` on the remote server keeps its file tree up to date via filesystem watchers. The client's `RemoteRepoMetadataModel` has no filesystem access and currently no mechanism to receive incremental updates — its only write API (`update_file_tree_entry`) replaces the entire `FileTreeEntry`, which is too expensive for frequent watcher-driven changes.
|
||||
|
||||
We need two new capabilities:
|
||||
1. **Server side**: After the `LocalRepoMetadataModel` applies watcher-driven mutations, generate a serializable incremental update describing what changed.
|
||||
2. **Client side**: The `RemoteRepoMetadataModel` applies that incremental update to its own `FileTreeEntry`.
|
||||
|
||||
These two APIs form the data layer of the sync protocol. The transport layer (protobuf encoding and SSH streaming) is out of scope for this spec but the Rust types are designed to map 1:1 to the proto schema for trivial conversion.
|
||||
|
||||
## Relevant Code
|
||||
|
||||
- `crates/repo_metadata/src/local_model.rs:121` — `FileTreeMutation` enum (the internal mutation representation)
|
||||
- `crates/repo_metadata/src/local_model.rs:542` — `compute_file_tree_mutations()` (Phase 1: background I/O)
|
||||
- `crates/repo_metadata/src/local_model.rs:607` — `apply_file_tree_mutations()` (Phase 2: main-thread tree ops)
|
||||
- `crates/repo_metadata/src/local_model.rs:218` — `handle_watcher_event()` (orchestrates Phase 1 → Phase 2)
|
||||
- `crates/repo_metadata/src/local_model.rs:699` — `ensure_parent_directories_exist()` (tree helper, needs extraction)
|
||||
- `crates/repo_metadata/src/remote_model.rs:96` — `insert_repository()`, `update_file_tree_entry()` (existing write API)
|
||||
- `crates/repo_metadata/src/file_tree_store.rs:10` — `FileTreeEntry` struct and mutation primitives
|
||||
- `crates/repo_metadata/src/file_tree_store.rs:149` — `FileTreeEntryState`, `FileTreeFileMetadata`, `FileTreeDirectoryEntryState`
|
||||
- `crates/repo_metadata/src/wrapper_model.rs:27` — `RepoMetadataEvent` (unified event enum to extend)
|
||||
|
||||
## Current State
|
||||
|
||||
### Watcher → mutation flow (server side)
|
||||
|
||||
`LocalRepoMetadataModel::handle_watcher_event` receives `BulkFilesystemWatcherEvent`s, groups changes by repository, then runs a two-phase pipeline:
|
||||
|
||||
1. **`compute_file_tree_mutations`** (async, background thread) — performs filesystem I/O (`exists()`, `is_dir()`, `build_tree()`, gitignore checks) and produces `Vec<FileTreeMutation>`.
|
||||
2. **`apply_file_tree_mutations`** (sync, main thread) — walks the mutation list and directly mutates the `FileTreeEntry` using its primitives (`remove`, `insert_child_state`, `insert_entry_at_path`, `find_or_insert_directory`).
|
||||
|
||||
The `FileTreeMutation` enum has four variants:
|
||||
- `Remove(PathBuf)`
|
||||
- `AddFile { path, is_ignored, extension }`
|
||||
- `AddDirectorySubtree { dir_path, subtree: Entry }` — `Entry` is a recursive tree
|
||||
- `AddEmptyDirectory { path, is_ignored }`
|
||||
|
||||
These mutations are consumed internally and never leave the model. There is no mechanism to observe or forward them.
|
||||
|
||||
### RemoteRepoMetadataModel (client side)
|
||||
|
||||
A stub model with read-only query API and three write methods:
|
||||
- `insert_repository` — sets full `FileTreeState` for a new repo
|
||||
- `remove_repository` — drops a repo
|
||||
- `update_file_tree_entry` — replaces the *entire* `FileTreeEntry`
|
||||
|
||||
There is no incremental update path. The `update_file_tree_entry` method is a full replacement, not a patch.
|
||||
|
||||
### FileTreeEntry internals
|
||||
|
||||
`FileTreeEntry` wraps `FileTreeMapStore`, which stores two flattened hash maps:
|
||||
- `state_map: HashMap<Arc<Path>, FileTreeEntryState>` — path → metadata
|
||||
- `parent_to_child_map: HashMap<Arc<Path>, HashSet<Arc<Path>>>` — parent → children
|
||||
|
||||
This flat representation is important: the incremental update format should express changes in terms of these same two maps so that applying an update is a direct merge.
|
||||
|
||||
## Proposed Changes
|
||||
|
||||
### 1. New module: `file_tree_update.rs`
|
||||
|
||||
New types that mirror the proto schema 1:1:
|
||||
|
||||
```rust
|
||||
/// Mirrors `RepoMetadataUpdate` proto.
|
||||
/// A batch of incremental changes for a single repository.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RepoMetadataUpdate {
|
||||
/// Which repository this update targets.
|
||||
pub repo_path: StandardizedPath,
|
||||
/// Paths to remove from the tree.
|
||||
pub remove_entries: Vec<PathBuf>,
|
||||
/// Subtree patches to add or replace.
|
||||
pub update_entries: Vec<FileTreeEntryUpdate>,
|
||||
}
|
||||
|
||||
/// Mirrors `FileTreeEntry` proto.
|
||||
/// Describes a subtree patch rooted at a specific parent directory.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileTreeEntryUpdate {
|
||||
/// The parent directory whose subtree is being patched.
|
||||
pub parent_path_to_replace: PathBuf,
|
||||
/// Metadata for each node in the subtree.
|
||||
/// Directories must appear before their children (depth-first pre-order).
|
||||
pub subtree_metadata: Vec<RepoNodeMetadata>,
|
||||
}
|
||||
|
||||
/// Mirrors `RepoNodeMetadata` proto.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RepoNodeMetadata {
|
||||
Directory(DirectoryNodeMetadata),
|
||||
File(FileNodeMetadata),
|
||||
}
|
||||
|
||||
/// Mirrors `DirectoryNodeMetadata` proto.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DirectoryNodeMetadata {
|
||||
pub path: PathBuf,
|
||||
pub ignored: bool,
|
||||
pub loaded: bool,
|
||||
}
|
||||
|
||||
/// Mirrors `FileNodeMetadata` proto.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileNodeMetadata {
|
||||
pub path: PathBuf,
|
||||
pub extension: Option<String>,
|
||||
pub ignored: bool,
|
||||
}
|
||||
```
|
||||
|
||||
Each `FileTreeEntryUpdate` represents a subtree patch rooted at a specific parent. Parent→child relationships are not sent explicitly — they are derived implicitly during application because each node's parent is determined by its path, and `insert_child_state` registers the child in `parent_to_child_map`. This simplifies the wire format: only `remove_entries` (paths) and `subtree_metadata` (node metadata in depth-first pre-order) are needed.
|
||||
|
||||
### 2. Server side: generate `RepoMetadataUpdate` from `FileTreeMutation`s
|
||||
|
||||
#### Configuration flag
|
||||
|
||||
Add a field to `LocalRepoMetadataModel`:
|
||||
|
||||
```rust
|
||||
pub struct LocalRepoMetadataModel {
|
||||
// ... existing fields ...
|
||||
/// When true, emit `IncrementalUpdateReady` events after applying
|
||||
/// watcher mutations. Only the remote server variant enables this.
|
||||
emit_incremental_updates: bool,
|
||||
}
|
||||
```
|
||||
|
||||
Defaults to `false`. A new constructor or setter enables it for the remote server context.
|
||||
|
||||
#### Conversion function
|
||||
|
||||
Add a method that converts `Vec<FileTreeMutation>` → `RepoMetadataUpdate`:
|
||||
|
||||
```rust
|
||||
impl LocalRepoMetadataModel {
|
||||
/// Converts internal file tree mutations into a serializable
|
||||
/// `RepoMetadataUpdate` suitable for sending to the remote client.
|
||||
fn generate_repo_metadata_update(
|
||||
repo_path: &StandardizedPath,
|
||||
mutations: &[FileTreeMutation],
|
||||
) -> RepoMetadataUpdate { ... }
|
||||
}
|
||||
```
|
||||
|
||||
The conversion logic per variant:
|
||||
- `Remove(path)` → append to `remove_entries`
|
||||
- `AddFile { path, is_ignored, extension }` → create a `FileTreeEntryUpdate` with `parent_path_to_replace` = parent of `path`, one `FileNodeMetadata`
|
||||
- `AddDirectorySubtree { dir_path, subtree }` → flatten the recursive `Entry` into a `Vec<RepoNodeMetadata>` in depth-first pre-order, set `parent_path_to_replace` = parent of `dir_path`
|
||||
- `AddEmptyDirectory { path, is_ignored }` → same shape as `AddFile` but with `DirectoryNodeMetadata`
|
||||
|
||||
The `Entry` flattening walks the recursive tree depth-first, emitting directory metadata before children, so the ordering guarantee is maintained.
|
||||
|
||||
#### New event variant
|
||||
|
||||
```rust
|
||||
pub enum RepositoryMetadataEvent {
|
||||
// ... existing variants ...
|
||||
/// Emitted after watcher mutations are applied, containing the
|
||||
/// serializable update for the remote client.
|
||||
IncrementalUpdateReady {
|
||||
update: RepoMetadataUpdate,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### Updated watcher handler flow
|
||||
|
||||
`apply_file_tree_mutations` returns the mutations that were actually applied (filtering out any that were skipped due to `lazy_load`). The update is then generated from only the applied mutations, ensuring the remote client never receives entries the server didn't apply.
|
||||
|
||||
```rust
|
||||
let applied = Self::apply_file_tree_mutations(&mut state.entry, mutations, lazy_load);
|
||||
ctx.emit(RepositoryMetadataEvent::FileTreeEntryUpdated { path: repo_path.clone() });
|
||||
|
||||
if model.emit_incremental_updates {
|
||||
let update = Self::generate_repo_metadata_update(&repo_path, &applied);
|
||||
ctx.emit(RepositoryMetadataEvent::IncrementalUpdateReady { update });
|
||||
}
|
||||
```
|
||||
|
||||
#### Lazy-loaded repositories
|
||||
|
||||
The remote server indexes both git repositories (via `DetectedRepositories`) and non-git directories (via `index_lazy_loaded_path` for file tree rendering). Lazy-loaded paths have `loaded: false` on unexpanded directories; when `lazy_load` is true, `apply_file_tree_mutations` skips mutations whose parent directory hasn't been expanded.
|
||||
|
||||
This filtering is critical for incremental updates: without it, the remote client would receive entries that the server's own tree doesn't contain, causing divergence. By generating the update from the *returned* applied mutations, the update accurately reflects the server's tree state regardless of whether the repository is fully indexed or lazily loaded.
|
||||
|
||||
When a user expands a collapsed directory on the remote client, the client calls `load_directory` to eagerly fetch its contents from the server. Today, the local file tree's `load_directory_from_model` is synchronous (local filesystem I/O), so no loading indicator exists. For the remote case this will involve a network round-trip, so a follow-up is needed to add an async flow with a loading/spinner state in the file tree UI while the request is in flight. The `loaded: false` field on `FileTreeDirectoryEntryState` already distinguishes collapsed (not-yet-loaded) directories from expanded ones, which can drive that loading state.
|
||||
|
||||
### 3. Client side: apply `RepoMetadataUpdate` on `RemoteRepoMetadataModel`
|
||||
|
||||
#### New method on `RemoteRepoMetadataModel`
|
||||
|
||||
```rust
|
||||
impl RemoteRepoMetadataModel {
|
||||
/// Applies an incremental update received from the remote server.
|
||||
pub fn apply_incremental_update(
|
||||
&mut self,
|
||||
update: RepoMetadataUpdate,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let id = /* look up RemoteRepositoryIdentifier from update.repo_path */;
|
||||
if let Some(IndexedRepoState::Indexed(state)) = self.repositories.get_mut(&id) {
|
||||
state.entry.apply_repo_metadata_update(&update);
|
||||
ctx.emit(RemoteRepositoryMetadataEvent::FileTreeEntryUpdated {
|
||||
id: id.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### New method on `FileTreeEntry`
|
||||
|
||||
The core mutation application logic lives on `FileTreeEntry` so it can be unit-tested independently:
|
||||
|
||||
```rust
|
||||
impl FileTreeEntry {
|
||||
/// Applies a `RepoMetadataUpdate` to this file tree entry.
|
||||
pub fn apply_repo_metadata_update(&mut self, update: &RepoMetadataUpdate) {
|
||||
// 1. Process removals
|
||||
for path in &update.remove_entries {
|
||||
self.remove(path);
|
||||
}
|
||||
|
||||
// 2. Process subtree patches
|
||||
for entry_update in &update.update_entries {
|
||||
self.apply_entry_update(entry_update);
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_entry_update(&mut self, update: &FileTreeEntryUpdate) {
|
||||
// Ensure parent directories exist up to parent_path_to_replace
|
||||
self.ensure_parent_directories_exist(&update.parent_path_to_replace);
|
||||
|
||||
// subtree_metadata is in depth-first pre-order: each directory
|
||||
// appears before its children. A single pass is sufficient because
|
||||
// by the time we encounter a file, its parent directory has already
|
||||
// been inserted. insert_child_state also registers the child in
|
||||
// parent_to_child_map, so no separate wiring step is needed.
|
||||
for node in &update.subtree_metadata {
|
||||
// ... match Directory / File, build state, insert_child_state
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Extract `ensure_parent_directories_exist` to `FileTreeEntry`
|
||||
|
||||
Currently a static method on `LocalRepoMetadataModel` (`local_model.rs:699`). Move it to `FileTreeEntry` so both the local apply path and the remote apply path can use it:
|
||||
|
||||
```rust
|
||||
impl FileTreeEntry {
|
||||
/// Ensures all ancestor directories between root and `target_parent`
|
||||
/// exist in the tree, creating unloaded directory entries as needed.
|
||||
pub fn ensure_parent_directories_exist(&mut self, target_parent: &Path) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
The existing `apply_file_tree_mutations` in `local_model.rs` is updated to call `root_entry.ensure_parent_directories_exist(parent)` instead of `Self::ensure_parent_directories_exist(root_entry, parent)`.
|
||||
|
||||
### 5. Forward `IncrementalUpdateReady` through the wrapper
|
||||
|
||||
Add a new variant to `RepoMetadataEvent` in `wrapper_model.rs`:
|
||||
|
||||
```rust
|
||||
pub enum RepoMetadataEvent {
|
||||
// ... existing variants ...
|
||||
IncrementalUpdateReady {
|
||||
update: RepoMetadataUpdate,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
And forward it in `forward_local_event`:
|
||||
|
||||
```rust
|
||||
RepositoryMetadataEvent::IncrementalUpdateReady { update } => {
|
||||
RepoMetadataEvent::IncrementalUpdateReady {
|
||||
update: update.clone(),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Crate structure update
|
||||
|
||||
```
|
||||
crates/repo_metadata/src/
|
||||
├── file_tree_update.rs (NEW — RepoMetadataUpdate and related types)
|
||||
├── file_tree_store.rs (MODIFIED — add apply_repo_metadata_update, ensure_parent_directories_exist)
|
||||
├── local_model.rs (MODIFIED — emit_incremental_updates flag, generate_repo_metadata_update)
|
||||
├── remote_model.rs (MODIFIED — apply_incremental_update)
|
||||
├── wrapper_model.rs (MODIFIED — forward IncrementalUpdateReady)
|
||||
└── lib.rs (MODIFIED — re-export new types)
|
||||
```
|
||||
|
||||
## End-to-End Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant W as File Watcher
|
||||
participant L as LocalRepoMetadataModel<br/>(remote server)
|
||||
participant N as Network Layer<br/>(future, out of scope)
|
||||
participant R as RemoteRepoMetadataModel<br/>(client)
|
||||
|
||||
W->>L: BulkFilesystemWatcherEvent
|
||||
L->>L: compute_file_tree_mutations() [bg thread]
|
||||
L->>L: apply_file_tree_mutations() [main thread]
|
||||
alt emit_incremental_updates = true
|
||||
L->>L: generate_repo_metadata_update()
|
||||
L-->>N: emit IncrementalUpdateReady { update }
|
||||
N-->>R: (transport — protobuf over SSH)
|
||||
R->>R: apply_incremental_update(update)
|
||||
R->>R: emit FileTreeEntryUpdated
|
||||
end
|
||||
```
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
1. **Mutation ordering**: `apply_repo_metadata_update` processes removals before additions. This matches the current `apply_file_tree_mutations` order, which is correct because a "move" is expressed as remove-old + add-new. If ordering changes in the local model, the serialization must preserve that order.
|
||||
|
||||
2. **`FileId` preservation**: When a file already exists in the remote tree (e.g., only its `ignored` flag changed), `apply_entry_update` preserves the existing `FileId` via `get_mut` + `set_ignored` rather than creating a new entry. New files get a fresh `FileId`. Cross-environment `FileId` equality is not guaranteed; if needed, `FileId` would be added to the proto.
|
||||
|
||||
3. **Entry flattening fidelity**: The `AddDirectorySubtree` → `FileTreeEntryUpdate` conversion must faithfully reproduce the recursive `Entry`'s parent→child and metadata structure. A mismatch would cause the client tree to diverge from the server. Unit tests comparing round-tripped trees mitigate this.
|
||||
|
||||
4. **Large updates**: A single watcher batch could touch many files (e.g., `git checkout` of a branch with many changes). The `RepoMetadataUpdate` for such a batch could be large. The proto schema supports pagination by tree depth (described in the parent design doc) but this spec does not implement it — the full batch is sent as one update. This can be revisited if bandwidth proves problematic.
|
||||
|
||||
## Testing and Validation
|
||||
|
||||
### Unit tests in `repo_metadata` crate
|
||||
|
||||
- **`generate_repo_metadata_update` tests**: Construct `FileTreeMutation` lists covering each variant (Remove, AddFile, AddDirectorySubtree, AddEmptyDirectory) → verify the resulting `RepoMetadataUpdate` has correct `remove_entries` and `update_entries` structure.
|
||||
- **`Entry` flattening round-trip**: Build a recursive `Entry`, flatten it via the `AddDirectorySubtree` conversion path, apply the resulting `FileTreeEntryUpdate` to an empty `FileTreeEntry`, and verify the tree matches the original.
|
||||
- **`apply_repo_metadata_update` tests on `FileTreeEntry`**: Start with a known tree state, apply a `RepoMetadataUpdate`, verify the resulting tree matches expectations (correct entries added, removed, parent→child relationships correct).
|
||||
- **`apply_incremental_update` on `RemoteRepoMetadataModel`**: Verify that applying an update emits `FileTreeEntryUpdated` and that `get_repository` returns the updated state.
|
||||
- **`ensure_parent_directories_exist` extraction**: Existing local model tests continue to pass after moving the helper to `FileTreeEntry`.
|
||||
- **Lazy-load filtering**: Mutations targeting unloaded parent directories are excluded from the applied list and therefore excluded from the generated update. Mutations targeting loaded parents pass through.
|
||||
|
||||
### Integration tests
|
||||
|
||||
- End-to-end test that creates a `LocalRepoMetadataModel` with `emit_incremental_updates = true`, triggers watcher events, captures the emitted `RepoMetadataUpdate`, applies it to a `RemoteRepoMetadataModel`, and verifies both models have equivalent tree state.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- **Proto definitions**: Define the actual `.proto` schema and implement `From`/`Into` conversions between the Rust types and generated proto types.
|
||||
- **Transport layer**: Wire the `IncrementalUpdateReady` event through the SSH protobuf stream.
|
||||
- **Initial full sync**: The initial tree sync (described in the parent design doc as `FetchInitialRepoMetadata`) uses the same `FileTreeEntryUpdate` shape but as a response rather than a push. Implement this as a separate request/response flow.
|
||||
- **Paginated sync**: Split large updates by tree depth for progressive rendering.
|
||||
- **Lazy loading over network**: The client's `load_directory` for remote repos needs a request/response cycle to the server, not covered here.
|
||||
@@ -0,0 +1,159 @@
|
||||
# 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<CanonicalizedPath, IndexedRepoState>` + 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<Gitignore>`, and an optional `ModelHandle<Repository>`.
|
||||
* **`FileTreeEntry`** (`file_tree_store.rs`) — wraps `FileTreeMapStore` (parent→children + path→metadata hash maps) plus a `root_path: Arc<Path>`.
|
||||
* **`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<RepositoryMetadataModel>`, 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<RemoteRepositoryIdentifier, IndexedRepoState>,
|
||||
}
|
||||
```
|
||||
#### 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<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) -> bool`
|
||||
* `repository_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, emits `RepositoryUpdated`.
|
||||
* `remove_repository(&mut self, id: &RemoteRepositoryIdentifier, ctx: &mut ModelContext<Self>)` — removes state, emits `RepositoryRemoved`.
|
||||
* `update_file_tree_entry(&mut self, id: &RemoteRepositoryIdentifier, entry: FileTreeEntry, ctx: &mut ModelContext<Self>)` — 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<LocalRepoMetadataModel>,
|
||||
remote: ModelHandle<RemoteRepoMetadataModel>,
|
||||
}
|
||||
```
|
||||
#### Construction
|
||||
```rust
|
||||
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
|
||||
```rust
|
||||
#[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) -> bool`
|
||||
* `repository_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 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<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) -> bool`
|
||||
* `find_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 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<RepositoryMetadataModel>` → `ModelHandle<RepoMetadataModel>`. 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<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 `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.
|
||||
Reference in New Issue
Block a user