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