//! Server-side diff state management. //! //! [`RemoteDiffStateManager`] is an entity that manages per-(repo, mode) //! `LocalDiffStateModel` instances and tracks which connections are subscribed //! to each. It owns model creation, event subscriptions, and content reload //! spawning. `ServerModel` subscribes to its `DiffStateUpdate` events to //! handle proto conversion and wire delivery. use std::collections::{HashMap, HashSet}; use std::sync::Arc; use itertools::Itertools; use warp_util::standardized_path::StandardizedPath; use warpui::r#async::SpawnedFutureHandle; use warpui::{AppContext, Entity, ModelContext, ModelHandle}; use super::protocol::RequestId; use super::server_model::ConnectionId; use crate::code_review::diff_state::{ BackendOrigin, DiffMetadata, DiffMode, DiffState, DiffStateModelEvent, FileDiffAndContent, GitDiffWithBaseContent, LocalDiffStateModel, }; // ── Key type ──────────────────────────────────────────────────────── /// Composite key: each (repo, mode) gets its own `LocalDiffStateModel`. #[derive(Hash, Eq, PartialEq, Clone, Debug)] pub(super) struct DiffModelKey { pub repo_path: StandardizedPath, pub mode: DiffMode, } // ── Pending response tracker ──────────────────────────────────────── /// Tracks a `GetDiffState` request that arrived while the model was still loading. /// The response is sent once `NewDiffsComputed` fires. pub(super) struct PendingDiffStateResponse { pub request_id: RequestId, pub conn_id: ConnectionId, } // ── Action / outcome types ───────────────────────────────────── /// Outcome of [`RemoteDiffStateManager::subscribe`]. #[allow(clippy::large_enum_variant)] pub(super) enum SubscribeOutcome { /// Respond with this snapshot immediately. RespondWithSnapshot { key: DiffModelKey, state: DiffState, metadata: Option, }, /// An async operation is in flight (content reload or model loading). /// The manager tracks spawned handles internally. Async, } /// Domain-level dispatch action returned by event processing and content /// reload completion. Proto conversion is handled by `ServerModel` at /// dispatch time. pub(super) enum DiffStateUpdate { /// Build and send a snapshot to subscribers. Entries with a `request_id` /// receive a `GetDiffStateResponse`; entries without receive a /// server-initiated push `DiffStateSnapshot`. Snapshot { repo_path: String, mode: DiffMode, state: DiffState, metadata: Option, diffs: Option>, /// Each subscriber is a connection plus an optional request ID. /// `Some(request_id)` → pending `GetDiffState` response (sent with request_id). /// `None` → already-subscribed connection that receives a server-initiated push. subscribers: Vec<(ConnectionId, Option)>, }, /// Build and send a metadata update to all subscribers. MetadataUpdate { repo_path: StandardizedPath, mode: DiffMode, metadata: DiffMetadata, subscribers: Vec, }, /// Build and send a single-file delta to all subscribers. FileDelta { repo_path: StandardizedPath, mode: DiffMode, /// Repo-relative path for the changed file. path: String, diff: Option>, metadata: Option, subscribers: Vec, }, } // ── RemoteDiffStateManager ──────────────────────────────────────── /// Manages the lifecycle of server-side `LocalDiffStateModel` instances and /// per-connection subscription tracking. /// /// A model is created when the first `GetDiffState` arrives for a given key /// and dropped when the last connection unsubscribes (or disconnects). pub(super) struct RemoteDiffStateManager { /// One model per (repo, mode). Mode is immutable — pinned at construction. states: HashMap>, /// Per-key set of subscribed connections. key_to_connections: HashMap>, /// Pending `GetDiffState` responses waiting for the model to finish loading. pending_responses: HashMap>, /// In-progress content reload handles, keyed by request ID. in_progress: HashMap, } impl Entity for RemoteDiffStateManager { type Event = DiffStateUpdate; } impl RemoteDiffStateManager { pub fn new() -> Self { Self { states: HashMap::new(), key_to_connections: HashMap::new(), pending_responses: HashMap::new(), in_progress: HashMap::new(), } } // ── Model CRUD ────────────────────────────────────────────────── pub fn get_model(&self, key: &DiffModelKey) -> Option<&ModelHandle> { self.states.get(key) } pub fn insert_model(&mut self, key: DiffModelKey, model: ModelHandle) { self.states.insert(key, model); } pub fn remove_model(&mut self, key: &DiffModelKey) { self.states.remove(key); self.pending_responses.remove(key); self.key_to_connections.remove(key); } /// Reads the current `DiffState` and cloned `DiffMetadata` from the model /// for `key`. Returns `None` when the model is absent. pub fn read_state_and_metadata( &self, key: &DiffModelKey, app: &AppContext, ) -> Option<(DiffState, Option)> { self.states.get(key).map(|model| { let m = model.as_ref(app); (m.get(), m.metadata().cloned()) }) } // ── Connection subscription tracking ──────────────────────────── /// Records that `conn_id` is subscribed to `key`. pub fn subscribe_connection(&mut self, key: DiffModelKey, conn_id: ConnectionId) { self.key_to_connections .entry(key) .or_default() .insert(conn_id); } /// Removes `conn_id`'s subscription for `key`. /// If the key has zero remaining subscribers the model is dropped inline. pub fn unsubscribe_connection(&mut self, key: &DiffModelKey, conn_id: ConnectionId) { if let Some(pending) = self.pending_responses.get_mut(key) { pending.retain(|p| p.conn_id != conn_id); } if let Some(connections) = self.key_to_connections.get_mut(key) { connections.remove(&conn_id); if connections.is_empty() { self.remove_model(key); } } } /// Removes all subscriptions for a disconnected connection. /// Orphaned models (no remaining subscribers) are dropped inline. pub fn remove_connection(&mut self, conn_id: ConnectionId) { let keys = self .key_to_connections .iter() .filter(|(_, conns)| conns.contains(&conn_id)) .map(|(key, _)| key.clone()) .collect_vec(); for key in keys { self.unsubscribe_connection(&key, conn_id); } } /// Returns the connection IDs subscribed to `key`. pub fn subscribed_connections(&self, key: &DiffModelKey) -> Vec { self.key_to_connections .get(key) .map(|conns| conns.iter().copied().collect()) .unwrap_or_default() } // ── Pending response tracking ─────────────────────────────────── /// Returns `true` if there are pending responses queued for `key`. pub fn has_pending_responses(&self, key: &DiffModelKey) -> bool { self.pending_responses .get(key) .is_some_and(|v| !v.is_empty()) } /// Registers a pending `GetDiffState` response to be sent once the model loads. pub fn add_pending_response( &mut self, key: DiffModelKey, request_id: RequestId, conn_id: ConnectionId, ) { self.pending_responses .entry(key) .or_default() .push(PendingDiffStateResponse { request_id, conn_id, }); } /// Drains all pending responses for `key`. pub fn drain_pending_responses(&mut self, key: &DiffModelKey) -> Vec { self.pending_responses.remove(key).unwrap_or_default() } // ── High-level operations ──────────────────────────────────── /// Handles a `GetDiffState` subscription request. /// /// Subscribes the connection, looks up or creates the model, and returns /// an outcome describing the result. When a content reload is needed it /// is spawned internally; when a new model is created the event /// subscription is wired up internally. pub fn subscribe( &mut self, repo_path: StandardizedPath, mode: DiffMode, request_id: &RequestId, conn_id: ConnectionId, ctx: &mut ModelContext, ) -> SubscribeOutcome { let key = DiffModelKey { repo_path, mode }; self.subscribe_connection(key.clone(), conn_id); if let Some(model) = self.get_model(&key) { let model_ref = model.as_ref(ctx); let state = model_ref.get(); match state { DiffState::Loaded => { let already_in_flight = self.has_pending_responses(&key); self.add_pending_response(key.clone(), request_id.clone(), conn_id); if !already_in_flight { self.spawn_content_reload(key, request_id, ctx); } SubscribeOutcome::Async } DiffState::Error(_) | DiffState::NotInRepository => { SubscribeOutcome::RespondWithSnapshot { key, state, metadata: model_ref.metadata().cloned(), } } DiffState::Loading | DiffState::Disconnected => { self.add_pending_response(key, request_id.clone(), conn_id); SubscribeOutcome::Async } } } else { // Model doesn't exist — create it and wire up event subscription. let repo_path_str = key.repo_path.to_string(); let mode = key.mode.clone(); let model = ctx.add_model(|ctx| { let mut m = LocalDiffStateModel::new(Some(repo_path_str), BackendOrigin::RemoteDaemon, ctx); m.set_diff_mode(mode, false, false, ctx); m.set_code_review_metadata_refresh_enabled(true, ctx); m }); self.insert_model(key.clone(), model.clone()); self.add_pending_response(key.clone(), request_id.clone(), conn_id); let key_for_sub = key; ctx.subscribe_to_model(&model, move |me, _, event, ctx| { me.handle_model_event(&key_for_sub, event, ctx); }); SubscribeOutcome::Async } } /// Processes a `DiffStateModelEvent`, builds domain-level dispatch /// actions, and emits them as entity events for `ServerModel` to handle. fn handle_model_event( &mut self, key: &DiffModelKey, event: &DiffStateModelEvent, ctx: &mut ModelContext, ) { match event { DiffStateModelEvent::NewDiffsComputed { diffs, .. } => { let Some((state, metadata)) = self.read_state_and_metadata(key, ctx) else { log::warn!("NewDiffsComputed for absent model key={key:?}"); return; }; let pending = self.drain_pending_responses(key); let responded_conns: HashSet = pending.iter().map(|p| p.conn_id).collect(); let mut subscribers: Vec<(ConnectionId, Option)> = pending .into_iter() .map(|p| (p.conn_id, Some(p.request_id))) .collect(); subscribers.extend( self.subscribed_connections(key) .into_iter() .filter(|c| !responded_conns.contains(c)) .map(|c| (c, None)), ); ctx.emit(DiffStateUpdate::Snapshot { repo_path: key.repo_path.to_string(), mode: key.mode.clone(), state, metadata, diffs: diffs.clone(), subscribers, }); } DiffStateModelEvent::MetadataRefreshed(metadata) => { ctx.emit(DiffStateUpdate::MetadataUpdate { repo_path: key.repo_path.clone(), mode: key.mode.clone(), metadata: metadata.as_ref().clone(), subscribers: self.subscribed_connections(key), }); } DiffStateModelEvent::CurrentBranchChanged => { let Some(model) = self.get_model(key) else { return; }; let Some(metadata) = model.as_ref(ctx).metadata() else { return; }; ctx.emit(DiffStateUpdate::MetadataUpdate { repo_path: key.repo_path.clone(), mode: key.mode.clone(), metadata: metadata.clone(), subscribers: self.subscribed_connections(key), }); } DiffStateModelEvent::SingleFileUpdated { path, diff } => { let metadata = self .get_model(key) .and_then(|m| m.as_ref(ctx).metadata().cloned()); ctx.emit(DiffStateUpdate::FileDelta { repo_path: key.repo_path.clone(), mode: key.mode.clone(), path: path.clone(), diff: diff.clone(), metadata, subscribers: self.subscribed_connections(key), }); } DiffStateModelEvent::ConnectionLost => { // Client-only event — should not occur on the server side. log::warn!("Unexpected ConnectionLost event on server-side model key={key:?}"); } DiffStateModelEvent::BranchesReceived(_) | DiffStateModelEvent::GitOpCompleted(_) | DiffStateModelEvent::CommitMessageGenerated(_) | DiffStateModelEvent::BranchCommittedFilesReceived(_) => { // Client-only events don't go through this tracker. } } } /// Reads model state, drains pending responses, and emits a `Snapshot` /// dispatch so `ServerModel` can deliver the results to waiting clients. fn resolve_pending_responses( &mut self, key: &DiffModelKey, diffs: Option, ctx: &mut ModelContext, ) { let Some((state, metadata)) = self.read_state_and_metadata(key, ctx) else { log::warn!("Content reload completed for absent model key={key:?}"); return; }; let diffs_arc = diffs.map(Arc::new); let subscribers = self .drain_pending_responses(key) .into_iter() .map(|p| (p.conn_id, Some(p.request_id))) .collect(); ctx.emit(DiffStateUpdate::Snapshot { repo_path: key.repo_path.to_string(), mode: key.mode.clone(), state, metadata, diffs: diffs_arc, subscribers, }); } /// Spawns an async diff reload with `content_at_base` for late-joining subscribers. fn spawn_content_reload( &mut self, key: DiffModelKey, request_id: &RequestId, ctx: &mut ModelContext, ) { let diff_mode = key.mode.clone(); let repo_path = std::path::PathBuf::from(key.repo_path.as_str()); let resolve_id = request_id.clone(); let abort_id = request_id.clone(); let abort_key = key.clone(); let handle = ctx.spawn_abortable( async move { LocalDiffStateModel::load_diffs_with_content_for_mode(diff_mode, repo_path).await }, move |me, diffs, ctx| { me.in_progress.remove(&resolve_id); me.resolve_pending_responses(&key, diffs, ctx); }, move |me, ctx| { log::info!("Request cancelled (request_id={abort_id})"); me.in_progress.remove(&abort_id); // Drain pending responses with current state instead of orphaning them. me.resolve_pending_responses(&abort_key, None, ctx); }, ); self.in_progress.insert(request_id.clone(), handle); } /// Cancels an in-progress content reload, if one exists for this request. /// Returns `true` if a request was found and aborted. pub fn abort_request(&mut self, request_id: &RequestId) -> bool { if let Some(handle) = self.in_progress.remove(request_id) { handle.abort(); true } else { false } } /// Removes a specific pending response by request_id across all keys. /// Called by `handle_abort` when the client times out a request. /// Returns `true` if a pending response was found and removed. pub fn abort_pending_response(&mut self, request_id: &RequestId) -> bool { for pending in self.pending_responses.values_mut() { if let Some(pos) = pending.iter().position(|p| &p.request_id == request_id) { pending.remove(pos); return true; } } false } } #[cfg(test)] #[path = "diff_state_tracker_tests.rs"] mod tests;