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