project_diff.rs

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