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