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_store::{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 db::indoc;
1295    use editor::test::editor_test_context::{assert_state_with_diff, EditorTestContext};
1296    use gpui::TestAppContext;
1297    use project::FakeFs;
1298    use serde_json::json;
1299    use settings::SettingsStore;
1300    use std::path::Path;
1301    use unindent::Unindent as _;
1302    use util::path;
1303
1304    use super::*;
1305
1306    #[ctor::ctor]
1307    fn init_logger() {
1308        env_logger::init();
1309    }
1310
1311    fn init_test(cx: &mut TestAppContext) {
1312        cx.update(|cx| {
1313            let store = SettingsStore::test(cx);
1314            cx.set_global(store);
1315            theme::init(theme::LoadThemes::JustBase, cx);
1316            language::init(cx);
1317            Project::init_settings(cx);
1318            workspace::init_settings(cx);
1319            editor::init(cx);
1320            crate::init(cx);
1321        });
1322    }
1323
1324    #[gpui::test]
1325    async fn test_save_after_restore(cx: &mut TestAppContext) {
1326        init_test(cx);
1327
1328        let fs = FakeFs::new(cx.executor());
1329        fs.insert_tree(
1330            path!("/project"),
1331            json!({
1332                ".git": {},
1333                "foo.txt": "FOO\n",
1334            }),
1335        )
1336        .await;
1337        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1338        let (workspace, cx) =
1339            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1340        let diff = cx.new_window_entity(|window, cx| {
1341            ProjectDiff::new(project.clone(), workspace, window, cx)
1342        });
1343        cx.run_until_parked();
1344
1345        fs.set_head_for_repo(
1346            path!("/project/.git").as_ref(),
1347            &[("foo.txt".into(), "foo\n".into())],
1348        );
1349        fs.set_index_for_repo(
1350            path!("/project/.git").as_ref(),
1351            &[("foo.txt".into(), "foo\n".into())],
1352        );
1353        cx.run_until_parked();
1354
1355        let editor = diff.update(cx, |diff, _| diff.editor.clone());
1356        assert_state_with_diff(
1357            &editor,
1358            cx,
1359            &"
1360                - foo
1361                + ˇFOO
1362            "
1363            .unindent(),
1364        );
1365
1366        editor.update_in(cx, |editor, window, cx| {
1367            editor.git_restore(&Default::default(), window, cx);
1368        });
1369        cx.run_until_parked();
1370
1371        assert_state_with_diff(&editor, cx, &"ˇ".unindent());
1372
1373        let text = String::from_utf8(fs.read_file_sync("/project/foo.txt").unwrap()).unwrap();
1374        assert_eq!(text, "foo\n");
1375    }
1376
1377    #[gpui::test]
1378    async fn test_scroll_to_beginning_with_deletion(cx: &mut TestAppContext) {
1379        init_test(cx);
1380
1381        let fs = FakeFs::new(cx.executor());
1382        fs.insert_tree(
1383            path!("/project"),
1384            json!({
1385                ".git": {},
1386                "bar": "BAR\n",
1387                "foo": "FOO\n",
1388            }),
1389        )
1390        .await;
1391        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1392        let (workspace, cx) =
1393            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1394        let diff = cx.new_window_entity(|window, cx| {
1395            ProjectDiff::new(project.clone(), workspace, window, cx)
1396        });
1397        cx.run_until_parked();
1398
1399        fs.set_head_and_index_for_repo(
1400            path!("/project/.git").as_ref(),
1401            &[
1402                ("bar".into(), "bar\n".into()),
1403                ("foo".into(), "foo\n".into()),
1404            ],
1405        );
1406        cx.run_until_parked();
1407
1408        let editor = cx.update_window_entity(&diff, |diff, window, cx| {
1409            diff.move_to_path(
1410                PathKey::namespaced(TRACKED_NAMESPACE, Path::new("foo").into()),
1411                window,
1412                cx,
1413            );
1414            diff.editor.clone()
1415        });
1416        assert_state_with_diff(
1417            &editor,
1418            cx,
1419            &"
1420                - bar
1421                + BAR
1422
1423                - ˇfoo
1424                + FOO
1425            "
1426            .unindent(),
1427        );
1428
1429        let editor = cx.update_window_entity(&diff, |diff, window, cx| {
1430            diff.move_to_path(
1431                PathKey::namespaced(TRACKED_NAMESPACE, Path::new("bar").into()),
1432                window,
1433                cx,
1434            );
1435            diff.editor.clone()
1436        });
1437        assert_state_with_diff(
1438            &editor,
1439            cx,
1440            &"
1441                - ˇbar
1442                + BAR
1443
1444                - foo
1445                + FOO
1446            "
1447            .unindent(),
1448        );
1449    }
1450
1451    #[gpui::test]
1452    async fn test_hunks_after_restore_then_modify(cx: &mut TestAppContext) {
1453        init_test(cx);
1454
1455        let fs = FakeFs::new(cx.executor());
1456        fs.insert_tree(
1457            path!("/project"),
1458            json!({
1459                ".git": {},
1460                "foo": "modified\n",
1461            }),
1462        )
1463        .await;
1464        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1465        let (workspace, cx) =
1466            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1467        let buffer = project
1468            .update(cx, |project, cx| {
1469                project.open_local_buffer(path!("/project/foo"), cx)
1470            })
1471            .await
1472            .unwrap();
1473        let buffer_editor = cx.new_window_entity(|window, cx| {
1474            Editor::for_buffer(buffer, Some(project.clone()), window, cx)
1475        });
1476        let diff = cx.new_window_entity(|window, cx| {
1477            ProjectDiff::new(project.clone(), workspace, window, cx)
1478        });
1479        cx.run_until_parked();
1480
1481        fs.set_head_for_repo(
1482            path!("/project/.git").as_ref(),
1483            &[("foo".into(), "original\n".into())],
1484        );
1485        cx.run_until_parked();
1486
1487        let diff_editor = diff.update(cx, |diff, _| diff.editor.clone());
1488
1489        assert_state_with_diff(
1490            &diff_editor,
1491            cx,
1492            &"
1493                - original
1494                + ˇmodified
1495            "
1496            .unindent(),
1497        );
1498
1499        let prev_buffer_hunks =
1500            cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
1501                let snapshot = buffer_editor.snapshot(window, cx);
1502                let snapshot = &snapshot.buffer_snapshot;
1503                let prev_buffer_hunks = buffer_editor
1504                    .diff_hunks_in_ranges(&[editor::Anchor::min()..editor::Anchor::max()], snapshot)
1505                    .collect::<Vec<_>>();
1506                buffer_editor.git_restore(&Default::default(), window, cx);
1507                prev_buffer_hunks
1508            });
1509        assert_eq!(prev_buffer_hunks.len(), 1);
1510        cx.run_until_parked();
1511
1512        let new_buffer_hunks =
1513            cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
1514                let snapshot = buffer_editor.snapshot(window, cx);
1515                let snapshot = &snapshot.buffer_snapshot;
1516                let new_buffer_hunks = buffer_editor
1517                    .diff_hunks_in_ranges(&[editor::Anchor::min()..editor::Anchor::max()], snapshot)
1518                    .collect::<Vec<_>>();
1519                buffer_editor.git_restore(&Default::default(), window, cx);
1520                new_buffer_hunks
1521            });
1522        assert_eq!(new_buffer_hunks.as_slice(), &[]);
1523
1524        cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
1525            buffer_editor.set_text("different\n", window, cx);
1526            buffer_editor.save(false, project.clone(), window, cx)
1527        })
1528        .await
1529        .unwrap();
1530
1531        cx.run_until_parked();
1532
1533        assert_state_with_diff(
1534            &diff_editor,
1535            cx,
1536            &"
1537                - original
1538                + ˇdifferent
1539            "
1540            .unindent(),
1541        );
1542    }
1543
1544    use crate::project_diff::{self, ProjectDiff};
1545
1546    #[gpui::test]
1547    async fn test_go_to_prev_hunk_multibuffer(cx: &mut TestAppContext) {
1548        init_test(cx);
1549
1550        let fs = FakeFs::new(cx.executor());
1551        fs.insert_tree(
1552            "/a",
1553            json!({
1554                ".git":{},
1555                "a.txt": "created\n",
1556                "b.txt": "really changed\n",
1557                "c.txt": "unchanged\n"
1558            }),
1559        )
1560        .await;
1561
1562        fs.set_git_content_for_repo(
1563            Path::new("/a/.git"),
1564            &[
1565                ("b.txt".into(), "before\n".to_string(), None),
1566                ("c.txt".into(), "unchanged\n".to_string(), None),
1567                ("d.txt".into(), "deleted\n".to_string(), None),
1568            ],
1569        );
1570
1571        let project = Project::test(fs, [Path::new("/a")], cx).await;
1572        let (workspace, cx) =
1573            cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1574
1575        cx.run_until_parked();
1576
1577        cx.focus(&workspace);
1578        cx.update(|window, cx| {
1579            window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
1580        });
1581
1582        cx.run_until_parked();
1583
1584        let item = workspace.update(cx, |workspace, cx| {
1585            workspace.active_item_as::<ProjectDiff>(cx).unwrap()
1586        });
1587        cx.focus(&item);
1588        let editor = item.update(cx, |item, _| item.editor.clone());
1589
1590        let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
1591
1592        cx.assert_excerpts_with_selections(indoc!(
1593            "
1594            [EXCERPT]
1595            before
1596            really changed
1597            [EXCERPT]
1598            [FOLDED]
1599            [EXCERPT]
1600            ˇcreated
1601        "
1602        ));
1603
1604        cx.dispatch_action(editor::actions::GoToPreviousHunk);
1605
1606        cx.assert_excerpts_with_selections(indoc!(
1607            "
1608            [EXCERPT]
1609            before
1610            really changed
1611            [EXCERPT]
1612            ˇ[FOLDED]
1613            [EXCERPT]
1614            created
1615        "
1616        ));
1617
1618        cx.dispatch_action(editor::actions::GoToPreviousHunk);
1619
1620        cx.assert_excerpts_with_selections(indoc!(
1621            "
1622            [EXCERPT]
1623            ˇbefore
1624            really changed
1625            [EXCERPT]
1626            [FOLDED]
1627            [EXCERPT]
1628            created
1629        "
1630        ));
1631    }
1632}