project_diff.rs

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