883 lines · 31.7 KB · Rust
RawDownload
//! 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.