16 KiB
Remote Server File Tree Protocol — Tech Spec
Linear: APP-3788
Problem
The remote server binary (crates/remote_server) currently only handles Initialize/InitializeResponse. We need to:
- Boot repo metadata models on the server so it can index directories and keep file trees up to date
- Let the client tell the server which directories to index (via
NavigatedToDirectory) - Let the client fetch the initial tree and receive subsequent incremental updates as push messages
Current State
Remote server (crates/remote_server)
ServerModelsingleton handles stdin/stdout protobuf I/Orun()boots a headless warpui app with onlyServerModel- Proto schema has only
Initialize/InitializeResponse
repo_metadata crate
LocalRepoMetadataModel— indexes repos, subscribes toDetectedRepositoriesfor auto-indexing, hasemit_incremental_updates: boolfield and emitsIncrementalUpdateReadywhen enabledDetectedRepositoriessingleton — runs async git detection viadetect_possible_git_repo(), emitsDetectedGitRepoevents. UsesDirectoryWatcherto register watch directories.DirectoryWatchersingleton — manages filesystem watchers and routes changes toRepositorysubscribers via aTaskQueueLocalRepoMetadataModelalso supports lazy-loaded non-git directories viaindex_lazy_loaded_path()(first-level-only tree,loaded: falseon subdirectories)- The incremental update types (
RepoMetadataUpdate,FileTreeEntryUpdate, etc.) already exist infile_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.
// ── 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:
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:
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 }:
- 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) - If a git repo was found (
Some(git_root)):- Full indexing was already triggered by the
DetectedGitRepo→LocalRepoMetadataModelsubscription insidedetect_possible_git_repo - Respond with
{ indexed_path: git_root, is_git: true } - Client waits for
RepositoryIndexedPushbefore callingFetchFileTree
- Full indexing was already triggered by the
- 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
FetchFileTreeimmediately
- Call
FetchFileTree
When the server receives FetchFileTree { repo_path }:
- Look up the repository in
LocalRepoMetadataModelviaget_repository(&repo_path) - If
Indexed: serialize the fullFileTreeEntryas one or moreFetchFileTreeResponsechunks (see section on streaming pagination below) - If
Pending: returnErrorResponse— the client retries after receivingRepositoryIndexedPush - If
Failedor not found: returnErrorResponse
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:
- Convert the
RepoMetadataUpdatetoFileTreeUpdatePushproto - Send as a
ServerMessagewith emptyrequest_id
4. Conversion layer: Rust types ↔ Proto
Add a new module crates/remote_server/src/file_tree_proto.rs with:
RepoMetadataUpdate→FileTreeUpdatePushprotoFileTreeEntry→FetchFileTreeResponseproto (full tree serialization with chunking)- Proto
FetchFileTreeResponse/FileTreeUpdatePush→RepoMetadataUpdatefor 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 ServerMessages. For push messages (empty request_id), route to event emission instead of completing a pending request:
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:
- 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) - If git repo found: respond with
{ indexed_path: git_root, is_git: true }. Full indexing was already triggered byDetectedGitRepo→LocalRepoMetadataModel. Client waits forRepositoryIndexedPushbeforeFetchFileTree. - If no git repo: server calls
index_lazy_loaded_path(path)for first-level data, responds with{ indexed_path: path, is_git: false }. Client callsFetchFileTreeimmediately.
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:
- Server serializes the tree top-to-bottom (breadth-first or depth-first pre-order)
- Each
FetchFileTreeResponsechunk contains a batch of entries plus abool sync_completeflag - The server segments by a target byte budget per chunk (e.g. 256KB)
- 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
RemoteRepoMetadataModelandFileTreeView LoadDirectoryrequest for expanding collapsed directories over the network- Subscription management (unsubscribe from updates when file tree is closed)