15 KiB
Remote DiffStateModel
Linear: APP-4351
Context
DiffStateModel (app/src/code_review/diff_state.rs) is a per-repo model that owns a Repository handle, fs watcher subscription, diff loading, metadata refresh, and mode selection. It emits DiffStateModelEvent with four variants: CurrentBranchChanged, NewDiffsComputed, SingleFileUpdated, MetadataRefreshed. CodeReviewView subscribes to these events and renders diffs identically regardless of how they were produced.
WorkingDirectoriesModel (app/src/pane_group/working_directories.rs) stores diff_state_models: HashMap<PathBuf, ModelHandle<DiffStateModel>> and lazily creates models via get_or_create_diff_state_model. CodeReviewView holds a ModelHandle<DiffStateModel> obtained from this map.
The remote server protocol (crates/remote_server/proto/remote_server.proto) uses length-prefixed protobuf over SSH stdio. Push events flow through ClientEvent → RemoteServerManager::forward_client_event → RemoteServerManagerEvent → app-layer subscribers. ServerModel (app/src/remote_server/server_model.rs) is the daemon-side orchestrator that dispatches ClientMessages and sends ServerMessage responses and pushes.
Today DiffStateModel only works with local git repositories. To support code review on remote environments (SSH sessions), we need to split the model into local/remote variants behind a wrapper, add proto messages for diff state exchange, build a server-side GlobalDiffStateModel that manages diff state per (repo, mode), and implement a client-side RemoteDiffStateModel that receives server pushes.
Proposed changes
1. Split DiffStateModel into wrapper + local + remote
Refactor app/src/code_review/diff_state.rs into a module directory app/src/code_review/diff_state/:
mod.rs—DiffStateModelwrapper holding aDiffStateBackendenum (Local/Remote), delegating every read/write method to the active sub-model.local.rs—LocalDiffStateModel(renamed from the currentDiffStateModel), retaining all existing behavior.remote.rs—RemoteDiffStateModel, initially a no-op stub with defaults for every read method.
The wrapper subscribes to the active sub-model and re-emits events via forward_event. CodeReviewView subscribes to the wrapper and renders diffs identically regardless of which backend is active. DiffStateModelEvent keeps its name — it's shared between local and remote.
Additional mechanical changes in the split:
- Wrap
NewDiffsComputedpayload inArc:Option<GitDiffWithBaseContent>→Option<Arc<GitDiffWithBaseContent>>for cheap cloning during event forwarding. - Simplify
DiffState::Loadedto a unit variant (no innerGitDiffData). The diff payload is accessed viaDiffStateModel::get()or arrives in the event itself. - Update
WorkingDirectoriesModel: cache key changes fromHashMap<PathBuf, ...>toHashMap<BufferLocation, ...>, andget_or_create_diff_state_modeltakesBufferLocationinstead ofPathBuf. All call sites wrap paths inBufferLocation::Local(...).BufferLocation(app/src/code/buffer_location.rs) already hasLocal(PathBuf)andRemote(RemotePath)variants withHash + Eq. - All callers in
code_review_view.rs,code_review_header/mod.rs,right_panel.rspassctxto wrapper methods (the wrapper needsAppContextto dereference its innerModelHandle).
2. Proto messages
Add to crates/remote_server/proto/remote_server.proto.
Client → Server:
GetDiffState { repo_path, mode }— request/response. The server responds with aGetDiffStateResponse(snapshot or error), then pushes subsequent changes. Follows theNavigatedToDirectory→NavigatedToDirectoryResponse+RepoMetadataSnapshotpattern.UnsubscribeDiffState { repo_path, mode }— notification (fire-and-forget). Tells the server the client no longer needs updates for this (repo, mode).DiscardFilesRequest { repo_path, files, should_stash, branch_name?, mode }— request/response. Runsgit restore/git stash/git rmon the remote filesystem for the specified files.filesis a list ofFileStatusInfo { path, status }.should_stashcontrols whether changes are stashed (recoverable) or discarded.branch_namespecifies the branch to restore against (absent means HEAD).modeidentifies which(repo, mode)diff state model the server should use — the server looks up the exact model viaDiffModelKeyrather than picking an arbitrary model for the repo.
Server → Client:
GetDiffStateResponse—oneof result { DiffStateSnapshot snapshot, DiffStateError error }. Matches theWriteFileResponse/RunCommandResponsepattern.DiffStateSnapshot(push) — full state for a (repo, mode). Includes metadata + fullGitDiffData. Pushed on structural changes (NewDiffsComputed).DiffStateMetadataUpdate(push) — metadata-only update forMetadataRefreshedevents. Avoids re-serializing the entire diff payload on every 5-second throttled refresh.DiffStateFileDelta(push) — single-file diff update forSingleFileUpdatedevents. Carries oneFileDiff+ file path + updated metadata. Debounced at 2s on the server.DiscardFilesResponse—oneof result { DiscardFilesSuccess, DiscardFilesError }. Returned after processing aDiscardFilesRequest.
Wire into ClientMessage.oneof (field numbers 18–20) and ServerMessage.oneof (field numbers 18–22). Client: get_diff_state = 18, unsubscribe_diff_state = 19, discard_files = 20. Server: get_diff_state_response = 18, diff_state_snapshot = 19, diff_state_metadata_update = 20, diff_state_file_delta = 21, discard_files_response = 22. Current max field numbers: ClientMessage = 17 (ResolveConflict), ServerMessage = 17 (ResolveConflictResponse).
Sub-messages mirror the Rust domain types with two notable divergences:
DiffModeandGitFileStatususeoneof(with per-variant wrapper messages) instead of the Rustenum+ struct pattern, sinceoneofmaps more faithfully to Rust tagged enums with per-variant data.FileDiffcollapsesFileDiff+FileDiffAndContentinto a single message with anoptional string content_at_basefield. The Rust split exists for memory reasons (!Cloneon the content-carrying variant); on the wire, every context that sends aFileDiffalso requires base content for editor rendering (set_base), so there's no case where the field is absent by design — only absent for binary files or failedgit show.
Conversion lives in a new diff_state_proto.rs, following the repo_metadata_proto.rs pattern.
No RefreshDiffMetadata message — the server pushes metadata changes automatically via its watcher, matching the RepoMetadata pattern.
No ChangeDiffMode message — mode changes are handled client-side: RemoteDiffStateModel.set_diff_mode() sends UnsubscribeDiffState for the old mode, resets internal state, then sends GetDiffState for the new mode (see §5). The server's per-model mode remains immutable (shared across connections), but the client model manages the transition internally.
Rust wire types (in a new shared module, e.g. diff_state_wire.rs — both client and server need these types):
/// Wire payload for a full diff state snapshot (after routing).
pub struct DiffStateSnapshotData {
pub metadata: Option<DiffMetadata>,
pub state: DiffState,
pub diffs: Option<GitDiffData>, // present when state is Loaded
}
/// Wire payload for a single-file diff delta (after routing).
pub struct DiffStateFileDeltaData {
pub path: PathBuf,
pub diff: Option<FileDiff>,
pub metadata: Option<DiffMetadata>,
}
No new state enum — the existing DiffState has the right variants (NotInRepository, Loading, Error(String), Loaded). After §1's changes, Loaded is a unit variant (no inner data); the diff payload is carried separately in DiffStateSnapshotData.diffs. The proto oneof state mirrors the variants directly.
Wire ↔ event type bridging. The wire FileDiff includes content_at_base (the file content at HEAD or merge-base), which the Rust side splits into FileDiff + FileDiffAndContent. On receipt, the diff_state_proto.rs conversion layer reconstructs FileDiffAndContent { file_diff, content_at_head: proto.content_at_base } from each wire FileDiff. The RemoteDiffStateModel wraps the full payload in Arc before emitting NewDiffsComputed(Some(Arc::new(...))). This means remote code review editors receive base content eagerly — the same as local — and can call set_base() immediately without a separate RPC.
3. Server-side GlobalDiffStateModel
New file: app/src/remote_server/diff_state_tracker.rs.
#[derive(Hash, Eq, PartialEq, Clone)]
struct DiffModelKey {
repo_path: StandardizedPath,
mode: DiffMode,
}
pub struct GlobalDiffStateModel {
states: HashMap<DiffModelKey, ModelHandle<LocalDiffStateModel>>,
/// key → connections: used for push fan-out and orphan detection.
key_to_connections: HashMap<DiffModelKey, HashSet<ConnectionId>>,
}
DiffModelKey uses StandardizedPath (not RepositoryIdentifier) because the server daemon only manages local-to-the-remote-host repositories — the Remote variant of RepositoryIdentifier is never used on the server side. This matches the existing ServerModel convention where per-connection state is keyed on StandardizedPath (e.g. snapshot_sent_roots_by_connection).
Per-(repo, mode) models with immutable mode. The server keys models on (repo_path, mode).
Lifecycle:
GetDiffStatearrives as a request.GlobalDiffStateModellooks up or creates aLocalDiffStateModelfor the key. If already loaded, responds immediately. If loading, usesctx.spawnto respond onceNewDiffsComputedfires. ReusesRepositoryhandles fromDetectedRepositories(already detected by priorNavigatedToDirectory). If no repository has been detected yet (e.g.GetDiffStatearrives beforeNavigatedToDirectory), responds withDiffStateError— the client retries afterNavigatedToDirectorycompletes.- After responding, subsequent model events are pushed to subscribed connections only via
send_to_diff_state_subscribers(key, msg), which looks upconnections_by_key[key]. Targeted sends avoid broadcasting large diff payloads (~500KB–2MB). UnsubscribeDiffStatecallsunsubscribe_connectionfor the specific key. If no subscribers remain, the model is dropped.remove_connection(conn_id)iterateskey_to_connectionsto find all keys the connection belongs to and callsunsubscribe_connectionfor each, dropping orphaned models.
Event → push mapping:
NewDiffsComputed→ fullDiffStateSnapshotMetadataRefreshed→DiffStateMetadataUpdate(metadata only, no diffs)CurrentBranchChanged→DiffStateMetadataUpdate(metadata only — diffs for the new branch haven't been computed yet;NewDiffsComputedfollows with actual diffs)SingleFileUpdated→DiffStateFileDelta(debounced at 2s)
4. RemoteDiffStateModel implementation
Fill in the no-op stub in diff_state/remote.rs (created in §1) to:
- Hold
repo_id: RepositoryIdentifier(alwaysRemotevariant),mode: DiffMode(mutable),state: DiffState,metadata: Option<DiffMetadata>. - Apply incoming
DiffStateSnapshotData,DiffStateMetadataUpdate, andDiffStateFileDeltaDatafrom server pushes. - Reconstruct
FileDiffAndContent { file_diff, content_at_head }from wireFileDiff(extractingcontent_at_base→content_at_head) and wrapGitDiffData→Arc<GitDiffWithBaseContent>before emitting events. - Emit
DiffStateModelEventvariants matching the server push mapping (§3). - Own the subscribe/unsubscribe lifecycle for mode changes via
set_diff_mode()(see §5).
DiffMode is mutable on the client-side RemoteDiffStateModel, matching LocalDiffStateModel's existing pattern where set_diff_mode mutates the mode field in place and triggers a reload. The server-side model remains immutable (keyed on (repo_path, mode) and shared across connections), but that constraint doesn't apply to the per-client remote model. This keeps the wrapper's delegation symmetric between Local and Remote backends, and avoids the need to destroy/recreate the model handle on mode changes (which would require re-wiring event subscriptions and introduces subscribe-before-request race conditions).
Required read API surface (defined in the wrapper's delegation interface):
- Core:
get(),diff_mode(),get_current_branch_name(),get_main_branch_name(),get_stats_for_current_mode(),get_uncommitted_stats(),has_head() - Git operations (stubs for v1 —
GitOperationsInCodeReviewwon't be enabled for remote):is_git_operation_blocked(),pr_info(),is_pr_info_refreshing(),is_on_main_branch(),unpushed_commits(),upstream_ref(),upstream_differs_from_main() - Mutations:
set_diff_mode(),load_diffs_for_current_repo(),set_code_review_metadata_refresh_enabled(),discard_files(),refresh_metadata_and_pr_info()
For v1, mutation methods that are local-only (load_diffs_for_current_repo, refresh_metadata_and_pr_info, set_code_review_metadata_refresh_enabled) remain no-ops on RemoteDiffStateModel — the server drives all state.
5. Mode changes and unsubscribe
RemoteDiffStateModel.set_diff_mode() handles mode transitions internally, mirroring how LocalDiffStateModel.set_diff_mode() mutates mode and triggers a reload:
- Sends
UnsubscribeDiffState { repo_path, mode: old_mode }to the server. - Updates
self.modeto the new mode. - Resets internal state:
self.state = DiffState::Loading, clearsself.diffsandself.metadata. - Emits
NewDiffsComputed(None)so the view shows a loading spinner. - Sends
GetDiffState { repo_path, mode: new_mode }to the server. - Server responds → model applies the snapshot, transitions to
Loaded, emitsNewDiffsComputed(Some(...)).
Since the ModelHandle never changes, the wrapper's event subscription (set up once at construction) remains valid across mode changes — no re-wiring needed.
Unsubscribe cases: code review pane close, mode change, repo change (cycling), connection drop, drop_unused_diff_state_models (tab close).
6. New ClientEvent / RemoteServerManagerEvent variants
ClientEvent carries raw proto-derived data (StandardizedPath, DiffMode). forward_client_event in RemoteServerManager attaches host_id, constructs RepositoryIdentifier::Remote(...), and emits the corresponding manager event. This follows the RepoMetadataSnapshotReceived pattern.
Three new variants each for ClientEvent and RemoteServerManagerEvent:
DiffStateSnapshotReceivedDiffStateMetadataUpdateReceivedDiffStateFileDeltaReceived
push_message_to_event in RemoteServerClient maps the new ServerMessage variants to ClientEvent variants.
7. WorkingDirectoriesModel integration
After §1's cache key migration, get_or_create_diff_state_model accepts BufferLocation and the map uses HashMap<BufferLocation, ModelHandle<DiffStateModel>>. When a BufferLocation::Remote(...) is passed, DiffStateModel::new (the wrapper constructor) creates a RemoteDiffStateModel in Loading state, subscribes to its events, and sends GetDiffState to the server — mirroring how the Local branch creates and subscribes to LocalDiffStateModel today.