project_diff.rs

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