project_diff.rs

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