Files
galaxy/app/src/code_review/diff_state/remote.rs
T

879 lines
34 KiB
Rust

//! Remote diff state model.
//!
//! Client-side model for a single remote repository diff state subscription
//! received from the remote server. Presents the same read API as
//! `LocalDiffStateModel` and emits the same `DiffStateModelEvent` variants.
//!
//! The active [`DiffMode`] can change; the model handles this by unsubscribing
//! from the old `(repo_path, mode)` subscription and re-subscribing with the
//! new mode.
use std::sync::Arc;
use galaxy_core::{send_telemetry_from_ctx, HostId, SessionId};
use instant::Instant;
use remote_server::manager::{RemoteServerManager, RemoteServerManagerEvent};
use warp_util::remote_path::RemotePath;
use warp_util::standardized_path::StandardizedPath;
use warpui::{ModelContext, SingletonEntity};
use super::{
BackendOrigin, CommitChainMode, DiffMetadata, DiffMode, DiffOperation, DiffState,
DiffStateError, DiffStateModelEvent, DiffStats, FileDiffAndContent, GitDiffData,
GitDiffWithBaseContent,
};
use crate::code_review::telemetry_event::CodeReviewTelemetryEvent;
use crate::remote_server::diff_state_proto::{try_decode_file_delta, try_decode_snapshot};
use crate::remote_server::proto;
use crate::util::git::{BranchEntry, Commit, FileChangeEntry, PrInfo};
// ── Internal state ────────────────────────────────────────────────
#[derive(Default)]
enum InternalRemoteDiffState {
#[default]
Loading,
NotInRepository,
Loaded(GitDiffData),
Error(String),
/// The remote connection was lost. Preserves stale data until the model
/// can re-establish the server-side subscription.
Disconnected,
}
// ── Model ────────────────────────────────────────────────────────────────────
pub struct RemoteDiffStateModel {
remote_path: RemotePath,
mode: DiffMode,
state: InternalRemoteDiffState,
metadata: Option<DiffMetadata>,
/// Start time for the latest caller-tracked full diff snapshot request.
tracked_diff_load_start_time: Option<Instant>,
}
impl warpui::Entity for RemoteDiffStateModel {
type Event = DiffStateModelEvent;
}
impl RemoteDiffStateModel {
/// Creates a new remote diff state model.
///
/// Identity is `(host_id, repo_path, mode)`. The model is session-agnostic:
/// the manager resolves a connected session for the host on every outbound
/// RPC, and host-level connect/disconnect events drive subscription
/// lifecycle.
///
/// `preferred_session` is the session that opened this review (the
/// triggering callsite). It is used only for the *initial* `GetDiffState`
/// dispatch and is deliberately not stored: a shared, long-lived model
/// must not pin a session, and later re-triggers supply their own session
/// (or `None`) rather than reusing a stale one.
///
/// A session for this host is required at construction time. The model starts in `Loading` and
/// issues the initial `GetDiffState` request. Runtime disconnects transition the model through
/// `mark_disconnected`; subsequent reconnects re-subscribe via the `HostConnected` event handler.
pub fn new(
remote_path: RemotePath,
mode: DiffMode,
preferred_session: Option<SessionId>,
ctx: &mut ModelContext<Self>,
) -> Self {
// Subscribe to RemoteServerManager push events and filter by remote_path and diff_mode
let mgr_handle = RemoteServerManager::handle(ctx);
ctx.subscribe_to_model(&mgr_handle, |me, _, event, ctx| {
me.handle_manager_event(event, ctx)
});
let host_id = remote_path.host_id.clone();
let repo_path = remote_path.path.clone();
let mode_clone = mode.clone();
mgr_handle.update(ctx, |mgr, ctx| {
mgr.get_diff_state(
host_id,
repo_path,
proto::DiffMode::from(&mode_clone),
preferred_session,
ctx,
);
});
Self {
remote_path,
mode,
state: InternalRemoteDiffState::Loading,
metadata: None,
tracked_diff_load_start_time: None,
}
}
// ── Event handler ───────────────────────────────────────────
fn matches_remote_path_and_mode(
&self,
host_id: &HostId,
repo_path: &StandardizedPath,
mode: &proto::DiffMode,
) -> bool {
let remote_mode = proto::DiffMode::from(&self.mode);
self.remote_path.matches(host_id, repo_path) && mode == &remote_mode
}
fn handle_manager_event(
&mut self,
event: &RemoteServerManagerEvent,
ctx: &mut ModelContext<Self>,
) {
match event {
RemoteServerManagerEvent::DiffStateSnapshotReceived {
host_id,
repo_path,
mode,
snapshot,
} => {
if !self.matches_remote_path_and_mode(host_id, repo_path, mode) {
return;
}
self.handle_snapshot_received(snapshot, ctx);
}
RemoteServerManagerEvent::DiffStateMetadataUpdateReceived {
host_id,
repo_path,
mode,
update,
} => {
if !self.matches_remote_path_and_mode(host_id, repo_path, mode) {
return;
}
self.handle_metadata_update_received(update, ctx);
}
RemoteServerManagerEvent::DiffStateFileDeltaReceived {
host_id,
repo_path,
mode,
delta,
} => {
if !self.matches_remote_path_and_mode(host_id, repo_path, mode) {
return;
}
self.handle_file_delta_received(delta, ctx);
}
RemoteServerManagerEvent::GetBranchesResponse {
repo_path, result, ..
} if repo_path == &self.remote_path.path => {
let branches = match result {
Ok(branch_infos) => branch_infos
.iter()
.map(|info| BranchEntry {
name: info.name.clone(),
is_main: info.is_main,
})
.collect(),
Err(err) => {
log::warn!("RemoteDiffStateModel: GetBranches failed: {err}");
vec![]
}
};
ctx.emit(DiffStateModelEvent::BranchesReceived(branches));
}
RemoteServerManagerEvent::CommitChainResponse {
host_id,
repo_path,
result,
} if self.remote_path.matches(host_id, repo_path) => {
self.handle_git_commit_chain_response(result, ctx);
}
RemoteServerManagerEvent::GitPushResponse {
host_id,
repo_path,
result,
} if self.remote_path.matches(host_id, repo_path) => {
self.handle_git_push_response(result, ctx);
}
RemoteServerManagerEvent::CreatePrResponse {
host_id,
repo_path,
result,
} if self.remote_path.matches(host_id, repo_path) => {
self.handle_create_pr_response(result, ctx);
}
RemoteServerManagerEvent::GenerateCommitMessageResponse {
host_id,
repo_path,
result,
} if self.remote_path.matches(host_id, repo_path) => {
// AI ran on the daemon; just relay the result to the dialog.
ctx.emit(DiffStateModelEvent::CommitMessageGenerated(result.clone()));
}
RemoteServerManagerEvent::GetCommittedBranchFilesResponse {
host_id,
repo_path,
result,
} if self.remote_path.matches(host_id, repo_path) => {
self.handle_get_committed_branch_files_response(result, ctx);
}
RemoteServerManagerEvent::HostDisconnected { host_id }
if host_id == &self.remote_path.host_id =>
{
self.mark_disconnected(ctx);
}
RemoteServerManagerEvent::HostConnected { host_id }
if host_id == &self.remote_path.host_id
&& matches!(self.state, InternalRemoteDiffState::Disconnected) =>
{
// Reconnect is event-driven with no viewing-session in scope
// (and the prior session may be gone), so re-subscribe over
// any connected session for the host.
self.resubscribe(false, None, ctx);
}
_ => {}
}
}
/// Marks the model as disconnected, preserving any stale data and
/// emitting `ConnectionLost`.
fn mark_disconnected(&mut self, ctx: &mut ModelContext<Self>) {
if matches!(self.state, InternalRemoteDiffState::Disconnected) {
return;
}
self.tracked_diff_load_start_time = None;
self.state = InternalRemoteDiffState::Disconnected;
ctx.emit(DiffStateModelEvent::ConnectionLost);
}
/// Re-sends `GetDiffState` for this model's `(host_id, repo, mode)` and
/// transitions to `Loading` while waiting for a fresh snapshot.
///
/// `preferred_session` is supplied by the triggering callsite (the
/// session-scoped view) so the request rides the connection that needs the
/// result; `None` (e.g. reconnect) falls back to any connected session.
fn resubscribe(
&mut self,
track_load_duration: bool,
preferred_session: Option<SessionId>,
ctx: &mut ModelContext<Self>,
) {
// Always overwrite to avoid carrying a stale `Instant` from a prior
// tracked load that was interrupted by a session blip.
self.tracked_diff_load_start_time = track_load_duration.then(Instant::now);
let host_id = self.remote_path.host_id.clone();
let repo_path = self.remote_path.path.clone();
let mode = self.mode.clone();
RemoteServerManager::handle(ctx).update(ctx, |mgr, ctx| {
mgr.get_diff_state(
host_id,
repo_path,
proto::DiffMode::from(&mode),
preferred_session,
ctx,
);
});
self.state = InternalRemoteDiffState::Loading;
ctx.emit(DiffStateModelEvent::NewDiffsComputed {
diffs: None,
load_duration: None,
});
}
// ── Proto → state conversion helpers ────────────────────────────────────────────────
fn handle_snapshot_received(
&mut self,
snapshot: &proto::DiffStateSnapshot,
ctx: &mut ModelContext<Self>,
) {
match try_decode_snapshot(snapshot) {
Ok((metadata, state, diffs)) => self.apply_snapshot(metadata, state, diffs, ctx),
Err(error) => {
self.tracked_diff_load_start_time = None;
galaxy_core::safe_error!(
safe: ("RemoteDiffStateModel: failed to decode diff state snapshot"),
full: ("RemoteDiffStateModel: failed to decode diff state snapshot: {error}")
);
}
}
}
fn handle_metadata_update_received(
&mut self,
update: &proto::DiffStateMetadataUpdate,
ctx: &mut ModelContext<Self>,
) {
match update
.metadata
.as_ref()
.map(DiffMetadata::try_from)
.transpose()
{
Ok(Some(metadata)) => self.apply_metadata_update(&metadata, ctx),
Ok(None) => {}
Err(error) => {
galaxy_core::safe_error!(
safe: ("RemoteDiffStateModel: failed to decode diff state metadata update"),
full: ("RemoteDiffStateModel: failed to decode diff state metadata update: {error}")
);
}
}
}
fn handle_file_delta_received(
&mut self,
delta: &proto::DiffStateFileDelta,
ctx: &mut ModelContext<Self>,
) {
match try_decode_file_delta(delta) {
Ok((file_path, diff, metadata)) => {
self.apply_file_delta(file_path, diff, metadata, ctx)
}
Err(error) => {
galaxy_core::safe_error!(
safe: ("RemoteDiffStateModel: failed to decode diff state file delta"),
full: ("RemoteDiffStateModel: failed to decode diff state file delta: {error}")
);
}
}
}
// ── Apply methods ──────────────────────────────────────────────────────
/// Requests a fresh diff snapshot from the remote server, including file
/// content. Unlike the former `replay_latest_diffs` (which reconstructed
/// data from cached `GitDiffData` and lost `content_at_head`), this sends
/// an actual `GetDiffState` RPC so the server can reload content from disk.
///
/// Does NOT transition to `Loading` or emit `NewDiffsComputed(None)` first,
/// so existing views subscribed to this model won't flash a loading state.
/// The server response arrives as a `DiffStateSnapshotReceived` event and
/// flows through `apply_snapshot` normally.
pub(crate) fn fetch_fresh_snapshot(
&mut self,
track_load_duration: bool,
preferred_session: Option<SessionId>,
ctx: &mut ModelContext<Self>,
) {
if track_load_duration {
self.tracked_diff_load_start_time = Some(Instant::now());
}
let host_id = self.remote_path.host_id.clone();
let repo_path = self.remote_path.path.clone();
let mode = self.mode.clone();
// `preferred_session` is supplied per-call by the triggering view (the
// session showing the review); `None` falls back to any connected
// session for the host. Never cached on this shared model.
RemoteServerManager::handle(ctx).update(ctx, |mgr, ctx| {
mgr.get_diff_state(
host_id,
repo_path,
proto::DiffMode::from(&mode),
preferred_session,
ctx,
);
});
}
fn apply_snapshot(
&mut self,
metadata: Option<DiffMetadata>,
state: DiffState,
diffs: Option<GitDiffWithBaseContent>,
ctx: &mut ModelContext<Self>,
) {
// Update metadata, detecting branch changes.
if let Some(metadata) = &metadata {
self.apply_metadata_update(metadata, ctx);
}
// Update state.
match state {
// Disconnected is never produced by proto deserialization.
DiffState::Disconnected => {}
DiffState::NotInRepository => {
self.tracked_diff_load_start_time = None;
self.state = InternalRemoteDiffState::NotInRepository;
ctx.emit(DiffStateModelEvent::NewDiffsComputed {
diffs: None,
load_duration: None,
});
}
DiffState::Loading => {
self.state = InternalRemoteDiffState::Loading;
ctx.emit(DiffStateModelEvent::NewDiffsComputed {
diffs: None,
load_duration: None,
});
}
DiffState::Error(msg) => {
let load_duration = self
.tracked_diff_load_start_time
.take()
.map(|start| start.elapsed());
let err = DiffStateError::from_message(&msg);
err.report_and_log();
send_telemetry_from_ctx!(
CodeReviewTelemetryEvent::LoadDiffFailed {
backend_origin: BackendOrigin::ClientRemote,
operation: DiffOperation::RemoteDiff,
mode: self.mode.clone(),
error: err.to_string(),
load_duration,
},
ctx
);
self.state = InternalRemoteDiffState::Error(msg);
ctx.emit(DiffStateModelEvent::NewDiffsComputed {
diffs: None,
load_duration: None,
});
}
DiffState::Loaded => {
let Some(base_content) = diffs else {
let load_duration = self
.tracked_diff_load_start_time
.take()
.map(|start| start.elapsed());
let err = DiffStateError::empty_diff_data();
err.report_and_log();
send_telemetry_from_ctx!(
CodeReviewTelemetryEvent::LoadDiffFailed {
backend_origin: BackendOrigin::ClientRemote,
operation: DiffOperation::RemoteDiff,
mode: self.mode.clone(),
error: err.to_string(),
load_duration,
},
ctx
);
self.state = InternalRemoteDiffState::Error(err.to_string());
ctx.emit(DiffStateModelEvent::NewDiffsComputed {
diffs: None,
load_duration: None,
});
return;
};
let diffs = GitDiffData::from(&base_content);
let load_duration = self
.tracked_diff_load_start_time
.take()
.map(|start| start.elapsed());
self.state = InternalRemoteDiffState::Loaded(diffs);
ctx.emit(DiffStateModelEvent::NewDiffsComputed {
diffs: Some(Arc::new(base_content)),
load_duration,
});
}
}
}
fn apply_metadata_update(&mut self, metadata: &DiffMetadata, ctx: &mut ModelContext<Self>) {
let previous_branch = self
.metadata
.as_ref()
.map(|m| m.current_branch_name.as_str());
let branch_changed =
previous_branch.is_some_and(|prev| prev != metadata.current_branch_name.as_str());
let metadata = metadata.clone();
self.metadata = Some(metadata.clone());
// Only emit CurrentBranchChanged when there was a previous branch to
// compare against. On the first metadata update (initial snapshot)
// previous_branch is None — that's initial population, not a switch.
if branch_changed {
ctx.emit(DiffStateModelEvent::CurrentBranchChanged);
}
ctx.emit(DiffStateModelEvent::MetadataRefreshed(Box::new(metadata)));
}
fn apply_file_delta(
&mut self,
file_path: String,
diff: Option<FileDiffAndContent>,
metadata: Option<DiffMetadata>,
ctx: &mut ModelContext<Self>,
) {
if let Some(metadata) = &metadata {
self.apply_metadata_update(metadata, ctx);
}
let InternalRemoteDiffState::Loaded(ref mut diffs) = self.state else {
// Ignore file deltas until the initial snapshot has loaded.
return;
};
if let Some(ref new_diff) = diff {
if let Some(pos) = diffs.files.iter().position(|f| f.file_path == file_path) {
diffs.files[pos] = new_diff.file_diff.clone();
} else {
diffs.files.push(new_diff.file_diff.clone());
}
} else {
diffs.files.retain(|f| f.file_path != file_path);
}
diffs.total_additions = diffs.files.iter().map(|f| f.additions()).sum();
diffs.total_deletions = diffs.files.iter().map(|f| f.deletions()).sum();
diffs.files_changed = diffs.files.len();
ctx.emit(DiffStateModelEvent::SingleFileUpdated {
path: file_path,
diff: diff.map(Arc::new),
});
}
// ── Cleanup ──────────────────────────────────────────────────────
/// Sends `UnsubscribeDiffState` to the server. Call before dropping the
/// model (the wrapper calls it during mode switch / pane close).
pub fn unsubscribe(&self, ctx: &mut ModelContext<Self>) {
RemoteServerManager::handle(ctx)
.as_ref(ctx)
.unsubscribe_diff_state(
self.remote_path.host_id.clone(),
&self.remote_path.path,
proto::DiffMode::from(&self.mode),
);
}
// ── Read API (matching LocalDiffStateModel interface) ────────────
pub fn get(&self) -> DiffState {
match &self.state {
InternalRemoteDiffState::NotInRepository => DiffState::NotInRepository,
InternalRemoteDiffState::Loading => DiffState::Loading,
InternalRemoteDiffState::Loaded(_) => DiffState::Loaded,
InternalRemoteDiffState::Error(msg) => DiffState::Error(msg.clone()),
InternalRemoteDiffState::Disconnected => DiffState::Disconnected,
}
}
pub fn diff_mode(&self) -> DiffMode {
self.mode.clone()
}
pub fn get_uncommitted_stats(&self) -> Option<DiffStats> {
self.metadata
.as_ref()
.map(|m| m.against_head.aggregate_stats)
}
/// Per-file entries for uncommitted-vs-HEAD changes, from synced metadata.
pub fn uncommitted_file_entries(&self) -> &[FileChangeEntry] {
self.metadata
.as_ref()
.map(|m| m.against_head.files.as_slice())
.unwrap_or(&[])
}
pub fn get_main_branch_name(&self) -> Option<String> {
self.metadata
.as_ref()
.map(|m| m.main_branch_name.clone())
.filter(|s| !s.is_empty())
}
pub fn get_current_branch_name(&self) -> Option<String> {
self.metadata
.as_ref()
.map(|m| m.current_branch_name.clone())
.filter(|s| !s.is_empty())
}
pub fn is_on_main_branch(&self) -> bool {
self.metadata.as_ref().is_some_and(|m| {
!m.current_branch_name.is_empty() && m.current_branch_name == m.main_branch_name
})
}
pub fn unpushed_commits(&self) -> &[Commit] {
self.metadata
.as_ref()
.map(|m| m.unpushed_commits.as_slice())
.unwrap_or(&[])
}
pub fn upstream_ref(&self) -> Option<&str> {
self.metadata
.as_ref()
.and_then(|m| m.upstream_ref.as_deref())
}
pub fn upstream_differs_from_main(&self) -> bool {
match (self.upstream_ref(), self.get_main_branch_name().as_deref()) {
(Some(upstream), Some(main)) => upstream != main,
_ => false,
}
}
pub fn has_head(&self) -> bool {
self.metadata.as_ref().is_some_and(|m| m.has_head_commit)
}
pub fn remote_path(&self) -> RemotePath {
self.remote_path.clone()
}
// ── Git operation event handlers ─────────────────────────────────
/// Converts a proto `GitOpDelta` to domain types and applies it through
/// the shared `apply_git_op_delta` (the single delta-application path), so
/// the proto-driven and domain-driven callers stay in sync.
fn apply_delta_from_proto(
&mut self,
delta: &remote_server::proto::GitOpDelta,
ctx: &mut ModelContext<Self>,
) {
let commits = delta.unpushed_commits.iter().map(Commit::from).collect();
self.apply_git_op_delta(commits, delta.upstream_ref.clone(), ctx);
}
fn handle_git_commit_chain_response(
&mut self,
result: &Result<remote_server::manager::CommitChainSuccess, String>,
ctx: &mut ModelContext<Self>,
) {
let domain_result = match result {
Ok(success) => {
// Apply the delta before emitting the completion event so the
// header updates immediately. PR info is returned in the event
// result and refreshed through the shared `GitHubRepoModel`.
let commits = success
.delta
.unpushed_commits
.iter()
.map(Commit::from)
.collect();
let pr_info = success.pr_info.as_ref().map(PrInfo::from);
let metadata = self.metadata.get_or_insert_with(DiffMetadata::default);
metadata.unpushed_commits = commits;
metadata.upstream_ref = success.delta.upstream_ref.clone();
ctx.emit(DiffStateModelEvent::MetadataRefreshed(Box::new(
metadata.clone(),
)));
Ok(pr_info)
}
Err(msg) => Err(msg.clone()),
};
ctx.emit(DiffStateModelEvent::GitOpCompleted(
super::GitOpResult::CommitChainCompleted(domain_result),
));
}
fn handle_git_push_response(
&mut self,
result: &Result<remote_server::proto::GitOpDelta, String>,
ctx: &mut ModelContext<Self>,
) {
let domain_result = match result {
Ok(delta) => {
self.apply_delta_from_proto(delta, ctx);
Ok(())
}
Err(msg) => Err(msg.clone()),
};
ctx.emit(DiffStateModelEvent::GitOpCompleted(
super::GitOpResult::PushCompleted(domain_result),
));
}
fn handle_create_pr_response(
&mut self,
result: &Result<remote_server::proto::PrInfo, String>,
ctx: &mut ModelContext<Self>,
) {
let domain_result = match result {
Ok(proto_pr) => Ok(PrInfo::from(proto_pr)),
Err(msg) => Err(msg.clone()),
};
ctx.emit(DiffStateModelEvent::GitOpCompleted(
super::GitOpResult::PrCreated(domain_result),
));
}
/// Handles a `GetCommittedBranchFilesResponse`: converts the proto entries
/// to domain types and emits `BranchCommittedFilesReceived` for the Create
/// PR dialog's Changes box. On error, logs and emits an empty list so the
/// dialog renders an empty box rather than showing stale data.
fn handle_get_committed_branch_files_response(
&self,
result: &Result<Vec<remote_server::proto::FileChangeEntry>, String>,
ctx: &mut ModelContext<Self>,
) {
let files = match result {
Ok(files) => files.iter().map(FileChangeEntry::from).collect(),
Err(msg) => {
log::warn!("RemoteDiffStateModel: GetCommittedBranchFiles failed: {msg}");
Vec::new()
}
};
ctx.emit(DiffStateModelEvent::BranchCommittedFilesReceived(files));
}
// ── Remote git operations (async; results arrive via manager events) ──
//
// Each dispatches via `RemoteServerManager` and returns immediately; the
// response lands as a manager event in `handle_manager_event`, which
// converts it into the corresponding `DiffStateModelEvent`.
/// Runs a commit chain via the remote server manager. The result
/// arrives as a `CommitChainResponse` manager event, handled above.
#[allow(clippy::too_many_arguments)]
pub fn git_commit_chain(
&self,
mode: CommitChainMode,
message: String,
include_unstaged: bool,
branch: String,
autogenerate_pr_content: bool,
ctx: &mut ModelContext<Self>,
) {
let host_id = self.remote_path.host_id.clone();
let repo_path = self.remote_path.path.clone();
RemoteServerManager::handle(ctx).update(ctx, |mgr, ctx| {
mgr.git_commit_chain(
host_id,
repo_path,
proto::GitCommitChainMode::from(&mode),
message,
include_unstaged,
branch,
autogenerate_pr_content,
ctx,
);
});
}
/// Issues an AI commit-message generation request via the remote server
/// manager. The result arrives as a `GenerateCommitMessageResponse`
/// manager event, handled in `handle_manager_event`.
pub fn generate_commit_message(
&self,
include_unstaged: bool,
branch_name: String,
ctx: &mut ModelContext<Self>,
) {
let host_id = self.remote_path.host_id.clone();
let repo_path = self.remote_path.path.clone();
RemoteServerManager::handle(ctx).update(ctx, |mgr, ctx| {
mgr.git_generate_commit_message(host_id, repo_path, include_unstaged, branch_name, ctx);
});
}
/// Fetches the committed branch files (`merge_base(HEAD, main)..HEAD`) for
/// the current branch via the remote `GitGetCommittedBranchFiles` RPC. The
/// result arrives as a `GetCommittedBranchFilesResponse` manager event,
/// handled in `handle_manager_event`, which emits
/// `BranchCommittedFilesReceived` for the Create PR dialog.
pub fn fetch_committed_branch_files(&self, ctx: &mut ModelContext<Self>) {
let host_id = self.remote_path.host_id.clone();
let repo_path = self.remote_path.path.clone();
RemoteServerManager::handle(ctx).update(ctx, |mgr, ctx| {
mgr.git_get_committed_branch_files(host_id, repo_path, ctx);
});
}
/// Pushes the branch via the remote server manager.
pub fn git_push(&self, branch: String, ctx: &mut ModelContext<Self>) {
let host_id = self.remote_path.host_id.clone();
let repo_path = self.remote_path.path.clone();
RemoteServerManager::handle(ctx).update(ctx, |mgr, ctx| {
mgr.git_push_branch(host_id, repo_path, branch, ctx);
});
}
/// Creates a PR via the remote server manager. When `autogenerate_content`
/// is set, the daemon AI-generates the PR title/body (falling back to
/// `gh pr create --fill`); `branch` is passed as context for that generation.
#[allow(clippy::too_many_arguments)]
pub fn create_pr(
&self,
branch: String,
autogenerate_content: bool,
ctx: &mut ModelContext<Self>,
) {
let host_id = self.remote_path.host_id.clone();
let repo_path = self.remote_path.path.clone();
RemoteServerManager::handle(ctx).update(ctx, |mgr, ctx| {
mgr.git_create_pr(host_id, repo_path, branch, autogenerate_content, ctx);
});
}
// ── Write API ────────────────────────────────────────────────────
pub fn set_diff_mode(
&mut self,
mode: DiffMode,
track_load_duration: bool,
preferred_session: Option<SessionId>,
ctx: &mut ModelContext<Self>,
) {
if self.mode == mode {
return;
}
// Unsubscribe from the old mode before switching, then re-send
// GetDiffState for the new mode over `preferred_session` (the
// triggering view's session) when provided, else any connected
// session for the host.
self.unsubscribe(ctx);
self.mode = mode;
self.resubscribe(track_load_duration, preferred_session, ctx);
}
/// Fetches branches for the remote repository via the `GetBranches` RPC.
/// The response is handled in `handle_manager_event` which emits
/// `DiffStateModelEvent::BranchesReceived`.
pub fn fetch_branches(&self, ctx: &mut ModelContext<Self>) {
let host_id = self.remote_path.host_id.clone();
let repo_path = self.remote_path.path.clone();
RemoteServerManager::handle(ctx).update(ctx, |mgr, ctx| {
mgr.get_branches(host_id, repo_path, None, false, ctx);
});
}
/// Sends a `DiscardFiles` request to the remote server.
/// The server's watcher will push updated diff snapshots on success.
pub fn discard_files(
&self,
file_infos: Vec<super::FileStatusInfo>,
should_stash: bool,
branch_name: Option<String>,
ctx: &mut ModelContext<Self>,
) {
let host_id = self.remote_path.host_id.clone();
let repo_path = self.remote_path.path.clone();
let mode = self.mode.clone();
let proto_files = file_infos.iter().map(proto::FileStatusInfo::from).collect();
RemoteServerManager::handle(ctx).update(ctx, |mgr, ctx| {
mgr.discard_files(
host_id,
repo_path,
proto_files,
should_stash,
branch_name,
proto::DiffMode::from(&mode),
ctx,
);
});
}
/// Applies a post-git-operation delta (refreshed unpushed commits +
/// upstream ref returned by the daemon) to the cached metadata and emits
/// `MetadataRefreshed`, so the code review header updates immediately
/// rather than waiting for the next server-pushed snapshot.
pub fn apply_git_op_delta(
&mut self,
unpushed_commits: Vec<Commit>,
upstream_ref: Option<String>,
ctx: &mut ModelContext<Self>,
) {
let metadata = self.metadata.get_or_insert_with(DiffMetadata::default);
metadata.unpushed_commits = unpushed_commits;
metadata.upstream_ref = upstream_ref;
ctx.emit(DiffStateModelEvent::MetadataRefreshed(Box::new(
metadata.clone(),
)));
}
}
#[cfg(test)]
#[path = "remote_tests.rs"]
mod remote_tests;