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