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                        editor.start_temporary_diff_override();
 382                    }
 383                }
 384            });
 385            diff_display_editor
 386        });
 387        let editor_subscription = cx.subscribe_in(&editor, window, Self::handle_editor_event);
 388
 389        let primary_editor = editor.read(cx).rhs_editor().clone();
 390        let review_comment_subscription =
 391            cx.subscribe(&primary_editor, |this, _editor, event: &EditorEvent, cx| {
 392                if let EditorEvent::ReviewCommentsChanged { total_count } = event {
 393                    this.review_comment_count = *total_count;
 394                    cx.notify();
 395                }
 396            });
 397
 398        let branch_diff_subscription = cx.subscribe_in(
 399            &branch_diff,
 400            window,
 401            move |this, _git_store, event, window, cx| match event {
 402                BranchDiffEvent::FileListChanged => {
 403                    this._task = window.spawn(cx, {
 404                        let this = cx.weak_entity();
 405                        async |cx| Self::refresh(this, RefreshReason::StatusesChanged, cx).await
 406                    })
 407                }
 408            },
 409        );
 410
 411        let mut was_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
 412        let mut was_collapse_untracked_diff =
 413            GitPanelSettings::get_global(cx).collapse_untracked_diff;
 414        cx.observe_global_in::<SettingsStore>(window, move |this, window, cx| {
 415            let is_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
 416            let is_collapse_untracked_diff =
 417                GitPanelSettings::get_global(cx).collapse_untracked_diff;
 418            if is_sort_by_path != was_sort_by_path
 419                || is_collapse_untracked_diff != was_collapse_untracked_diff
 420            {
 421                this._task = {
 422                    window.spawn(cx, {
 423                        let this = cx.weak_entity();
 424                        async |cx| Self::refresh(this, RefreshReason::StatusesChanged, cx).await
 425                    })
 426                }
 427            }
 428            was_sort_by_path = is_sort_by_path;
 429            was_collapse_untracked_diff = is_collapse_untracked_diff;
 430        })
 431        .detach();
 432
 433        let task = window.spawn(cx, {
 434            let this = cx.weak_entity();
 435            async |cx| Self::refresh(this, RefreshReason::StatusesChanged, cx).await
 436        });
 437
 438        Self {
 439            project,
 440            workspace: workspace.downgrade(),
 441            branch_diff,
 442            focus_handle,
 443            editor,
 444            multibuffer,
 445            buffer_diff_subscriptions: Default::default(),
 446            pending_scroll: None,
 447            review_comment_count: 0,
 448            _task: task,
 449            _subscription: Subscription::join(
 450                branch_diff_subscription,
 451                Subscription::join(editor_subscription, review_comment_subscription),
 452            ),
 453        }
 454    }
 455
 456    pub fn diff_base<'a>(&'a self, cx: &'a App) -> &'a DiffBase {
 457        self.branch_diff.read(cx).diff_base()
 458    }
 459
 460    pub fn move_to_entry(
 461        &mut self,
 462        entry: GitStatusEntry,
 463        window: &mut Window,
 464        cx: &mut Context<Self>,
 465    ) {
 466        let Some(git_repo) = self.branch_diff.read(cx).repo() else {
 467            return;
 468        };
 469        let repo = git_repo.read(cx);
 470        let sort_prefix = sort_prefix(repo, &entry.repo_path, entry.status, cx);
 471        let path_key = PathKey::with_sort_prefix(sort_prefix, entry.repo_path.as_ref().clone());
 472
 473        self.move_to_path(path_key, window, cx)
 474    }
 475
 476    pub fn move_to_project_path(
 477        &mut self,
 478        project_path: &ProjectPath,
 479        window: &mut Window,
 480        cx: &mut Context<Self>,
 481    ) {
 482        let Some(git_repo) = self.branch_diff.read(cx).repo() else {
 483            return;
 484        };
 485        let Some(repo_path) = git_repo
 486            .read(cx)
 487            .project_path_to_repo_path(project_path, cx)
 488        else {
 489            return;
 490        };
 491        let status = git_repo
 492            .read(cx)
 493            .status_for_path(&repo_path)
 494            .map(|entry| entry.status)
 495            .unwrap_or(FileStatus::Untracked);
 496        let sort_prefix = sort_prefix(&git_repo.read(cx), &repo_path, status, cx);
 497        let path_key = PathKey::with_sort_prefix(sort_prefix, repo_path.as_ref().clone());
 498        self.move_to_path(path_key, window, cx)
 499    }
 500
 501    pub fn active_path(&self, cx: &App) -> Option<ProjectPath> {
 502        let editor = self.editor.read(cx).focused_editor().read(cx);
 503        let multibuffer = editor.buffer().read(cx);
 504        let position = editor.selections.newest_anchor().head();
 505        let snapshot = multibuffer.snapshot(cx);
 506        let (text_anchor, _) = snapshot.anchor_to_buffer_anchor(position)?;
 507        let buffer = multibuffer.buffer(text_anchor.buffer_id)?;
 508
 509        let file = buffer.read(cx).file()?;
 510        Some(ProjectPath {
 511            worktree_id: file.worktree_id(cx),
 512            path: file.path().clone(),
 513        })
 514    }
 515
 516    fn move_to_beginning(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 517        self.editor.update(cx, |editor, cx| {
 518            editor.rhs_editor().update(cx, |editor, cx| {
 519                editor.change_selections(Default::default(), window, cx, |s| {
 520                    s.select_ranges(vec![multi_buffer::Anchor::Min..multi_buffer::Anchor::Min]);
 521                });
 522            });
 523        });
 524    }
 525
 526    fn move_to_path(&mut self, path_key: PathKey, window: &mut Window, cx: &mut Context<Self>) {
 527        if let Some(position) = self.multibuffer.read(cx).location_for_path(&path_key, cx) {
 528            self.editor.update(cx, |editor, cx| {
 529                editor.rhs_editor().update(cx, |editor, cx| {
 530                    editor.change_selections(
 531                        SelectionEffects::scroll(Autoscroll::focused()),
 532                        window,
 533                        cx,
 534                        |s| {
 535                            s.select_ranges([position..position]);
 536                        },
 537                    )
 538                })
 539            });
 540        } else {
 541            self.pending_scroll = Some(path_key);
 542        }
 543    }
 544
 545    pub fn calculate_changed_lines(&self, cx: &App) -> (u32, u32) {
 546        self.multibuffer.read(cx).snapshot(cx).total_changed_lines()
 547    }
 548
 549    /// Returns the total count of review comments across all hunks/files.
 550    pub fn total_review_comment_count(&self) -> usize {
 551        self.review_comment_count
 552    }
 553
 554    /// Returns a reference to the splittable editor.
 555    pub fn editor(&self) -> &Entity<SplittableEditor> {
 556        &self.editor
 557    }
 558
 559    fn button_states(&self, cx: &App) -> ButtonStates {
 560        let editor = self.editor.read(cx).rhs_editor().read(cx);
 561        let snapshot = self.multibuffer.read(cx).snapshot(cx);
 562        let prev_next = snapshot.diff_hunks().nth(1).is_some();
 563        let mut selection = true;
 564
 565        let mut ranges = editor
 566            .selections
 567            .disjoint_anchor_ranges()
 568            .collect::<Vec<_>>();
 569        if !ranges.iter().any(|range| range.start != range.end) {
 570            selection = false;
 571            let anchor = editor.selections.newest_anchor().head();
 572            if let Some((_, excerpt_range)) = snapshot.excerpt_containing(anchor..anchor)
 573                && let Some(range) = snapshot
 574                    .anchor_in_buffer(excerpt_range.context.start)
 575                    .zip(snapshot.anchor_in_buffer(excerpt_range.context.end))
 576                    .map(|(start, end)| start..end)
 577            {
 578                ranges = vec![range];
 579            } else {
 580                ranges = Vec::default();
 581            };
 582        }
 583        let mut has_staged_hunks = false;
 584        let mut has_unstaged_hunks = false;
 585        for hunk in editor.diff_hunks_in_ranges(&ranges, &snapshot) {
 586            match hunk.status.secondary {
 587                DiffHunkSecondaryStatus::HasSecondaryHunk
 588                | DiffHunkSecondaryStatus::SecondaryHunkAdditionPending => {
 589                    has_unstaged_hunks = true;
 590                }
 591                DiffHunkSecondaryStatus::OverlapsWithSecondaryHunk => {
 592                    has_staged_hunks = true;
 593                    has_unstaged_hunks = true;
 594                }
 595                DiffHunkSecondaryStatus::NoSecondaryHunk
 596                | DiffHunkSecondaryStatus::SecondaryHunkRemovalPending => {
 597                    has_staged_hunks = true;
 598                }
 599            }
 600        }
 601        let mut stage_all = false;
 602        let mut unstage_all = false;
 603        self.workspace
 604            .read_with(cx, |workspace, cx| {
 605                if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
 606                    let git_panel = git_panel.read(cx);
 607                    stage_all = git_panel.can_stage_all();
 608                    unstage_all = git_panel.can_unstage_all();
 609                }
 610            })
 611            .ok();
 612
 613        ButtonStates {
 614            stage: has_unstaged_hunks,
 615            unstage: has_staged_hunks,
 616            prev_next,
 617            selection,
 618            stage_all,
 619            unstage_all,
 620        }
 621    }
 622
 623    fn handle_editor_event(
 624        &mut self,
 625        editor: &Entity<SplittableEditor>,
 626        event: &EditorEvent,
 627        window: &mut Window,
 628        cx: &mut Context<Self>,
 629    ) {
 630        match event {
 631            EditorEvent::SelectionsChanged { local: true } => {
 632                let Some(project_path) = self.active_path(cx) else {
 633                    return;
 634                };
 635                self.workspace
 636                    .update(cx, |workspace, cx| {
 637                        if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
 638                            git_panel.update(cx, |git_panel, cx| {
 639                                git_panel.select_entry_by_path(project_path, window, cx)
 640                            })
 641                        }
 642                    })
 643                    .ok();
 644            }
 645            EditorEvent::Saved => {
 646                self._task = cx.spawn_in(window, async move |this, cx| {
 647                    Self::refresh(this, RefreshReason::EditorSaved, cx).await
 648                });
 649            }
 650            _ => {}
 651        }
 652        if editor.focus_handle(cx).contains_focused(window, cx)
 653            && self.multibuffer.read(cx).is_empty()
 654        {
 655            self.focus_handle.focus(window, cx)
 656        }
 657    }
 658
 659    #[instrument(skip_all)]
 660    fn register_buffer(
 661        &mut self,
 662        path_key: PathKey,
 663        file_status: FileStatus,
 664        buffer: Entity<Buffer>,
 665        diff: Entity<BufferDiff>,
 666        window: &mut Window,
 667        cx: &mut Context<Self>,
 668    ) -> Option<BufferId> {
 669        let subscription = cx.subscribe_in(&diff, window, move |this, _, _, window, cx| {
 670            this._task = window.spawn(cx, {
 671                let this = cx.weak_entity();
 672                async |cx| Self::refresh(this, RefreshReason::DiffChanged, cx).await
 673            })
 674        });
 675        self.buffer_diff_subscriptions
 676            .insert(path_key.path.clone(), (diff.clone(), subscription));
 677
 678        // TODO(split-diff) we shouldn't have a conflict addon when split
 679        let conflict_addon = self
 680            .editor
 681            .read(cx)
 682            .rhs_editor()
 683            .read(cx)
 684            .addon::<ConflictAddon>()
 685            .expect("project diff editor should have a conflict addon");
 686
 687        let snapshot = buffer.read(cx).snapshot();
 688        let diff_snapshot = diff.read(cx).snapshot(cx);
 689
 690        let excerpt_ranges = {
 691            let diff_hunk_ranges = diff_snapshot
 692                .hunks_intersecting_range(
 693                    Anchor::min_max_range_for_buffer(snapshot.remote_id()),
 694                    &snapshot,
 695                )
 696                .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot));
 697            let conflicts = conflict_addon
 698                .conflict_set(snapshot.remote_id())
 699                .map(|conflict_set| conflict_set.read(cx).snapshot().conflicts)
 700                .unwrap_or_default();
 701            let mut conflicts = conflicts
 702                .iter()
 703                .map(|conflict| conflict.range.to_point(&snapshot))
 704                .peekable();
 705
 706            if conflicts.peek().is_some() {
 707                conflicts.collect::<Vec<_>>()
 708            } else {
 709                diff_hunk_ranges.collect()
 710            }
 711        };
 712
 713        let mut needs_fold = None;
 714
 715        let (was_empty, is_excerpt_newly_added) = self.editor.update(cx, |editor, cx| {
 716            let was_empty = editor.rhs_editor().read(cx).buffer().read(cx).is_empty();
 717            let is_newly_added = editor.update_excerpts_for_path(
 718                path_key.clone(),
 719                buffer,
 720                excerpt_ranges,
 721                multibuffer_context_lines(cx),
 722                diff,
 723                cx,
 724            );
 725            (was_empty, is_newly_added)
 726        });
 727
 728        self.editor.update(cx, |editor, cx| {
 729            editor.rhs_editor().update(cx, |editor, cx| {
 730                if was_empty {
 731                    editor.change_selections(
 732                        SelectionEffects::no_scroll(),
 733                        window,
 734                        cx,
 735                        |selections| {
 736                            selections.select_ranges([
 737                                multi_buffer::Anchor::Min..multi_buffer::Anchor::Min
 738                            ])
 739                        },
 740                    );
 741                }
 742                if is_excerpt_newly_added
 743                    && (file_status.is_deleted()
 744                        || (file_status.is_untracked()
 745                            && GitPanelSettings::get_global(cx).collapse_untracked_diff))
 746                {
 747                    needs_fold = Some(snapshot.text.remote_id());
 748                }
 749            })
 750        });
 751
 752        if self.multibuffer.read(cx).is_empty()
 753            && self
 754                .editor
 755                .read(cx)
 756                .focus_handle(cx)
 757                .contains_focused(window, cx)
 758        {
 759            self.focus_handle.focus(window, cx);
 760        } else if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() {
 761            self.editor.update(cx, |editor, cx| {
 762                editor.focus_handle(cx).focus(window, cx);
 763            });
 764        }
 765        if self.pending_scroll.as_ref() == Some(&path_key) {
 766            self.move_to_path(path_key, window, cx);
 767        }
 768
 769        needs_fold
 770    }
 771
 772    #[instrument(skip_all)]
 773    pub async fn refresh(
 774        this: WeakEntity<Self>,
 775        reason: RefreshReason,
 776        cx: &mut AsyncWindowContext,
 777    ) -> Result<()> {
 778        let mut path_keys = Vec::new();
 779        let buffers_to_load = this.update(cx, |this, cx| {
 780            let (repo, buffers_to_load) = this.branch_diff.update(cx, |branch_diff, cx| {
 781                let load_buffers = branch_diff.load_buffers(cx);
 782                (branch_diff.repo().cloned(), load_buffers)
 783            });
 784            let mut previous_paths = this
 785                .multibuffer
 786                .read(cx)
 787                .snapshot(cx)
 788                .buffers_with_paths()
 789                .map(|(_, path_key)| path_key.clone())
 790                .collect::<HashSet<_>>();
 791
 792            if let Some(repo) = repo {
 793                let repo = repo.read(cx);
 794
 795                path_keys = Vec::with_capacity(buffers_to_load.len());
 796                for entry in buffers_to_load.iter() {
 797                    let sort_prefix = sort_prefix(&repo, &entry.repo_path, entry.file_status, cx);
 798                    let path_key =
 799                        PathKey::with_sort_prefix(sort_prefix, entry.repo_path.as_ref().clone());
 800                    previous_paths.remove(&path_key);
 801                    path_keys.push(path_key)
 802                }
 803            }
 804
 805            this.editor.update(cx, |editor, cx| {
 806                for path in previous_paths {
 807                    if let Some(buffer) = this.multibuffer.read(cx).buffer_for_path(&path, cx) {
 808                        let skip = match reason {
 809                            RefreshReason::DiffChanged | RefreshReason::EditorSaved => {
 810                                buffer.read(cx).is_dirty()
 811                            }
 812                            RefreshReason::StatusesChanged => false,
 813                        };
 814                        if skip {
 815                            continue;
 816                        }
 817                    }
 818
 819                    this.buffer_diff_subscriptions.remove(&path.path);
 820                    editor.remove_excerpts_for_path(path, cx);
 821                }
 822            });
 823            buffers_to_load
 824        })?;
 825
 826        let mut buffers_to_fold = Vec::new();
 827
 828        for (entry, path_key) in buffers_to_load.into_iter().zip(path_keys.into_iter()) {
 829            if let Some((buffer, diff)) = entry.load.await.log_err() {
 830                // We might be lagging behind enough that all future entry.load futures are no longer pending.
 831                // If that is the case, this task will never yield, starving the foreground thread of execution time.
 832                yield_now().await;
 833                cx.update(|window, cx| {
 834                    this.update(cx, |this, cx| {
 835                        let multibuffer = this.multibuffer.read(cx);
 836                        let skip = multibuffer.buffer(buffer.read(cx).remote_id()).is_some()
 837                            && multibuffer
 838                                .diff_for(buffer.read(cx).remote_id())
 839                                .is_some_and(|prev_diff| prev_diff.entity_id() == diff.entity_id())
 840                            && match reason {
 841                                RefreshReason::DiffChanged | RefreshReason::EditorSaved => {
 842                                    buffer.read(cx).is_dirty()
 843                                }
 844                                RefreshReason::StatusesChanged => false,
 845                            };
 846                        if !skip {
 847                            if let Some(buffer_id) = this.register_buffer(
 848                                path_key,
 849                                entry.file_status,
 850                                buffer,
 851                                diff,
 852                                window,
 853                                cx,
 854                            ) {
 855                                buffers_to_fold.push(buffer_id);
 856                            }
 857                        }
 858                    })
 859                    .ok();
 860                })?;
 861            }
 862        }
 863        this.update(cx, |this, cx| {
 864            if !buffers_to_fold.is_empty() {
 865                this.editor.update(cx, |editor, cx| {
 866                    editor
 867                        .rhs_editor()
 868                        .update(cx, |editor, cx| editor.fold_buffers(buffers_to_fold, cx));
 869                });
 870            }
 871            this.pending_scroll.take();
 872            cx.notify();
 873        })?;
 874
 875        Ok(())
 876    }
 877
 878    #[cfg(any(test, feature = "test-support"))]
 879    pub fn excerpt_paths(&self, cx: &App) -> Vec<std::sync::Arc<util::rel_path::RelPath>> {
 880        let snapshot = self
 881            .editor()
 882            .read(cx)
 883            .rhs_editor()
 884            .read(cx)
 885            .buffer()
 886            .read(cx)
 887            .snapshot(cx);
 888        snapshot
 889            .excerpts()
 890            .map(|excerpt| {
 891                snapshot
 892                    .path_for_buffer(excerpt.context.start.buffer_id)
 893                    .unwrap()
 894                    .path
 895                    .clone()
 896            })
 897            .collect()
 898    }
 899}
 900
 901fn sort_prefix(repo: &Repository, repo_path: &RepoPath, status: FileStatus, cx: &App) -> u64 {
 902    let settings = GitPanelSettings::get_global(cx);
 903
 904    if settings.sort_by_path && !settings.tree_view {
 905        TRACKED_SORT_PREFIX
 906    } else if repo.had_conflict_on_last_merge_head_change(repo_path) {
 907        CONFLICT_SORT_PREFIX
 908    } else if status.is_created() {
 909        NEW_SORT_PREFIX
 910    } else {
 911        TRACKED_SORT_PREFIX
 912    }
 913}
 914
 915impl EventEmitter<EditorEvent> for ProjectDiff {}
 916
 917impl Focusable for ProjectDiff {
 918    fn focus_handle(&self, cx: &App) -> FocusHandle {
 919        if self.multibuffer.read(cx).is_empty() {
 920            self.focus_handle.clone()
 921        } else {
 922            self.editor.focus_handle(cx)
 923        }
 924    }
 925}
 926
 927impl Item for ProjectDiff {
 928    type Event = EditorEvent;
 929
 930    fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
 931        Some(Icon::new(IconName::GitBranch).color(Color::Muted))
 932    }
 933
 934    fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) {
 935        Editor::to_item_events(event, f)
 936    }
 937
 938    fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 939        self.editor.update(cx, |editor, cx| {
 940            editor.rhs_editor().update(cx, |primary_editor, cx| {
 941                primary_editor.deactivated(window, cx);
 942            })
 943        });
 944    }
 945
 946    fn navigate(
 947        &mut self,
 948        data: Arc<dyn Any + Send>,
 949        window: &mut Window,
 950        cx: &mut Context<Self>,
 951    ) -> bool {
 952        self.editor.update(cx, |editor, cx| {
 953            editor.rhs_editor().update(cx, |primary_editor, cx| {
 954                primary_editor.navigate(data, window, cx)
 955            })
 956        })
 957    }
 958
 959    fn tab_tooltip_text(&self, cx: &App) -> Option<SharedString> {
 960        match self.diff_base(cx) {
 961            DiffBase::Head => Some("Project Diff".into()),
 962            DiffBase::Merge { .. } => Some("Branch Diff".into()),
 963        }
 964    }
 965
 966    fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
 967        Label::new(self.tab_content_text(0, cx))
 968            .color(if params.selected {
 969                Color::Default
 970            } else {
 971                Color::Muted
 972            })
 973            .into_any_element()
 974    }
 975
 976    fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
 977        match self.branch_diff.read(cx).diff_base() {
 978            DiffBase::Head => "Uncommitted Changes".into(),
 979            DiffBase::Merge { base_ref } => format!("Changes since {}", base_ref).into(),
 980        }
 981    }
 982
 983    fn telemetry_event_text(&self) -> Option<&'static str> {
 984        Some("Project Diff Opened")
 985    }
 986
 987    fn as_searchable(&self, _: &Entity<Self>, _cx: &App) -> Option<Box<dyn SearchableItemHandle>> {
 988        Some(Box::new(self.editor.clone()))
 989    }
 990
 991    fn for_each_project_item(
 992        &self,
 993        cx: &App,
 994        f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
 995    ) {
 996        self.editor
 997            .read(cx)
 998            .rhs_editor()
 999            .read(cx)
1000            .for_each_project_item(cx, f)
1001    }
1002
1003    fn set_nav_history(
1004        &mut self,
1005        nav_history: ItemNavHistory,
1006        _: &mut Window,
1007        cx: &mut Context<Self>,
1008    ) {
1009        self.editor.update(cx, |editor, cx| {
1010            editor.rhs_editor().update(cx, |primary_editor, _| {
1011                primary_editor.set_nav_history(Some(nav_history));
1012            })
1013        });
1014    }
1015
1016    fn can_split(&self) -> bool {
1017        true
1018    }
1019
1020    fn clone_on_split(
1021        &self,
1022        _workspace_id: Option<workspace::WorkspaceId>,
1023        window: &mut Window,
1024        cx: &mut Context<Self>,
1025    ) -> Task<Option<Entity<Self>>>
1026    where
1027        Self: Sized,
1028    {
1029        let Some(workspace) = self.workspace.upgrade() else {
1030            return Task::ready(None);
1031        };
1032        Task::ready(Some(cx.new(|cx| {
1033            ProjectDiff::new(self.project.clone(), workspace, window, cx)
1034        })))
1035    }
1036
1037    fn is_dirty(&self, cx: &App) -> bool {
1038        self.multibuffer.read(cx).is_dirty(cx)
1039    }
1040
1041    fn has_conflict(&self, cx: &App) -> bool {
1042        self.multibuffer.read(cx).has_conflict(cx)
1043    }
1044
1045    fn can_save(&self, _: &App) -> bool {
1046        true
1047    }
1048
1049    fn save(
1050        &mut self,
1051        options: SaveOptions,
1052        project: Entity<Project>,
1053        window: &mut Window,
1054        cx: &mut Context<Self>,
1055    ) -> Task<Result<()>> {
1056        self.editor.update(cx, |editor, cx| {
1057            editor.rhs_editor().update(cx, |primary_editor, cx| {
1058                primary_editor.save(options, project, window, cx)
1059            })
1060        })
1061    }
1062
1063    fn save_as(
1064        &mut self,
1065        _: Entity<Project>,
1066        _: ProjectPath,
1067        _window: &mut Window,
1068        _: &mut Context<Self>,
1069    ) -> Task<Result<()>> {
1070        unreachable!()
1071    }
1072
1073    fn reload(
1074        &mut self,
1075        project: Entity<Project>,
1076        window: &mut Window,
1077        cx: &mut Context<Self>,
1078    ) -> Task<Result<()>> {
1079        self.editor.update(cx, |editor, cx| {
1080            editor.rhs_editor().update(cx, |primary_editor, cx| {
1081                primary_editor.reload(project, window, cx)
1082            })
1083        })
1084    }
1085
1086    fn act_as_type<'a>(
1087        &'a self,
1088        type_id: TypeId,
1089        self_handle: &'a Entity<Self>,
1090        cx: &'a App,
1091    ) -> Option<gpui::AnyEntity> {
1092        if type_id == TypeId::of::<Self>() {
1093            Some(self_handle.clone().into())
1094        } else if type_id == TypeId::of::<Editor>() {
1095            Some(self.editor.read(cx).rhs_editor().clone().into())
1096        } else if type_id == TypeId::of::<SplittableEditor>() {
1097            Some(self.editor.clone().into())
1098        } else {
1099            None
1100        }
1101    }
1102
1103    fn added_to_workspace(
1104        &mut self,
1105        workspace: &mut Workspace,
1106        window: &mut Window,
1107        cx: &mut Context<Self>,
1108    ) {
1109        self.editor.update(cx, |editor, cx| {
1110            editor.added_to_workspace(workspace, window, cx)
1111        });
1112    }
1113}
1114
1115impl Render for ProjectDiff {
1116    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1117        let is_empty = self.multibuffer.read(cx).is_empty();
1118        let is_branch_diff_view = matches!(self.diff_base(cx), DiffBase::Merge { .. });
1119
1120        div()
1121            .track_focus(&self.focus_handle)
1122            .key_context(if is_empty { "EmptyPane" } else { "GitDiff" })
1123            .when(is_branch_diff_view, |this| {
1124                this.on_action(cx.listener(Self::review_diff))
1125            })
1126            .bg(cx.theme().colors().editor_background)
1127            .flex()
1128            .items_center()
1129            .justify_center()
1130            .size_full()
1131            .when(is_empty, |el| {
1132                let remote_button = if let Some(panel) = self
1133                    .workspace
1134                    .upgrade()
1135                    .and_then(|workspace| workspace.read(cx).panel::<GitPanel>(cx))
1136                {
1137                    panel.update(cx, |panel, cx| panel.render_remote_button(cx))
1138                } else {
1139                    None
1140                };
1141                let keybinding_focus_handle = self.focus_handle(cx);
1142                el.child(
1143                    v_flex()
1144                        .gap_1()
1145                        .child(
1146                            h_flex()
1147                                .justify_around()
1148                                .child(Label::new("No uncommitted changes")),
1149                        )
1150                        .map(|el| match remote_button {
1151                            Some(button) => el.child(h_flex().justify_around().child(button)),
1152                            None => el.child(
1153                                h_flex()
1154                                    .justify_around()
1155                                    .child(Label::new("Remote up to date")),
1156                            ),
1157                        })
1158                        .child(
1159                            h_flex().justify_around().mt_1().child(
1160                                Button::new("project-diff-close-button", "Close")
1161                                    // .style(ButtonStyle::Transparent)
1162                                    .key_binding(KeyBinding::for_action_in(
1163                                        &CloseActiveItem::default(),
1164                                        &keybinding_focus_handle,
1165                                        cx,
1166                                    ))
1167                                    .on_click(move |_, window, cx| {
1168                                        window.focus(&keybinding_focus_handle, cx);
1169                                        window.dispatch_action(
1170                                            Box::new(CloseActiveItem::default()),
1171                                            cx,
1172                                        );
1173                                    }),
1174                            ),
1175                        ),
1176                )
1177            })
1178            .when(!is_empty, |el| el.child(self.editor.clone()))
1179    }
1180}
1181
1182impl SerializableItem for ProjectDiff {
1183    fn serialized_item_kind() -> &'static str {
1184        "ProjectDiff"
1185    }
1186
1187    fn cleanup(
1188        _: workspace::WorkspaceId,
1189        _: Vec<workspace::ItemId>,
1190        _: &mut Window,
1191        _: &mut App,
1192    ) -> Task<Result<()>> {
1193        Task::ready(Ok(()))
1194    }
1195
1196    fn deserialize(
1197        project: Entity<Project>,
1198        workspace: WeakEntity<Workspace>,
1199        workspace_id: workspace::WorkspaceId,
1200        item_id: workspace::ItemId,
1201        window: &mut Window,
1202        cx: &mut App,
1203    ) -> Task<Result<Entity<Self>>> {
1204        let db = persistence::ProjectDiffDb::global(cx);
1205        window.spawn(cx, async move |cx| {
1206            let diff_base = db.get_diff_base(item_id, workspace_id)?;
1207
1208            let diff = cx.update(|window, cx| {
1209                let branch_diff = cx
1210                    .new(|cx| branch_diff::BranchDiff::new(diff_base, project.clone(), window, cx));
1211                let workspace = workspace.upgrade().context("workspace gone")?;
1212                anyhow::Ok(
1213                    cx.new(|cx| ProjectDiff::new_impl(branch_diff, project, workspace, window, cx)),
1214                )
1215            })??;
1216
1217            Ok(diff)
1218        })
1219    }
1220
1221    fn serialize(
1222        &mut self,
1223        workspace: &mut Workspace,
1224        item_id: workspace::ItemId,
1225        _closing: bool,
1226        _window: &mut Window,
1227        cx: &mut Context<Self>,
1228    ) -> Option<Task<Result<()>>> {
1229        let workspace_id = workspace.database_id()?;
1230        let diff_base = self.diff_base(cx).clone();
1231
1232        let db = persistence::ProjectDiffDb::global(cx);
1233        Some(cx.background_spawn({
1234            async move {
1235                db.save_diff_base(item_id, workspace_id, diff_base.clone())
1236                    .await
1237            }
1238        }))
1239    }
1240
1241    fn should_serialize(&self, _: &Self::Event) -> bool {
1242        false
1243    }
1244}
1245
1246mod persistence {
1247
1248    use anyhow::Context as _;
1249    use db::{
1250        sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
1251        sqlez_macros::sql,
1252    };
1253    use project::git_store::branch_diff::DiffBase;
1254    use workspace::{ItemId, WorkspaceDb, WorkspaceId};
1255
1256    pub struct ProjectDiffDb(ThreadSafeConnection);
1257
1258    impl Domain for ProjectDiffDb {
1259        const NAME: &str = stringify!(ProjectDiffDb);
1260
1261        const MIGRATIONS: &[&str] = &[sql!(
1262                CREATE TABLE project_diffs(
1263                    workspace_id INTEGER,
1264                    item_id INTEGER UNIQUE,
1265
1266                    diff_base TEXT,
1267
1268                    PRIMARY KEY(workspace_id, item_id),
1269                    FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
1270                    ON DELETE CASCADE
1271                ) STRICT;
1272        )];
1273    }
1274
1275    db::static_connection!(ProjectDiffDb, [WorkspaceDb]);
1276
1277    impl ProjectDiffDb {
1278        pub async fn save_diff_base(
1279            &self,
1280            item_id: ItemId,
1281            workspace_id: WorkspaceId,
1282            diff_base: DiffBase,
1283        ) -> anyhow::Result<()> {
1284            self.write(move |connection| {
1285                let sql_stmt = sql!(
1286                    INSERT OR REPLACE INTO project_diffs(item_id, workspace_id, diff_base) VALUES (?, ?, ?)
1287                );
1288                let diff_base_str = serde_json::to_string(&diff_base)?;
1289                let mut query = connection.exec_bound::<(ItemId, WorkspaceId, String)>(sql_stmt)?;
1290                query((item_id, workspace_id, diff_base_str)).context(format!(
1291                    "exec_bound failed to execute or parse for: {}",
1292                    sql_stmt
1293                ))
1294            })
1295            .await
1296        }
1297
1298        pub fn get_diff_base(
1299            &self,
1300            item_id: ItemId,
1301            workspace_id: WorkspaceId,
1302        ) -> anyhow::Result<DiffBase> {
1303            let sql_stmt =
1304                sql!(SELECT diff_base FROM project_diffs WHERE item_id =  ?AND workspace_id =  ?);
1305            let diff_base_str = self.select_row_bound::<(ItemId, WorkspaceId), String>(sql_stmt)?(
1306                (item_id, workspace_id),
1307            )
1308            .context(::std::format!(
1309                "Error in get_diff_base, select_row_bound failed to execute or parse for: {}",
1310                sql_stmt
1311            ))?;
1312            let Some(diff_base_str) = diff_base_str else {
1313                return Ok(DiffBase::Head);
1314            };
1315            serde_json::from_str(&diff_base_str).context("deserializing diff base")
1316        }
1317    }
1318}
1319
1320pub struct ProjectDiffToolbar {
1321    project_diff: Option<WeakEntity<ProjectDiff>>,
1322    workspace: WeakEntity<Workspace>,
1323}
1324
1325impl ProjectDiffToolbar {
1326    pub fn new(workspace: &Workspace, _: &mut Context<Self>) -> Self {
1327        Self {
1328            project_diff: None,
1329            workspace: workspace.weak_handle(),
1330        }
1331    }
1332
1333    fn project_diff(&self, _: &App) -> Option<Entity<ProjectDiff>> {
1334        self.project_diff.as_ref()?.upgrade()
1335    }
1336
1337    fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context<Self>) {
1338        if let Some(project_diff) = self.project_diff(cx) {
1339            project_diff.focus_handle(cx).focus(window, cx);
1340        }
1341        let action = action.boxed_clone();
1342        cx.defer(move |cx| {
1343            cx.dispatch_action(action.as_ref());
1344        })
1345    }
1346
1347    fn stage_all(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1348        self.workspace
1349            .update(cx, |workspace, cx| {
1350                if let Some(panel) = workspace.panel::<GitPanel>(cx) {
1351                    panel.update(cx, |panel, cx| {
1352                        panel.stage_all(&Default::default(), window, cx);
1353                    });
1354                }
1355            })
1356            .ok();
1357    }
1358
1359    fn unstage_all(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1360        self.workspace
1361            .update(cx, |workspace, cx| {
1362                let Some(panel) = workspace.panel::<GitPanel>(cx) else {
1363                    return;
1364                };
1365                panel.update(cx, |panel, cx| {
1366                    panel.unstage_all(&Default::default(), window, cx);
1367                });
1368            })
1369            .ok();
1370    }
1371}
1372
1373impl EventEmitter<ToolbarItemEvent> for ProjectDiffToolbar {}
1374
1375impl ToolbarItemView for ProjectDiffToolbar {
1376    fn set_active_pane_item(
1377        &mut self,
1378        active_pane_item: Option<&dyn ItemHandle>,
1379        _: &mut Window,
1380        cx: &mut Context<Self>,
1381    ) -> ToolbarItemLocation {
1382        self.project_diff = active_pane_item
1383            .and_then(|item| item.act_as::<ProjectDiff>(cx))
1384            .filter(|item| item.read(cx).diff_base(cx) == &DiffBase::Head)
1385            .map(|entity| entity.downgrade());
1386        if self.project_diff.is_some() {
1387            ToolbarItemLocation::PrimaryRight
1388        } else {
1389            ToolbarItemLocation::Hidden
1390        }
1391    }
1392
1393    fn pane_focus_update(
1394        &mut self,
1395        _pane_focused: bool,
1396        _window: &mut Window,
1397        _cx: &mut Context<Self>,
1398    ) {
1399    }
1400}
1401
1402struct ButtonStates {
1403    stage: bool,
1404    unstage: bool,
1405    prev_next: bool,
1406    selection: bool,
1407    stage_all: bool,
1408    unstage_all: bool,
1409}
1410
1411impl Render for ProjectDiffToolbar {
1412    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1413        let Some(project_diff) = self.project_diff(cx) else {
1414            return div();
1415        };
1416        let focus_handle = project_diff.focus_handle(cx);
1417        let button_states = project_diff.read(cx).button_states(cx);
1418        let review_count = project_diff.read(cx).total_review_comment_count();
1419
1420        h_group_xl()
1421            .my_neg_1()
1422            .py_1()
1423            .items_center()
1424            .flex_wrap()
1425            .justify_between()
1426            .child(
1427                h_group_sm()
1428                    .when(button_states.selection, |el| {
1429                        el.child(
1430                            Button::new("stage", "Toggle Staged")
1431                                .tooltip(Tooltip::for_action_title_in(
1432                                    "Toggle Staged",
1433                                    &ToggleStaged,
1434                                    &focus_handle,
1435                                ))
1436                                .disabled(!button_states.stage && !button_states.unstage)
1437                                .on_click(cx.listener(|this, _, window, cx| {
1438                                    this.dispatch_action(&ToggleStaged, window, cx)
1439                                })),
1440                        )
1441                    })
1442                    .when(!button_states.selection, |el| {
1443                        el.child(
1444                            Button::new("stage", "Stage")
1445                                .tooltip(Tooltip::for_action_title_in(
1446                                    "Stage and go to next hunk",
1447                                    &StageAndNext,
1448                                    &focus_handle,
1449                                ))
1450                                .disabled(
1451                                    !button_states.prev_next
1452                                        && !button_states.stage_all
1453                                        && !button_states.unstage_all,
1454                                )
1455                                .on_click(cx.listener(|this, _, window, cx| {
1456                                    this.dispatch_action(&StageAndNext, window, cx)
1457                                })),
1458                        )
1459                        .child(
1460                            Button::new("unstage", "Unstage")
1461                                .tooltip(Tooltip::for_action_title_in(
1462                                    "Unstage and go to next hunk",
1463                                    &UnstageAndNext,
1464                                    &focus_handle,
1465                                ))
1466                                .disabled(
1467                                    !button_states.prev_next
1468                                        && !button_states.stage_all
1469                                        && !button_states.unstage_all,
1470                                )
1471                                .on_click(cx.listener(|this, _, window, cx| {
1472                                    this.dispatch_action(&UnstageAndNext, window, cx)
1473                                })),
1474                        )
1475                    }),
1476            )
1477            // n.b. the only reason these arrows are here is because we don't
1478            // support "undo" for staging so we need a way to go back.
1479            .child(
1480                h_group_sm()
1481                    .child(
1482                        IconButton::new("up", IconName::ArrowUp)
1483                            .shape(ui::IconButtonShape::Square)
1484                            .tooltip(Tooltip::for_action_title_in(
1485                                "Go to previous hunk",
1486                                &GoToPreviousHunk,
1487                                &focus_handle,
1488                            ))
1489                            .disabled(!button_states.prev_next)
1490                            .on_click(cx.listener(|this, _, window, cx| {
1491                                this.dispatch_action(&GoToPreviousHunk, window, cx)
1492                            })),
1493                    )
1494                    .child(
1495                        IconButton::new("down", IconName::ArrowDown)
1496                            .shape(ui::IconButtonShape::Square)
1497                            .tooltip(Tooltip::for_action_title_in(
1498                                "Go to next hunk",
1499                                &GoToHunk,
1500                                &focus_handle,
1501                            ))
1502                            .disabled(!button_states.prev_next)
1503                            .on_click(cx.listener(|this, _, window, cx| {
1504                                this.dispatch_action(&GoToHunk, window, cx)
1505                            })),
1506                    ),
1507            )
1508            .child(vertical_divider())
1509            .child(
1510                h_group_sm()
1511                    .when(
1512                        button_states.unstage_all && !button_states.stage_all,
1513                        |el| {
1514                            el.child(
1515                                Button::new("unstage-all", "Unstage All")
1516                                    .tooltip(Tooltip::for_action_title_in(
1517                                        "Unstage all changes",
1518                                        &UnstageAll,
1519                                        &focus_handle,
1520                                    ))
1521                                    .on_click(cx.listener(|this, _, window, cx| {
1522                                        this.unstage_all(window, cx)
1523                                    })),
1524                            )
1525                        },
1526                    )
1527                    .when(
1528                        !button_states.unstage_all || button_states.stage_all,
1529                        |el| {
1530                            el.child(
1531                                // todo make it so that changing to say "Unstaged"
1532                                // doesn't change the position.
1533                                div().child(
1534                                    Button::new("stage-all", "Stage All")
1535                                        .disabled(!button_states.stage_all)
1536                                        .tooltip(Tooltip::for_action_title_in(
1537                                            "Stage all changes",
1538                                            &StageAll,
1539                                            &focus_handle,
1540                                        ))
1541                                        .on_click(cx.listener(|this, _, window, cx| {
1542                                            this.stage_all(window, cx)
1543                                        })),
1544                                ),
1545                            )
1546                        },
1547                    )
1548                    .child(
1549                        Button::new("commit", "Commit")
1550                            .tooltip(Tooltip::for_action_title_in(
1551                                "Commit",
1552                                &Commit,
1553                                &focus_handle,
1554                            ))
1555                            .on_click(cx.listener(|this, _, window, cx| {
1556                                this.dispatch_action(&Commit, window, cx);
1557                            })),
1558                    ),
1559            )
1560            // "Send Review to Agent" button (only shown when there are review comments)
1561            .when(review_count > 0, |el| {
1562                el.child(vertical_divider()).child(
1563                    render_send_review_to_agent_button(review_count, &focus_handle).on_click(
1564                        cx.listener(|this, _, window, cx| {
1565                            this.dispatch_action(&SendReviewToAgent, window, cx)
1566                        }),
1567                    ),
1568                )
1569            })
1570    }
1571}
1572
1573fn render_send_review_to_agent_button(review_count: usize, focus_handle: &FocusHandle) -> Button {
1574    Button::new(
1575        "send-review",
1576        format!("Send Review to Agent ({})", review_count),
1577    )
1578    .start_icon(
1579        Icon::new(IconName::ZedAssistant)
1580            .size(IconSize::Small)
1581            .color(Color::Muted),
1582    )
1583    .tooltip(Tooltip::for_action_title_in(
1584        "Send all review comments to the Agent panel",
1585        &SendReviewToAgent,
1586        focus_handle,
1587    ))
1588}
1589
1590pub struct BranchDiffToolbar {
1591    project_diff: Option<WeakEntity<ProjectDiff>>,
1592}
1593
1594impl BranchDiffToolbar {
1595    pub fn new(_cx: &mut Context<Self>) -> Self {
1596        Self { project_diff: None }
1597    }
1598
1599    fn project_diff(&self, _: &App) -> Option<Entity<ProjectDiff>> {
1600        self.project_diff.as_ref()?.upgrade()
1601    }
1602
1603    fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context<Self>) {
1604        if let Some(project_diff) = self.project_diff(cx) {
1605            project_diff.focus_handle(cx).focus(window, cx);
1606        }
1607        let action = action.boxed_clone();
1608        cx.defer(move |cx| {
1609            cx.dispatch_action(action.as_ref());
1610        })
1611    }
1612}
1613
1614impl EventEmitter<ToolbarItemEvent> for BranchDiffToolbar {}
1615
1616impl ToolbarItemView for BranchDiffToolbar {
1617    fn set_active_pane_item(
1618        &mut self,
1619        active_pane_item: Option<&dyn ItemHandle>,
1620        _: &mut Window,
1621        cx: &mut Context<Self>,
1622    ) -> ToolbarItemLocation {
1623        self.project_diff = active_pane_item
1624            .and_then(|item| item.act_as::<ProjectDiff>(cx))
1625            .filter(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Merge { .. }))
1626            .map(|entity| entity.downgrade());
1627        if self.project_diff.is_some() {
1628            ToolbarItemLocation::PrimaryRight
1629        } else {
1630            ToolbarItemLocation::Hidden
1631        }
1632    }
1633
1634    fn pane_focus_update(
1635        &mut self,
1636        _pane_focused: bool,
1637        _window: &mut Window,
1638        _cx: &mut Context<Self>,
1639    ) {
1640    }
1641}
1642
1643impl Render for BranchDiffToolbar {
1644    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1645        let Some(project_diff) = self.project_diff(cx) else {
1646            return div();
1647        };
1648        let focus_handle = project_diff.focus_handle(cx);
1649        let review_count = project_diff.read(cx).total_review_comment_count();
1650        let (additions, deletions) = project_diff.read(cx).calculate_changed_lines(cx);
1651
1652        let is_multibuffer_empty = project_diff.read(cx).multibuffer.read(cx).is_empty();
1653        let is_ai_enabled = AgentSettings::get_global(cx).enabled(cx);
1654
1655        let show_review_button = !is_multibuffer_empty && is_ai_enabled;
1656
1657        h_group_xl()
1658            .my_neg_1()
1659            .py_1()
1660            .items_center()
1661            .flex_wrap()
1662            .justify_end()
1663            .gap_2()
1664            .when(!is_multibuffer_empty, |this| {
1665                this.child(DiffStat::new(
1666                    "branch-diff-stat",
1667                    additions as usize,
1668                    deletions as usize,
1669                ))
1670            })
1671            .when(show_review_button, |this| {
1672                let focus_handle = focus_handle.clone();
1673                this.child(Divider::vertical()).child(
1674                    Button::new("review-diff", "Review Diff")
1675                        .start_icon(
1676                            Icon::new(IconName::ZedAssistant)
1677                                .size(IconSize::Small)
1678                                .color(Color::Muted),
1679                        )
1680                        .key_binding(KeyBinding::for_action_in(&ReviewDiff, &focus_handle, cx))
1681                        .tooltip(move |_, cx| {
1682                            Tooltip::with_meta_in(
1683                                "Review Diff",
1684                                Some(&ReviewDiff),
1685                                "Send this diff for your last agent to review.",
1686                                &focus_handle,
1687                                cx,
1688                            )
1689                        })
1690                        .on_click(cx.listener(|this, _, window, cx| {
1691                            this.dispatch_action(&ReviewDiff, window, cx);
1692                        })),
1693                )
1694            })
1695            .when(review_count > 0, |this| {
1696                this.child(vertical_divider()).child(
1697                    render_send_review_to_agent_button(review_count, &focus_handle).on_click(
1698                        cx.listener(|this, _, window, cx| {
1699                            this.dispatch_action(&SendReviewToAgent, window, cx)
1700                        }),
1701                    ),
1702                )
1703            })
1704    }
1705}
1706
1707struct BranchDiffAddon {
1708    branch_diff: Entity<branch_diff::BranchDiff>,
1709}
1710
1711impl Addon for BranchDiffAddon {
1712    fn to_any(&self) -> &dyn std::any::Any {
1713        self
1714    }
1715
1716    fn override_status_for_buffer_id(
1717        &self,
1718        buffer_id: language::BufferId,
1719        cx: &App,
1720    ) -> Option<FileStatus> {
1721        self.branch_diff
1722            .read(cx)
1723            .status_for_buffer_id(buffer_id, cx)
1724    }
1725}
1726
1727#[cfg(test)]
1728mod tests {
1729    use collections::HashMap;
1730    use db::indoc;
1731    use editor::test::editor_test_context::{EditorTestContext, assert_state_with_diff};
1732    use git::status::{TrackedStatus, UnmergedStatus, UnmergedStatusCode};
1733    use gpui::TestAppContext;
1734    use project::FakeFs;
1735    use serde_json::json;
1736    use settings::{DiffViewStyle, SettingsStore};
1737    use std::path::Path;
1738    use unindent::Unindent as _;
1739    use util::{
1740        path,
1741        rel_path::{RelPath, rel_path},
1742    };
1743
1744    use workspace::MultiWorkspace;
1745
1746    use super::*;
1747
1748    #[ctor::ctor]
1749    fn init_logger() {
1750        zlog::init_test();
1751    }
1752
1753    fn init_test(cx: &mut TestAppContext) {
1754        cx.update(|cx| {
1755            let store = SettingsStore::test(cx);
1756            cx.set_global(store);
1757            cx.update_global::<SettingsStore, _>(|store, cx| {
1758                store.update_user_settings(cx, |settings| {
1759                    settings.editor.diff_view_style = Some(DiffViewStyle::Unified);
1760                });
1761            });
1762            theme_settings::init(theme::LoadThemes::JustBase, cx);
1763            editor::init(cx);
1764            crate::init(cx);
1765        });
1766    }
1767
1768    #[gpui::test]
1769    async fn test_save_after_restore(cx: &mut TestAppContext) {
1770        init_test(cx);
1771
1772        let fs = FakeFs::new(cx.executor());
1773        fs.insert_tree(
1774            path!("/project"),
1775            json!({
1776                ".git": {},
1777                "foo.txt": "FOO\n",
1778            }),
1779        )
1780        .await;
1781        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1782
1783        fs.set_head_for_repo(
1784            path!("/project/.git").as_ref(),
1785            &[("foo.txt", "foo\n".into())],
1786            "deadbeef",
1787        );
1788        fs.set_index_for_repo(
1789            path!("/project/.git").as_ref(),
1790            &[("foo.txt", "foo\n".into())],
1791        );
1792
1793        let (multi_workspace, cx) =
1794            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1795        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1796        let diff = cx.new_window_entity(|window, cx| {
1797            ProjectDiff::new(project.clone(), workspace, window, cx)
1798        });
1799        cx.run_until_parked();
1800
1801        let editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).rhs_editor().clone());
1802        assert_state_with_diff(
1803            &editor,
1804            cx,
1805            &"
1806                - ˇfoo
1807                + FOO
1808            "
1809            .unindent(),
1810        );
1811
1812        editor
1813            .update_in(cx, |editor, window, cx| {
1814                editor.git_restore(&Default::default(), window, cx);
1815                editor.save(SaveOptions::default(), project.clone(), window, cx)
1816            })
1817            .await
1818            .unwrap();
1819        cx.run_until_parked();
1820
1821        assert_state_with_diff(&editor, cx, &"ˇ".unindent());
1822
1823        let text = String::from_utf8(fs.read_file_sync("/project/foo.txt").unwrap()).unwrap();
1824        assert_eq!(text, "foo\n");
1825    }
1826
1827    #[gpui::test]
1828    async fn test_scroll_to_beginning_with_deletion(cx: &mut TestAppContext) {
1829        init_test(cx);
1830
1831        let fs = FakeFs::new(cx.executor());
1832        fs.insert_tree(
1833            path!("/project"),
1834            json!({
1835                ".git": {},
1836                "bar": "BAR\n",
1837                "foo": "FOO\n",
1838            }),
1839        )
1840        .await;
1841        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1842        let (multi_workspace, cx) =
1843            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1844        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1845        let diff = cx.new_window_entity(|window, cx| {
1846            ProjectDiff::new(project.clone(), workspace, window, cx)
1847        });
1848        cx.run_until_parked();
1849
1850        fs.set_head_and_index_for_repo(
1851            path!("/project/.git").as_ref(),
1852            &[("bar", "bar\n".into()), ("foo", "foo\n".into())],
1853        );
1854        cx.run_until_parked();
1855
1856        let editor = cx.update_window_entity(&diff, |diff, window, cx| {
1857            diff.move_to_path(
1858                PathKey::with_sort_prefix(TRACKED_SORT_PREFIX, rel_path("foo").into_arc()),
1859                window,
1860                cx,
1861            );
1862            diff.editor.read(cx).rhs_editor().clone()
1863        });
1864        assert_state_with_diff(
1865            &editor,
1866            cx,
1867            &"
1868                - bar
1869                + BAR
1870
1871                - ˇfoo
1872                + FOO
1873            "
1874            .unindent(),
1875        );
1876
1877        let editor = cx.update_window_entity(&diff, |diff, window, cx| {
1878            diff.move_to_path(
1879                PathKey::with_sort_prefix(TRACKED_SORT_PREFIX, rel_path("bar").into_arc()),
1880                window,
1881                cx,
1882            );
1883            diff.editor.read(cx).rhs_editor().clone()
1884        });
1885        assert_state_with_diff(
1886            &editor,
1887            cx,
1888            &"
1889                - ˇbar
1890                + BAR
1891
1892                - foo
1893                + FOO
1894            "
1895            .unindent(),
1896        );
1897    }
1898
1899    #[gpui::test]
1900    async fn test_hunks_after_restore_then_modify(cx: &mut TestAppContext) {
1901        init_test(cx);
1902
1903        let fs = FakeFs::new(cx.executor());
1904        fs.insert_tree(
1905            path!("/project"),
1906            json!({
1907                ".git": {},
1908                "foo": "modified\n",
1909            }),
1910        )
1911        .await;
1912        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1913        let (multi_workspace, cx) =
1914            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1915        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1916        fs.set_head_for_repo(
1917            path!("/project/.git").as_ref(),
1918            &[("foo", "original\n".into())],
1919            "deadbeef",
1920        );
1921
1922        let buffer = project
1923            .update(cx, |project, cx| {
1924                project.open_local_buffer(path!("/project/foo"), cx)
1925            })
1926            .await
1927            .unwrap();
1928        let buffer_editor = cx.new_window_entity(|window, cx| {
1929            Editor::for_buffer(buffer, Some(project.clone()), window, cx)
1930        });
1931        let diff = cx.new_window_entity(|window, cx| {
1932            ProjectDiff::new(project.clone(), workspace, window, cx)
1933        });
1934        cx.run_until_parked();
1935
1936        let diff_editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).rhs_editor().clone());
1937
1938        assert_state_with_diff(
1939            &diff_editor,
1940            cx,
1941            &"
1942                - ˇoriginal
1943                + modified
1944            "
1945            .unindent(),
1946        );
1947
1948        let prev_buffer_hunks =
1949            cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
1950                let snapshot = buffer_editor.snapshot(window, cx);
1951                let snapshot = &snapshot.buffer_snapshot();
1952                let prev_buffer_hunks = buffer_editor
1953                    .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], snapshot)
1954                    .collect::<Vec<_>>();
1955                buffer_editor.git_restore(&Default::default(), window, cx);
1956                prev_buffer_hunks
1957            });
1958        assert_eq!(prev_buffer_hunks.len(), 1);
1959        cx.run_until_parked();
1960
1961        let new_buffer_hunks =
1962            cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
1963                let snapshot = buffer_editor.snapshot(window, cx);
1964                let snapshot = &snapshot.buffer_snapshot();
1965                buffer_editor
1966                    .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], snapshot)
1967                    .collect::<Vec<_>>()
1968            });
1969        assert_eq!(new_buffer_hunks.as_slice(), &[]);
1970
1971        cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
1972            buffer_editor.set_text("different\n", window, cx);
1973            buffer_editor.save(
1974                SaveOptions {
1975                    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}