project_diff.rs

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