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