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::Result;
   8use buffer_diff::{BufferDiff, DiffHunkSecondaryStatus};
   9use collections::HashSet;
  10use editor::{
  11    Editor, EditorEvent, SelectionEffects,
  12    actions::{GoToHunk, GoToPreviousHunk},
  13    multibuffer_context_lines,
  14    scroll::Autoscroll,
  15};
  16use futures::StreamExt;
  17use git::{
  18    Commit, StageAll, StageAndNext, ToggleStaged, UnstageAll, UnstageAndNext,
  19    repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus},
  20    status::FileStatus,
  21};
  22use gpui::{
  23    Action, AnyElement, AnyView, App, AppContext as _, AsyncWindowContext, Entity, EventEmitter,
  24    FocusHandle, Focusable, Render, Subscription, Task, WeakEntity, actions,
  25};
  26use language::{Anchor, Buffer, Capability, OffsetRangeExt};
  27use multi_buffer::{MultiBuffer, PathKey};
  28use project::{
  29    Project, ProjectPath,
  30    git_store::{GitStore, GitStoreEvent, RepositoryEvent},
  31};
  32use settings::{Settings, SettingsStore};
  33use std::ops::Range;
  34use std::{
  35    any::{Any, TypeId},
  36    sync::Arc,
  37};
  38use theme::ActiveTheme;
  39use ui::{KeyBinding, Tooltip, prelude::*, vertical_divider};
  40use util::ResultExt as _;
  41use workspace::{
  42    CloseActiveItem, ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation,
  43    ToolbarItemView, Workspace,
  44    item::{BreadcrumbText, Item, ItemEvent, ItemHandle, SaveOptions, TabContentParams},
  45    searchable::SearchableItemHandle,
  46};
  47
  48actions!(
  49    git,
  50    [
  51        /// Shows the diff between the working directory and the index.
  52        Diff,
  53        /// Adds files to the git staging area.
  54        Add,
  55        /// Shows the diff between the working directory and the default branch.
  56        DiffToDefaultBranch,
  57    ]
  58);
  59
  60pub struct ProjectDiff {
  61    project: Entity<Project>,
  62    multibuffer: Entity<MultiBuffer>,
  63    editor: Entity<Editor>,
  64    git_store: Entity<GitStore>,
  65    workspace: WeakEntity<Workspace>,
  66    focus_handle: FocusHandle,
  67    update_needed: postage::watch::Sender<()>,
  68    pending_scroll: Option<PathKey>,
  69    diff_base_kind: DiffBaseKind,
  70    _task: Task<Result<()>>,
  71    _subscription: Subscription,
  72}
  73
  74#[derive(Copy, Clone, PartialEq, Eq)]
  75pub enum DiffBaseKind {
  76    Head,
  77    MergeBaseOfDefaultBranch,
  78}
  79
  80#[derive(Debug)]
  81struct DiffBuffer {
  82    path_key: PathKey,
  83    buffer: Entity<Buffer>,
  84    diff: Entity<BufferDiff>,
  85    file_status: FileStatus,
  86}
  87
  88const CONFLICT_NAMESPACE: u32 = 1;
  89const TRACKED_NAMESPACE: u32 = 2;
  90const NEW_NAMESPACE: u32 = 3;
  91
  92impl ProjectDiff {
  93    pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context<Workspace>) {
  94        workspace.register_action(Self::deploy);
  95        workspace.register_action(Self::diff_to_default_branch);
  96        workspace.register_action(|workspace, _: &Add, window, cx| {
  97            Self::deploy(workspace, &Diff, window, cx);
  98        });
  99        workspace::register_serializable_item::<ProjectDiff>(cx);
 100    }
 101
 102    fn deploy(
 103        workspace: &mut Workspace,
 104        _: &Diff,
 105        window: &mut Window,
 106        cx: &mut Context<Workspace>,
 107    ) {
 108        Self::deploy_at(workspace, DiffBaseKind::Head, None, window, cx)
 109    }
 110
 111    fn diff_to_default_branch(
 112        workspace: &mut Workspace,
 113        _: &DiffToDefaultBranch,
 114        window: &mut Window,
 115        cx: &mut Context<Workspace>,
 116    ) {
 117        Self::deploy_at(
 118            workspace,
 119            DiffBaseKind::MergeBaseOfDefaultBranch,
 120            None,
 121            window,
 122            cx,
 123        );
 124    }
 125
 126    pub fn deploy_at(
 127        workspace: &mut Workspace,
 128        diff_base_kind: DiffBaseKind,
 129        entry: Option<GitStatusEntry>,
 130        window: &mut Window,
 131        cx: &mut Context<Workspace>,
 132    ) {
 133        telemetry::event!(
 134            match diff_base_kind {
 135                DiffBaseKind::MergeBaseOfDefaultBranch => "Git Branch Diff Opened",
 136                DiffBaseKind::Head => "Git Diff Opened",
 137            },
 138            source = if entry.is_some() {
 139                "Git Panel"
 140            } else {
 141                "Action"
 142            }
 143        );
 144        let project_diff =
 145            if let Some(existing) = Self::existing_project_diff(workspace, diff_base_kind, cx) {
 146                workspace.activate_item(&existing, true, true, window, cx);
 147                existing
 148            } else {
 149                let workspace_handle = cx.entity();
 150                let project_diff = cx.new(|cx| {
 151                    Self::new(
 152                        workspace.project().clone(),
 153                        workspace_handle,
 154                        diff_base_kind,
 155                        window,
 156                        cx,
 157                    )
 158                });
 159                workspace.add_item_to_active_pane(
 160                    Box::new(project_diff.clone()),
 161                    None,
 162                    true,
 163                    window,
 164                    cx,
 165                );
 166                project_diff
 167            };
 168        if let Some(entry) = entry {
 169            project_diff.update(cx, |project_diff, cx| {
 170                project_diff.move_to_entry(entry, window, cx);
 171            })
 172        }
 173    }
 174
 175    pub fn existing_project_diff(
 176        workspace: &mut Workspace,
 177        diff_base_kind: DiffBaseKind,
 178        cx: &mut Context<Workspace>,
 179    ) -> Option<Entity<Self>> {
 180        workspace
 181            .items_of_type::<Self>(cx)
 182            .find(|item| item.read(cx).diff_base_kind == diff_base_kind)
 183    }
 184
 185    pub fn autoscroll(&self, cx: &mut Context<Self>) {
 186        self.editor.update(cx, |editor, cx| {
 187            editor.request_autoscroll(Autoscroll::fit(), cx);
 188        })
 189    }
 190
 191    fn new(
 192        project: Entity<Project>,
 193        workspace: Entity<Workspace>,
 194        diff_base_kind: DiffBaseKind,
 195        window: &mut Window,
 196        cx: &mut Context<Self>,
 197    ) -> Self {
 198        let focus_handle = cx.focus_handle();
 199        let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite));
 200
 201        let editor = cx.new(|cx| {
 202            let mut diff_display_editor =
 203                Editor::for_multibuffer(multibuffer.clone(), Some(project.clone()), window, cx);
 204            diff_display_editor.disable_diagnostics(cx);
 205            diff_display_editor.set_expand_all_diff_hunks(cx);
 206            match diff_base_kind {
 207                DiffBaseKind::Head => {
 208                    diff_display_editor.register_addon(GitPanelAddon {
 209                        workspace: workspace.downgrade(),
 210                    });
 211                }
 212                DiffBaseKind::MergeBaseOfDefaultBranch => {
 213                    diff_display_editor.set_render_diff_hunk_controls(
 214                        Arc::new(|_, _, _, _, _, _, _, _| gpui::Empty.into_any_element()),
 215                        cx,
 216                    );
 217                    //
 218                }
 219            }
 220            diff_display_editor
 221        });
 222        window.defer(cx, {
 223            let workspace = workspace.clone();
 224            let editor = editor.clone();
 225            move |window, cx| {
 226                workspace.update(cx, |workspace, cx| {
 227                    editor.update(cx, |editor, cx| {
 228                        editor.added_to_workspace(workspace, window, cx);
 229                    })
 230                });
 231            }
 232        });
 233        cx.subscribe_in(&editor, window, Self::handle_editor_event)
 234            .detach();
 235
 236        let git_store = project.read(cx).git_store().clone();
 237        let git_store_subscription = cx.subscribe_in(
 238            &git_store,
 239            window,
 240            move |this, _git_store, event, _window, _cx| match event {
 241                GitStoreEvent::ActiveRepositoryChanged(_)
 242                | GitStoreEvent::RepositoryUpdated(_, RepositoryEvent::Updated { .. }, true)
 243                | GitStoreEvent::ConflictsUpdated => {
 244                    *this.update_needed.borrow_mut() = ();
 245                }
 246                _ => {}
 247            },
 248        );
 249
 250        let mut was_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
 251        let mut was_collapse_untracked_diff =
 252            GitPanelSettings::get_global(cx).collapse_untracked_diff;
 253        cx.observe_global::<SettingsStore>(move |this, cx| {
 254            let is_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
 255            let is_collapse_untracked_diff =
 256                GitPanelSettings::get_global(cx).collapse_untracked_diff;
 257            if is_sort_by_path != was_sort_by_path
 258                || is_collapse_untracked_diff != was_collapse_untracked_diff
 259            {
 260                *this.update_needed.borrow_mut() = ();
 261            }
 262            was_sort_by_path = is_sort_by_path;
 263            was_collapse_untracked_diff = is_collapse_untracked_diff;
 264        })
 265        .detach();
 266
 267        let (mut send, recv) = postage::watch::channel::<()>();
 268        let worker = window.spawn(cx, {
 269            let this = cx.weak_entity();
 270            async |cx| Self::handle_status_updates(this, diff_base_kind, recv, cx).await
 271        });
 272        // Kick off a refresh immediately
 273        *send.borrow_mut() = ();
 274
 275        Self {
 276            project,
 277            git_store: git_store.clone(),
 278            workspace: workspace.downgrade(),
 279            diff_base_kind,
 280            focus_handle,
 281            editor,
 282            multibuffer,
 283            pending_scroll: None,
 284            update_needed: send,
 285            _task: worker,
 286            _subscription: git_store_subscription,
 287        }
 288    }
 289
 290    pub fn move_to_entry(
 291        &mut self,
 292        entry: GitStatusEntry,
 293        window: &mut Window,
 294        cx: &mut Context<Self>,
 295    ) {
 296        let Some(git_repo) = self.git_store.read(cx).active_repository() else {
 297            return;
 298        };
 299        let repo = git_repo.read(cx);
 300
 301        let namespace = if repo.had_conflict_on_last_merge_head_change(&entry.repo_path) {
 302            CONFLICT_NAMESPACE
 303        } else if entry.status.is_created() {
 304            NEW_NAMESPACE
 305        } else {
 306            TRACKED_NAMESPACE
 307        };
 308
 309        let path_key = PathKey::namespaced(namespace, entry.repo_path.0);
 310
 311        self.move_to_path(path_key, window, cx)
 312    }
 313
 314    pub fn active_path(&self, cx: &App) -> Option<ProjectPath> {
 315        let editor = self.editor.read(cx);
 316        let position = editor.selections.newest_anchor().head();
 317        let multi_buffer = editor.buffer().read(cx);
 318        let (_, buffer, _) = multi_buffer.excerpt_containing(position, cx)?;
 319
 320        let file = buffer.read(cx).file()?;
 321        Some(ProjectPath {
 322            worktree_id: file.worktree_id(cx),
 323            path: file.path().clone(),
 324        })
 325    }
 326
 327    fn move_to_path(&mut self, path_key: PathKey, window: &mut Window, cx: &mut Context<Self>) {
 328        if let Some(position) = self.multibuffer.read(cx).location_for_path(&path_key, cx) {
 329            self.editor.update(cx, |editor, cx| {
 330                editor.change_selections(
 331                    SelectionEffects::scroll(Autoscroll::focused()),
 332                    window,
 333                    cx,
 334                    |s| {
 335                        s.select_ranges([position..position]);
 336                    },
 337                )
 338            });
 339        } else {
 340            self.pending_scroll = Some(path_key);
 341        }
 342    }
 343
 344    fn button_states(&self, cx: &App) -> ButtonStates {
 345        let editor = self.editor.read(cx);
 346        let snapshot = self.multibuffer.read(cx).snapshot(cx);
 347        let prev_next = snapshot.diff_hunks().nth(1).is_some();
 348        let mut selection = true;
 349
 350        let mut ranges = editor
 351            .selections
 352            .disjoint_anchor_ranges()
 353            .collect::<Vec<_>>();
 354        if !ranges.iter().any(|range| range.start != range.end) {
 355            selection = false;
 356            if let Some((excerpt_id, buffer, range)) = self.editor.read(cx).active_excerpt(cx) {
 357                ranges = vec![multi_buffer::Anchor::range_in_buffer(
 358                    excerpt_id,
 359                    buffer.read(cx).remote_id(),
 360                    range,
 361                )];
 362            } else {
 363                ranges = Vec::default();
 364            }
 365        }
 366        let mut has_staged_hunks = false;
 367        let mut has_unstaged_hunks = false;
 368        for hunk in editor.diff_hunks_in_ranges(&ranges, &snapshot) {
 369            match hunk.secondary_status {
 370                DiffHunkSecondaryStatus::HasSecondaryHunk
 371                | DiffHunkSecondaryStatus::SecondaryHunkAdditionPending => {
 372                    has_unstaged_hunks = true;
 373                }
 374                DiffHunkSecondaryStatus::OverlapsWithSecondaryHunk => {
 375                    has_staged_hunks = true;
 376                    has_unstaged_hunks = true;
 377                }
 378                DiffHunkSecondaryStatus::NoSecondaryHunk
 379                | DiffHunkSecondaryStatus::SecondaryHunkRemovalPending => {
 380                    has_staged_hunks = true;
 381                }
 382            }
 383        }
 384        let mut stage_all = false;
 385        let mut unstage_all = false;
 386        self.workspace
 387            .read_with(cx, |workspace, cx| {
 388                if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
 389                    let git_panel = git_panel.read(cx);
 390                    stage_all = git_panel.can_stage_all();
 391                    unstage_all = git_panel.can_unstage_all();
 392                }
 393            })
 394            .ok();
 395
 396        ButtonStates {
 397            stage: has_unstaged_hunks,
 398            unstage: has_staged_hunks,
 399            prev_next,
 400            selection,
 401            stage_all,
 402            unstage_all,
 403        }
 404    }
 405
 406    fn handle_editor_event(
 407        &mut self,
 408        editor: &Entity<Editor>,
 409        event: &EditorEvent,
 410        window: &mut Window,
 411        cx: &mut Context<Self>,
 412    ) {
 413        if let EditorEvent::SelectionsChanged { local: true } = event {
 414            let Some(project_path) = self.active_path(cx) else {
 415                return;
 416            };
 417            if self.diff_base_kind != DiffBaseKind::Head {
 418                return;
 419            }
 420            self.workspace
 421                .update(cx, |workspace, cx| {
 422                    if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
 423                        git_panel.update(cx, |git_panel, cx| {
 424                            git_panel.select_entry_by_path(project_path, window, cx)
 425                        })
 426                    }
 427                })
 428                .ok();
 429        }
 430        if editor.focus_handle(cx).contains_focused(window, cx)
 431            && self.multibuffer.read(cx).is_empty()
 432        {
 433            self.focus_handle.focus(window)
 434        }
 435    }
 436
 437    fn load_buffers(&mut self, cx: &mut Context<Self>) -> Vec<Task<Result<DiffBuffer>>> {
 438        let Some(repo) = self.git_store.read(cx).active_repository() else {
 439            self.multibuffer.update(cx, |multibuffer, cx| {
 440                multibuffer.clear(cx);
 441            });
 442            return vec![];
 443        };
 444
 445        let mut previous_paths = self.multibuffer.read(cx).paths().collect::<HashSet<_>>();
 446
 447        let mut result = vec![];
 448        repo.update(cx, |repo, cx| {
 449            for entry in repo.cached_status() {
 450                if !entry.status.has_changes() {
 451                    continue;
 452                }
 453                let Some(project_path) = repo.repo_path_to_project_path(&entry.repo_path, cx)
 454                else {
 455                    continue;
 456                };
 457                let namespace = if GitPanelSettings::get_global(cx).sort_by_path {
 458                    TRACKED_NAMESPACE
 459                } else if repo.had_conflict_on_last_merge_head_change(&entry.repo_path) {
 460                    CONFLICT_NAMESPACE
 461                } else if entry.status.is_created() {
 462                    NEW_NAMESPACE
 463                } else {
 464                    TRACKED_NAMESPACE
 465                };
 466                let path_key = PathKey::namespaced(namespace, entry.repo_path.0.clone());
 467
 468                previous_paths.remove(&path_key);
 469                let load_buffer = self
 470                    .project
 471                    .update(cx, |project, cx| project.open_buffer(project_path, cx));
 472
 473                let project = self.project.clone();
 474                result.push(cx.spawn(async move |_, cx| {
 475                    let buffer = load_buffer.await?;
 476                    let changes = project
 477                        .update(cx, |project, cx| {
 478                            project.open_uncommitted_diff(buffer.clone(), cx)
 479                        })?
 480                        .await?;
 481                    Ok(DiffBuffer {
 482                        path_key,
 483                        buffer,
 484                        diff: changes,
 485                        file_status: entry.status,
 486                    })
 487                }));
 488            }
 489        });
 490        self.multibuffer.update(cx, |multibuffer, cx| {
 491            for path in previous_paths {
 492                multibuffer.remove_excerpts_for_path(path, cx);
 493            }
 494        });
 495        result
 496    }
 497
 498    fn register_buffer(
 499        &mut self,
 500        diff_buffer: DiffBuffer,
 501        window: &mut Window,
 502        cx: &mut Context<Self>,
 503    ) {
 504        let path_key = diff_buffer.path_key;
 505        let buffer = diff_buffer.buffer;
 506        let diff = diff_buffer.diff;
 507
 508        let conflict_addon = self
 509            .editor
 510            .read(cx)
 511            .addon::<ConflictAddon>()
 512            .expect("project diff editor should have a conflict addon");
 513
 514        let snapshot = buffer.read(cx).snapshot();
 515        let diff = diff.read(cx);
 516        let diff_hunk_ranges = diff
 517            .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx)
 518            .map(|diff_hunk| diff_hunk.buffer_range);
 519        let conflicts = conflict_addon
 520            .conflict_set(snapshot.remote_id())
 521            .map(|conflict_set| conflict_set.read(cx).snapshot().conflicts)
 522            .unwrap_or_default();
 523        let conflicts = conflicts.iter().map(|conflict| conflict.range.clone());
 524
 525        let excerpt_ranges = merge_anchor_ranges(diff_hunk_ranges, conflicts, &snapshot)
 526            .map(|range| range.to_point(&snapshot))
 527            .collect::<Vec<_>>();
 528
 529        let (was_empty, is_excerpt_newly_added) = self.multibuffer.update(cx, |multibuffer, cx| {
 530            let was_empty = multibuffer.is_empty();
 531            let (_, is_newly_added) = multibuffer.set_excerpts_for_path(
 532                path_key.clone(),
 533                buffer,
 534                excerpt_ranges,
 535                multibuffer_context_lines(cx),
 536                cx,
 537            );
 538            (was_empty, is_newly_added)
 539        });
 540
 541        self.editor.update(cx, |editor, cx| {
 542            if was_empty {
 543                editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| {
 544                    // TODO select the very beginning (possibly inside a deletion)
 545                    selections.select_ranges([0..0])
 546                });
 547            }
 548            if is_excerpt_newly_added
 549                && (diff_buffer.file_status.is_deleted()
 550                    || (diff_buffer.file_status.is_untracked()
 551                        && GitPanelSettings::get_global(cx).collapse_untracked_diff))
 552            {
 553                editor.fold_buffer(snapshot.text.remote_id(), cx)
 554            }
 555        });
 556
 557        if self.multibuffer.read(cx).is_empty()
 558            && self
 559                .editor
 560                .read(cx)
 561                .focus_handle(cx)
 562                .contains_focused(window, cx)
 563        {
 564            self.focus_handle.focus(window);
 565        } else if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() {
 566            self.editor.update(cx, |editor, cx| {
 567                editor.focus_handle(cx).focus(window);
 568            });
 569        }
 570        if self.pending_scroll.as_ref() == Some(&path_key) {
 571            self.move_to_path(path_key, window, cx);
 572        }
 573    }
 574
 575    fn refresh_merge_base_of_default_branch(
 576        &mut self,
 577        window: &mut Window,
 578        cx: &mut Context<Self>,
 579    ) -> Task<()> {
 580        let Some(repo) = self.git_store.read(cx).active_repository() else {
 581            self.multibuffer.update(cx, |multibuffer, cx| {
 582                multibuffer.clear(cx);
 583            });
 584            return;
 585        };
 586        let default_branch = repo.read(cx).default_branch();
 587        let task = cx.spawn(async move |this, cx| {
 588            let Some(default_branch) = default_branch.await else {
 589                return;
 590            };
 591            let merge_base = repo
 592                .update(cx, |repo, cx| {
 593                    repo.merge_base("HEAD".to_string(), default_branch)
 594                })
 595                .await?;
 596            let diff = repo
 597                .update(cx, |repo, cx| repo.diff_to_commit(merge_base))
 598                .await?;
 599        });
 600
 601        cx.foreground_executor()
 602            .spawn(async move |this, cx| task.await.log_err())
 603    }
 604
 605    pub async fn handle_status_updates(
 606        this: WeakEntity<Self>,
 607        diff_base_kind: DiffBaseKind,
 608        mut recv: postage::watch::Receiver<()>,
 609        cx: &mut AsyncWindowContext,
 610    ) -> Result<()> {
 611        while (recv.next().await).is_some() {
 612            match diff_base_kind {
 613                DiffBaseKind::Head => {
 614                    let buffers_to_load = this.update(cx, |this, cx| this.load_buffers(cx))?;
 615                    for buffer_to_load in buffers_to_load {
 616                        if let Some(buffer) = buffer_to_load.await.log_err() {
 617                            cx.update(|window, cx| {
 618                                this.update(cx, |this, cx| {
 619                                    this.register_buffer(buffer, window, cx)
 620                                })
 621                                .ok();
 622                            })?;
 623                        }
 624                    }
 625                }
 626                DiffBaseKind::MergeBaseOfDefaultBranch => {
 627                    this.update_in(cx, |this, window, cx| {
 628                        this.refresh_merge_base_of_default_branch(window, cx)
 629                    })?
 630                    .await;
 631                }
 632            };
 633
 634            this.update(cx, |this, cx| {
 635                this.pending_scroll.take();
 636                cx.notify();
 637            })?;
 638        }
 639
 640        Ok(())
 641    }
 642
 643    #[cfg(any(test, feature = "test-support"))]
 644    pub fn excerpt_paths(&self, cx: &App) -> Vec<String> {
 645        self.multibuffer
 646            .read(cx)
 647            .excerpt_paths()
 648            .map(|key| key.path().to_string_lossy().to_string())
 649            .collect()
 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
 678            .update(cx, |editor, cx| editor.deactivated(window, cx));
 679    }
 680
 681    fn navigate(
 682        &mut self,
 683        data: Box<dyn Any>,
 684        window: &mut Window,
 685        cx: &mut Context<Self>,
 686    ) -> bool {
 687        self.editor
 688            .update(cx, |editor, cx| editor.navigate(data, window, cx))
 689    }
 690
 691    fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
 692        Some("Project Diff".into())
 693    }
 694
 695    fn tab_content(&self, params: TabContentParams, _window: &Window, _: &App) -> AnyElement {
 696        Label::new("Uncommitted Changes")
 697            .color(if params.selected {
 698                Color::Default
 699            } else {
 700                Color::Muted
 701            })
 702            .into_any_element()
 703    }
 704
 705    fn tab_content_text(&self, _detail: usize, _: &App) -> SharedString {
 706        "Uncommitted Changes".into()
 707    }
 708
 709    fn telemetry_event_text(&self) -> Option<&'static str> {
 710        Some("Project Diff Opened")
 711    }
 712
 713    fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
 714        Some(Box::new(self.editor.clone()))
 715    }
 716
 717    fn for_each_project_item(
 718        &self,
 719        cx: &App,
 720        f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
 721    ) {
 722        self.editor.for_each_project_item(cx, f)
 723    }
 724
 725    fn is_singleton(&self, _: &App) -> bool {
 726        false
 727    }
 728
 729    fn set_nav_history(
 730        &mut self,
 731        nav_history: ItemNavHistory,
 732        _: &mut Window,
 733        cx: &mut Context<Self>,
 734    ) {
 735        self.editor.update(cx, |editor, _| {
 736            editor.set_nav_history(Some(nav_history));
 737        });
 738    }
 739
 740    fn clone_on_split(
 741        &self,
 742        _workspace_id: Option<workspace::WorkspaceId>,
 743        window: &mut Window,
 744        cx: &mut Context<Self>,
 745    ) -> Option<Entity<Self>>
 746    where
 747        Self: Sized,
 748    {
 749        let workspace = self.workspace.upgrade()?;
 750        Some(cx.new(|cx| {
 751            ProjectDiff::new(
 752                self.project.clone(),
 753                workspace,
 754                self.diff_base_kind,
 755                window,
 756                cx,
 757            )
 758        }))
 759    }
 760
 761    fn is_dirty(&self, cx: &App) -> bool {
 762        self.multibuffer.read(cx).is_dirty(cx)
 763    }
 764
 765    fn has_conflict(&self, cx: &App) -> bool {
 766        self.multibuffer.read(cx).has_conflict(cx)
 767    }
 768
 769    fn can_save(&self, _: &App) -> bool {
 770        true
 771    }
 772
 773    fn save(
 774        &mut self,
 775        options: SaveOptions,
 776        project: Entity<Project>,
 777        window: &mut Window,
 778        cx: &mut Context<Self>,
 779    ) -> Task<Result<()>> {
 780        self.editor.save(options, project, window, cx)
 781    }
 782
 783    fn save_as(
 784        &mut self,
 785        _: Entity<Project>,
 786        _: ProjectPath,
 787        _window: &mut Window,
 788        _: &mut Context<Self>,
 789    ) -> Task<Result<()>> {
 790        unreachable!()
 791    }
 792
 793    fn reload(
 794        &mut self,
 795        project: Entity<Project>,
 796        window: &mut Window,
 797        cx: &mut Context<Self>,
 798    ) -> Task<Result<()>> {
 799        self.editor.reload(project, window, cx)
 800    }
 801
 802    fn act_as_type<'a>(
 803        &'a self,
 804        type_id: TypeId,
 805        self_handle: &'a Entity<Self>,
 806        _: &'a App,
 807    ) -> Option<AnyView> {
 808        if type_id == TypeId::of::<Self>() {
 809            Some(self_handle.to_any())
 810        } else if type_id == TypeId::of::<Editor>() {
 811            Some(self.editor.to_any())
 812        } else {
 813            None
 814        }
 815    }
 816
 817    fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
 818        ToolbarItemLocation::PrimaryLeft
 819    }
 820
 821    fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
 822        self.editor.breadcrumbs(theme, cx)
 823    }
 824
 825    fn added_to_workspace(
 826        &mut self,
 827        workspace: &mut Workspace,
 828        window: &mut Window,
 829        cx: &mut Context<Self>,
 830    ) {
 831        self.editor.update(cx, |editor, cx| {
 832            editor.added_to_workspace(workspace, window, cx)
 833        });
 834    }
 835}
 836
 837impl Render for ProjectDiff {
 838    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 839        let is_empty = self.multibuffer.read(cx).is_empty();
 840
 841        div()
 842            .track_focus(&self.focus_handle)
 843            .key_context(if is_empty { "EmptyPane" } else { "GitDiff" })
 844            .bg(cx.theme().colors().editor_background)
 845            .flex()
 846            .items_center()
 847            .justify_center()
 848            .size_full()
 849            .when(is_empty, |el| {
 850                let remote_button = if let Some(panel) = self
 851                    .workspace
 852                    .upgrade()
 853                    .and_then(|workspace| workspace.read(cx).panel::<GitPanel>(cx))
 854                {
 855                    panel.update(cx, |panel, cx| panel.render_remote_button(cx))
 856                } else {
 857                    None
 858                };
 859                let keybinding_focus_handle = self.focus_handle(cx);
 860                el.child(
 861                    v_flex()
 862                        .gap_1()
 863                        .child(
 864                            h_flex()
 865                                .justify_around()
 866                                .child(Label::new("No uncommitted changes")),
 867                        )
 868                        .map(|el| match remote_button {
 869                            Some(button) => el.child(h_flex().justify_around().child(button)),
 870                            None => el.child(
 871                                h_flex()
 872                                    .justify_around()
 873                                    .child(Label::new("Remote up to date")),
 874                            ),
 875                        })
 876                        .child(
 877                            h_flex().justify_around().mt_1().child(
 878                                Button::new("project-diff-close-button", "Close")
 879                                    // .style(ButtonStyle::Transparent)
 880                                    .key_binding(KeyBinding::for_action_in(
 881                                        &CloseActiveItem::default(),
 882                                        &keybinding_focus_handle,
 883                                        window,
 884                                        cx,
 885                                    ))
 886                                    .on_click(move |_, window, cx| {
 887                                        window.focus(&keybinding_focus_handle);
 888                                        window.dispatch_action(
 889                                            Box::new(CloseActiveItem::default()),
 890                                            cx,
 891                                        );
 892                                    }),
 893                            ),
 894                        ),
 895                )
 896            })
 897            .when(!is_empty, |el| el.child(self.editor.clone()))
 898    }
 899}
 900
 901impl SerializableItem for ProjectDiff {
 902    fn serialized_item_kind() -> &'static str {
 903        "ProjectDiff"
 904    }
 905
 906    fn cleanup(
 907        _: workspace::WorkspaceId,
 908        _: Vec<workspace::ItemId>,
 909        _: &mut Window,
 910        _: &mut App,
 911    ) -> Task<Result<()>> {
 912        Task::ready(Ok(()))
 913    }
 914
 915    fn deserialize(
 916        _project: Entity<Project>,
 917        workspace: WeakEntity<Workspace>,
 918        _workspace_id: workspace::WorkspaceId,
 919        _item_id: workspace::ItemId,
 920        window: &mut Window,
 921        cx: &mut App,
 922    ) -> Task<Result<Entity<Self>>> {
 923        window.spawn(cx, async move |cx| {
 924            workspace.update_in(cx, |workspace, window, cx| {
 925                let workspace_handle = cx.entity();
 926                // todo!()
 927                cx.new(|cx| {
 928                    Self::new(
 929                        workspace.project().clone(),
 930                        workspace_handle,
 931                        DiffBaseKind::Head,
 932                        window,
 933                        cx,
 934                    )
 935                })
 936            })
 937        })
 938    }
 939
 940    fn serialize(
 941        &mut self,
 942        _workspace: &mut Workspace,
 943        _item_id: workspace::ItemId,
 944        _closing: bool,
 945        _window: &mut Window,
 946        _cx: &mut Context<Self>,
 947    ) -> Option<Task<Result<()>>> {
 948        None
 949    }
 950
 951    fn should_serialize(&self, _: &Self::Event) -> bool {
 952        false
 953    }
 954}
 955
 956pub struct ProjectDiffToolbar {
 957    project_diff: Option<WeakEntity<ProjectDiff>>,
 958    workspace: WeakEntity<Workspace>,
 959}
 960
 961impl ProjectDiffToolbar {
 962    pub fn new(workspace: &Workspace, _: &mut Context<Self>) -> Self {
 963        Self {
 964            project_diff: None,
 965            workspace: workspace.weak_handle(),
 966        }
 967    }
 968
 969    fn project_diff(&self, _: &App) -> Option<Entity<ProjectDiff>> {
 970        self.project_diff.as_ref()?.upgrade()
 971    }
 972
 973    fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context<Self>) {
 974        if let Some(project_diff) = self.project_diff(cx) {
 975            project_diff.focus_handle(cx).focus(window);
 976        }
 977        let action = action.boxed_clone();
 978        cx.defer(move |cx| {
 979            cx.dispatch_action(action.as_ref());
 980        })
 981    }
 982
 983    fn stage_all(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 984        self.workspace
 985            .update(cx, |workspace, cx| {
 986                if let Some(panel) = workspace.panel::<GitPanel>(cx) {
 987                    panel.update(cx, |panel, cx| {
 988                        panel.stage_all(&Default::default(), window, cx);
 989                    });
 990                }
 991            })
 992            .ok();
 993    }
 994
 995    fn unstage_all(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 996        self.workspace
 997            .update(cx, |workspace, cx| {
 998                let Some(panel) = workspace.panel::<GitPanel>(cx) else {
 999                    return;
1000                };
1001                panel.update(cx, |panel, cx| {
1002                    panel.unstage_all(&Default::default(), window, cx);
1003                });
1004            })
1005            .ok();
1006    }
1007}
1008
1009impl EventEmitter<ToolbarItemEvent> for ProjectDiffToolbar {}
1010
1011impl ToolbarItemView for ProjectDiffToolbar {
1012    fn set_active_pane_item(
1013        &mut self,
1014        active_pane_item: Option<&dyn ItemHandle>,
1015        _: &mut Window,
1016        cx: &mut Context<Self>,
1017    ) -> ToolbarItemLocation {
1018        self.project_diff = active_pane_item
1019            .and_then(|item| item.act_as::<ProjectDiff>(cx))
1020            .map(|entity| entity.downgrade());
1021        if self.project_diff.is_some() {
1022            ToolbarItemLocation::PrimaryRight
1023        } else {
1024            ToolbarItemLocation::Hidden
1025        }
1026    }
1027
1028    fn pane_focus_update(
1029        &mut self,
1030        _pane_focused: bool,
1031        _window: &mut Window,
1032        _cx: &mut Context<Self>,
1033    ) {
1034    }
1035}
1036
1037struct ButtonStates {
1038    stage: bool,
1039    unstage: bool,
1040    prev_next: bool,
1041    selection: bool,
1042    stage_all: bool,
1043    unstage_all: bool,
1044}
1045
1046impl Render for ProjectDiffToolbar {
1047    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1048        let Some(project_diff) = self.project_diff(cx) else {
1049            return div();
1050        };
1051        let focus_handle = project_diff.focus_handle(cx);
1052        let button_states = project_diff.read(cx).button_states(cx);
1053
1054        h_group_xl()
1055            .my_neg_1()
1056            .py_1()
1057            .items_center()
1058            .flex_wrap()
1059            .justify_between()
1060            .child(
1061                h_group_sm()
1062                    .when(button_states.selection, |el| {
1063                        el.child(
1064                            Button::new("stage", "Toggle Staged")
1065                                .tooltip(Tooltip::for_action_title_in(
1066                                    "Toggle Staged",
1067                                    &ToggleStaged,
1068                                    &focus_handle,
1069                                ))
1070                                .disabled(!button_states.stage && !button_states.unstage)
1071                                .on_click(cx.listener(|this, _, window, cx| {
1072                                    this.dispatch_action(&ToggleStaged, window, cx)
1073                                })),
1074                        )
1075                    })
1076                    .when(!button_states.selection, |el| {
1077                        el.child(
1078                            Button::new("stage", "Stage")
1079                                .tooltip(Tooltip::for_action_title_in(
1080                                    "Stage and go to next hunk",
1081                                    &StageAndNext,
1082                                    &focus_handle,
1083                                ))
1084                                .on_click(cx.listener(|this, _, window, cx| {
1085                                    this.dispatch_action(&StageAndNext, window, cx)
1086                                })),
1087                        )
1088                        .child(
1089                            Button::new("unstage", "Unstage")
1090                                .tooltip(Tooltip::for_action_title_in(
1091                                    "Unstage and go to next hunk",
1092                                    &UnstageAndNext,
1093                                    &focus_handle,
1094                                ))
1095                                .on_click(cx.listener(|this, _, window, cx| {
1096                                    this.dispatch_action(&UnstageAndNext, window, cx)
1097                                })),
1098                        )
1099                    }),
1100            )
1101            // n.b. the only reason these arrows are here is because we don't
1102            // support "undo" for staging so we need a way to go back.
1103            .child(
1104                h_group_sm()
1105                    .child(
1106                        IconButton::new("up", IconName::ArrowUp)
1107                            .shape(ui::IconButtonShape::Square)
1108                            .tooltip(Tooltip::for_action_title_in(
1109                                "Go to previous hunk",
1110                                &GoToPreviousHunk,
1111                                &focus_handle,
1112                            ))
1113                            .disabled(!button_states.prev_next)
1114                            .on_click(cx.listener(|this, _, window, cx| {
1115                                this.dispatch_action(&GoToPreviousHunk, window, cx)
1116                            })),
1117                    )
1118                    .child(
1119                        IconButton::new("down", IconName::ArrowDown)
1120                            .shape(ui::IconButtonShape::Square)
1121                            .tooltip(Tooltip::for_action_title_in(
1122                                "Go to next hunk",
1123                                &GoToHunk,
1124                                &focus_handle,
1125                            ))
1126                            .disabled(!button_states.prev_next)
1127                            .on_click(cx.listener(|this, _, window, cx| {
1128                                this.dispatch_action(&GoToHunk, window, cx)
1129                            })),
1130                    ),
1131            )
1132            .child(vertical_divider())
1133            .child(
1134                h_group_sm()
1135                    .when(
1136                        button_states.unstage_all && !button_states.stage_all,
1137                        |el| {
1138                            el.child(
1139                                Button::new("unstage-all", "Unstage All")
1140                                    .tooltip(Tooltip::for_action_title_in(
1141                                        "Unstage all changes",
1142                                        &UnstageAll,
1143                                        &focus_handle,
1144                                    ))
1145                                    .on_click(cx.listener(|this, _, window, cx| {
1146                                        this.unstage_all(window, cx)
1147                                    })),
1148                            )
1149                        },
1150                    )
1151                    .when(
1152                        !button_states.unstage_all || button_states.stage_all,
1153                        |el| {
1154                            el.child(
1155                                // todo make it so that changing to say "Unstaged"
1156                                // doesn't change the position.
1157                                div().child(
1158                                    Button::new("stage-all", "Stage All")
1159                                        .disabled(!button_states.stage_all)
1160                                        .tooltip(Tooltip::for_action_title_in(
1161                                            "Stage all changes",
1162                                            &StageAll,
1163                                            &focus_handle,
1164                                        ))
1165                                        .on_click(cx.listener(|this, _, window, cx| {
1166                                            this.stage_all(window, cx)
1167                                        })),
1168                                ),
1169                            )
1170                        },
1171                    )
1172                    .child(
1173                        Button::new("commit", "Commit")
1174                            .tooltip(Tooltip::for_action_title_in(
1175                                "Commit",
1176                                &Commit,
1177                                &focus_handle,
1178                            ))
1179                            .on_click(cx.listener(|this, _, window, cx| {
1180                                this.dispatch_action(&Commit, window, cx);
1181                            })),
1182                    ),
1183            )
1184    }
1185}
1186
1187#[derive(IntoElement, RegisterComponent)]
1188pub struct ProjectDiffEmptyState {
1189    pub no_repo: bool,
1190    pub can_push_and_pull: bool,
1191    pub focus_handle: Option<FocusHandle>,
1192    pub current_branch: Option<Branch>,
1193    // has_pending_commits: bool,
1194    // ahead_of_remote: bool,
1195    // no_git_repository: bool,
1196}
1197
1198impl RenderOnce for ProjectDiffEmptyState {
1199    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
1200        let status_against_remote = |ahead_by: usize, behind_by: usize| -> bool {
1201            matches!(self.current_branch, Some(Branch {
1202                    upstream:
1203                        Some(Upstream {
1204                            tracking:
1205                                UpstreamTracking::Tracked(UpstreamTrackingStatus {
1206                                    ahead, behind, ..
1207                                }),
1208                            ..
1209                        }),
1210                    ..
1211                }) if (ahead > 0) == (ahead_by > 0) && (behind > 0) == (behind_by > 0))
1212        };
1213
1214        let change_count = |current_branch: &Branch| -> (usize, usize) {
1215            match current_branch {
1216                Branch {
1217                    upstream:
1218                        Some(Upstream {
1219                            tracking:
1220                                UpstreamTracking::Tracked(UpstreamTrackingStatus {
1221                                    ahead, behind, ..
1222                                }),
1223                            ..
1224                        }),
1225                    ..
1226                } => (*ahead as usize, *behind as usize),
1227                _ => (0, 0),
1228            }
1229        };
1230
1231        let not_ahead_or_behind = status_against_remote(0, 0);
1232        let ahead_of_remote = status_against_remote(1, 0);
1233        let branch_not_on_remote = if let Some(branch) = self.current_branch.as_ref() {
1234            branch.upstream.is_none()
1235        } else {
1236            false
1237        };
1238
1239        let has_branch_container = |branch: &Branch| {
1240            h_flex()
1241                .max_w(px(420.))
1242                .bg(cx.theme().colors().text.opacity(0.05))
1243                .border_1()
1244                .border_color(cx.theme().colors().border)
1245                .rounded_sm()
1246                .gap_8()
1247                .px_6()
1248                .py_4()
1249                .map(|this| {
1250                    if ahead_of_remote {
1251                        let ahead_count = change_count(branch).0;
1252                        let ahead_string = format!("{} Commits Ahead", ahead_count);
1253                        this.child(
1254                            v_flex()
1255                                .child(Headline::new(ahead_string).size(HeadlineSize::Small))
1256                                .child(
1257                                    Label::new(format!("Push your changes to {}", branch.name()))
1258                                        .color(Color::Muted),
1259                                ),
1260                        )
1261                        .child(div().child(render_push_button(
1262                            self.focus_handle,
1263                            "push".into(),
1264                            ahead_count as u32,
1265                        )))
1266                    } else if branch_not_on_remote {
1267                        this.child(
1268                            v_flex()
1269                                .child(Headline::new("Publish Branch").size(HeadlineSize::Small))
1270                                .child(
1271                                    Label::new(format!("Create {} on remote", branch.name()))
1272                                        .color(Color::Muted),
1273                                ),
1274                        )
1275                        .child(
1276                            div().child(render_publish_button(self.focus_handle, "publish".into())),
1277                        )
1278                    } else {
1279                        this.child(Label::new("Remote status unknown").color(Color::Muted))
1280                    }
1281                })
1282        };
1283
1284        v_flex().size_full().items_center().justify_center().child(
1285            v_flex()
1286                .gap_1()
1287                .when(self.no_repo, |this| {
1288                    // TODO: add git init
1289                    this.text_center()
1290                        .child(Label::new("No Repository").color(Color::Muted))
1291                })
1292                .map(|this| {
1293                    if not_ahead_or_behind && self.current_branch.is_some() {
1294                        this.text_center()
1295                            .child(Label::new("No Changes").color(Color::Muted))
1296                    } else {
1297                        this.when_some(self.current_branch.as_ref(), |this, branch| {
1298                            this.child(has_branch_container(branch))
1299                        })
1300                    }
1301                }),
1302        )
1303    }
1304}
1305
1306mod preview {
1307    use git::repository::{
1308        Branch, CommitSummary, Upstream, UpstreamTracking, UpstreamTrackingStatus,
1309    };
1310    use ui::prelude::*;
1311
1312    use super::ProjectDiffEmptyState;
1313
1314    // View this component preview using `workspace: open component-preview`
1315    impl Component for ProjectDiffEmptyState {
1316        fn scope() -> ComponentScope {
1317            ComponentScope::VersionControl
1318        }
1319
1320        fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
1321            let unknown_upstream: Option<UpstreamTracking> = None;
1322            let ahead_of_upstream: Option<UpstreamTracking> = Some(
1323                UpstreamTrackingStatus {
1324                    ahead: 2,
1325                    behind: 0,
1326                }
1327                .into(),
1328            );
1329
1330            let not_ahead_or_behind_upstream: Option<UpstreamTracking> = Some(
1331                UpstreamTrackingStatus {
1332                    ahead: 0,
1333                    behind: 0,
1334                }
1335                .into(),
1336            );
1337
1338            fn branch(upstream: Option<UpstreamTracking>) -> Branch {
1339                Branch {
1340                    is_head: true,
1341                    ref_name: "some-branch".into(),
1342                    upstream: upstream.map(|tracking| Upstream {
1343                        ref_name: "origin/some-branch".into(),
1344                        tracking,
1345                    }),
1346                    most_recent_commit: Some(CommitSummary {
1347                        sha: "abc123".into(),
1348                        subject: "Modify stuff".into(),
1349                        commit_timestamp: 1710932954,
1350                        has_parent: true,
1351                    }),
1352                }
1353            }
1354
1355            let no_repo_state = ProjectDiffEmptyState {
1356                no_repo: true,
1357                can_push_and_pull: false,
1358                focus_handle: None,
1359                current_branch: None,
1360            };
1361
1362            let no_changes_state = ProjectDiffEmptyState {
1363                no_repo: false,
1364                can_push_and_pull: true,
1365                focus_handle: None,
1366                current_branch: Some(branch(not_ahead_or_behind_upstream)),
1367            };
1368
1369            let ahead_of_upstream_state = ProjectDiffEmptyState {
1370                no_repo: false,
1371                can_push_and_pull: true,
1372                focus_handle: None,
1373                current_branch: Some(branch(ahead_of_upstream)),
1374            };
1375
1376            let unknown_upstream_state = ProjectDiffEmptyState {
1377                no_repo: false,
1378                can_push_and_pull: true,
1379                focus_handle: None,
1380                current_branch: Some(branch(unknown_upstream)),
1381            };
1382
1383            let (width, height) = (px(480.), px(320.));
1384
1385            Some(
1386                v_flex()
1387                    .gap_6()
1388                    .children(vec![
1389                        example_group(vec![
1390                            single_example(
1391                                "No Repo",
1392                                div()
1393                                    .w(width)
1394                                    .h(height)
1395                                    .child(no_repo_state)
1396                                    .into_any_element(),
1397                            ),
1398                            single_example(
1399                                "No Changes",
1400                                div()
1401                                    .w(width)
1402                                    .h(height)
1403                                    .child(no_changes_state)
1404                                    .into_any_element(),
1405                            ),
1406                            single_example(
1407                                "Unknown Upstream",
1408                                div()
1409                                    .w(width)
1410                                    .h(height)
1411                                    .child(unknown_upstream_state)
1412                                    .into_any_element(),
1413                            ),
1414                            single_example(
1415                                "Ahead of Remote",
1416                                div()
1417                                    .w(width)
1418                                    .h(height)
1419                                    .child(ahead_of_upstream_state)
1420                                    .into_any_element(),
1421                            ),
1422                        ])
1423                        .vertical(),
1424                    ])
1425                    .into_any_element(),
1426            )
1427        }
1428    }
1429}
1430
1431fn merge_anchor_ranges<'a>(
1432    left: impl 'a + Iterator<Item = Range<Anchor>>,
1433    right: impl 'a + Iterator<Item = Range<Anchor>>,
1434    snapshot: &'a language::BufferSnapshot,
1435) -> impl 'a + Iterator<Item = Range<Anchor>> {
1436    let mut left = left.fuse().peekable();
1437    let mut right = right.fuse().peekable();
1438
1439    std::iter::from_fn(move || {
1440        let Some(left_range) = left.peek() else {
1441            return right.next();
1442        };
1443        let Some(right_range) = right.peek() else {
1444            return left.next();
1445        };
1446
1447        let mut next_range = if left_range.start.cmp(&right_range.start, snapshot).is_lt() {
1448            left.next().unwrap()
1449        } else {
1450            right.next().unwrap()
1451        };
1452
1453        // Extend the basic range while there's overlap with a range from either stream.
1454        loop {
1455            if let Some(left_range) = left
1456                .peek()
1457                .filter(|range| range.start.cmp(&next_range.end, snapshot).is_le())
1458                .cloned()
1459            {
1460                left.next();
1461                next_range.end = left_range.end;
1462            } else if let Some(right_range) = right
1463                .peek()
1464                .filter(|range| range.start.cmp(&next_range.end, snapshot).is_le())
1465                .cloned()
1466            {
1467                right.next();
1468                next_range.end = right_range.end;
1469            } else {
1470                break;
1471            }
1472        }
1473
1474        Some(next_range)
1475    })
1476}
1477
1478#[cfg(not(target_os = "windows"))]
1479#[cfg(test)]
1480mod tests {
1481    use db::indoc;
1482    use editor::test::editor_test_context::{EditorTestContext, assert_state_with_diff};
1483    use git::status::{UnmergedStatus, UnmergedStatusCode};
1484    use gpui::TestAppContext;
1485    use project::FakeFs;
1486    use serde_json::json;
1487    use settings::SettingsStore;
1488    use std::path::Path;
1489    use unindent::Unindent as _;
1490    use util::path;
1491
1492    use super::*;
1493
1494    #[ctor::ctor]
1495    fn init_logger() {
1496        zlog::init_test();
1497    }
1498
1499    fn init_test(cx: &mut TestAppContext) {
1500        cx.update(|cx| {
1501            let store = SettingsStore::test(cx);
1502            cx.set_global(store);
1503            theme::init(theme::LoadThemes::JustBase, cx);
1504            language::init(cx);
1505            Project::init_settings(cx);
1506            workspace::init_settings(cx);
1507            editor::init(cx);
1508            crate::init(cx);
1509        });
1510    }
1511
1512    #[gpui::test]
1513    async fn test_save_after_restore(cx: &mut TestAppContext) {
1514        init_test(cx);
1515
1516        let fs = FakeFs::new(cx.executor());
1517        fs.insert_tree(
1518            path!("/project"),
1519            json!({
1520                ".git": {},
1521                "foo.txt": "FOO\n",
1522            }),
1523        )
1524        .await;
1525        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1526        let (workspace, cx) =
1527            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1528        let diff = cx.new_window_entity(|window, cx| {
1529            ProjectDiff::new(project.clone(), workspace, DiffBaseKind::Head, window, cx)
1530        });
1531        cx.run_until_parked();
1532
1533        fs.set_head_for_repo(
1534            path!("/project/.git").as_ref(),
1535            &[("foo.txt".into(), "foo\n".into())],
1536            "deadbeef",
1537        );
1538        fs.set_index_for_repo(
1539            path!("/project/.git").as_ref(),
1540            &[("foo.txt".into(), "foo\n".into())],
1541        );
1542        cx.run_until_parked();
1543
1544        let editor = diff.read_with(cx, |diff, _| diff.editor.clone());
1545        assert_state_with_diff(
1546            &editor,
1547            cx,
1548            &"
1549                - foo
1550                + ˇFOO
1551            "
1552            .unindent(),
1553        );
1554
1555        editor.update_in(cx, |editor, window, cx| {
1556            editor.git_restore(&Default::default(), window, cx);
1557        });
1558        cx.run_until_parked();
1559
1560        assert_state_with_diff(&editor, cx, &"ˇ".unindent());
1561
1562        let text = String::from_utf8(fs.read_file_sync("/project/foo.txt").unwrap()).unwrap();
1563        assert_eq!(text, "foo\n");
1564    }
1565
1566    #[gpui::test]
1567    async fn test_scroll_to_beginning_with_deletion(cx: &mut TestAppContext) {
1568        init_test(cx);
1569
1570        let fs = FakeFs::new(cx.executor());
1571        fs.insert_tree(
1572            path!("/project"),
1573            json!({
1574                ".git": {},
1575                "bar": "BAR\n",
1576                "foo": "FOO\n",
1577            }),
1578        )
1579        .await;
1580        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1581        let (workspace, cx) =
1582            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1583        let diff = cx.new_window_entity(|window, cx| {
1584            ProjectDiff::new(project.clone(), workspace, DiffBaseKind::Head, window, cx)
1585        });
1586        cx.run_until_parked();
1587
1588        fs.set_head_and_index_for_repo(
1589            path!("/project/.git").as_ref(),
1590            &[
1591                ("bar".into(), "bar\n".into()),
1592                ("foo".into(), "foo\n".into()),
1593            ],
1594        );
1595        cx.run_until_parked();
1596
1597        let editor = cx.update_window_entity(&diff, |diff, window, cx| {
1598            diff.move_to_path(
1599                PathKey::namespaced(TRACKED_NAMESPACE, Path::new("foo").into()),
1600                window,
1601                cx,
1602            );
1603            diff.editor.clone()
1604        });
1605        assert_state_with_diff(
1606            &editor,
1607            cx,
1608            &"
1609                - bar
1610                + BAR
1611
1612                - ˇfoo
1613                + FOO
1614            "
1615            .unindent(),
1616        );
1617
1618        let editor = cx.update_window_entity(&diff, |diff, window, cx| {
1619            diff.move_to_path(
1620                PathKey::namespaced(TRACKED_NAMESPACE, Path::new("bar").into()),
1621                window,
1622                cx,
1623            );
1624            diff.editor.clone()
1625        });
1626        assert_state_with_diff(
1627            &editor,
1628            cx,
1629            &"
1630                - ˇbar
1631                + BAR
1632
1633                - foo
1634                + FOO
1635            "
1636            .unindent(),
1637        );
1638    }
1639
1640    #[gpui::test]
1641    async fn test_hunks_after_restore_then_modify(cx: &mut TestAppContext) {
1642        init_test(cx);
1643
1644        let fs = FakeFs::new(cx.executor());
1645        fs.insert_tree(
1646            path!("/project"),
1647            json!({
1648                ".git": {},
1649                "foo": "modified\n",
1650            }),
1651        )
1652        .await;
1653        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1654        let (workspace, cx) =
1655            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1656        let buffer = project
1657            .update(cx, |project, cx| {
1658                project.open_local_buffer(path!("/project/foo"), cx)
1659            })
1660            .await
1661            .unwrap();
1662        let buffer_editor = cx.new_window_entity(|window, cx| {
1663            Editor::for_buffer(buffer, Some(project.clone()), window, cx)
1664        });
1665        let diff = cx.new_window_entity(|window, cx| {
1666            ProjectDiff::new(project.clone(), workspace, DiffBaseKind::Head, window, cx)
1667        });
1668        cx.run_until_parked();
1669
1670        fs.set_head_for_repo(
1671            path!("/project/.git").as_ref(),
1672            &[("foo".into(), "original\n".into())],
1673            "deadbeef",
1674        );
1675        cx.run_until_parked();
1676
1677        let diff_editor = diff.read_with(cx, |diff, _| diff.editor.clone());
1678
1679        assert_state_with_diff(
1680            &diff_editor,
1681            cx,
1682            &"
1683                - original
1684                + ˇmodified
1685            "
1686            .unindent(),
1687        );
1688
1689        let prev_buffer_hunks =
1690            cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
1691                let snapshot = buffer_editor.snapshot(window, cx);
1692                let snapshot = &snapshot.buffer_snapshot;
1693                let prev_buffer_hunks = buffer_editor
1694                    .diff_hunks_in_ranges(&[editor::Anchor::min()..editor::Anchor::max()], snapshot)
1695                    .collect::<Vec<_>>();
1696                buffer_editor.git_restore(&Default::default(), window, cx);
1697                prev_buffer_hunks
1698            });
1699        assert_eq!(prev_buffer_hunks.len(), 1);
1700        cx.run_until_parked();
1701
1702        let new_buffer_hunks =
1703            cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
1704                let snapshot = buffer_editor.snapshot(window, cx);
1705                let snapshot = &snapshot.buffer_snapshot;
1706                buffer_editor
1707                    .diff_hunks_in_ranges(&[editor::Anchor::min()..editor::Anchor::max()], snapshot)
1708                    .collect::<Vec<_>>()
1709            });
1710        assert_eq!(new_buffer_hunks.as_slice(), &[]);
1711
1712        cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
1713            buffer_editor.set_text("different\n", window, cx);
1714            buffer_editor.save(
1715                SaveOptions {
1716                    format: false,
1717                    autosave: false,
1718                },
1719                project.clone(),
1720                window,
1721                cx,
1722            )
1723        })
1724        .await
1725        .unwrap();
1726
1727        cx.run_until_parked();
1728
1729        cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
1730            buffer_editor.expand_all_diff_hunks(&Default::default(), window, cx);
1731        });
1732
1733        assert_state_with_diff(
1734            &buffer_editor,
1735            cx,
1736            &"
1737                - original
1738                + different
1739                  ˇ"
1740            .unindent(),
1741        );
1742
1743        assert_state_with_diff(
1744            &diff_editor,
1745            cx,
1746            &"
1747                - original
1748                + different
1749                  ˇ"
1750            .unindent(),
1751        );
1752    }
1753
1754    use crate::{
1755        conflict_view::resolve_conflict,
1756        project_diff::{self, ProjectDiff},
1757    };
1758
1759    #[gpui::test]
1760    async fn test_go_to_prev_hunk_multibuffer(cx: &mut TestAppContext) {
1761        init_test(cx);
1762
1763        let fs = FakeFs::new(cx.executor());
1764        fs.insert_tree(
1765            "/a",
1766            json!({
1767                ".git": {},
1768                "a.txt": "created\n",
1769                "b.txt": "really changed\n",
1770                "c.txt": "unchanged\n"
1771            }),
1772        )
1773        .await;
1774
1775        fs.set_git_content_for_repo(
1776            Path::new("/a/.git"),
1777            &[
1778                ("b.txt".into(), "before\n".to_string(), None),
1779                ("c.txt".into(), "unchanged\n".to_string(), None),
1780                ("d.txt".into(), "deleted\n".to_string(), None),
1781            ],
1782        );
1783
1784        let project = Project::test(fs, [Path::new("/a")], cx).await;
1785        let (workspace, cx) =
1786            cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1787
1788        cx.run_until_parked();
1789
1790        cx.focus(&workspace);
1791        cx.update(|window, cx| {
1792            window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
1793        });
1794
1795        cx.run_until_parked();
1796
1797        let item = workspace.update(cx, |workspace, cx| {
1798            workspace.active_item_as::<ProjectDiff>(cx).unwrap()
1799        });
1800        cx.focus(&item);
1801        let editor = item.read_with(cx, |item, _| item.editor.clone());
1802
1803        let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
1804
1805        cx.assert_excerpts_with_selections(indoc!(
1806            "
1807            [EXCERPT]
1808            before
1809            really changed
1810            [EXCERPT]
1811            [FOLDED]
1812            [EXCERPT]
1813            ˇcreated
1814        "
1815        ));
1816
1817        cx.dispatch_action(editor::actions::GoToPreviousHunk);
1818
1819        cx.assert_excerpts_with_selections(indoc!(
1820            "
1821            [EXCERPT]
1822            before
1823            really changed
1824            [EXCERPT]
1825            ˇ[FOLDED]
1826            [EXCERPT]
1827            created
1828        "
1829        ));
1830
1831        cx.dispatch_action(editor::actions::GoToPreviousHunk);
1832
1833        cx.assert_excerpts_with_selections(indoc!(
1834            "
1835            [EXCERPT]
1836            ˇbefore
1837            really changed
1838            [EXCERPT]
1839            [FOLDED]
1840            [EXCERPT]
1841            created
1842        "
1843        ));
1844    }
1845
1846    #[gpui::test]
1847    async fn test_excerpts_splitting_after_restoring_the_middle_excerpt(cx: &mut TestAppContext) {
1848        init_test(cx);
1849
1850        let git_contents = indoc! {r#"
1851            #[rustfmt::skip]
1852            fn main() {
1853                let x = 0.0; // this line will be removed
1854                // 1
1855                // 2
1856                // 3
1857                let y = 0.0; // this line will be removed
1858                // 1
1859                // 2
1860                // 3
1861                let arr = [
1862                    0.0, // this line will be removed
1863                    0.0, // this line will be removed
1864                    0.0, // this line will be removed
1865                    0.0, // this line will be removed
1866                ];
1867            }
1868        "#};
1869        let buffer_contents = indoc! {"
1870            #[rustfmt::skip]
1871            fn main() {
1872                // 1
1873                // 2
1874                // 3
1875                // 1
1876                // 2
1877                // 3
1878                let arr = [
1879                ];
1880            }
1881        "};
1882
1883        let fs = FakeFs::new(cx.executor());
1884        fs.insert_tree(
1885            "/a",
1886            json!({
1887                ".git": {},
1888                "main.rs": buffer_contents,
1889            }),
1890        )
1891        .await;
1892
1893        fs.set_git_content_for_repo(
1894            Path::new("/a/.git"),
1895            &[("main.rs".into(), git_contents.to_owned(), None)],
1896        );
1897
1898        let project = Project::test(fs, [Path::new("/a")], cx).await;
1899        let (workspace, cx) =
1900            cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1901
1902        cx.run_until_parked();
1903
1904        cx.focus(&workspace);
1905        cx.update(|window, cx| {
1906            window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
1907        });
1908
1909        cx.run_until_parked();
1910
1911        let item = workspace.update(cx, |workspace, cx| {
1912            workspace.active_item_as::<ProjectDiff>(cx).unwrap()
1913        });
1914        cx.focus(&item);
1915        let editor = item.read_with(cx, |item, _| item.editor.clone());
1916
1917        let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
1918
1919        cx.assert_excerpts_with_selections(&format!("[EXCERPT]\nˇ{git_contents}"));
1920
1921        cx.dispatch_action(editor::actions::GoToHunk);
1922        cx.dispatch_action(editor::actions::GoToHunk);
1923        cx.dispatch_action(git::Restore);
1924        cx.dispatch_action(editor::actions::MoveToBeginning);
1925
1926        cx.assert_excerpts_with_selections(&format!("[EXCERPT]\nˇ{git_contents}"));
1927    }
1928
1929    #[gpui::test]
1930    async fn test_saving_resolved_conflicts(cx: &mut TestAppContext) {
1931        init_test(cx);
1932
1933        let fs = FakeFs::new(cx.executor());
1934        fs.insert_tree(
1935            path!("/project"),
1936            json!({
1937                ".git": {},
1938                "foo": "<<<<<<< x\nours\n=======\ntheirs\n>>>>>>> y\n",
1939            }),
1940        )
1941        .await;
1942        fs.set_status_for_repo(
1943            Path::new(path!("/project/.git")),
1944            &[(
1945                Path::new("foo"),
1946                UnmergedStatus {
1947                    first_head: UnmergedStatusCode::Updated,
1948                    second_head: UnmergedStatusCode::Updated,
1949                }
1950                .into(),
1951            )],
1952        );
1953        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1954        let (workspace, cx) =
1955            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1956        let diff = cx.new_window_entity(|window, cx| {
1957            ProjectDiff::new(project.clone(), workspace, DiffBaseKind::Head, window, cx)
1958        });
1959        cx.run_until_parked();
1960
1961        cx.update(|window, cx| {
1962            let editor = diff.read(cx).editor.clone();
1963            let excerpt_ids = editor.read(cx).buffer().read(cx).excerpt_ids();
1964            assert_eq!(excerpt_ids.len(), 1);
1965            let excerpt_id = excerpt_ids[0];
1966            let buffer = editor
1967                .read(cx)
1968                .buffer()
1969                .read(cx)
1970                .all_buffers()
1971                .into_iter()
1972                .next()
1973                .unwrap();
1974            let buffer_id = buffer.read(cx).remote_id();
1975            let conflict_set = diff
1976                .read(cx)
1977                .editor
1978                .read(cx)
1979                .addon::<ConflictAddon>()
1980                .unwrap()
1981                .conflict_set(buffer_id)
1982                .unwrap();
1983            assert!(conflict_set.read(cx).has_conflict);
1984            let snapshot = conflict_set.read(cx).snapshot();
1985            assert_eq!(snapshot.conflicts.len(), 1);
1986
1987            let ours_range = snapshot.conflicts[0].ours.clone();
1988
1989            resolve_conflict(
1990                editor.downgrade(),
1991                excerpt_id,
1992                snapshot.conflicts[0].clone(),
1993                vec![ours_range],
1994                window,
1995                cx,
1996            )
1997        })
1998        .await;
1999
2000        let contents = fs.read_file_sync(path!("/project/foo")).unwrap();
2001        let contents = String::from_utf8(contents).unwrap();
2002        assert_eq!(contents, "ours\n");
2003    }
2004}