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