project_diff.rs

   1use crate::{
   2    conflict_view::ConflictAddon,
   3    git_panel::{GitPanel, GitPanelAddon, GitStatusEntry},
   4    git_panel_settings::GitPanelSettings,
   5    remote_button::{render_publish_button, render_push_button},
   6    resolve_active_repository,
   7};
   8use agent_settings::AgentSettings;
   9use anyhow::{Context as _, Result, anyhow};
  10use buffer_diff::{BufferDiff, DiffHunkSecondaryStatus};
  11use collections::{HashMap, HashSet};
  12use editor::{
  13    Addon, Editor, EditorEvent, EditorSettings, SelectionEffects, SplittableEditor,
  14    actions::{GoToHunk, GoToPreviousHunk, SendReviewToAgent},
  15    multibuffer_context_lines,
  16    scroll::Autoscroll,
  17};
  18use git::repository::DiffType;
  19
  20use git::{
  21    Commit, StageAll, StageAndNext, ToggleStaged, UnstageAll, UnstageAndNext,
  22    repository::{Branch, RepoPath, Upstream, UpstreamTracking, UpstreamTrackingStatus},
  23    status::FileStatus,
  24};
  25use gpui::{
  26    Action, AnyElement, App, AppContext as _, AsyncWindowContext, Entity, EventEmitter,
  27    FocusHandle, Focusable, Render, Subscription, Task, WeakEntity, actions,
  28};
  29use language::{Anchor, Buffer, BufferId, Capability, OffsetRangeExt};
  30use multi_buffer::{MultiBuffer, PathKey};
  31use project::{
  32    Project, ProjectPath,
  33    git_store::{
  34        Repository,
  35        branch_diff::{self, BranchDiffEvent, DiffBase},
  36    },
  37};
  38use settings::{Settings, SettingsStore};
  39use smol::future::yield_now;
  40use std::any::{Any, TypeId};
  41use std::sync::Arc;
  42use theme::ActiveTheme;
  43use ui::{DiffStat, Divider, KeyBinding, Tooltip, prelude::*, vertical_divider};
  44use util::{ResultExt as _, rel_path::RelPath};
  45use workspace::{
  46    CloseActiveItem, ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation,
  47    ToolbarItemView, Workspace,
  48    item::{Item, ItemEvent, ItemHandle, SaveOptions, TabContentParams},
  49    notifications::NotifyTaskExt,
  50    searchable::SearchableItemHandle,
  51};
  52use zed_actions::agent::ReviewBranchDiff;
  53use ztracing::instrument;
  54
  55actions!(
  56    git,
  57    [
  58        /// Shows the diff between the working directory and the index.
  59        Diff,
  60        /// Adds files to the git staging area.
  61        Add,
  62        /// Shows the diff between the working directory and your default
  63        /// branch (typically main or master).
  64        BranchDiff,
  65        /// Opens a new agent thread with the branch diff for review.
  66        ReviewDiff,
  67        LeaderAndFollower,
  68    ]
  69);
  70
  71pub struct ProjectDiff {
  72    project: Entity<Project>,
  73    multibuffer: Entity<MultiBuffer>,
  74    branch_diff: Entity<branch_diff::BranchDiff>,
  75    editor: Entity<SplittableEditor>,
  76    buffer_diff_subscriptions: HashMap<Arc<RelPath>, (Entity<BufferDiff>, Subscription)>,
  77    workspace: WeakEntity<Workspace>,
  78    focus_handle: FocusHandle,
  79    pending_scroll: Option<PathKey>,
  80    review_comment_count: usize,
  81    _task: Task<Result<()>>,
  82    _subscription: Subscription,
  83}
  84
  85#[derive(Clone, Copy, Debug, PartialEq, Eq)]
  86pub enum RefreshReason {
  87    DiffChanged,
  88    StatusesChanged,
  89    EditorSaved,
  90}
  91
  92const CONFLICT_SORT_PREFIX: u64 = 1;
  93const TRACKED_SORT_PREFIX: u64 = 2;
  94const NEW_SORT_PREFIX: u64 = 3;
  95
  96impl ProjectDiff {
  97    pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context<Workspace>) {
  98        workspace.register_action(Self::deploy);
  99        workspace.register_action(Self::deploy_branch_diff);
 100        workspace.register_action(|workspace, _: &Add, window, cx| {
 101            Self::deploy(workspace, &Diff, window, cx);
 102        });
 103        workspace::register_serializable_item::<ProjectDiff>(cx);
 104    }
 105
 106    fn deploy(
 107        workspace: &mut Workspace,
 108        _: &Diff,
 109        window: &mut Window,
 110        cx: &mut Context<Workspace>,
 111    ) {
 112        Self::deploy_at(workspace, None, window, cx)
 113    }
 114
 115    fn deploy_branch_diff(
 116        workspace: &mut Workspace,
 117        _: &BranchDiff,
 118        window: &mut Window,
 119        cx: &mut Context<Workspace>,
 120    ) {
 121        telemetry::event!("Git Branch Diff Opened");
 122        let project = workspace.project().clone();
 123
 124        let existing = workspace
 125            .items_of_type::<Self>(cx)
 126            .find(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Merge { .. }));
 127        if let Some(existing) = existing {
 128            workspace.activate_item(&existing, true, true, window, cx);
 129            return;
 130        }
 131        let workspace = cx.entity();
 132        let workspace_weak = workspace.downgrade();
 133        window
 134            .spawn(cx, async move |cx| {
 135                let this = cx
 136                    .update(|window, cx| {
 137                        Self::new_with_default_branch(project, workspace.clone(), window, cx)
 138                    })?
 139                    .await?;
 140                workspace
 141                    .update_in(cx, |workspace, window, cx| {
 142                        workspace.add_item_to_active_pane(Box::new(this), None, true, window, cx);
 143                    })
 144                    .ok();
 145                anyhow::Ok(())
 146            })
 147            .detach_and_notify_err(workspace_weak, window, cx);
 148    }
 149
 150    fn review_diff(&mut self, _: &ReviewDiff, window: &mut Window, cx: &mut Context<Self>) {
 151        let diff_base = self.diff_base(cx).clone();
 152        let DiffBase::Merge { base_ref } = diff_base else {
 153            return;
 154        };
 155
 156        let Some(repo) = self.branch_diff.read(cx).repo().cloned() else {
 157            return;
 158        };
 159
 160        let diff_receiver = repo.update(cx, |repo, cx| {
 161            repo.diff(
 162                DiffType::MergeBase {
 163                    base_ref: base_ref.clone(),
 164                },
 165                cx,
 166            )
 167        });
 168
 169        let workspace = self.workspace.clone();
 170
 171        window
 172            .spawn(cx, {
 173                let workspace = workspace.clone();
 174                async move |cx| {
 175                    let diff_text = diff_receiver.await??;
 176
 177                    if let Some(workspace) = workspace.upgrade() {
 178                        workspace.update_in(cx, |_workspace, window, cx| {
 179                            window.dispatch_action(
 180                                ReviewBranchDiff {
 181                                    diff_text: diff_text.into(),
 182                                    base_ref: base_ref.to_string().into(),
 183                                }
 184                                .boxed_clone(),
 185                                cx,
 186                            );
 187                        })?;
 188                    }
 189
 190                    anyhow::Ok(())
 191                }
 192            })
 193            .detach_and_notify_err(workspace, window, cx);
 194    }
 195
 196    pub fn deploy_at(
 197        workspace: &mut Workspace,
 198        entry: Option<GitStatusEntry>,
 199        window: &mut Window,
 200        cx: &mut Context<Workspace>,
 201    ) {
 202        telemetry::event!(
 203            "Git Diff Opened",
 204            source = if entry.is_some() {
 205                "Git Panel"
 206            } else {
 207                "Action"
 208            }
 209        );
 210        let intended_repo = resolve_active_repository(workspace, cx);
 211
 212        let existing = workspace
 213            .items_of_type::<Self>(cx)
 214            .find(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Head));
 215        let project_diff = if let Some(existing) = existing {
 216            existing.update(cx, |project_diff, cx| {
 217                project_diff.move_to_beginning(window, cx);
 218            });
 219
 220            workspace.activate_item(&existing, true, true, window, cx);
 221            existing
 222        } else {
 223            let workspace_handle = cx.entity();
 224            let project_diff =
 225                cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx));
 226            workspace.add_item_to_active_pane(
 227                Box::new(project_diff.clone()),
 228                None,
 229                true,
 230                window,
 231                cx,
 232            );
 233            project_diff
 234        };
 235
 236        if let Some(intended) = &intended_repo {
 237            let needs_switch = project_diff
 238                .read(cx)
 239                .branch_diff
 240                .read(cx)
 241                .repo()
 242                .map_or(true, |current| current.read(cx).id != intended.read(cx).id);
 243            if needs_switch {
 244                project_diff.update(cx, |project_diff, cx| {
 245                    project_diff.branch_diff.update(cx, |branch_diff, cx| {
 246                        branch_diff.set_repo(Some(intended.clone()), cx);
 247                    });
 248                });
 249            }
 250        }
 251
 252        if let Some(entry) = entry {
 253            project_diff.update(cx, |project_diff, cx| {
 254                project_diff.move_to_entry(entry, window, cx);
 255            })
 256        }
 257    }
 258
 259    pub fn deploy_at_project_path(
 260        workspace: &mut Workspace,
 261        project_path: ProjectPath,
 262        window: &mut Window,
 263        cx: &mut Context<Workspace>,
 264    ) {
 265        telemetry::event!("Git Diff Opened", source = "Agent Panel");
 266        let existing = workspace
 267            .items_of_type::<Self>(cx)
 268            .find(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Head));
 269        let project_diff = if let Some(existing) = existing {
 270            workspace.activate_item(&existing, true, true, window, cx);
 271            existing
 272        } else {
 273            let workspace_handle = cx.entity();
 274            let project_diff =
 275                cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx));
 276            workspace.add_item_to_active_pane(
 277                Box::new(project_diff.clone()),
 278                None,
 279                true,
 280                window,
 281                cx,
 282            );
 283            project_diff
 284        };
 285        project_diff.update(cx, |project_diff, cx| {
 286            project_diff.move_to_project_path(&project_path, window, cx);
 287        });
 288    }
 289
 290    pub fn autoscroll(&self, cx: &mut Context<Self>) {
 291        self.editor.update(cx, |editor, cx| {
 292            editor.rhs_editor().update(cx, |editor, cx| {
 293                editor.request_autoscroll(Autoscroll::fit(), cx);
 294            })
 295        })
 296    }
 297
 298    fn new_with_default_branch(
 299        project: Entity<Project>,
 300        workspace: Entity<Workspace>,
 301        window: &mut Window,
 302        cx: &mut App,
 303    ) -> Task<Result<Entity<Self>>> {
 304        let Some(repo) = project.read(cx).git_store().read(cx).active_repository() else {
 305            return Task::ready(Err(anyhow!("No active repository")));
 306        };
 307        let main_branch = repo.update(cx, |repo, _| repo.default_branch(true));
 308        window.spawn(cx, async move |cx| {
 309            let main_branch = main_branch
 310                .await??
 311                .context("Could not determine default branch")?;
 312
 313            let branch_diff = cx.new_window_entity(|window, cx| {
 314                branch_diff::BranchDiff::new(
 315                    DiffBase::Merge {
 316                        base_ref: main_branch,
 317                    },
 318                    project.clone(),
 319                    window,
 320                    cx,
 321                )
 322            })?;
 323            cx.new_window_entity(|window, cx| {
 324                Self::new_impl(branch_diff, project, workspace, window, cx)
 325            })
 326        })
 327    }
 328
 329    fn new(
 330        project: Entity<Project>,
 331        workspace: Entity<Workspace>,
 332        window: &mut Window,
 333        cx: &mut Context<Self>,
 334    ) -> Self {
 335        let branch_diff =
 336            cx.new(|cx| branch_diff::BranchDiff::new(DiffBase::Head, project.clone(), window, cx));
 337        Self::new_impl(branch_diff, project, workspace, window, cx)
 338    }
 339
 340    fn new_impl(
 341        branch_diff: Entity<branch_diff::BranchDiff>,
 342        project: Entity<Project>,
 343        workspace: Entity<Workspace>,
 344        window: &mut Window,
 345        cx: &mut Context<Self>,
 346    ) -> Self {
 347        let focus_handle = cx.focus_handle();
 348        let multibuffer = cx.new(|cx| {
 349            let mut multibuffer = MultiBuffer::new(Capability::ReadWrite);
 350            multibuffer.set_all_diff_hunks_expanded(cx);
 351            multibuffer
 352        });
 353
 354        let editor = cx.new(|cx| {
 355            let diff_display_editor = SplittableEditor::new(
 356                EditorSettings::get_global(cx).diff_view_style,
 357                multibuffer.clone(),
 358                project.clone(),
 359                workspace.clone(),
 360                window,
 361                cx,
 362            );
 363            match branch_diff.read(cx).diff_base() {
 364                DiffBase::Head => {}
 365                DiffBase::Merge { .. } => diff_display_editor.set_render_diff_hunk_controls(
 366                    Arc::new(|_, _, _, _, _, _, _, _| gpui::Empty.into_any_element()),
 367                    cx,
 368                ),
 369            }
 370            diff_display_editor.rhs_editor().update(cx, |editor, cx| {
 371                editor.disable_diagnostics(cx);
 372                editor.set_show_diff_review_button(true, cx);
 373
 374                match branch_diff.read(cx).diff_base() {
 375                    DiffBase::Head => {
 376                        editor.register_addon(GitPanelAddon {
 377                            workspace: workspace.downgrade(),
 378                        });
 379                    }
 380                    DiffBase::Merge { .. } => {
 381                        editor.register_addon(BranchDiffAddon {
 382                            branch_diff: branch_diff.clone(),
 383                        });
 384                        editor.start_temporary_diff_override();
 385                    }
 386                }
 387            });
 388            diff_display_editor
 389        });
 390        let editor_subscription = cx.subscribe_in(&editor, window, Self::handle_editor_event);
 391
 392        let primary_editor = editor.read(cx).rhs_editor().clone();
 393        let review_comment_subscription =
 394            cx.subscribe(&primary_editor, |this, _editor, event: &EditorEvent, cx| {
 395                if let EditorEvent::ReviewCommentsChanged { total_count } = event {
 396                    this.review_comment_count = *total_count;
 397                    cx.notify();
 398                }
 399            });
 400
 401        let branch_diff_subscription = cx.subscribe_in(
 402            &branch_diff,
 403            window,
 404            move |this, _git_store, event, window, cx| match event {
 405                BranchDiffEvent::FileListChanged => {
 406                    this._task = window.spawn(cx, {
 407                        let this = cx.weak_entity();
 408                        async |cx| Self::refresh(this, RefreshReason::StatusesChanged, cx).await
 409                    })
 410                }
 411            },
 412        );
 413
 414        let mut was_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
 415        let mut was_collapse_untracked_diff =
 416            GitPanelSettings::get_global(cx).collapse_untracked_diff;
 417        cx.observe_global_in::<SettingsStore>(window, move |this, window, cx| {
 418            let is_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
 419            let is_collapse_untracked_diff =
 420                GitPanelSettings::get_global(cx).collapse_untracked_diff;
 421            if is_sort_by_path != was_sort_by_path
 422                || is_collapse_untracked_diff != was_collapse_untracked_diff
 423            {
 424                this._task = {
 425                    window.spawn(cx, {
 426                        let this = cx.weak_entity();
 427                        async |cx| Self::refresh(this, RefreshReason::StatusesChanged, cx).await
 428                    })
 429                }
 430            }
 431            was_sort_by_path = is_sort_by_path;
 432            was_collapse_untracked_diff = is_collapse_untracked_diff;
 433        })
 434        .detach();
 435
 436        let task = window.spawn(cx, {
 437            let this = cx.weak_entity();
 438            async |cx| Self::refresh(this, RefreshReason::StatusesChanged, cx).await
 439        });
 440
 441        Self {
 442            project,
 443            workspace: workspace.downgrade(),
 444            branch_diff,
 445            focus_handle,
 446            editor,
 447            multibuffer,
 448            buffer_diff_subscriptions: Default::default(),
 449            pending_scroll: None,
 450            review_comment_count: 0,
 451            _task: task,
 452            _subscription: Subscription::join(
 453                branch_diff_subscription,
 454                Subscription::join(editor_subscription, review_comment_subscription),
 455            ),
 456        }
 457    }
 458
 459    pub fn diff_base<'a>(&'a self, cx: &'a App) -> &'a DiffBase {
 460        self.branch_diff.read(cx).diff_base()
 461    }
 462
 463    pub fn move_to_entry(
 464        &mut self,
 465        entry: GitStatusEntry,
 466        window: &mut Window,
 467        cx: &mut Context<Self>,
 468    ) {
 469        let Some(git_repo) = self.branch_diff.read(cx).repo() else {
 470            return;
 471        };
 472        let repo = git_repo.read(cx);
 473        let sort_prefix = sort_prefix(repo, &entry.repo_path, entry.status, cx);
 474        let path_key = PathKey::with_sort_prefix(sort_prefix, entry.repo_path.as_ref().clone());
 475
 476        self.move_to_path(path_key, window, cx)
 477    }
 478
 479    pub fn move_to_project_path(
 480        &mut self,
 481        project_path: &ProjectPath,
 482        window: &mut Window,
 483        cx: &mut Context<Self>,
 484    ) {
 485        let Some(git_repo) = self.branch_diff.read(cx).repo() else {
 486            return;
 487        };
 488        let Some(repo_path) = git_repo
 489            .read(cx)
 490            .project_path_to_repo_path(project_path, cx)
 491        else {
 492            return;
 493        };
 494        let status = git_repo
 495            .read(cx)
 496            .status_for_path(&repo_path)
 497            .map(|entry| entry.status)
 498            .unwrap_or(FileStatus::Untracked);
 499        let sort_prefix = sort_prefix(&git_repo.read(cx), &repo_path, status, cx);
 500        let path_key = PathKey::with_sort_prefix(sort_prefix, repo_path.as_ref().clone());
 501        self.move_to_path(path_key, window, cx)
 502    }
 503
 504    pub fn active_path(&self, cx: &App) -> Option<ProjectPath> {
 505        let editor = self.editor.read(cx).focused_editor().read(cx);
 506        let position = editor.selections.newest_anchor().head();
 507        let multi_buffer = editor.buffer().read(cx);
 508        let (_, buffer, _) = multi_buffer.excerpt_containing(position, cx)?;
 509
 510        let file = buffer.read(cx).file()?;
 511        Some(ProjectPath {
 512            worktree_id: file.worktree_id(cx),
 513            path: file.path().clone(),
 514        })
 515    }
 516
 517    fn move_to_beginning(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 518        self.editor.update(cx, |editor, cx| {
 519            editor.rhs_editor().update(cx, |editor, cx| {
 520                editor.change_selections(Default::default(), window, cx, |s| {
 521                    s.select_ranges(vec![
 522                        multi_buffer::Anchor::min()..multi_buffer::Anchor::min(),
 523                    ]);
 524                });
 525            });
 526        });
 527    }
 528
 529    fn move_to_path(&mut self, path_key: PathKey, window: &mut Window, cx: &mut Context<Self>) {
 530        if let Some(position) = self.multibuffer.read(cx).location_for_path(&path_key, cx) {
 531            self.editor.update(cx, |editor, cx| {
 532                editor.rhs_editor().update(cx, |editor, cx| {
 533                    editor.change_selections(
 534                        SelectionEffects::scroll(Autoscroll::focused()),
 535                        window,
 536                        cx,
 537                        |s| {
 538                            s.select_ranges([position..position]);
 539                        },
 540                    )
 541                })
 542            });
 543        } else {
 544            self.pending_scroll = Some(path_key);
 545        }
 546    }
 547
 548    pub fn calculate_changed_lines(&self, cx: &App) -> (u32, u32) {
 549        let snapshot = self.multibuffer.read(cx).snapshot(cx);
 550        let mut total_additions = 0u32;
 551        let mut total_deletions = 0u32;
 552
 553        let mut seen_buffers = HashSet::default();
 554        for (_, buffer, _) in snapshot.excerpts() {
 555            let buffer_id = buffer.remote_id();
 556            if !seen_buffers.insert(buffer_id) {
 557                continue;
 558            }
 559
 560            let Some(diff) = snapshot.diff_for_buffer_id(buffer_id) else {
 561                continue;
 562            };
 563
 564            let base_text = diff.base_text();
 565
 566            for hunk in diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, buffer) {
 567                let added_rows = hunk.range.end.row.saturating_sub(hunk.range.start.row);
 568                total_additions += added_rows;
 569
 570                let base_start = base_text
 571                    .offset_to_point(hunk.diff_base_byte_range.start)
 572                    .row;
 573                let base_end = base_text.offset_to_point(hunk.diff_base_byte_range.end).row;
 574                let deleted_rows = base_end.saturating_sub(base_start);
 575
 576                total_deletions += deleted_rows;
 577            }
 578        }
 579
 580        (total_additions, total_deletions)
 581    }
 582
 583    /// Returns the total count of review comments across all hunks/files.
 584    pub fn total_review_comment_count(&self) -> usize {
 585        self.review_comment_count
 586    }
 587
 588    /// Returns a reference to the splittable editor.
 589    pub fn editor(&self) -> &Entity<SplittableEditor> {
 590        &self.editor
 591    }
 592
 593    fn button_states(&self, cx: &App) -> ButtonStates {
 594        let editor = self.editor.read(cx).rhs_editor().read(cx);
 595        let snapshot = self.multibuffer.read(cx).snapshot(cx);
 596        let prev_next = snapshot.diff_hunks().nth(1).is_some();
 597        let mut selection = true;
 598
 599        let mut ranges = editor
 600            .selections
 601            .disjoint_anchor_ranges()
 602            .collect::<Vec<_>>();
 603        if !ranges.iter().any(|range| range.start != range.end) {
 604            selection = false;
 605            if let Some((excerpt_id, _, range)) = self
 606                .editor
 607                .read(cx)
 608                .rhs_editor()
 609                .read(cx)
 610                .active_excerpt(cx)
 611            {
 612                ranges = vec![multi_buffer::Anchor::range_in_buffer(excerpt_id, range)];
 613            } else {
 614                ranges = Vec::default();
 615            }
 616        }
 617        let mut has_staged_hunks = false;
 618        let mut has_unstaged_hunks = false;
 619        for hunk in editor.diff_hunks_in_ranges(&ranges, &snapshot) {
 620            match hunk.status.secondary {
 621                DiffHunkSecondaryStatus::HasSecondaryHunk
 622                | DiffHunkSecondaryStatus::SecondaryHunkAdditionPending => {
 623                    has_unstaged_hunks = true;
 624                }
 625                DiffHunkSecondaryStatus::OverlapsWithSecondaryHunk => {
 626                    has_staged_hunks = true;
 627                    has_unstaged_hunks = true;
 628                }
 629                DiffHunkSecondaryStatus::NoSecondaryHunk
 630                | DiffHunkSecondaryStatus::SecondaryHunkRemovalPending => {
 631                    has_staged_hunks = true;
 632                }
 633            }
 634        }
 635        let mut stage_all = false;
 636        let mut unstage_all = false;
 637        self.workspace
 638            .read_with(cx, |workspace, cx| {
 639                if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
 640                    let git_panel = git_panel.read(cx);
 641                    stage_all = git_panel.can_stage_all();
 642                    unstage_all = git_panel.can_unstage_all();
 643                }
 644            })
 645            .ok();
 646
 647        ButtonStates {
 648            stage: has_unstaged_hunks,
 649            unstage: has_staged_hunks,
 650            prev_next,
 651            selection,
 652            stage_all,
 653            unstage_all,
 654        }
 655    }
 656
 657    fn handle_editor_event(
 658        &mut self,
 659        editor: &Entity<SplittableEditor>,
 660        event: &EditorEvent,
 661        window: &mut Window,
 662        cx: &mut Context<Self>,
 663    ) {
 664        match event {
 665            EditorEvent::SelectionsChanged { local: true } => {
 666                let Some(project_path) = self.active_path(cx) else {
 667                    return;
 668                };
 669                self.workspace
 670                    .update(cx, |workspace, cx| {
 671                        if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
 672                            git_panel.update(cx, |git_panel, cx| {
 673                                git_panel.select_entry_by_path(project_path, window, cx)
 674                            })
 675                        }
 676                    })
 677                    .ok();
 678            }
 679            EditorEvent::Saved => {
 680                self._task = cx.spawn_in(window, async move |this, cx| {
 681                    Self::refresh(this, RefreshReason::EditorSaved, cx).await
 682                });
 683            }
 684            _ => {}
 685        }
 686        if editor.focus_handle(cx).contains_focused(window, cx)
 687            && self.multibuffer.read(cx).is_empty()
 688        {
 689            self.focus_handle.focus(window, cx)
 690        }
 691    }
 692
 693    #[instrument(skip_all)]
 694    fn register_buffer(
 695        &mut self,
 696        path_key: PathKey,
 697        file_status: FileStatus,
 698        buffer: Entity<Buffer>,
 699        diff: Entity<BufferDiff>,
 700        window: &mut Window,
 701        cx: &mut Context<Self>,
 702    ) -> Option<BufferId> {
 703        let subscription = cx.subscribe_in(&diff, window, move |this, _, _, window, cx| {
 704            this._task = window.spawn(cx, {
 705                let this = cx.weak_entity();
 706                async |cx| Self::refresh(this, RefreshReason::DiffChanged, cx).await
 707            })
 708        });
 709        self.buffer_diff_subscriptions
 710            .insert(path_key.path.clone(), (diff.clone(), subscription));
 711
 712        // TODO(split-diff) we shouldn't have a conflict addon when split
 713        let conflict_addon = self
 714            .editor
 715            .read(cx)
 716            .rhs_editor()
 717            .read(cx)
 718            .addon::<ConflictAddon>()
 719            .expect("project diff editor should have a conflict addon");
 720
 721        let snapshot = buffer.read(cx).snapshot();
 722        let diff_snapshot = diff.read(cx).snapshot(cx);
 723
 724        let excerpt_ranges = {
 725            let diff_hunk_ranges = diff_snapshot
 726                .hunks_intersecting_range(
 727                    Anchor::min_max_range_for_buffer(snapshot.remote_id()),
 728                    &snapshot,
 729                )
 730                .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot));
 731            let conflicts = conflict_addon
 732                .conflict_set(snapshot.remote_id())
 733                .map(|conflict_set| conflict_set.read(cx).snapshot().conflicts)
 734                .unwrap_or_default();
 735            let mut conflicts = conflicts
 736                .iter()
 737                .map(|conflict| conflict.range.to_point(&snapshot))
 738                .peekable();
 739
 740            if conflicts.peek().is_some() {
 741                conflicts.collect::<Vec<_>>()
 742            } else {
 743                diff_hunk_ranges.collect()
 744            }
 745        };
 746
 747        let mut needs_fold = None;
 748
 749        let (was_empty, is_excerpt_newly_added) = self.editor.update(cx, |editor, cx| {
 750            let was_empty = editor.rhs_editor().read(cx).buffer().read(cx).is_empty();
 751            let (_, is_newly_added) = editor.set_excerpts_for_path(
 752                path_key.clone(),
 753                buffer,
 754                excerpt_ranges,
 755                multibuffer_context_lines(cx),
 756                diff,
 757                cx,
 758            );
 759            (was_empty, is_newly_added)
 760        });
 761
 762        self.editor.update(cx, |editor, cx| {
 763            editor.rhs_editor().update(cx, |editor, cx| {
 764                if was_empty {
 765                    editor.change_selections(
 766                        SelectionEffects::no_scroll(),
 767                        window,
 768                        cx,
 769                        |selections| {
 770                            selections.select_ranges([
 771                                multi_buffer::Anchor::min()..multi_buffer::Anchor::min()
 772                            ])
 773                        },
 774                    );
 775                }
 776                if is_excerpt_newly_added
 777                    && (file_status.is_deleted()
 778                        || (file_status.is_untracked()
 779                            && GitPanelSettings::get_global(cx).collapse_untracked_diff))
 780                {
 781                    needs_fold = Some(snapshot.text.remote_id());
 782                }
 783            })
 784        });
 785
 786        if self.multibuffer.read(cx).is_empty()
 787            && self
 788                .editor
 789                .read(cx)
 790                .focus_handle(cx)
 791                .contains_focused(window, cx)
 792        {
 793            self.focus_handle.focus(window, cx);
 794        } else if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() {
 795            self.editor.update(cx, |editor, cx| {
 796                editor.focus_handle(cx).focus(window, cx);
 797            });
 798        }
 799        if self.pending_scroll.as_ref() == Some(&path_key) {
 800            self.move_to_path(path_key, window, cx);
 801        }
 802
 803        needs_fold
 804    }
 805
 806    #[instrument(skip_all)]
 807    pub async fn refresh(
 808        this: WeakEntity<Self>,
 809        reason: RefreshReason,
 810        cx: &mut AsyncWindowContext,
 811    ) -> Result<()> {
 812        let mut path_keys = Vec::new();
 813        let buffers_to_load = this.update(cx, |this, cx| {
 814            let (repo, buffers_to_load) = this.branch_diff.update(cx, |branch_diff, cx| {
 815                let load_buffers = branch_diff.load_buffers(cx);
 816                (branch_diff.repo().cloned(), load_buffers)
 817            });
 818            let mut previous_paths = this
 819                .multibuffer
 820                .read(cx)
 821                .paths()
 822                .cloned()
 823                .collect::<HashSet<_>>();
 824
 825            if let Some(repo) = repo {
 826                let repo = repo.read(cx);
 827
 828                path_keys = Vec::with_capacity(buffers_to_load.len());
 829                for entry in buffers_to_load.iter() {
 830                    let sort_prefix = sort_prefix(&repo, &entry.repo_path, entry.file_status, cx);
 831                    let path_key =
 832                        PathKey::with_sort_prefix(sort_prefix, entry.repo_path.as_ref().clone());
 833                    previous_paths.remove(&path_key);
 834                    path_keys.push(path_key)
 835                }
 836            }
 837
 838            this.editor.update(cx, |editor, cx| {
 839                for path in previous_paths {
 840                    if let Some(buffer) = this.multibuffer.read(cx).buffer_for_path(&path, cx) {
 841                        let skip = match reason {
 842                            RefreshReason::DiffChanged | RefreshReason::EditorSaved => {
 843                                buffer.read(cx).is_dirty()
 844                            }
 845                            RefreshReason::StatusesChanged => false,
 846                        };
 847                        if skip {
 848                            continue;
 849                        }
 850                    }
 851
 852                    this.buffer_diff_subscriptions.remove(&path.path);
 853                    editor.remove_excerpts_for_path(path, cx);
 854                }
 855            });
 856            buffers_to_load
 857        })?;
 858
 859        let mut buffers_to_fold = Vec::new();
 860
 861        for (entry, path_key) in buffers_to_load.into_iter().zip(path_keys.into_iter()) {
 862            if let Some((buffer, diff)) = entry.load.await.log_err() {
 863                // We might be lagging behind enough that all future entry.load futures are no longer pending.
 864                // If that is the case, this task will never yield, starving the foreground thread of execution time.
 865                yield_now().await;
 866                cx.update(|window, cx| {
 867                    this.update(cx, |this, cx| {
 868                        let multibuffer = this.multibuffer.read(cx);
 869                        let skip = multibuffer.buffer(buffer.read(cx).remote_id()).is_some()
 870                            && multibuffer
 871                                .diff_for(buffer.read(cx).remote_id())
 872                                .is_some_and(|prev_diff| prev_diff.entity_id() == diff.entity_id())
 873                            && match reason {
 874                                RefreshReason::DiffChanged | RefreshReason::EditorSaved => {
 875                                    buffer.read(cx).is_dirty()
 876                                }
 877                                RefreshReason::StatusesChanged => false,
 878                            };
 879                        if !skip {
 880                            if let Some(buffer_id) = this.register_buffer(
 881                                path_key,
 882                                entry.file_status,
 883                                buffer,
 884                                diff,
 885                                window,
 886                                cx,
 887                            ) {
 888                                buffers_to_fold.push(buffer_id);
 889                            }
 890                        }
 891                    })
 892                    .ok();
 893                })?;
 894            }
 895        }
 896        this.update(cx, |this, cx| {
 897            if !buffers_to_fold.is_empty() {
 898                this.editor.update(cx, |editor, cx| {
 899                    editor
 900                        .rhs_editor()
 901                        .update(cx, |editor, cx| editor.fold_buffers(buffers_to_fold, cx));
 902                });
 903            }
 904            this.pending_scroll.take();
 905            cx.notify();
 906        })?;
 907
 908        Ok(())
 909    }
 910
 911    #[cfg(any(test, feature = "test-support"))]
 912    pub fn excerpt_paths(&self, cx: &App) -> Vec<std::sync::Arc<util::rel_path::RelPath>> {
 913        self.multibuffer
 914            .read(cx)
 915            .paths()
 916            .map(|key| key.path.clone())
 917            .collect()
 918    }
 919}
 920
 921fn sort_prefix(repo: &Repository, repo_path: &RepoPath, status: FileStatus, cx: &App) -> u64 {
 922    let settings = GitPanelSettings::get_global(cx);
 923
 924    if settings.sort_by_path && !settings.tree_view {
 925        TRACKED_SORT_PREFIX
 926    } else if repo.had_conflict_on_last_merge_head_change(repo_path) {
 927        CONFLICT_SORT_PREFIX
 928    } else if status.is_created() {
 929        NEW_SORT_PREFIX
 930    } else {
 931        TRACKED_SORT_PREFIX
 932    }
 933}
 934
 935impl EventEmitter<EditorEvent> for ProjectDiff {}
 936
 937impl Focusable for ProjectDiff {
 938    fn focus_handle(&self, cx: &App) -> FocusHandle {
 939        if self.multibuffer.read(cx).is_empty() {
 940            self.focus_handle.clone()
 941        } else {
 942            self.editor.focus_handle(cx)
 943        }
 944    }
 945}
 946
 947impl Item for ProjectDiff {
 948    type Event = EditorEvent;
 949
 950    fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
 951        Some(Icon::new(IconName::GitBranch).color(Color::Muted))
 952    }
 953
 954    fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) {
 955        Editor::to_item_events(event, f)
 956    }
 957
 958    fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 959        self.editor.update(cx, |editor, cx| {
 960            editor.rhs_editor().update(cx, |primary_editor, cx| {
 961                primary_editor.deactivated(window, cx);
 962            })
 963        });
 964    }
 965
 966    fn navigate(
 967        &mut self,
 968        data: Arc<dyn Any + Send>,
 969        window: &mut Window,
 970        cx: &mut Context<Self>,
 971    ) -> bool {
 972        self.editor.update(cx, |editor, cx| {
 973            editor.rhs_editor().update(cx, |primary_editor, cx| {
 974                primary_editor.navigate(data, window, cx)
 975            })
 976        })
 977    }
 978
 979    fn tab_tooltip_text(&self, cx: &App) -> Option<SharedString> {
 980        match self.diff_base(cx) {
 981            DiffBase::Head => Some("Project Diff".into()),
 982            DiffBase::Merge { .. } => Some("Branch Diff".into()),
 983        }
 984    }
 985
 986    fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
 987        Label::new(self.tab_content_text(0, cx))
 988            .color(if params.selected {
 989                Color::Default
 990            } else {
 991                Color::Muted
 992            })
 993            .into_any_element()
 994    }
 995
 996    fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
 997        match self.branch_diff.read(cx).diff_base() {
 998            DiffBase::Head => "Uncommitted Changes".into(),
 999            DiffBase::Merge { base_ref } => format!("Changes since {}", base_ref).into(),
1000        }
1001    }
1002
1003    fn telemetry_event_text(&self) -> Option<&'static str> {
1004        Some("Project Diff Opened")
1005    }
1006
1007    fn as_searchable(&self, _: &Entity<Self>, _cx: &App) -> Option<Box<dyn SearchableItemHandle>> {
1008        Some(Box::new(self.editor.clone()))
1009    }
1010
1011    fn for_each_project_item(
1012        &self,
1013        cx: &App,
1014        f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
1015    ) {
1016        self.editor
1017            .read(cx)
1018            .rhs_editor()
1019            .read(cx)
1020            .for_each_project_item(cx, f)
1021    }
1022
1023    fn set_nav_history(
1024        &mut self,
1025        nav_history: ItemNavHistory,
1026        _: &mut Window,
1027        cx: &mut Context<Self>,
1028    ) {
1029        self.editor.update(cx, |editor, cx| {
1030            editor.rhs_editor().update(cx, |primary_editor, _| {
1031                primary_editor.set_nav_history(Some(nav_history));
1032            })
1033        });
1034    }
1035
1036    fn can_split(&self) -> bool {
1037        true
1038    }
1039
1040    fn clone_on_split(
1041        &self,
1042        _workspace_id: Option<workspace::WorkspaceId>,
1043        window: &mut Window,
1044        cx: &mut Context<Self>,
1045    ) -> Task<Option<Entity<Self>>>
1046    where
1047        Self: Sized,
1048    {
1049        let Some(workspace) = self.workspace.upgrade() else {
1050            return Task::ready(None);
1051        };
1052        Task::ready(Some(cx.new(|cx| {
1053            ProjectDiff::new(self.project.clone(), workspace, window, cx)
1054        })))
1055    }
1056
1057    fn is_dirty(&self, cx: &App) -> bool {
1058        self.multibuffer.read(cx).is_dirty(cx)
1059    }
1060
1061    fn has_conflict(&self, cx: &App) -> bool {
1062        self.multibuffer.read(cx).has_conflict(cx)
1063    }
1064
1065    fn can_save(&self, _: &App) -> bool {
1066        true
1067    }
1068
1069    fn save(
1070        &mut self,
1071        options: SaveOptions,
1072        project: Entity<Project>,
1073        window: &mut Window,
1074        cx: &mut Context<Self>,
1075    ) -> Task<Result<()>> {
1076        self.editor.update(cx, |editor, cx| {
1077            editor.rhs_editor().update(cx, |primary_editor, cx| {
1078                primary_editor.save(options, project, window, cx)
1079            })
1080        })
1081    }
1082
1083    fn save_as(
1084        &mut self,
1085        _: Entity<Project>,
1086        _: ProjectPath,
1087        _window: &mut Window,
1088        _: &mut Context<Self>,
1089    ) -> Task<Result<()>> {
1090        unreachable!()
1091    }
1092
1093    fn reload(
1094        &mut self,
1095        project: Entity<Project>,
1096        window: &mut Window,
1097        cx: &mut Context<Self>,
1098    ) -> Task<Result<()>> {
1099        self.editor.update(cx, |editor, cx| {
1100            editor.rhs_editor().update(cx, |primary_editor, cx| {
1101                primary_editor.reload(project, window, cx)
1102            })
1103        })
1104    }
1105
1106    fn act_as_type<'a>(
1107        &'a self,
1108        type_id: TypeId,
1109        self_handle: &'a Entity<Self>,
1110        cx: &'a App,
1111    ) -> Option<gpui::AnyEntity> {
1112        if type_id == TypeId::of::<Self>() {
1113            Some(self_handle.clone().into())
1114        } else if type_id == TypeId::of::<Editor>() {
1115            Some(self.editor.read(cx).rhs_editor().clone().into())
1116        } else if type_id == TypeId::of::<SplittableEditor>() {
1117            Some(self.editor.clone().into())
1118        } else {
1119            None
1120        }
1121    }
1122
1123    fn added_to_workspace(
1124        &mut self,
1125        workspace: &mut Workspace,
1126        window: &mut Window,
1127        cx: &mut Context<Self>,
1128    ) {
1129        self.editor.update(cx, |editor, cx| {
1130            editor.added_to_workspace(workspace, window, cx)
1131        });
1132    }
1133}
1134
1135impl Render for ProjectDiff {
1136    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1137        let is_empty = self.multibuffer.read(cx).is_empty();
1138        let is_branch_diff_view = matches!(self.diff_base(cx), DiffBase::Merge { .. });
1139
1140        div()
1141            .track_focus(&self.focus_handle)
1142            .key_context(if is_empty { "EmptyPane" } else { "GitDiff" })
1143            .when(is_branch_diff_view, |this| {
1144                this.on_action(cx.listener(Self::review_diff))
1145            })
1146            .bg(cx.theme().colors().editor_background)
1147            .flex()
1148            .items_center()
1149            .justify_center()
1150            .size_full()
1151            .when(is_empty, |el| {
1152                let remote_button = if let Some(panel) = self
1153                    .workspace
1154                    .upgrade()
1155                    .and_then(|workspace| workspace.read(cx).panel::<GitPanel>(cx))
1156                {
1157                    panel.update(cx, |panel, cx| panel.render_remote_button(cx))
1158                } else {
1159                    None
1160                };
1161                let keybinding_focus_handle = self.focus_handle(cx);
1162                el.child(
1163                    v_flex()
1164                        .gap_1()
1165                        .child(
1166                            h_flex()
1167                                .justify_around()
1168                                .child(Label::new("No uncommitted changes")),
1169                        )
1170                        .map(|el| match remote_button {
1171                            Some(button) => el.child(h_flex().justify_around().child(button)),
1172                            None => el.child(
1173                                h_flex()
1174                                    .justify_around()
1175                                    .child(Label::new("Remote up to date")),
1176                            ),
1177                        })
1178                        .child(
1179                            h_flex().justify_around().mt_1().child(
1180                                Button::new("project-diff-close-button", "Close")
1181                                    // .style(ButtonStyle::Transparent)
1182                                    .key_binding(KeyBinding::for_action_in(
1183                                        &CloseActiveItem::default(),
1184                                        &keybinding_focus_handle,
1185                                        cx,
1186                                    ))
1187                                    .on_click(move |_, window, cx| {
1188                                        window.focus(&keybinding_focus_handle, cx);
1189                                        window.dispatch_action(
1190                                            Box::new(CloseActiveItem::default()),
1191                                            cx,
1192                                        );
1193                                    }),
1194                            ),
1195                        ),
1196                )
1197            })
1198            .when(!is_empty, |el| el.child(self.editor.clone()))
1199    }
1200}
1201
1202impl SerializableItem for ProjectDiff {
1203    fn serialized_item_kind() -> &'static str {
1204        "ProjectDiff"
1205    }
1206
1207    fn cleanup(
1208        _: workspace::WorkspaceId,
1209        _: Vec<workspace::ItemId>,
1210        _: &mut Window,
1211        _: &mut App,
1212    ) -> Task<Result<()>> {
1213        Task::ready(Ok(()))
1214    }
1215
1216    fn deserialize(
1217        project: Entity<Project>,
1218        workspace: WeakEntity<Workspace>,
1219        workspace_id: workspace::WorkspaceId,
1220        item_id: workspace::ItemId,
1221        window: &mut Window,
1222        cx: &mut App,
1223    ) -> Task<Result<Entity<Self>>> {
1224        window.spawn(cx, async move |cx| {
1225            let diff_base = persistence::PROJECT_DIFF_DB.get_diff_base(item_id, workspace_id)?;
1226
1227            let diff = cx.update(|window, cx| {
1228                let branch_diff = cx
1229                    .new(|cx| branch_diff::BranchDiff::new(diff_base, project.clone(), window, cx));
1230                let workspace = workspace.upgrade().context("workspace gone")?;
1231                anyhow::Ok(
1232                    cx.new(|cx| ProjectDiff::new_impl(branch_diff, project, workspace, window, cx)),
1233                )
1234            })??;
1235
1236            Ok(diff)
1237        })
1238    }
1239
1240    fn serialize(
1241        &mut self,
1242        workspace: &mut Workspace,
1243        item_id: workspace::ItemId,
1244        _closing: bool,
1245        _window: &mut Window,
1246        cx: &mut Context<Self>,
1247    ) -> Option<Task<Result<()>>> {
1248        let workspace_id = workspace.database_id()?;
1249        let diff_base = self.diff_base(cx).clone();
1250
1251        Some(cx.background_spawn({
1252            async move {
1253                persistence::PROJECT_DIFF_DB
1254                    .save_diff_base(item_id, workspace_id, diff_base.clone())
1255                    .await
1256            }
1257        }))
1258    }
1259
1260    fn should_serialize(&self, _: &Self::Event) -> bool {
1261        false
1262    }
1263}
1264
1265mod persistence {
1266
1267    use anyhow::Context as _;
1268    use db::{
1269        sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
1270        sqlez_macros::sql,
1271    };
1272    use project::git_store::branch_diff::DiffBase;
1273    use workspace::{ItemId, WorkspaceDb, WorkspaceId};
1274
1275    pub struct ProjectDiffDb(ThreadSafeConnection);
1276
1277    impl Domain for ProjectDiffDb {
1278        const NAME: &str = stringify!(ProjectDiffDb);
1279
1280        const MIGRATIONS: &[&str] = &[sql!(
1281                CREATE TABLE project_diffs(
1282                    workspace_id INTEGER,
1283                    item_id INTEGER UNIQUE,
1284
1285                    diff_base TEXT,
1286
1287                    PRIMARY KEY(workspace_id, item_id),
1288                    FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
1289                    ON DELETE CASCADE
1290                ) STRICT;
1291        )];
1292    }
1293
1294    db::static_connection!(PROJECT_DIFF_DB, ProjectDiffDb, [WorkspaceDb]);
1295
1296    impl ProjectDiffDb {
1297        pub async fn save_diff_base(
1298            &self,
1299            item_id: ItemId,
1300            workspace_id: WorkspaceId,
1301            diff_base: DiffBase,
1302        ) -> anyhow::Result<()> {
1303            self.write(move |connection| {
1304                let sql_stmt = sql!(
1305                    INSERT OR REPLACE INTO project_diffs(item_id, workspace_id, diff_base) VALUES (?, ?, ?)
1306                );
1307                let diff_base_str = serde_json::to_string(&diff_base)?;
1308                let mut query = connection.exec_bound::<(ItemId, WorkspaceId, String)>(sql_stmt)?;
1309                query((item_id, workspace_id, diff_base_str)).context(format!(
1310                    "exec_bound failed to execute or parse for: {}",
1311                    sql_stmt
1312                ))
1313            })
1314            .await
1315        }
1316
1317        pub fn get_diff_base(
1318            &self,
1319            item_id: ItemId,
1320            workspace_id: WorkspaceId,
1321        ) -> anyhow::Result<DiffBase> {
1322            let sql_stmt =
1323                sql!(SELECT diff_base FROM project_diffs WHERE item_id =  ?AND workspace_id =  ?);
1324            let diff_base_str = self.select_row_bound::<(ItemId, WorkspaceId), String>(sql_stmt)?(
1325                (item_id, workspace_id),
1326            )
1327            .context(::std::format!(
1328                "Error in get_diff_base, select_row_bound failed to execute or parse for: {}",
1329                sql_stmt
1330            ))?;
1331            let Some(diff_base_str) = diff_base_str else {
1332                return Ok(DiffBase::Head);
1333            };
1334            serde_json::from_str(&diff_base_str).context("deserializing diff base")
1335        }
1336    }
1337}
1338
1339pub struct ProjectDiffToolbar {
1340    project_diff: Option<WeakEntity<ProjectDiff>>,
1341    workspace: WeakEntity<Workspace>,
1342}
1343
1344impl ProjectDiffToolbar {
1345    pub fn new(workspace: &Workspace, _: &mut Context<Self>) -> Self {
1346        Self {
1347            project_diff: None,
1348            workspace: workspace.weak_handle(),
1349        }
1350    }
1351
1352    fn project_diff(&self, _: &App) -> Option<Entity<ProjectDiff>> {
1353        self.project_diff.as_ref()?.upgrade()
1354    }
1355
1356    fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context<Self>) {
1357        if let Some(project_diff) = self.project_diff(cx) {
1358            project_diff.focus_handle(cx).focus(window, cx);
1359        }
1360        let action = action.boxed_clone();
1361        cx.defer(move |cx| {
1362            cx.dispatch_action(action.as_ref());
1363        })
1364    }
1365
1366    fn stage_all(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1367        self.workspace
1368            .update(cx, |workspace, cx| {
1369                if let Some(panel) = workspace.panel::<GitPanel>(cx) {
1370                    panel.update(cx, |panel, cx| {
1371                        panel.stage_all(&Default::default(), window, cx);
1372                    });
1373                }
1374            })
1375            .ok();
1376    }
1377
1378    fn unstage_all(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1379        self.workspace
1380            .update(cx, |workspace, cx| {
1381                let Some(panel) = workspace.panel::<GitPanel>(cx) else {
1382                    return;
1383                };
1384                panel.update(cx, |panel, cx| {
1385                    panel.unstage_all(&Default::default(), window, cx);
1386                });
1387            })
1388            .ok();
1389    }
1390}
1391
1392impl EventEmitter<ToolbarItemEvent> for ProjectDiffToolbar {}
1393
1394impl ToolbarItemView for ProjectDiffToolbar {
1395    fn set_active_pane_item(
1396        &mut self,
1397        active_pane_item: Option<&dyn ItemHandle>,
1398        _: &mut Window,
1399        cx: &mut Context<Self>,
1400    ) -> ToolbarItemLocation {
1401        self.project_diff = active_pane_item
1402            .and_then(|item| item.act_as::<ProjectDiff>(cx))
1403            .filter(|item| item.read(cx).diff_base(cx) == &DiffBase::Head)
1404            .map(|entity| entity.downgrade());
1405        if self.project_diff.is_some() {
1406            ToolbarItemLocation::PrimaryRight
1407        } else {
1408            ToolbarItemLocation::Hidden
1409        }
1410    }
1411
1412    fn pane_focus_update(
1413        &mut self,
1414        _pane_focused: bool,
1415        _window: &mut Window,
1416        _cx: &mut Context<Self>,
1417    ) {
1418    }
1419}
1420
1421struct ButtonStates {
1422    stage: bool,
1423    unstage: bool,
1424    prev_next: bool,
1425    selection: bool,
1426    stage_all: bool,
1427    unstage_all: bool,
1428}
1429
1430impl Render for ProjectDiffToolbar {
1431    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1432        let Some(project_diff) = self.project_diff(cx) else {
1433            return div();
1434        };
1435        let focus_handle = project_diff.focus_handle(cx);
1436        let button_states = project_diff.read(cx).button_states(cx);
1437        let review_count = project_diff.read(cx).total_review_comment_count();
1438
1439        h_group_xl()
1440            .my_neg_1()
1441            .py_1()
1442            .items_center()
1443            .flex_wrap()
1444            .justify_between()
1445            .child(
1446                h_group_sm()
1447                    .when(button_states.selection, |el| {
1448                        el.child(
1449                            Button::new("stage", "Toggle Staged")
1450                                .tooltip(Tooltip::for_action_title_in(
1451                                    "Toggle Staged",
1452                                    &ToggleStaged,
1453                                    &focus_handle,
1454                                ))
1455                                .disabled(!button_states.stage && !button_states.unstage)
1456                                .on_click(cx.listener(|this, _, window, cx| {
1457                                    this.dispatch_action(&ToggleStaged, window, cx)
1458                                })),
1459                        )
1460                    })
1461                    .when(!button_states.selection, |el| {
1462                        el.child(
1463                            Button::new("stage", "Stage")
1464                                .tooltip(Tooltip::for_action_title_in(
1465                                    "Stage and go to next hunk",
1466                                    &StageAndNext,
1467                                    &focus_handle,
1468                                ))
1469                                .disabled(
1470                                    !button_states.prev_next
1471                                        && !button_states.stage_all
1472                                        && !button_states.unstage_all,
1473                                )
1474                                .on_click(cx.listener(|this, _, window, cx| {
1475                                    this.dispatch_action(&StageAndNext, window, cx)
1476                                })),
1477                        )
1478                        .child(
1479                            Button::new("unstage", "Unstage")
1480                                .tooltip(Tooltip::for_action_title_in(
1481                                    "Unstage and go to next hunk",
1482                                    &UnstageAndNext,
1483                                    &focus_handle,
1484                                ))
1485                                .disabled(
1486                                    !button_states.prev_next
1487                                        && !button_states.stage_all
1488                                        && !button_states.unstage_all,
1489                                )
1490                                .on_click(cx.listener(|this, _, window, cx| {
1491                                    this.dispatch_action(&UnstageAndNext, window, cx)
1492                                })),
1493                        )
1494                    }),
1495            )
1496            // n.b. the only reason these arrows are here is because we don't
1497            // support "undo" for staging so we need a way to go back.
1498            .child(
1499                h_group_sm()
1500                    .child(
1501                        IconButton::new("up", IconName::ArrowUp)
1502                            .shape(ui::IconButtonShape::Square)
1503                            .tooltip(Tooltip::for_action_title_in(
1504                                "Go to previous hunk",
1505                                &GoToPreviousHunk,
1506                                &focus_handle,
1507                            ))
1508                            .disabled(!button_states.prev_next)
1509                            .on_click(cx.listener(|this, _, window, cx| {
1510                                this.dispatch_action(&GoToPreviousHunk, window, cx)
1511                            })),
1512                    )
1513                    .child(
1514                        IconButton::new("down", IconName::ArrowDown)
1515                            .shape(ui::IconButtonShape::Square)
1516                            .tooltip(Tooltip::for_action_title_in(
1517                                "Go to next hunk",
1518                                &GoToHunk,
1519                                &focus_handle,
1520                            ))
1521                            .disabled(!button_states.prev_next)
1522                            .on_click(cx.listener(|this, _, window, cx| {
1523                                this.dispatch_action(&GoToHunk, window, cx)
1524                            })),
1525                    ),
1526            )
1527            .child(vertical_divider())
1528            .child(
1529                h_group_sm()
1530                    .when(
1531                        button_states.unstage_all && !button_states.stage_all,
1532                        |el| {
1533                            el.child(
1534                                Button::new("unstage-all", "Unstage All")
1535                                    .tooltip(Tooltip::for_action_title_in(
1536                                        "Unstage all changes",
1537                                        &UnstageAll,
1538                                        &focus_handle,
1539                                    ))
1540                                    .on_click(cx.listener(|this, _, window, cx| {
1541                                        this.unstage_all(window, cx)
1542                                    })),
1543                            )
1544                        },
1545                    )
1546                    .when(
1547                        !button_states.unstage_all || button_states.stage_all,
1548                        |el| {
1549                            el.child(
1550                                // todo make it so that changing to say "Unstaged"
1551                                // doesn't change the position.
1552                                div().child(
1553                                    Button::new("stage-all", "Stage All")
1554                                        .disabled(!button_states.stage_all)
1555                                        .tooltip(Tooltip::for_action_title_in(
1556                                            "Stage all changes",
1557                                            &StageAll,
1558                                            &focus_handle,
1559                                        ))
1560                                        .on_click(cx.listener(|this, _, window, cx| {
1561                                            this.stage_all(window, cx)
1562                                        })),
1563                                ),
1564                            )
1565                        },
1566                    )
1567                    .child(
1568                        Button::new("commit", "Commit")
1569                            .tooltip(Tooltip::for_action_title_in(
1570                                "Commit",
1571                                &Commit,
1572                                &focus_handle,
1573                            ))
1574                            .on_click(cx.listener(|this, _, window, cx| {
1575                                this.dispatch_action(&Commit, window, cx);
1576                            })),
1577                    ),
1578            )
1579            // "Send Review to Agent" button (only shown when there are review comments)
1580            .when(review_count > 0, |el| {
1581                el.child(vertical_divider()).child(
1582                    render_send_review_to_agent_button(review_count, &focus_handle).on_click(
1583                        cx.listener(|this, _, window, cx| {
1584                            this.dispatch_action(&SendReviewToAgent, window, cx)
1585                        }),
1586                    ),
1587                )
1588            })
1589    }
1590}
1591
1592fn render_send_review_to_agent_button(review_count: usize, focus_handle: &FocusHandle) -> Button {
1593    Button::new(
1594        "send-review",
1595        format!("Send Review to Agent ({})", review_count),
1596    )
1597    .icon(IconName::ZedAssistant)
1598    .icon_position(IconPosition::Start)
1599    .tooltip(Tooltip::for_action_title_in(
1600        "Send all review comments to the Agent panel",
1601        &SendReviewToAgent,
1602        focus_handle,
1603    ))
1604}
1605
1606pub struct BranchDiffToolbar {
1607    project_diff: Option<WeakEntity<ProjectDiff>>,
1608}
1609
1610impl BranchDiffToolbar {
1611    pub fn new(_cx: &mut Context<Self>) -> Self {
1612        Self { project_diff: None }
1613    }
1614
1615    fn project_diff(&self, _: &App) -> Option<Entity<ProjectDiff>> {
1616        self.project_diff.as_ref()?.upgrade()
1617    }
1618
1619    fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context<Self>) {
1620        if let Some(project_diff) = self.project_diff(cx) {
1621            project_diff.focus_handle(cx).focus(window, cx);
1622        }
1623        let action = action.boxed_clone();
1624        cx.defer(move |cx| {
1625            cx.dispatch_action(action.as_ref());
1626        })
1627    }
1628}
1629
1630impl EventEmitter<ToolbarItemEvent> for BranchDiffToolbar {}
1631
1632impl ToolbarItemView for BranchDiffToolbar {
1633    fn set_active_pane_item(
1634        &mut self,
1635        active_pane_item: Option<&dyn ItemHandle>,
1636        _: &mut Window,
1637        cx: &mut Context<Self>,
1638    ) -> ToolbarItemLocation {
1639        self.project_diff = active_pane_item
1640            .and_then(|item| item.act_as::<ProjectDiff>(cx))
1641            .filter(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Merge { .. }))
1642            .map(|entity| entity.downgrade());
1643        if self.project_diff.is_some() {
1644            ToolbarItemLocation::PrimaryRight
1645        } else {
1646            ToolbarItemLocation::Hidden
1647        }
1648    }
1649
1650    fn pane_focus_update(
1651        &mut self,
1652        _pane_focused: bool,
1653        _window: &mut Window,
1654        _cx: &mut Context<Self>,
1655    ) {
1656    }
1657}
1658
1659impl Render for BranchDiffToolbar {
1660    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1661        let Some(project_diff) = self.project_diff(cx) else {
1662            return div();
1663        };
1664        let focus_handle = project_diff.focus_handle(cx);
1665        let review_count = project_diff.read(cx).total_review_comment_count();
1666        let (additions, deletions) = project_diff.read(cx).calculate_changed_lines(cx);
1667
1668        let is_multibuffer_empty = project_diff.read(cx).multibuffer.read(cx).is_empty();
1669        let is_ai_enabled = AgentSettings::get_global(cx).enabled(cx);
1670
1671        let show_review_button = !is_multibuffer_empty && is_ai_enabled;
1672
1673        h_group_xl()
1674            .my_neg_1()
1675            .py_1()
1676            .items_center()
1677            .flex_wrap()
1678            .justify_end()
1679            .gap_2()
1680            .when(!is_multibuffer_empty, |this| {
1681                this.child(DiffStat::new(
1682                    "branch-diff-stat",
1683                    additions as usize,
1684                    deletions as usize,
1685                ))
1686            })
1687            .when(show_review_button, |this| {
1688                let focus_handle = focus_handle.clone();
1689                this.child(Divider::vertical()).child(
1690                    Button::new("review-diff", "Review Diff")
1691                        .icon(IconName::ZedAssistant)
1692                        .icon_position(IconPosition::Start)
1693                        .icon_size(IconSize::Small)
1694                        .icon_color(Color::Muted)
1695                        .key_binding(KeyBinding::for_action_in(&ReviewDiff, &focus_handle, cx))
1696                        .tooltip(move |_, cx| {
1697                            Tooltip::with_meta_in(
1698                                "Review Diff",
1699                                Some(&ReviewDiff),
1700                                "Send this diff for your last agent to review.",
1701                                &focus_handle,
1702                                cx,
1703                            )
1704                        })
1705                        .on_click(cx.listener(|this, _, window, cx| {
1706                            this.dispatch_action(&ReviewDiff, window, cx);
1707                        })),
1708                )
1709            })
1710            .when(review_count > 0, |this| {
1711                this.child(vertical_divider()).child(
1712                    render_send_review_to_agent_button(review_count, &focus_handle).on_click(
1713                        cx.listener(|this, _, window, cx| {
1714                            this.dispatch_action(&SendReviewToAgent, window, cx)
1715                        }),
1716                    ),
1717                )
1718            })
1719    }
1720}
1721
1722#[derive(IntoElement, RegisterComponent)]
1723pub struct ProjectDiffEmptyState {
1724    pub no_repo: bool,
1725    pub can_push_and_pull: bool,
1726    pub focus_handle: Option<FocusHandle>,
1727    pub current_branch: Option<Branch>,
1728    // has_pending_commits: bool,
1729    // ahead_of_remote: bool,
1730    // no_git_repository: bool,
1731}
1732
1733impl RenderOnce for ProjectDiffEmptyState {
1734    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
1735        let status_against_remote = |ahead_by: usize, behind_by: usize| -> bool {
1736            matches!(self.current_branch, Some(Branch {
1737                    upstream:
1738                        Some(Upstream {
1739                            tracking:
1740                                UpstreamTracking::Tracked(UpstreamTrackingStatus {
1741                                    ahead, behind, ..
1742                                }),
1743                            ..
1744                        }),
1745                    ..
1746                }) if (ahead > 0) == (ahead_by > 0) && (behind > 0) == (behind_by > 0))
1747        };
1748
1749        let change_count = |current_branch: &Branch| -> (usize, usize) {
1750            match current_branch {
1751                Branch {
1752                    upstream:
1753                        Some(Upstream {
1754                            tracking:
1755                                UpstreamTracking::Tracked(UpstreamTrackingStatus {
1756                                    ahead, behind, ..
1757                                }),
1758                            ..
1759                        }),
1760                    ..
1761                } => (*ahead as usize, *behind as usize),
1762                _ => (0, 0),
1763            }
1764        };
1765
1766        let not_ahead_or_behind = status_against_remote(0, 0);
1767        let ahead_of_remote = status_against_remote(1, 0);
1768        let branch_not_on_remote = if let Some(branch) = self.current_branch.as_ref() {
1769            branch.upstream.is_none()
1770        } else {
1771            false
1772        };
1773
1774        let has_branch_container = |branch: &Branch| {
1775            h_flex()
1776                .max_w(px(420.))
1777                .bg(cx.theme().colors().text.opacity(0.05))
1778                .border_1()
1779                .border_color(cx.theme().colors().border)
1780                .rounded_sm()
1781                .gap_8()
1782                .px_6()
1783                .py_4()
1784                .map(|this| {
1785                    if ahead_of_remote {
1786                        let ahead_count = change_count(branch).0;
1787                        let ahead_string = format!("{} Commits Ahead", ahead_count);
1788                        this.child(
1789                            v_flex()
1790                                .child(Headline::new(ahead_string).size(HeadlineSize::Small))
1791                                .child(
1792                                    Label::new(format!("Push your changes to {}", branch.name()))
1793                                        .color(Color::Muted),
1794                                ),
1795                        )
1796                        .child(div().child(render_push_button(
1797                            self.focus_handle,
1798                            "push".into(),
1799                            ahead_count as u32,
1800                        )))
1801                    } else if branch_not_on_remote {
1802                        this.child(
1803                            v_flex()
1804                                .child(Headline::new("Publish Branch").size(HeadlineSize::Small))
1805                                .child(
1806                                    Label::new(format!("Create {} on remote", branch.name()))
1807                                        .color(Color::Muted),
1808                                ),
1809                        )
1810                        .child(
1811                            div().child(render_publish_button(self.focus_handle, "publish".into())),
1812                        )
1813                    } else {
1814                        this.child(Label::new("Remote status unknown").color(Color::Muted))
1815                    }
1816                })
1817        };
1818
1819        v_flex().size_full().items_center().justify_center().child(
1820            v_flex()
1821                .gap_1()
1822                .when(self.no_repo, |this| {
1823                    this.text_center()
1824                        .child(Label::new("No Repository").color(Color::Muted))
1825                        .child(
1826                            Button::new("initialize-repo", "Initialize Repository")
1827                                .on_click(move |_, _, cx| cx.dispatch_action(&git::Init)),
1828                        )
1829                })
1830                .map(|this| {
1831                    if not_ahead_or_behind && self.current_branch.is_some() {
1832                        this.text_center()
1833                            .child(Label::new("No Changes").color(Color::Muted))
1834                    } else {
1835                        this.when_some(self.current_branch.as_ref(), |this, branch| {
1836                            this.child(has_branch_container(branch))
1837                        })
1838                    }
1839                }),
1840        )
1841    }
1842}
1843
1844mod preview {
1845    use git::repository::{
1846        Branch, CommitSummary, Upstream, UpstreamTracking, UpstreamTrackingStatus,
1847    };
1848    use ui::prelude::*;
1849
1850    use super::ProjectDiffEmptyState;
1851
1852    // View this component preview using `workspace: open component-preview`
1853    impl Component for ProjectDiffEmptyState {
1854        fn scope() -> ComponentScope {
1855            ComponentScope::VersionControl
1856        }
1857
1858        fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
1859            let unknown_upstream: Option<UpstreamTracking> = None;
1860            let ahead_of_upstream: Option<UpstreamTracking> = Some(
1861                UpstreamTrackingStatus {
1862                    ahead: 2,
1863                    behind: 0,
1864                }
1865                .into(),
1866            );
1867
1868            let not_ahead_or_behind_upstream: Option<UpstreamTracking> = Some(
1869                UpstreamTrackingStatus {
1870                    ahead: 0,
1871                    behind: 0,
1872                }
1873                .into(),
1874            );
1875
1876            fn branch(upstream: Option<UpstreamTracking>) -> Branch {
1877                Branch {
1878                    is_head: true,
1879                    ref_name: "some-branch".into(),
1880                    upstream: upstream.map(|tracking| Upstream {
1881                        ref_name: "origin/some-branch".into(),
1882                        tracking,
1883                    }),
1884                    most_recent_commit: Some(CommitSummary {
1885                        sha: "abc123".into(),
1886                        subject: "Modify stuff".into(),
1887                        commit_timestamp: 1710932954,
1888                        author_name: "John Doe".into(),
1889                        has_parent: true,
1890                    }),
1891                }
1892            }
1893
1894            let no_repo_state = ProjectDiffEmptyState {
1895                no_repo: true,
1896                can_push_and_pull: false,
1897                focus_handle: None,
1898                current_branch: None,
1899            };
1900
1901            let no_changes_state = ProjectDiffEmptyState {
1902                no_repo: false,
1903                can_push_and_pull: true,
1904                focus_handle: None,
1905                current_branch: Some(branch(not_ahead_or_behind_upstream)),
1906            };
1907
1908            let ahead_of_upstream_state = ProjectDiffEmptyState {
1909                no_repo: false,
1910                can_push_and_pull: true,
1911                focus_handle: None,
1912                current_branch: Some(branch(ahead_of_upstream)),
1913            };
1914
1915            let unknown_upstream_state = ProjectDiffEmptyState {
1916                no_repo: false,
1917                can_push_and_pull: true,
1918                focus_handle: None,
1919                current_branch: Some(branch(unknown_upstream)),
1920            };
1921
1922            let (width, height) = (px(480.), px(320.));
1923
1924            Some(
1925                v_flex()
1926                    .gap_6()
1927                    .children(vec![
1928                        example_group(vec![
1929                            single_example(
1930                                "No Repo",
1931                                div()
1932                                    .w(width)
1933                                    .h(height)
1934                                    .child(no_repo_state)
1935                                    .into_any_element(),
1936                            ),
1937                            single_example(
1938                                "No Changes",
1939                                div()
1940                                    .w(width)
1941                                    .h(height)
1942                                    .child(no_changes_state)
1943                                    .into_any_element(),
1944                            ),
1945                            single_example(
1946                                "Unknown Upstream",
1947                                div()
1948                                    .w(width)
1949                                    .h(height)
1950                                    .child(unknown_upstream_state)
1951                                    .into_any_element(),
1952                            ),
1953                            single_example(
1954                                "Ahead of Remote",
1955                                div()
1956                                    .w(width)
1957                                    .h(height)
1958                                    .child(ahead_of_upstream_state)
1959                                    .into_any_element(),
1960                            ),
1961                        ])
1962                        .vertical(),
1963                    ])
1964                    .into_any_element(),
1965            )
1966        }
1967    }
1968}
1969
1970struct BranchDiffAddon {
1971    branch_diff: Entity<branch_diff::BranchDiff>,
1972}
1973
1974impl Addon for BranchDiffAddon {
1975    fn to_any(&self) -> &dyn std::any::Any {
1976        self
1977    }
1978
1979    fn override_status_for_buffer_id(
1980        &self,
1981        buffer_id: language::BufferId,
1982        cx: &App,
1983    ) -> Option<FileStatus> {
1984        self.branch_diff
1985            .read(cx)
1986            .status_for_buffer_id(buffer_id, cx)
1987    }
1988}
1989
1990#[cfg(test)]
1991mod tests {
1992    use collections::HashMap;
1993    use db::indoc;
1994    use editor::test::editor_test_context::{EditorTestContext, assert_state_with_diff};
1995    use git::status::{TrackedStatus, UnmergedStatus, UnmergedStatusCode};
1996    use gpui::TestAppContext;
1997    use project::FakeFs;
1998    use serde_json::json;
1999    use settings::{DiffViewStyle, SettingsStore};
2000    use std::path::Path;
2001    use unindent::Unindent as _;
2002    use util::{
2003        path,
2004        rel_path::{RelPath, rel_path},
2005    };
2006
2007    use workspace::MultiWorkspace;
2008
2009    use super::*;
2010
2011    #[ctor::ctor]
2012    fn init_logger() {
2013        zlog::init_test();
2014    }
2015
2016    fn init_test(cx: &mut TestAppContext) {
2017        cx.update(|cx| {
2018            let store = SettingsStore::test(cx);
2019            cx.set_global(store);
2020            cx.update_global::<SettingsStore, _>(|store, cx| {
2021                store.update_user_settings(cx, |settings| {
2022                    settings.editor.diff_view_style = Some(DiffViewStyle::Unified);
2023                });
2024            });
2025            theme::init(theme::LoadThemes::JustBase, cx);
2026            editor::init(cx);
2027            crate::init(cx);
2028        });
2029    }
2030
2031    #[gpui::test]
2032    async fn test_save_after_restore(cx: &mut TestAppContext) {
2033        init_test(cx);
2034
2035        let fs = FakeFs::new(cx.executor());
2036        fs.insert_tree(
2037            path!("/project"),
2038            json!({
2039                ".git": {},
2040                "foo.txt": "FOO\n",
2041            }),
2042        )
2043        .await;
2044        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
2045
2046        fs.set_head_for_repo(
2047            path!("/project/.git").as_ref(),
2048            &[("foo.txt", "foo\n".into())],
2049            "deadbeef",
2050        );
2051        fs.set_index_for_repo(
2052            path!("/project/.git").as_ref(),
2053            &[("foo.txt", "foo\n".into())],
2054        );
2055
2056        let (multi_workspace, cx) =
2057            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2058        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2059        let diff = cx.new_window_entity(|window, cx| {
2060            ProjectDiff::new(project.clone(), workspace, window, cx)
2061        });
2062        cx.run_until_parked();
2063
2064        let editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).rhs_editor().clone());
2065        assert_state_with_diff(
2066            &editor,
2067            cx,
2068            &"
2069                - ˇfoo
2070                + FOO
2071            "
2072            .unindent(),
2073        );
2074
2075        editor
2076            .update_in(cx, |editor, window, cx| {
2077                editor.git_restore(&Default::default(), window, cx);
2078                editor.save(SaveOptions::default(), project.clone(), window, cx)
2079            })
2080            .await
2081            .unwrap();
2082        cx.run_until_parked();
2083
2084        assert_state_with_diff(&editor, cx, &"ˇ".unindent());
2085
2086        let text = String::from_utf8(fs.read_file_sync("/project/foo.txt").unwrap()).unwrap();
2087        assert_eq!(text, "foo\n");
2088    }
2089
2090    #[gpui::test]
2091    async fn test_scroll_to_beginning_with_deletion(cx: &mut TestAppContext) {
2092        init_test(cx);
2093
2094        let fs = FakeFs::new(cx.executor());
2095        fs.insert_tree(
2096            path!("/project"),
2097            json!({
2098                ".git": {},
2099                "bar": "BAR\n",
2100                "foo": "FOO\n",
2101            }),
2102        )
2103        .await;
2104        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
2105        let (multi_workspace, cx) =
2106            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2107        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2108        let diff = cx.new_window_entity(|window, cx| {
2109            ProjectDiff::new(project.clone(), workspace, window, cx)
2110        });
2111        cx.run_until_parked();
2112
2113        fs.set_head_and_index_for_repo(
2114            path!("/project/.git").as_ref(),
2115            &[("bar", "bar\n".into()), ("foo", "foo\n".into())],
2116        );
2117        cx.run_until_parked();
2118
2119        let editor = cx.update_window_entity(&diff, |diff, window, cx| {
2120            diff.move_to_path(
2121                PathKey::with_sort_prefix(TRACKED_SORT_PREFIX, rel_path("foo").into_arc()),
2122                window,
2123                cx,
2124            );
2125            diff.editor.read(cx).rhs_editor().clone()
2126        });
2127        assert_state_with_diff(
2128            &editor,
2129            cx,
2130            &"
2131                - bar
2132                + BAR
2133
2134                - ˇfoo
2135                + FOO
2136            "
2137            .unindent(),
2138        );
2139
2140        let editor = cx.update_window_entity(&diff, |diff, window, cx| {
2141            diff.move_to_path(
2142                PathKey::with_sort_prefix(TRACKED_SORT_PREFIX, rel_path("bar").into_arc()),
2143                window,
2144                cx,
2145            );
2146            diff.editor.read(cx).rhs_editor().clone()
2147        });
2148        assert_state_with_diff(
2149            &editor,
2150            cx,
2151            &"
2152                - ˇbar
2153                + BAR
2154
2155                - foo
2156                + FOO
2157            "
2158            .unindent(),
2159        );
2160    }
2161
2162    #[gpui::test]
2163    async fn test_hunks_after_restore_then_modify(cx: &mut TestAppContext) {
2164        init_test(cx);
2165
2166        let fs = FakeFs::new(cx.executor());
2167        fs.insert_tree(
2168            path!("/project"),
2169            json!({
2170                ".git": {},
2171                "foo": "modified\n",
2172            }),
2173        )
2174        .await;
2175        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
2176        let (multi_workspace, cx) =
2177            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2178        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2179        fs.set_head_for_repo(
2180            path!("/project/.git").as_ref(),
2181            &[("foo", "original\n".into())],
2182            "deadbeef",
2183        );
2184
2185        let buffer = project
2186            .update(cx, |project, cx| {
2187                project.open_local_buffer(path!("/project/foo"), cx)
2188            })
2189            .await
2190            .unwrap();
2191        let buffer_editor = cx.new_window_entity(|window, cx| {
2192            Editor::for_buffer(buffer, Some(project.clone()), window, cx)
2193        });
2194        let diff = cx.new_window_entity(|window, cx| {
2195            ProjectDiff::new(project.clone(), workspace, window, cx)
2196        });
2197        cx.run_until_parked();
2198
2199        let diff_editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).rhs_editor().clone());
2200
2201        assert_state_with_diff(
2202            &diff_editor,
2203            cx,
2204            &"
2205                - ˇoriginal
2206                + modified
2207            "
2208            .unindent(),
2209        );
2210
2211        let prev_buffer_hunks =
2212            cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
2213                let snapshot = buffer_editor.snapshot(window, cx);
2214                let snapshot = &snapshot.buffer_snapshot();
2215                let prev_buffer_hunks = buffer_editor
2216                    .diff_hunks_in_ranges(&[editor::Anchor::min()..editor::Anchor::max()], snapshot)
2217                    .collect::<Vec<_>>();
2218                buffer_editor.git_restore(&Default::default(), window, cx);
2219                prev_buffer_hunks
2220            });
2221        assert_eq!(prev_buffer_hunks.len(), 1);
2222        cx.run_until_parked();
2223
2224        let new_buffer_hunks =
2225            cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
2226                let snapshot = buffer_editor.snapshot(window, cx);
2227                let snapshot = &snapshot.buffer_snapshot();
2228                buffer_editor
2229                    .diff_hunks_in_ranges(&[editor::Anchor::min()..editor::Anchor::max()], snapshot)
2230                    .collect::<Vec<_>>()
2231            });
2232        assert_eq!(new_buffer_hunks.as_slice(), &[]);
2233
2234        cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
2235            buffer_editor.set_text("different\n", window, cx);
2236            buffer_editor.save(
2237                SaveOptions {
2238                    format: false,
2239                    autosave: false,
2240                },
2241                project.clone(),
2242                window,
2243                cx,
2244            )
2245        })
2246        .await
2247        .unwrap();
2248
2249        cx.run_until_parked();
2250
2251        cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
2252            buffer_editor.expand_all_diff_hunks(&Default::default(), window, cx);
2253        });
2254
2255        assert_state_with_diff(
2256            &buffer_editor,
2257            cx,
2258            &"
2259                - original
2260                + different
2261                  ˇ"
2262            .unindent(),
2263        );
2264
2265        assert_state_with_diff(
2266            &diff_editor,
2267            cx,
2268            &"
2269                - ˇoriginal
2270                + different
2271            "
2272            .unindent(),
2273        );
2274    }
2275
2276    use crate::{
2277        conflict_view::resolve_conflict,
2278        project_diff::{self, ProjectDiff},
2279    };
2280
2281    #[gpui::test]
2282    async fn test_go_to_prev_hunk_multibuffer(cx: &mut TestAppContext) {
2283        init_test(cx);
2284
2285        let fs = FakeFs::new(cx.executor());
2286        fs.insert_tree(
2287            path!("/a"),
2288            json!({
2289                ".git": {},
2290                "a.txt": "created\n",
2291                "b.txt": "really changed\n",
2292                "c.txt": "unchanged\n"
2293            }),
2294        )
2295        .await;
2296
2297        fs.set_head_and_index_for_repo(
2298            Path::new(path!("/a/.git")),
2299            &[
2300                ("b.txt", "before\n".to_string()),
2301                ("c.txt", "unchanged\n".to_string()),
2302                ("d.txt", "deleted\n".to_string()),
2303            ],
2304        );
2305
2306        let project = Project::test(fs, [Path::new(path!("/a"))], cx).await;
2307        let (multi_workspace, cx) =
2308            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2309        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2310
2311        cx.run_until_parked();
2312
2313        cx.focus(&workspace);
2314        cx.update(|window, cx| {
2315            window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
2316        });
2317
2318        cx.run_until_parked();
2319
2320        let item = workspace.update(cx, |workspace, cx| {
2321            workspace.active_item_as::<ProjectDiff>(cx).unwrap()
2322        });
2323        cx.focus(&item);
2324        let editor = item.read_with(cx, |item, cx| item.editor.read(cx).rhs_editor().clone());
2325
2326        let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
2327
2328        cx.assert_excerpts_with_selections(indoc!(
2329            "
2330            [EXCERPT]
2331            before
2332            really changed
2333            [EXCERPT]
2334            [FOLDED]
2335            [EXCERPT]
2336            ˇcreated
2337        "
2338        ));
2339
2340        cx.dispatch_action(editor::actions::GoToPreviousHunk);
2341
2342        cx.assert_excerpts_with_selections(indoc!(
2343            "
2344            [EXCERPT]
2345            before
2346            really changed
2347            [EXCERPT]
2348            ˇ[FOLDED]
2349            [EXCERPT]
2350            created
2351        "
2352        ));
2353
2354        cx.dispatch_action(editor::actions::GoToPreviousHunk);
2355
2356        cx.assert_excerpts_with_selections(indoc!(
2357            "
2358            [EXCERPT]
2359            ˇbefore
2360            really changed
2361            [EXCERPT]
2362            [FOLDED]
2363            [EXCERPT]
2364            created
2365        "
2366        ));
2367    }
2368
2369    #[gpui::test]
2370    async fn test_excerpts_splitting_after_restoring_the_middle_excerpt(cx: &mut TestAppContext) {
2371        init_test(cx);
2372
2373        let git_contents = indoc! {r#"
2374            #[rustfmt::skip]
2375            fn main() {
2376                let x = 0.0; // this line will be removed
2377                // 1
2378                // 2
2379                // 3
2380                let y = 0.0; // this line will be removed
2381                // 1
2382                // 2
2383                // 3
2384                let arr = [
2385                    0.0, // this line will be removed
2386                    0.0, // this line will be removed
2387                    0.0, // this line will be removed
2388                    0.0, // this line will be removed
2389                ];
2390            }
2391        "#};
2392        let buffer_contents = indoc! {"
2393            #[rustfmt::skip]
2394            fn main() {
2395                // 1
2396                // 2
2397                // 3
2398                // 1
2399                // 2
2400                // 3
2401                let arr = [
2402                ];
2403            }
2404        "};
2405
2406        let fs = FakeFs::new(cx.executor());
2407        fs.insert_tree(
2408            path!("/a"),
2409            json!({
2410                ".git": {},
2411                "main.rs": buffer_contents,
2412            }),
2413        )
2414        .await;
2415
2416        fs.set_head_and_index_for_repo(
2417            Path::new(path!("/a/.git")),
2418            &[("main.rs", git_contents.to_owned())],
2419        );
2420
2421        let project = Project::test(fs, [Path::new(path!("/a"))], cx).await;
2422        let (multi_workspace, cx) =
2423            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2424        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2425
2426        cx.run_until_parked();
2427
2428        cx.focus(&workspace);
2429        cx.update(|window, cx| {
2430            window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
2431        });
2432
2433        cx.run_until_parked();
2434
2435        let item = workspace.update(cx, |workspace, cx| {
2436            workspace.active_item_as::<ProjectDiff>(cx).unwrap()
2437        });
2438        cx.focus(&item);
2439        let editor = item.read_with(cx, |item, cx| item.editor.read(cx).rhs_editor().clone());
2440
2441        let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
2442
2443        cx.assert_excerpts_with_selections(&format!("[EXCERPT]\nˇ{git_contents}"));
2444
2445        cx.dispatch_action(editor::actions::GoToHunk);
2446        cx.dispatch_action(editor::actions::GoToHunk);
2447        cx.dispatch_action(git::Restore);
2448        cx.dispatch_action(editor::actions::MoveToBeginning);
2449
2450        cx.assert_excerpts_with_selections(&format!("[EXCERPT]\nˇ{git_contents}"));
2451    }
2452
2453    #[gpui::test]
2454    async fn test_saving_resolved_conflicts(cx: &mut TestAppContext) {
2455        init_test(cx);
2456
2457        let fs = FakeFs::new(cx.executor());
2458        fs.insert_tree(
2459            path!("/project"),
2460            json!({
2461                ".git": {},
2462                "foo": "<<<<<<< x\nours\n=======\ntheirs\n>>>>>>> y\n",
2463            }),
2464        )
2465        .await;
2466        fs.set_status_for_repo(
2467            Path::new(path!("/project/.git")),
2468            &[(
2469                "foo",
2470                UnmergedStatus {
2471                    first_head: UnmergedStatusCode::Updated,
2472                    second_head: UnmergedStatusCode::Updated,
2473                }
2474                .into(),
2475            )],
2476        );
2477        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
2478        let (multi_workspace, cx) =
2479            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2480        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2481        let diff = cx.new_window_entity(|window, cx| {
2482            ProjectDiff::new(project.clone(), workspace, window, cx)
2483        });
2484        cx.run_until_parked();
2485
2486        cx.update(|window, cx| {
2487            let editor = diff.read(cx).editor.read(cx).rhs_editor().clone();
2488            let excerpt_ids = editor.read(cx).buffer().read(cx).excerpt_ids();
2489            assert_eq!(excerpt_ids.len(), 1);
2490            let excerpt_id = excerpt_ids[0];
2491            let buffer = editor
2492                .read(cx)
2493                .buffer()
2494                .read(cx)
2495                .all_buffers()
2496                .into_iter()
2497                .next()
2498                .unwrap();
2499            let buffer_id = buffer.read(cx).remote_id();
2500            let conflict_set = diff
2501                .read(cx)
2502                .editor
2503                .read(cx)
2504                .rhs_editor()
2505                .read(cx)
2506                .addon::<ConflictAddon>()
2507                .unwrap()
2508                .conflict_set(buffer_id)
2509                .unwrap();
2510            assert!(conflict_set.read(cx).has_conflict);
2511            let snapshot = conflict_set.read(cx).snapshot();
2512            assert_eq!(snapshot.conflicts.len(), 1);
2513
2514            let ours_range = snapshot.conflicts[0].ours.clone();
2515
2516            resolve_conflict(
2517                editor.downgrade(),
2518                excerpt_id,
2519                snapshot.conflicts[0].clone(),
2520                vec![ours_range],
2521                window,
2522                cx,
2523            )
2524        })
2525        .await;
2526
2527        let contents = fs.read_file_sync(path!("/project/foo")).unwrap();
2528        let contents = String::from_utf8(contents).unwrap();
2529        assert_eq!(contents, "ours\n");
2530    }
2531
2532    #[gpui::test(iterations = 50)]
2533    async fn test_split_diff_conflict_path_transition_with_dirty_buffer_invalid_anchor_panics(
2534        cx: &mut TestAppContext,
2535    ) {
2536        init_test(cx);
2537
2538        cx.update(|cx| {
2539            cx.update_global::<SettingsStore, _>(|store, cx| {
2540                store.update_user_settings(cx, |settings| {
2541                    settings.editor.diff_view_style = Some(DiffViewStyle::Split);
2542                });
2543            });
2544        });
2545
2546        let build_conflict_text: fn(usize) -> String = |tag: usize| {
2547            let mut lines = (0..80)
2548                .map(|line_index| format!("line {line_index}"))
2549                .collect::<Vec<_>>();
2550            for offset in [5usize, 20, 37, 61] {
2551                lines[offset] = format!("base-{tag}-line-{offset}");
2552            }
2553            format!("{}\n", lines.join("\n"))
2554        };
2555        let initial_conflict_text = build_conflict_text(0);
2556        let fs = FakeFs::new(cx.executor());
2557        fs.insert_tree(
2558            path!("/project"),
2559            json!({
2560                ".git": {},
2561                "helper.txt": "same\n",
2562                "conflict.txt": initial_conflict_text,
2563            }),
2564        )
2565        .await;
2566        fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
2567            state
2568                .refs
2569                .insert("MERGE_HEAD".into(), "conflict-head".into());
2570        })
2571        .unwrap();
2572        fs.set_status_for_repo(
2573            path!("/project/.git").as_ref(),
2574            &[(
2575                "conflict.txt",
2576                FileStatus::Unmerged(UnmergedStatus {
2577                    first_head: UnmergedStatusCode::Updated,
2578                    second_head: UnmergedStatusCode::Updated,
2579                }),
2580            )],
2581        );
2582        fs.set_merge_base_content_for_repo(
2583            path!("/project/.git").as_ref(),
2584            &[
2585                ("conflict.txt", build_conflict_text(1)),
2586                ("helper.txt", "same\n".to_string()),
2587            ],
2588        );
2589
2590        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
2591        let (multi_workspace, cx) =
2592            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2593        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2594        let _project_diff = cx
2595            .update(|window, cx| {
2596                ProjectDiff::new_with_default_branch(project.clone(), workspace, window, cx)
2597            })
2598            .await
2599            .unwrap();
2600        cx.run_until_parked();
2601
2602        let buffer = project
2603            .update(cx, |project, cx| {
2604                project.open_local_buffer(path!("/project/conflict.txt"), cx)
2605            })
2606            .await
2607            .unwrap();
2608        buffer.update(cx, |buffer, cx| buffer.edit([(0..0, "dirty\n")], None, cx));
2609        assert!(buffer.read_with(cx, |buffer, _| buffer.is_dirty()));
2610        cx.run_until_parked();
2611
2612        cx.update(|window, cx| {
2613            let fs = fs.clone();
2614            window
2615                .spawn(cx, async move |cx| {
2616                    cx.background_executor().simulate_random_delay().await;
2617                    fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
2618                        state.refs.insert("HEAD".into(), "head-1".into());
2619                        state.refs.remove("MERGE_HEAD");
2620                    })
2621                    .unwrap();
2622                    fs.set_status_for_repo(
2623                        path!("/project/.git").as_ref(),
2624                        &[
2625                            (
2626                                "conflict.txt",
2627                                FileStatus::Tracked(TrackedStatus {
2628                                    index_status: git::status::StatusCode::Modified,
2629                                    worktree_status: git::status::StatusCode::Modified,
2630                                }),
2631                            ),
2632                            (
2633                                "helper.txt",
2634                                FileStatus::Tracked(TrackedStatus {
2635                                    index_status: git::status::StatusCode::Modified,
2636                                    worktree_status: git::status::StatusCode::Modified,
2637                                }),
2638                            ),
2639                        ],
2640                    );
2641                    // FakeFs assigns deterministic OIDs by entry position; flipping order churns
2642                    // conflict diff identity without reaching into ProjectDiff internals.
2643                    fs.set_merge_base_content_for_repo(
2644                        path!("/project/.git").as_ref(),
2645                        &[
2646                            ("helper.txt", "helper-base\n".to_string()),
2647                            ("conflict.txt", build_conflict_text(2)),
2648                        ],
2649                    );
2650                })
2651                .detach();
2652        });
2653
2654        cx.update(|window, cx| {
2655            let buffer = buffer.clone();
2656            window
2657                .spawn(cx, async move |cx| {
2658                    cx.background_executor().simulate_random_delay().await;
2659                    for edit_index in 0..10 {
2660                        if edit_index > 0 {
2661                            cx.background_executor().simulate_random_delay().await;
2662                        }
2663                        buffer.update(cx, |buffer, cx| {
2664                            let len = buffer.len();
2665                            if edit_index % 2 == 0 {
2666                                buffer.edit(
2667                                    [(0..0, format!("status-burst-head-{edit_index}\n"))],
2668                                    None,
2669                                    cx,
2670                                );
2671                            } else {
2672                                buffer.edit(
2673                                    [(len..len, format!("status-burst-tail-{edit_index}\n"))],
2674                                    None,
2675                                    cx,
2676                                );
2677                            }
2678                        });
2679                    }
2680                })
2681                .detach();
2682        });
2683
2684        cx.run_until_parked();
2685    }
2686
2687    #[gpui::test]
2688    async fn test_new_hunk_in_modified_file(cx: &mut TestAppContext) {
2689        init_test(cx);
2690
2691        let fs = FakeFs::new(cx.executor());
2692        fs.insert_tree(
2693            path!("/project"),
2694            json!({
2695                ".git": {},
2696                "foo.txt": "
2697                    one
2698                    two
2699                    three
2700                    four
2701                    five
2702                    six
2703                    seven
2704                    eight
2705                    nine
2706                    ten
2707                    ELEVEN
2708                    twelve
2709                ".unindent()
2710            }),
2711        )
2712        .await;
2713        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
2714        let (multi_workspace, cx) =
2715            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2716        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2717        let diff = cx.new_window_entity(|window, cx| {
2718            ProjectDiff::new(project.clone(), workspace, window, cx)
2719        });
2720        cx.run_until_parked();
2721
2722        fs.set_head_and_index_for_repo(
2723            Path::new(path!("/project/.git")),
2724            &[(
2725                "foo.txt",
2726                "
2727                    one
2728                    two
2729                    three
2730                    four
2731                    five
2732                    six
2733                    seven
2734                    eight
2735                    nine
2736                    ten
2737                    eleven
2738                    twelve
2739                "
2740                .unindent(),
2741            )],
2742        );
2743        cx.run_until_parked();
2744
2745        let editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).rhs_editor().clone());
2746
2747        assert_state_with_diff(
2748            &editor,
2749            cx,
2750            &"
2751                  ˇnine
2752                  ten
2753                - eleven
2754                + ELEVEN
2755                  twelve
2756            "
2757            .unindent(),
2758        );
2759
2760        // The project diff updates its excerpts when a new hunk appears in a buffer that already has a diff.
2761        let buffer = project
2762            .update(cx, |project, cx| {
2763                project.open_local_buffer(path!("/project/foo.txt"), cx)
2764            })
2765            .await
2766            .unwrap();
2767        buffer.update(cx, |buffer, cx| {
2768            buffer.edit_via_marked_text(
2769                &"
2770                    one
2771                    «TWO»
2772                    three
2773                    four
2774                    five
2775                    six
2776                    seven
2777                    eight
2778                    nine
2779                    ten
2780                    ELEVEN
2781                    twelve
2782                "
2783                .unindent(),
2784                None,
2785                cx,
2786            );
2787        });
2788        project
2789            .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
2790            .await
2791            .unwrap();
2792        cx.run_until_parked();
2793
2794        assert_state_with_diff(
2795            &editor,
2796            cx,
2797            &"
2798                  one
2799                - two
2800                + TWO
2801                  three
2802                  four
2803                  five
2804                  ˇnine
2805                  ten
2806                - eleven
2807                + ELEVEN
2808                  twelve
2809            "
2810            .unindent(),
2811        );
2812    }
2813
2814    #[gpui::test]
2815    async fn test_branch_diff(cx: &mut TestAppContext) {
2816        init_test(cx);
2817
2818        let fs = FakeFs::new(cx.executor());
2819        fs.insert_tree(
2820            path!("/project"),
2821            json!({
2822                ".git": {},
2823                "a.txt": "C",
2824                "b.txt": "new",
2825                "c.txt": "in-merge-base-and-work-tree",
2826                "d.txt": "created-in-head",
2827            }),
2828        )
2829        .await;
2830        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
2831        let (multi_workspace, cx) =
2832            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2833        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2834        let diff = cx
2835            .update(|window, cx| {
2836                ProjectDiff::new_with_default_branch(project.clone(), workspace, window, cx)
2837            })
2838            .await
2839            .unwrap();
2840        cx.run_until_parked();
2841
2842        fs.set_head_for_repo(
2843            Path::new(path!("/project/.git")),
2844            &[("a.txt", "B".into()), ("d.txt", "created-in-head".into())],
2845            "sha",
2846        );
2847        // fs.set_index_for_repo(dot_git, index_state);
2848        fs.set_merge_base_content_for_repo(
2849            Path::new(path!("/project/.git")),
2850            &[
2851                ("a.txt", "A".into()),
2852                ("c.txt", "in-merge-base-and-work-tree".into()),
2853            ],
2854        );
2855        cx.run_until_parked();
2856
2857        let editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).rhs_editor().clone());
2858
2859        assert_state_with_diff(
2860            &editor,
2861            cx,
2862            &"
2863                - A
2864                + ˇC
2865                + new
2866                + created-in-head"
2867                .unindent(),
2868        );
2869
2870        let statuses: HashMap<Arc<RelPath>, Option<FileStatus>> =
2871            editor.update(cx, |editor, cx| {
2872                editor
2873                    .buffer()
2874                    .read(cx)
2875                    .all_buffers()
2876                    .iter()
2877                    .map(|buffer| {
2878                        (
2879                            buffer.read(cx).file().unwrap().path().clone(),
2880                            editor.status_for_buffer_id(buffer.read(cx).remote_id(), cx),
2881                        )
2882                    })
2883                    .collect()
2884            });
2885
2886        assert_eq!(
2887            statuses,
2888            HashMap::from_iter([
2889                (
2890                    rel_path("a.txt").into_arc(),
2891                    Some(FileStatus::Tracked(TrackedStatus {
2892                        index_status: git::status::StatusCode::Modified,
2893                        worktree_status: git::status::StatusCode::Modified
2894                    }))
2895                ),
2896                (rel_path("b.txt").into_arc(), Some(FileStatus::Untracked)),
2897                (
2898                    rel_path("d.txt").into_arc(),
2899                    Some(FileStatus::Tracked(TrackedStatus {
2900                        index_status: git::status::StatusCode::Added,
2901                        worktree_status: git::status::StatusCode::Added
2902                    }))
2903                )
2904            ])
2905        );
2906    }
2907
2908    #[gpui::test]
2909    async fn test_update_on_uncommit(cx: &mut TestAppContext) {
2910        init_test(cx);
2911
2912        let fs = FakeFs::new(cx.executor());
2913        fs.insert_tree(
2914            path!("/project"),
2915            json!({
2916                ".git": {},
2917                "README.md": "# My cool project\n".to_owned()
2918            }),
2919        )
2920        .await;
2921        fs.set_head_and_index_for_repo(
2922            Path::new(path!("/project/.git")),
2923            &[("README.md", "# My cool project\n".to_owned())],
2924        );
2925        let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await;
2926        let worktree_id = project.read_with(cx, |project, cx| {
2927            project.worktrees(cx).next().unwrap().read(cx).id()
2928        });
2929        let (multi_workspace, cx) =
2930            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2931        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2932        cx.run_until_parked();
2933
2934        let _editor = workspace
2935            .update_in(cx, |workspace, window, cx| {
2936                workspace.open_path((worktree_id, rel_path("README.md")), None, true, window, cx)
2937            })
2938            .await
2939            .unwrap()
2940            .downcast::<Editor>()
2941            .unwrap();
2942
2943        cx.focus(&workspace);
2944        cx.update(|window, cx| {
2945            window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
2946        });
2947        cx.run_until_parked();
2948        let item = workspace.update(cx, |workspace, cx| {
2949            workspace.active_item_as::<ProjectDiff>(cx).unwrap()
2950        });
2951        cx.focus(&item);
2952        let editor = item.read_with(cx, |item, cx| item.editor.read(cx).rhs_editor().clone());
2953
2954        fs.set_head_and_index_for_repo(
2955            Path::new(path!("/project/.git")),
2956            &[(
2957                "README.md",
2958                "# My cool project\nDetails to come.\n".to_owned(),
2959            )],
2960        );
2961        cx.run_until_parked();
2962
2963        let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
2964
2965        cx.assert_excerpts_with_selections("[EXCERPT]\nˇ# My cool project\nDetails to come.\n");
2966    }
2967
2968    #[gpui::test]
2969    async fn test_deploy_at_respects_worktree_override(cx: &mut TestAppContext) {
2970        init_test(cx);
2971
2972        let fs = FakeFs::new(cx.executor());
2973        fs.insert_tree(
2974            path!("/project_a"),
2975            json!({
2976                ".git": {},
2977                "a.txt": "CHANGED_A\n",
2978            }),
2979        )
2980        .await;
2981        fs.insert_tree(
2982            path!("/project_b"),
2983            json!({
2984                ".git": {},
2985                "b.txt": "CHANGED_B\n",
2986            }),
2987        )
2988        .await;
2989
2990        fs.set_head_and_index_for_repo(
2991            Path::new(path!("/project_a/.git")),
2992            &[("a.txt", "original_a\n".to_string())],
2993        );
2994        fs.set_head_and_index_for_repo(
2995            Path::new(path!("/project_b/.git")),
2996            &[("b.txt", "original_b\n".to_string())],
2997        );
2998
2999        let project = Project::test(
3000            fs.clone(),
3001            [
3002                Path::new(path!("/project_a")),
3003                Path::new(path!("/project_b")),
3004            ],
3005            cx,
3006        )
3007        .await;
3008
3009        let (worktree_a_id, worktree_b_id) = project.read_with(cx, |project, cx| {
3010            let mut worktrees: Vec<_> = project.worktrees(cx).collect();
3011            worktrees.sort_by_key(|w| w.read(cx).abs_path());
3012            (worktrees[0].read(cx).id(), worktrees[1].read(cx).id())
3013        });
3014
3015        let (multi_workspace, cx) =
3016            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3017        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3018        cx.run_until_parked();
3019
3020        // Select project A via the dropdown override and open the diff.
3021        workspace.update(cx, |workspace, cx| {
3022            workspace.set_active_worktree_override(Some(worktree_a_id), cx);
3023        });
3024        cx.focus(&workspace);
3025        cx.update(|window, cx| {
3026            window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
3027        });
3028        cx.run_until_parked();
3029
3030        let diff_item = workspace.update(cx, |workspace, cx| {
3031            workspace.active_item_as::<ProjectDiff>(cx).unwrap()
3032        });
3033        let paths_a = diff_item.read_with(cx, |diff, cx| diff.excerpt_paths(cx));
3034        assert_eq!(paths_a.len(), 1);
3035        assert_eq!(*paths_a[0], *"a.txt");
3036
3037        // Switch the override to project B and re-run the diff action.
3038        workspace.update(cx, |workspace, cx| {
3039            workspace.set_active_worktree_override(Some(worktree_b_id), cx);
3040        });
3041        cx.focus(&workspace);
3042        cx.update(|window, cx| {
3043            window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
3044        });
3045        cx.run_until_parked();
3046
3047        let same_diff_item = workspace.update(cx, |workspace, cx| {
3048            workspace.active_item_as::<ProjectDiff>(cx).unwrap()
3049        });
3050        assert_eq!(diff_item.entity_id(), same_diff_item.entity_id());
3051
3052        let paths_b = diff_item.read_with(cx, |diff, cx| diff.excerpt_paths(cx));
3053        assert_eq!(paths_b.len(), 1);
3054        assert_eq!(*paths_b[0], *"b.txt");
3055    }
3056}