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