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