first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -0,0 +1,472 @@
|
||||
//! 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<DiffMetadata>,
|
||||
},
|
||||
/// 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<DiffMetadata>,
|
||||
diffs: Option<Arc<GitDiffWithBaseContent>>,
|
||||
/// 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<RequestId>)>,
|
||||
},
|
||||
/// Build and send a metadata update to all subscribers.
|
||||
MetadataUpdate {
|
||||
repo_path: StandardizedPath,
|
||||
mode: DiffMode,
|
||||
metadata: DiffMetadata,
|
||||
subscribers: Vec<ConnectionId>,
|
||||
},
|
||||
/// 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<Arc<FileDiffAndContent>>,
|
||||
metadata: Option<DiffMetadata>,
|
||||
subscribers: Vec<ConnectionId>,
|
||||
},
|
||||
}
|
||||
|
||||
// ── 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<DiffModelKey, ModelHandle<LocalDiffStateModel>>,
|
||||
/// Per-key set of subscribed connections.
|
||||
key_to_connections: HashMap<DiffModelKey, HashSet<ConnectionId>>,
|
||||
/// Pending `GetDiffState` responses waiting for the model to finish loading.
|
||||
pending_responses: HashMap<DiffModelKey, Vec<PendingDiffStateResponse>>,
|
||||
/// In-progress content reload handles, keyed by request ID.
|
||||
in_progress: HashMap<RequestId, SpawnedFutureHandle>,
|
||||
}
|
||||
|
||||
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<LocalDiffStateModel>> {
|
||||
self.states.get(key)
|
||||
}
|
||||
|
||||
pub fn insert_model(&mut self, key: DiffModelKey, model: ModelHandle<LocalDiffStateModel>) {
|
||||
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<DiffMetadata>)> {
|
||||
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<ConnectionId> {
|
||||
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<PendingDiffStateResponse> {
|
||||
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<Self>,
|
||||
) -> 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<Self>,
|
||||
) {
|
||||
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<ConnectionId> =
|
||||
pending.iter().map(|p| p.conn_id).collect();
|
||||
let mut subscribers: Vec<(ConnectionId, Option<RequestId>)> = 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<GitDiffWithBaseContent>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
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<Self>,
|
||||
) {
|
||||
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;
|
||||
Reference in New Issue
Block a user