//! Unified diff state module.//!//! [`DiffStateModel`] is an enum that provides a unified API over local and remote models.//! It holds one of [`LocalDiffStateModel`] or [`RemoteDiffStateModel`] and dispatches//! operations to whichever is active.//! All consumers should use `DiffStateModel` rather than accessing sub-models directly. use std::path::PathBuf;use std::sync::Arc;use std::time::Duration; use anyhow::Result;use serde::{Deserialize, Serialize};use warp_core::SessionId;use warp_util::remote_path::RemotePath;use warp_util::standardized_path::StandardizedPath;use warpui::{AppContext, ModelContext, ModelHandle}; use crate::code_review::diff_size_limits::DiffSize;use crate::util::git::{BranchEntry, Commit, FileChangeEntry, PrInfo};#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]mod local;pub use local::LocalDiffStateModel;#[cfg(feature = "local_fs")]pub(crate) use local::diff_metadata_against_head; mod remote;pub use remote::RemoteDiffStateModel; #[cfg_attr(not(feature = "local_fs"), allow(dead_code))]mod error;pub(crate) use error::DiffStateError; /// What to chain after a commit: commit only, commit + push, or commit + push/// + create-PR. The single shared commit-chain vocabulary, used end to end: the/// commit dialog stores the user's selection as this, both the local/// (`git_actions::run_commit_chain`) and remote (`DiffStateModel::git_commit_chain`)/// backends accept it, and it's converted to the wire enum/// (`proto::GitCommitChainMode`) at the manager boundary via the `From` impl in/// the `diff_state_proto` module.#[allow(clippy::enum_variant_names)] // `Commit` prefix is intentional: every chain starts with a commit.#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub enum CommitChainMode { CommitOnly, CommitAndPush, CommitAndCreatePr,} /// Identifies the host of a [`DiffStateModel`] so failure telemetry can be/// attributed to where the model actually ran. This is more specific than the/// local/remote split already encoded by `is_local`: a [`LocalDiffStateModel`]/// can be instantiated on the user's client (`ClientLocal`) or on a remote/// daemon (`RemoteDaemon`) serving subscribers, and only the host knows which.#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]pub enum BackendOrigin { /// `LocalDiffStateModel` running on the user's client against local files. #[serde(rename = "client_local")] ClientLocal, /// `RemoteDiffStateModel` running on the user's client; talks to a daemon. #[serde(rename = "client_remote")] ClientRemote, /// `LocalDiffStateModel` running on a remote daemon, serving subscribers. #[serde(rename = "remote_daemon")] RemoteDaemon,} /// Identifies the diff-state operation that produced a [`DiffStateError`]/// on the `LoadDiffFailed` telemetry path. Carried alongside the error so/// failures can be sliced by originating operation — every operation shares/// the same failure pool, so the error variant alone doesn't reveal where/// it came from.////// Metadata-load failures are reported through a dedicated/// `LoadMetadataFailed` event and therefore don't need a variant here.#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]pub enum DiffOperation { /// Per-file diff refresh triggered by the file-invalidation queue. #[serde(rename = "file_invalidation")] FileInvalidation, /// Full repo-wide diff snapshot load. #[serde(rename = "diff_load")] DiffLoad, /// Client-side reaction to a remote daemon's diff-state response. #[serde(rename = "remote_diff")] RemoteDiff,} // -- Shared types ────────────────────────────────────────────────────── /// Represents the status of a file in the git working directory/// This matches Git Desktop's AppFileStatusKind enum#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]pub enum GitFileStatus { New, Modified, Deleted, Renamed { old_path: String }, Copied { old_path: String }, Untracked, Conflicted,} impl GitFileStatus { pub fn is_renamed(&self) -> bool { matches!(self, Self::Renamed { .. }) } pub fn is_new_file(&self) -> bool { matches!(self, Self::New | Self::Untracked) }} #[derive(Clone, Debug)]pub struct FileStatusInfo { pub path: StandardizedPath, pub status: GitFileStatus,} impl TryFrom<&str> for GitFileStatus { type Error = anyhow::Error; fn try_from(status_code: &str) -> Result<Self> { match status_code { ".M" | "M." | "MM" => Ok(GitFileStatus::Modified), ".A" | "A." | "AM" => Ok(GitFileStatus::New), ".D" | "D." | "AD" => Ok(GitFileStatus::Deleted), _ => Ok(GitFileStatus::Modified), // Default fallback } }} /// Represents a single line in a diff hunk, as rendered by `git diff`./// This matches Git Desktop's DiffLine structure.#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]pub struct DiffLine { pub line_type: DiffLineType, pub old_line_number: Option<usize>, pub new_line_number: Option<usize>, pub text: String, pub no_trailing_newline: bool,} #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]pub enum DiffLineType { Context, Add, Delete, HunkHeader,} /// Represents a hunk of changes in a file diff, as rendered by `git diff`,/// including the header and context lines before/after an insertion or/// deletion./// This matches Git Desktop's DiffHunk structure.#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]pub struct DiffHunk { pub old_start_line: usize, pub old_line_count: usize, pub new_start_line: usize, pub new_line_count: usize, pub lines: Vec<DiffLine>, pub unified_diff_start: usize, pub unified_diff_end: usize,} /// Represents the diff for a single file, as rendered by `git diff`./// This matches Git Desktop's FileDiff structure.#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]pub struct FileDiff { /// Repo-relative path for this diff file. Absolute file identities should use /// `StandardizedPath` or `LocalOrRemotePath` at API boundaries. pub file_path: String, pub status: GitFileStatus, pub hunks: Arc<Vec<DiffHunk>>, pub is_binary: bool, pub is_autogenerated: bool, pub max_line_number: usize, pub has_hidden_bidi_chars: bool, pub size: DiffSize,} impl FileDiff { pub fn is_empty(&self) -> bool { self.additions() == 0 && self.deletions() == 0 } /// Returns the number of added lines in this file diff pub fn additions(&self) -> usize { self.hunks .iter() .flat_map(|hunk| &hunk.lines) .filter(|line| line.line_type == DiffLineType::Add) .count() } /// Returns the number of deleted lines in this file diff pub fn deletions(&self) -> usize { self.hunks .iter() .flat_map(|hunk| &hunk.lines) .filter(|line| line.line_type == DiffLineType::Delete) .count() }} /// IMPORTANT: This struct contains expensive data like the full content of diff files/// at base. This should not be cloned at any time.#[derive(Debug)]pub struct FileDiffAndContent { pub file_diff: FileDiff, /// Full file content at the diff base (HEAD or merge-base), used by the /// code review editor to render inline diffs (`set_base`). /// /// `None` means no usable baseline exists and no editor is constructed: /// binary files, non-file entries (e.g. nested repo/worktree directories), /// failed `git show`, or content that was never loaded / was withheld on /// the wire (reconstruction from cached `GitDiffData`, over-budget files). /// /// `Some("")` means a baseline exists but is empty: new/untracked files /// that don't exist at the base (the diff correctly renders everything as /// additions) or files genuinely empty at the base commit. pub content_at_head: Option<String>,} /// IMPORTANT: This struct contains expensive data like the full content of diff files/// at base. This should not be cloned at any time.#[derive(Debug)]pub struct GitDiffWithBaseContent { pub files: Vec<FileDiffAndContent>, pub total_additions: usize, pub total_deletions: usize, pub files_changed: usize,} impl From<GitDiffWithBaseContent> for GitDiffData { fn from(value: GitDiffWithBaseContent) -> Self { Self { files: value.files.into_iter().map(|file| file.file_diff).collect(), total_additions: value.total_additions, total_deletions: value.total_deletions, files_changed: value.files_changed, } }} impl From<&GitDiffWithBaseContent> for GitDiffData { fn from(value: &GitDiffWithBaseContent) -> Self { Self { files: value .files .iter() .map(|file| file.file_diff.clone()) .collect(), total_additions: value.total_additions, total_deletions: value.total_deletions, files_changed: value.files_changed, } }} impl From<&GitDiffData> for GitDiffWithBaseContent { fn from(value: &GitDiffData) -> Self { Self { files: value .files .iter() .map(|file_diff| FileDiffAndContent { file_diff: file_diff.clone(), content_at_head: None, }) .collect(), total_additions: value.total_additions, total_deletions: value.total_deletions, files_changed: value.files_changed, } }} /// Represents the complete git diff information for a repository#[derive(Clone)]pub struct GitDiffData { pub files: Vec<FileDiff>, pub total_additions: usize, pub total_deletions: usize, pub files_changed: usize,} impl GitDiffData { pub fn is_dirty(&self) -> bool { self.total_additions + self.total_deletions + self.files_changed > 0 }} /// Some actions should only apply when a [`GitDiffData`] is dirty, i.e. not empty. This enum allows/// callers to express this preference.#[derive(Copy, Clone, Debug, Eq, PartialEq)]pub enum GitDeltaPreference { Always, OnlyDirty,} #[derive(Clone, Debug, PartialEq, Eq, Hash, Default, Serialize)]pub enum DiffMode { /// Show changes in working directory against latest commit (git diff) #[default] Head, /// Show changes in working directory against main branch (git diff $(git merge-base HEAD origin/master)) MainBranch, /// Show changes in working directory against an arbitrary branch (git diff $(git merge-base HEAD <branch>)) OtherBranch(#[serde(skip_serializing)] String),} impl DiffMode { /// Creates a DiffMode from a branch name. /// If the branch matches the repository's main branch, returns `MainBranch`; /// otherwise returns `OtherBranch(branch)`. pub fn from_branch(branch: &str, main_branch_name: Option<&str>) -> Self { if main_branch_name == Some(branch) { DiffMode::MainBranch } else { DiffMode::OtherBranch(branch.to_string()) } }} /// User-visible representation of the diffs we've loaded,/// which only includes changes against the specific base the user has selected.#[derive(Debug)]pub enum DiffState { NotInRepository, Loading, Error(String), Loaded, /// The remote connection was lost. The model will re-subscribe /// automatically when a session becomes available. Disconnected,} #[derive(Clone, Debug, Default)]pub struct DiffMetadata { pub main_branch_name: String, pub current_branch_name: String, pub against_head: DiffMetadataAgainstBase, pub against_base_branch: Option<DiffMetadataAgainstBase>, pub has_head_commit: bool, pub unpushed_commits: Vec<Commit>, pub upstream_ref: Option<String>,} #[derive(Clone, Default, Debug)]pub struct DiffMetadataAgainstBase { pub aggregate_stats: DiffStats, /// Per-file change entries (path + additions/deletions) for this base. /// Populated from the same numstat that produces `aggregate_stats`, so the /// git dialog's Changes box can render without a working-tree read — this /// is what lets the box populate for remote repos, where the list rides /// along in synced metadata. pub files: Vec<FileChangeEntry>,} impl DiffMetadataAgainstBase { pub fn is_dirty(&self) -> bool { !self.aggregate_stats.has_no_changes() }} #[derive(Debug, Copy, Clone, Default)]pub struct DiffStats { pub files_changed: usize, pub total_additions: usize, pub total_deletions: usize,} impl DiffStats { pub(crate) fn has_no_changes(&self) -> bool { self.files_changed == 0 }} #[derive(Debug)]pub enum DiffStateModelEvent { /// Event dispatched when the current branch changes. CurrentBranchChanged, /// Event dispatched when new diffs are computed (full reload). NewDiffsComputed { diffs: Option<Arc<GitDiffWithBaseContent>>, load_duration: Option<Duration>, }, /// Event dispatched when a single file's diff is updated incrementally. SingleFileUpdated { /// Repo-relative path for the updated file. path: String, diff: Option<Arc<FileDiffAndContent>>, }, /// Event dispatched when diff metadata (stats, branch info) is refreshed. MetadataRefreshed(Box<DiffMetadata>), /// The remote connection was lost. Stale diffs should be preserved while /// the model waits for a new subscription. ConnectionLost, /// Branch list received from the backend (local git or remote server). BranchesReceived(Vec<BranchEntry>), /// A remote git operation completed. The model has already applied any /// successful metadata delta to the cached metadata. GitOpCompleted(GitOpResult), /// An AI-generated commit message arrived from the remote daemon (issued /// at commit-dialog open). `Ok` carries the message, `Err` the error /// string. The `GitDialog` populates its message editor from this; the /// local path fills the editor directly without going through an event. CommitMessageGenerated(Result<String, String>), /// Committed branch files (`merge_base(HEAD, main)..HEAD`) arrived for the /// Create PR dialog's Changes box. Fetched on dialog open and delivered the /// same way for both backends: the local model computes them off-thread and /// emits this; the remote model emits it on the daemon's RPC response. BranchCommittedFilesReceived(Vec<FileChangeEntry>),} /// Result of a remote git operation, emitted via/// `DiffStateModelEvent::GitOpCompleted`. The model applies the post-op/// delta before emitting, so the dialog only handles UI concerns.#[derive(Debug, Clone)]pub enum GitOpResult { /// Commit chain completed. `Ok(Some(pr))` when create-PR was part of /// the chain; `Ok(None)` for commit-only or commit-and-push. CommitChainCompleted(Result<Option<PrInfo>, String>), /// Standalone push completed. PushCompleted(Result<(), String>), /// Standalone create-PR completed. PrCreated(Result<PrInfo, String>),} // ── Unified model ──────────────────────────────────────────────────────── /// Unified diff state model that dispatches to a local or remote backend.////// Only one variant is populated at a time, since a diff state belongs to/// exactly one repository (either local or remote). All consumers should/// interact with this enum rather than accessing sub-models directly.pub enum DiffStateModel { Local(ModelHandle<LocalDiffStateModel>), Remote(ModelHandle<RemoteDiffStateModel>),} impl warpui::Entity for DiffStateModel { type Event = DiffStateModelEvent;} impl DiffStateModel { // ── Construction ───────────────────────────────────────────────── /// Creates a new local-backed `DiffStateModel`. The wrapper subscribes /// to the inner model so it can forward events. pub fn new_local(path: PathBuf, ctx: &mut ModelContext<Self>) -> Self { let repo_path = Some(path.display().to_string()); let local = ctx .add_model(|ctx| LocalDiffStateModel::new(repo_path, BackendOrigin::ClientLocal, ctx)); ctx.subscribe_to_model(&local, |me, _, event, ctx| me.forward_event(event, ctx)); Self::Local(local) } /// Creates a new remote-backed `DiffStateModel`. The model is keyed by /// `(host_id, repo, mode)` and shared across sessions viewing the same /// repo. `preferred_session` is the session that opened this review (when /// known): `GetDiffState` is session-scoped, so the manager dispatches it /// over that session when it's connected and falls back to any connected /// session for the host otherwise. Callers must ensure a session for the /// host is connected before constructing. pub fn new_remote( remote_path: RemotePath, preferred_session: Option<SessionId>, ctx: &mut ModelContext<Self>, ) -> Self { let remote = ctx.add_model(|ctx| { RemoteDiffStateModel::new(remote_path, DiffMode::default(), preferred_session, ctx) }); ctx.subscribe_to_model(&remote, |me, _, event, ctx| me.forward_event(event, ctx)); Self::Remote(remote) } // ── Event forwarding ───────────────────────────────────────────── fn forward_event(&mut self, event: &DiffStateModelEvent, ctx: &mut ModelContext<Self>) { match event { DiffStateModelEvent::CurrentBranchChanged => { ctx.emit(DiffStateModelEvent::CurrentBranchChanged); } DiffStateModelEvent::NewDiffsComputed { diffs, load_duration, } => { ctx.emit(DiffStateModelEvent::NewDiffsComputed { diffs: diffs.clone(), load_duration: *load_duration, }); } DiffStateModelEvent::SingleFileUpdated { path, diff } => { ctx.emit(DiffStateModelEvent::SingleFileUpdated { path: path.clone(), diff: diff.clone(), });Lines 1–500 of 883. Load more
//! Unified diff state module. //! //! [`DiffStateModel`] is an enum that provides a unified API over local and remote models. //! It holds one of [`LocalDiffStateModel`] or [`RemoteDiffStateModel`] and dispatches //! operations to whichever is active. //! All consumers should use `DiffStateModel` rather than accessing sub-models directly. use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use anyhow::Result; use serde::{Deserialize, Serialize}; use warp_core::SessionId; use warp_util::remote_path::RemotePath; use warp_util::standardized_path::StandardizedPath; use warpui::{AppContext, ModelContext, ModelHandle}; use crate::code_review::diff_size_limits::DiffSize; use crate::util::git::{BranchEntry, Commit, FileChangeEntry, PrInfo}; #[cfg_attr(not(feature = "local_fs"), allow(dead_code))] mod local; pub use local::LocalDiffStateModel; #[cfg(feature = "local_fs")] pub(crate) use local::diff_metadata_against_head; mod remote; pub use remote::RemoteDiffStateModel; #[cfg_attr(not(feature = "local_fs"), allow(dead_code))] mod error; pub(crate) use error::DiffStateError; /// What to chain after a commit: commit only, commit + push, or commit + push /// + create-PR. The single shared commit-chain vocabulary, used end to end: the /// commit dialog stores the user's selection as this, both the local /// (`git_actions::run_commit_chain`) and remote (`DiffStateModel::git_commit_chain`) /// backends accept it, and it's converted to the wire enum /// (`proto::GitCommitChainMode`) at the manager boundary via the `From` impl in /// the `diff_state_proto` module. #[allow(clippy::enum_variant_names)] // `Commit` prefix is intentional: every chain starts with a commit. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CommitChainMode { CommitOnly, CommitAndPush, CommitAndCreatePr, } /// Identifies the host of a [`DiffStateModel`] so failure telemetry can be /// attributed to where the model actually ran. This is more specific than the /// local/remote split already encoded by `is_local`: a [`LocalDiffStateModel`] /// can be instantiated on the user's client (`ClientLocal`) or on a remote /// daemon (`RemoteDaemon`) serving subscribers, and only the host knows which. #[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)] pub enum BackendOrigin { /// `LocalDiffStateModel` running on the user's client against local files. #[serde(rename = "client_local")] ClientLocal, /// `RemoteDiffStateModel` running on the user's client; talks to a daemon. #[serde(rename = "client_remote")] ClientRemote, /// `LocalDiffStateModel` running on a remote daemon, serving subscribers. #[serde(rename = "remote_daemon")] RemoteDaemon, } /// Identifies the diff-state operation that produced a [`DiffStateError`] /// on the `LoadDiffFailed` telemetry path. Carried alongside the error so /// failures can be sliced by originating operation — every operation shares /// the same failure pool, so the error variant alone doesn't reveal where /// it came from. /// /// Metadata-load failures are reported through a dedicated /// `LoadMetadataFailed` event and therefore don't need a variant here. #[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)] #[cfg_attr(not(feature = "local_fs"), allow(dead_code))] pub enum DiffOperation { /// Per-file diff refresh triggered by the file-invalidation queue. #[serde(rename = "file_invalidation")] FileInvalidation, /// Full repo-wide diff snapshot load. #[serde(rename = "diff_load")] DiffLoad, /// Client-side reaction to a remote daemon's diff-state response. #[serde(rename = "remote_diff")] RemoteDiff, } // -- Shared types ────────────────────────────────────────────────────── /// Represents the status of a file in the git working directory /// This matches Git Desktop's AppFileStatusKind enum #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum GitFileStatus { New, Modified, Deleted, Renamed { old_path: String }, Copied { old_path: String }, Untracked, Conflicted, } impl GitFileStatus { pub fn is_renamed(&self) -> bool { matches!(self, Self::Renamed { .. }) } pub fn is_new_file(&self) -> bool { matches!(self, Self::New | Self::Untracked) } } #[derive(Clone, Debug)] pub struct FileStatusInfo { pub path: StandardizedPath, pub status: GitFileStatus, } impl TryFrom<&str> for GitFileStatus { type Error = anyhow::Error; fn try_from(status_code: &str) -> Result<Self> { match status_code { ".M" | "M." | "MM" => Ok(GitFileStatus::Modified), ".A" | "A." | "AM" => Ok(GitFileStatus::New), ".D" | "D." | "AD" => Ok(GitFileStatus::Deleted), _ => Ok(GitFileStatus::Modified), // Default fallback } } } /// Represents a single line in a diff hunk, as rendered by `git diff`. /// This matches Git Desktop's DiffLine structure. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct DiffLine { pub line_type: DiffLineType, pub old_line_number: Option<usize>, pub new_line_number: Option<usize>, pub text: String, pub no_trailing_newline: bool, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum DiffLineType { Context, Add, Delete, HunkHeader, } /// Represents a hunk of changes in a file diff, as rendered by `git diff`, /// including the header and context lines before/after an insertion or /// deletion. /// This matches Git Desktop's DiffHunk structure. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct DiffHunk { pub old_start_line: usize, pub old_line_count: usize, pub new_start_line: usize, pub new_line_count: usize, pub lines: Vec<DiffLine>, pub unified_diff_start: usize, pub unified_diff_end: usize, } /// Represents the diff for a single file, as rendered by `git diff`. /// This matches Git Desktop's FileDiff structure. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct FileDiff { /// Repo-relative path for this diff file. Absolute file identities should use /// `StandardizedPath` or `LocalOrRemotePath` at API boundaries. pub file_path: String, pub status: GitFileStatus, pub hunks: Arc<Vec<DiffHunk>>, pub is_binary: bool, pub is_autogenerated: bool, pub max_line_number: usize, pub has_hidden_bidi_chars: bool, pub size: DiffSize, } impl FileDiff { pub fn is_empty(&self) -> bool { self.additions() == 0 && self.deletions() == 0 } /// Returns the number of added lines in this file diff pub fn additions(&self) -> usize { self.hunks .iter() .flat_map(|hunk| &hunk.lines) .filter(|line| line.line_type == DiffLineType::Add) .count() } /// Returns the number of deleted lines in this file diff pub fn deletions(&self) -> usize { self.hunks .iter() .flat_map(|hunk| &hunk.lines) .filter(|line| line.line_type == DiffLineType::Delete) .count() } } /// IMPORTANT: This struct contains expensive data like the full content of diff files /// at base. This should not be cloned at any time. #[derive(Debug)] pub struct FileDiffAndContent { pub file_diff: FileDiff, /// Full file content at the diff base (HEAD or merge-base), used by the /// code review editor to render inline diffs (`set_base`). /// /// `None` means no usable baseline exists and no editor is constructed: /// binary files, non-file entries (e.g. nested repo/worktree directories), /// failed `git show`, or content that was never loaded / was withheld on /// the wire (reconstruction from cached `GitDiffData`, over-budget files). /// /// `Some("")` means a baseline exists but is empty: new/untracked files /// that don't exist at the base (the diff correctly renders everything as /// additions) or files genuinely empty at the base commit. pub content_at_head: Option<String>, } /// IMPORTANT: This struct contains expensive data like the full content of diff files /// at base. This should not be cloned at any time. #[derive(Debug)] pub struct GitDiffWithBaseContent { pub files: Vec<FileDiffAndContent>, pub total_additions: usize, pub total_deletions: usize, pub files_changed: usize, } impl From<GitDiffWithBaseContent> for GitDiffData { fn from(value: GitDiffWithBaseContent) -> Self { Self { files: value.files.into_iter().map(|file| file.file_diff).collect(), total_additions: value.total_additions, total_deletions: value.total_deletions, files_changed: value.files_changed, } } } impl From<&GitDiffWithBaseContent> for GitDiffData { fn from(value: &GitDiffWithBaseContent) -> Self { Self { files: value .files .iter() .map(|file| file.file_diff.clone()) .collect(), total_additions: value.total_additions, total_deletions: value.total_deletions, files_changed: value.files_changed, } } } impl From<&GitDiffData> for GitDiffWithBaseContent { fn from(value: &GitDiffData) -> Self { Self { files: value .files .iter() .map(|file_diff| FileDiffAndContent { file_diff: file_diff.clone(), content_at_head: None, }) .collect(), total_additions: value.total_additions, total_deletions: value.total_deletions, files_changed: value.files_changed, } } } /// Represents the complete git diff information for a repository #[derive(Clone)] pub struct GitDiffData { pub files: Vec<FileDiff>, pub total_additions: usize, pub total_deletions: usize, pub files_changed: usize, } impl GitDiffData { pub fn is_dirty(&self) -> bool { self.total_additions + self.total_deletions + self.files_changed > 0 } } /// Some actions should only apply when a [`GitDiffData`] is dirty, i.e. not empty. This enum allows /// callers to express this preference. #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum GitDeltaPreference { Always, OnlyDirty, } #[derive(Clone, Debug, PartialEq, Eq, Hash, Default, Serialize)] pub enum DiffMode { /// Show changes in working directory against latest commit (git diff) #[default] Head, /// Show changes in working directory against main branch (git diff $(git merge-base HEAD origin/master)) MainBranch, /// Show changes in working directory against an arbitrary branch (git diff $(git merge-base HEAD <branch>)) OtherBranch(#[serde(skip_serializing)] String), } impl DiffMode { /// Creates a DiffMode from a branch name. /// If the branch matches the repository's main branch, returns `MainBranch`; /// otherwise returns `OtherBranch(branch)`. pub fn from_branch(branch: &str, main_branch_name: Option<&str>) -> Self { if main_branch_name == Some(branch) { DiffMode::MainBranch } else { DiffMode::OtherBranch(branch.to_string()) } } } /// User-visible representation of the diffs we've loaded, /// which only includes changes against the specific base the user has selected. #[derive(Debug)] pub enum DiffState { NotInRepository, Loading, Error(String), Loaded, /// The remote connection was lost. The model will re-subscribe /// automatically when a session becomes available. Disconnected, } #[derive(Clone, Debug, Default)] pub struct DiffMetadata { pub main_branch_name: String, pub current_branch_name: String, pub against_head: DiffMetadataAgainstBase, pub against_base_branch: Option<DiffMetadataAgainstBase>, pub has_head_commit: bool, pub unpushed_commits: Vec<Commit>, pub upstream_ref: Option<String>, } #[derive(Clone, Default, Debug)] pub struct DiffMetadataAgainstBase { pub aggregate_stats: DiffStats, /// Per-file change entries (path + additions/deletions) for this base. /// Populated from the same numstat that produces `aggregate_stats`, so the /// git dialog's Changes box can render without a working-tree read — this /// is what lets the box populate for remote repos, where the list rides /// along in synced metadata. pub files: Vec<FileChangeEntry>, } impl DiffMetadataAgainstBase { pub fn is_dirty(&self) -> bool { !self.aggregate_stats.has_no_changes() } } #[derive(Debug, Copy, Clone, Default)] pub struct DiffStats { pub files_changed: usize, pub total_additions: usize, pub total_deletions: usize, } impl DiffStats { pub(crate) fn has_no_changes(&self) -> bool { self.files_changed == 0 } } #[derive(Debug)] pub enum DiffStateModelEvent { /// Event dispatched when the current branch changes. CurrentBranchChanged, /// Event dispatched when new diffs are computed (full reload). NewDiffsComputed { diffs: Option<Arc<GitDiffWithBaseContent>>, load_duration: Option<Duration>, }, /// Event dispatched when a single file's diff is updated incrementally. SingleFileUpdated { /// Repo-relative path for the updated file. path: String, diff: Option<Arc<FileDiffAndContent>>, }, /// Event dispatched when diff metadata (stats, branch info) is refreshed. MetadataRefreshed(Box<DiffMetadata>), /// The remote connection was lost. Stale diffs should be preserved while /// the model waits for a new subscription. ConnectionLost, /// Branch list received from the backend (local git or remote server). BranchesReceived(Vec<BranchEntry>), /// A remote git operation completed. The model has already applied any /// successful metadata delta to the cached metadata. GitOpCompleted(GitOpResult), /// An AI-generated commit message arrived from the remote daemon (issued /// at commit-dialog open). `Ok` carries the message, `Err` the error /// string. The `GitDialog` populates its message editor from this; the /// local path fills the editor directly without going through an event. CommitMessageGenerated(Result<String, String>), /// Committed branch files (`merge_base(HEAD, main)..HEAD`) arrived for the /// Create PR dialog's Changes box. Fetched on dialog open and delivered the /// same way for both backends: the local model computes them off-thread and /// emits this; the remote model emits it on the daemon's RPC response. BranchCommittedFilesReceived(Vec<FileChangeEntry>), } /// Result of a remote git operation, emitted via /// `DiffStateModelEvent::GitOpCompleted`. The model applies the post-op /// delta before emitting, so the dialog only handles UI concerns. #[derive(Debug, Clone)] pub enum GitOpResult { /// Commit chain completed. `Ok(Some(pr))` when create-PR was part of /// the chain; `Ok(None)` for commit-only or commit-and-push. CommitChainCompleted(Result<Option<PrInfo>, String>), /// Standalone push completed. PushCompleted(Result<(), String>), /// Standalone create-PR completed. PrCreated(Result<PrInfo, String>), } // ── Unified model ──────────────────────────────────────────────────────── /// Unified diff state model that dispatches to a local or remote backend. /// /// Only one variant is populated at a time, since a diff state belongs to /// exactly one repository (either local or remote). All consumers should /// interact with this enum rather than accessing sub-models directly. pub enum DiffStateModel { Local(ModelHandle<LocalDiffStateModel>), Remote(ModelHandle<RemoteDiffStateModel>), } impl warpui::Entity for DiffStateModel { type Event = DiffStateModelEvent; } impl DiffStateModel { // ── Construction ───────────────────────────────────────────────── /// Creates a new local-backed `DiffStateModel`. The wrapper subscribes /// to the inner model so it can forward events. pub fn new_local(path: PathBuf, ctx: &mut ModelContext<Self>) -> Self { let repo_path = Some(path.display().to_string()); let local = ctx .add_model(|ctx| LocalDiffStateModel::new(repo_path, BackendOrigin::ClientLocal, ctx)); ctx.subscribe_to_model(&local, |me, _, event, ctx| me.forward_event(event, ctx)); Self::Local(local) } /// Creates a new remote-backed `DiffStateModel`. The model is keyed by /// `(host_id, repo, mode)` and shared across sessions viewing the same /// repo. `preferred_session` is the session that opened this review (when /// known): `GetDiffState` is session-scoped, so the manager dispatches it /// over that session when it's connected and falls back to any connected /// session for the host otherwise. Callers must ensure a session for the /// host is connected before constructing. pub fn new_remote( remote_path: RemotePath, preferred_session: Option<SessionId>, ctx: &mut ModelContext<Self>, ) -> Self { let remote = ctx.add_model(|ctx| { RemoteDiffStateModel::new(remote_path, DiffMode::default(), preferred_session, ctx) }); ctx.subscribe_to_model(&remote, |me, _, event, ctx| me.forward_event(event, ctx)); Self::Remote(remote) } // ── Event forwarding ───────────────────────────────────────────── fn forward_event(&mut self, event: &DiffStateModelEvent, ctx: &mut ModelContext<Self>) { match event { DiffStateModelEvent::CurrentBranchChanged => { ctx.emit(DiffStateModelEvent::CurrentBranchChanged); } DiffStateModelEvent::NewDiffsComputed { diffs, load_duration, } => { ctx.emit(DiffStateModelEvent::NewDiffsComputed { diffs: diffs.clone(), load_duration: *load_duration, }); } DiffStateModelEvent::SingleFileUpdated { path, diff } => { ctx.emit(DiffStateModelEvent::SingleFileUpdated { path: path.clone(), diff: diff.clone(), });