project_diff.rs

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