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 telemetry_event_text(&self) -> Option<&'static str> {
 551        Some("Project Diff Opened")
 552    }
 553
 554    fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
 555        Some(Box::new(self.editor.clone()))
 556    }
 557
 558    fn for_each_project_item(
 559        &self,
 560        cx: &App,
 561        f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
 562    ) {
 563        self.editor.for_each_project_item(cx, f)
 564    }
 565
 566    fn is_singleton(&self, _: &App) -> bool {
 567        false
 568    }
 569
 570    fn set_nav_history(
 571        &mut self,
 572        nav_history: ItemNavHistory,
 573        _: &mut Window,
 574        cx: &mut Context<Self>,
 575    ) {
 576        self.editor.update(cx, |editor, _| {
 577            editor.set_nav_history(Some(nav_history));
 578        });
 579    }
 580
 581    fn clone_on_split(
 582        &self,
 583        _workspace_id: Option<workspace::WorkspaceId>,
 584        window: &mut Window,
 585        cx: &mut Context<Self>,
 586    ) -> Option<Entity<Self>>
 587    where
 588        Self: Sized,
 589    {
 590        let workspace = self.workspace.upgrade()?;
 591        Some(cx.new(|cx| ProjectDiff::new(self.project.clone(), workspace, window, cx)))
 592    }
 593
 594    fn is_dirty(&self, cx: &App) -> bool {
 595        self.multibuffer.read(cx).is_dirty(cx)
 596    }
 597
 598    fn has_conflict(&self, cx: &App) -> bool {
 599        self.multibuffer.read(cx).has_conflict(cx)
 600    }
 601
 602    fn can_save(&self, _: &App) -> bool {
 603        true
 604    }
 605
 606    fn save(
 607        &mut self,
 608        format: bool,
 609        project: Entity<Project>,
 610        window: &mut Window,
 611        cx: &mut Context<Self>,
 612    ) -> Task<Result<()>> {
 613        self.editor.save(format, project, window, cx)
 614    }
 615
 616    fn save_as(
 617        &mut self,
 618        _: Entity<Project>,
 619        _: ProjectPath,
 620        _window: &mut Window,
 621        _: &mut Context<Self>,
 622    ) -> Task<Result<()>> {
 623        unreachable!()
 624    }
 625
 626    fn reload(
 627        &mut self,
 628        project: Entity<Project>,
 629        window: &mut Window,
 630        cx: &mut Context<Self>,
 631    ) -> Task<Result<()>> {
 632        self.editor.reload(project, window, cx)
 633    }
 634
 635    fn act_as_type<'a>(
 636        &'a self,
 637        type_id: TypeId,
 638        self_handle: &'a Entity<Self>,
 639        _: &'a App,
 640    ) -> Option<AnyView> {
 641        if type_id == TypeId::of::<Self>() {
 642            Some(self_handle.to_any())
 643        } else if type_id == TypeId::of::<Editor>() {
 644            Some(self.editor.to_any())
 645        } else {
 646            None
 647        }
 648    }
 649
 650    fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
 651        ToolbarItemLocation::PrimaryLeft
 652    }
 653
 654    fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
 655        self.editor.breadcrumbs(theme, cx)
 656    }
 657
 658    fn added_to_workspace(
 659        &mut self,
 660        workspace: &mut Workspace,
 661        window: &mut Window,
 662        cx: &mut Context<Self>,
 663    ) {
 664        self.editor.update(cx, |editor, cx| {
 665            editor.added_to_workspace(workspace, window, cx)
 666        });
 667    }
 668}
 669
 670impl Render for ProjectDiff {
 671    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 672        let is_empty = self.multibuffer.read(cx).is_empty();
 673
 674        div()
 675            .track_focus(&self.focus_handle)
 676            .key_context(if is_empty { "EmptyPane" } else { "GitDiff" })
 677            .bg(cx.theme().colors().editor_background)
 678            .flex()
 679            .items_center()
 680            .justify_center()
 681            .size_full()
 682            .when(is_empty, |el| {
 683                let remote_button = if let Some(panel) = self
 684                    .workspace
 685                    .upgrade()
 686                    .and_then(|workspace| workspace.read(cx).panel::<GitPanel>(cx))
 687                {
 688                    panel.update(cx, |panel, cx| panel.render_remote_button(cx))
 689                } else {
 690                    None
 691                };
 692                let keybinding_focus_handle = self.focus_handle(cx).clone();
 693                el.child(
 694                    v_flex()
 695                        .gap_1()
 696                        .child(
 697                            h_flex()
 698                                .justify_around()
 699                                .child(Label::new("No uncommitted changes")),
 700                        )
 701                        .map(|el| match remote_button {
 702                            Some(button) => el.child(h_flex().justify_around().child(button)),
 703                            None => el.child(
 704                                h_flex()
 705                                    .justify_around()
 706                                    .child(Label::new("Remote up to date")),
 707                            ),
 708                        })
 709                        .child(
 710                            h_flex().justify_around().mt_1().child(
 711                                Button::new("project-diff-close-button", "Close")
 712                                    // .style(ButtonStyle::Transparent)
 713                                    .key_binding(KeyBinding::for_action_in(
 714                                        &CloseActiveItem::default(),
 715                                        &keybinding_focus_handle,
 716                                        window,
 717                                        cx,
 718                                    ))
 719                                    .on_click(move |_, window, cx| {
 720                                        window.focus(&keybinding_focus_handle);
 721                                        window.dispatch_action(
 722                                            Box::new(CloseActiveItem::default()),
 723                                            cx,
 724                                        );
 725                                    }),
 726                            ),
 727                        ),
 728                )
 729            })
 730            .when(!is_empty, |el| el.child(self.editor.clone()))
 731    }
 732}
 733
 734impl SerializableItem for ProjectDiff {
 735    fn serialized_item_kind() -> &'static str {
 736        "ProjectDiff"
 737    }
 738
 739    fn cleanup(
 740        _: workspace::WorkspaceId,
 741        _: Vec<workspace::ItemId>,
 742        _: &mut Window,
 743        _: &mut App,
 744    ) -> Task<Result<()>> {
 745        Task::ready(Ok(()))
 746    }
 747
 748    fn deserialize(
 749        _project: Entity<Project>,
 750        workspace: WeakEntity<Workspace>,
 751        _workspace_id: workspace::WorkspaceId,
 752        _item_id: workspace::ItemId,
 753        window: &mut Window,
 754        cx: &mut App,
 755    ) -> Task<Result<Entity<Self>>> {
 756        window.spawn(cx, async move |cx| {
 757            workspace.update_in(cx, |workspace, window, cx| {
 758                let workspace_handle = cx.entity();
 759                cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx))
 760            })
 761        })
 762    }
 763
 764    fn serialize(
 765        &mut self,
 766        _workspace: &mut Workspace,
 767        _item_id: workspace::ItemId,
 768        _closing: bool,
 769        _window: &mut Window,
 770        _cx: &mut Context<Self>,
 771    ) -> Option<Task<Result<()>>> {
 772        None
 773    }
 774
 775    fn should_serialize(&self, _: &Self::Event) -> bool {
 776        false
 777    }
 778}
 779
 780pub struct ProjectDiffToolbar {
 781    project_diff: Option<WeakEntity<ProjectDiff>>,
 782    workspace: WeakEntity<Workspace>,
 783}
 784
 785impl ProjectDiffToolbar {
 786    pub fn new(workspace: &Workspace, _: &mut Context<Self>) -> Self {
 787        Self {
 788            project_diff: None,
 789            workspace: workspace.weak_handle(),
 790        }
 791    }
 792
 793    fn project_diff(&self, _: &App) -> Option<Entity<ProjectDiff>> {
 794        self.project_diff.as_ref()?.upgrade()
 795    }
 796
 797    fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context<Self>) {
 798        if let Some(project_diff) = self.project_diff(cx) {
 799            project_diff.focus_handle(cx).focus(window);
 800        }
 801        let action = action.boxed_clone();
 802        cx.defer(move |cx| {
 803            cx.dispatch_action(action.as_ref());
 804        })
 805    }
 806
 807    fn stage_all(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 808        self.workspace
 809            .update(cx, |workspace, cx| {
 810                if let Some(panel) = workspace.panel::<GitPanel>(cx) {
 811                    panel.update(cx, |panel, cx| {
 812                        panel.stage_all(&Default::default(), window, cx);
 813                    });
 814                }
 815            })
 816            .ok();
 817    }
 818
 819    fn unstage_all(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 820        self.workspace
 821            .update(cx, |workspace, cx| {
 822                let Some(panel) = workspace.panel::<GitPanel>(cx) else {
 823                    return;
 824                };
 825                panel.update(cx, |panel, cx| {
 826                    panel.unstage_all(&Default::default(), window, cx);
 827                });
 828            })
 829            .ok();
 830    }
 831}
 832
 833impl EventEmitter<ToolbarItemEvent> for ProjectDiffToolbar {}
 834
 835impl ToolbarItemView for ProjectDiffToolbar {
 836    fn set_active_pane_item(
 837        &mut self,
 838        active_pane_item: Option<&dyn ItemHandle>,
 839        _: &mut Window,
 840        cx: &mut Context<Self>,
 841    ) -> ToolbarItemLocation {
 842        self.project_diff = active_pane_item
 843            .and_then(|item| item.act_as::<ProjectDiff>(cx))
 844            .map(|entity| entity.downgrade());
 845        if self.project_diff.is_some() {
 846            ToolbarItemLocation::PrimaryRight
 847        } else {
 848            ToolbarItemLocation::Hidden
 849        }
 850    }
 851
 852    fn pane_focus_update(
 853        &mut self,
 854        _pane_focused: bool,
 855        _window: &mut Window,
 856        _cx: &mut Context<Self>,
 857    ) {
 858    }
 859}
 860
 861struct ButtonStates {
 862    stage: bool,
 863    unstage: bool,
 864    prev_next: bool,
 865    selection: bool,
 866    stage_all: bool,
 867    unstage_all: bool,
 868}
 869
 870impl Render for ProjectDiffToolbar {
 871    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 872        let Some(project_diff) = self.project_diff(cx) else {
 873            return div();
 874        };
 875        let focus_handle = project_diff.focus_handle(cx);
 876        let button_states = project_diff.read(cx).button_states(cx);
 877
 878        h_group_xl()
 879            .my_neg_1()
 880            .items_center()
 881            .py_1()
 882            .pl_2()
 883            .pr_1()
 884            .flex_wrap()
 885            .justify_between()
 886            .child(
 887                h_group_sm()
 888                    .when(button_states.selection, |el| {
 889                        el.child(
 890                            Button::new("stage", "Toggle Staged")
 891                                .tooltip(Tooltip::for_action_title_in(
 892                                    "Toggle Staged",
 893                                    &ToggleStaged,
 894                                    &focus_handle,
 895                                ))
 896                                .disabled(!button_states.stage && !button_states.unstage)
 897                                .on_click(cx.listener(|this, _, window, cx| {
 898                                    this.dispatch_action(&ToggleStaged, window, cx)
 899                                })),
 900                        )
 901                    })
 902                    .when(!button_states.selection, |el| {
 903                        el.child(
 904                            Button::new("stage", "Stage")
 905                                .tooltip(Tooltip::for_action_title_in(
 906                                    "Stage and go to next hunk",
 907                                    &StageAndNext,
 908                                    &focus_handle,
 909                                ))
 910                                .on_click(cx.listener(|this, _, window, cx| {
 911                                    this.dispatch_action(&StageAndNext, window, cx)
 912                                })),
 913                        )
 914                        .child(
 915                            Button::new("unstage", "Unstage")
 916                                .tooltip(Tooltip::for_action_title_in(
 917                                    "Unstage and go to next hunk",
 918                                    &UnstageAndNext,
 919                                    &focus_handle,
 920                                ))
 921                                .on_click(cx.listener(|this, _, window, cx| {
 922                                    this.dispatch_action(&UnstageAndNext, window, cx)
 923                                })),
 924                        )
 925                    }),
 926            )
 927            // n.b. the only reason these arrows are here is because we don't
 928            // support "undo" for staging so we need a way to go back.
 929            .child(
 930                h_group_sm()
 931                    .child(
 932                        IconButton::new("up", IconName::ArrowUp)
 933                            .shape(ui::IconButtonShape::Square)
 934                            .tooltip(Tooltip::for_action_title_in(
 935                                "Go to previous hunk",
 936                                &GoToPreviousHunk,
 937                                &focus_handle,
 938                            ))
 939                            .disabled(!button_states.prev_next)
 940                            .on_click(cx.listener(|this, _, window, cx| {
 941                                this.dispatch_action(&GoToPreviousHunk, window, cx)
 942                            })),
 943                    )
 944                    .child(
 945                        IconButton::new("down", IconName::ArrowDown)
 946                            .shape(ui::IconButtonShape::Square)
 947                            .tooltip(Tooltip::for_action_title_in(
 948                                "Go to next hunk",
 949                                &GoToHunk,
 950                                &focus_handle,
 951                            ))
 952                            .disabled(!button_states.prev_next)
 953                            .on_click(cx.listener(|this, _, window, cx| {
 954                                this.dispatch_action(&GoToHunk, window, cx)
 955                            })),
 956                    ),
 957            )
 958            .child(vertical_divider())
 959            .child(
 960                h_group_sm()
 961                    .when(
 962                        button_states.unstage_all && !button_states.stage_all,
 963                        |el| {
 964                            el.child(
 965                                Button::new("unstage-all", "Unstage All")
 966                                    .tooltip(Tooltip::for_action_title_in(
 967                                        "Unstage all changes",
 968                                        &UnstageAll,
 969                                        &focus_handle,
 970                                    ))
 971                                    .on_click(cx.listener(|this, _, window, cx| {
 972                                        this.unstage_all(window, cx)
 973                                    })),
 974                            )
 975                        },
 976                    )
 977                    .when(
 978                        !button_states.unstage_all || button_states.stage_all,
 979                        |el| {
 980                            el.child(
 981                                // todo make it so that changing to say "Unstaged"
 982                                // doesn't change the position.
 983                                div().child(
 984                                    Button::new("stage-all", "Stage All")
 985                                        .disabled(!button_states.stage_all)
 986                                        .tooltip(Tooltip::for_action_title_in(
 987                                            "Stage all changes",
 988                                            &StageAll,
 989                                            &focus_handle,
 990                                        ))
 991                                        .on_click(cx.listener(|this, _, window, cx| {
 992                                            this.stage_all(window, cx)
 993                                        })),
 994                                ),
 995                            )
 996                        },
 997                    )
 998                    .child(
 999                        Button::new("commit", "Commit")
1000                            .tooltip(Tooltip::for_action_title_in(
1001                                "Commit",
1002                                &Commit,
1003                                &focus_handle,
1004                            ))
1005                            .on_click(cx.listener(|this, _, window, cx| {
1006                                this.dispatch_action(&Commit, window, cx);
1007                            })),
1008                    ),
1009            )
1010    }
1011}
1012
1013#[derive(IntoElement, RegisterComponent)]
1014pub struct ProjectDiffEmptyState {
1015    pub no_repo: bool,
1016    pub can_push_and_pull: bool,
1017    pub focus_handle: Option<FocusHandle>,
1018    pub current_branch: Option<Branch>,
1019    // has_pending_commits: bool,
1020    // ahead_of_remote: bool,
1021    // no_git_repository: bool,
1022}
1023
1024impl RenderOnce for ProjectDiffEmptyState {
1025    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
1026        let status_against_remote = |ahead_by: usize, behind_by: usize| -> bool {
1027            match self.current_branch {
1028                Some(Branch {
1029                    upstream:
1030                        Some(Upstream {
1031                            tracking:
1032                                UpstreamTracking::Tracked(UpstreamTrackingStatus {
1033                                    ahead, behind, ..
1034                                }),
1035                            ..
1036                        }),
1037                    ..
1038                }) if (ahead > 0) == (ahead_by > 0) && (behind > 0) == (behind_by > 0) => true,
1039                _ => false,
1040            }
1041        };
1042
1043        let change_count = |current_branch: &Branch| -> (usize, usize) {
1044            match current_branch {
1045                Branch {
1046                    upstream:
1047                        Some(Upstream {
1048                            tracking:
1049                                UpstreamTracking::Tracked(UpstreamTrackingStatus {
1050                                    ahead, behind, ..
1051                                }),
1052                            ..
1053                        }),
1054                    ..
1055                } => (*ahead as usize, *behind as usize),
1056                _ => (0, 0),
1057            }
1058        };
1059
1060        let not_ahead_or_behind = status_against_remote(0, 0);
1061        let ahead_of_remote = status_against_remote(1, 0);
1062        let branch_not_on_remote = if let Some(branch) = self.current_branch.as_ref() {
1063            branch.upstream.is_none()
1064        } else {
1065            false
1066        };
1067
1068        let has_branch_container = |branch: &Branch| {
1069            h_flex()
1070                .max_w(px(420.))
1071                .bg(cx.theme().colors().text.opacity(0.05))
1072                .border_1()
1073                .border_color(cx.theme().colors().border)
1074                .rounded_sm()
1075                .gap_8()
1076                .px_6()
1077                .py_4()
1078                .map(|this| {
1079                    if ahead_of_remote {
1080                        let ahead_count = change_count(branch).0;
1081                        let ahead_string = format!("{} Commits Ahead", ahead_count);
1082                        this.child(
1083                            v_flex()
1084                                .child(Headline::new(ahead_string).size(HeadlineSize::Small))
1085                                .child(
1086                                    Label::new(format!("Push your changes to {}", branch.name))
1087                                        .color(Color::Muted),
1088                                ),
1089                        )
1090                        .child(div().child(render_push_button(
1091                            self.focus_handle,
1092                            "push".into(),
1093                            ahead_count as u32,
1094                        )))
1095                    } else if branch_not_on_remote {
1096                        this.child(
1097                            v_flex()
1098                                .child(Headline::new("Publish Branch").size(HeadlineSize::Small))
1099                                .child(
1100                                    Label::new(format!("Create {} on remote", branch.name))
1101                                        .color(Color::Muted),
1102                                ),
1103                        )
1104                        .child(
1105                            div().child(render_publish_button(self.focus_handle, "publish".into())),
1106                        )
1107                    } else {
1108                        this.child(Label::new("Remote status unknown").color(Color::Muted))
1109                    }
1110                })
1111        };
1112
1113        v_flex().size_full().items_center().justify_center().child(
1114            v_flex()
1115                .gap_1()
1116                .when(self.no_repo, |this| {
1117                    // TODO: add git init
1118                    this.text_center()
1119                        .child(Label::new("No Repository").color(Color::Muted))
1120                })
1121                .map(|this| {
1122                    if not_ahead_or_behind && self.current_branch.is_some() {
1123                        this.text_center()
1124                            .child(Label::new("No Changes").color(Color::Muted))
1125                    } else {
1126                        this.when_some(self.current_branch.as_ref(), |this, branch| {
1127                            this.child(has_branch_container(&branch))
1128                        })
1129                    }
1130                }),
1131        )
1132    }
1133}
1134
1135mod preview {
1136    use git::repository::{
1137        Branch, CommitSummary, Upstream, UpstreamTracking, UpstreamTrackingStatus,
1138    };
1139    use ui::prelude::*;
1140
1141    use super::ProjectDiffEmptyState;
1142
1143    // View this component preview using `workspace: open component-preview`
1144    impl Component for ProjectDiffEmptyState {
1145        fn scope() -> ComponentScope {
1146            ComponentScope::VersionControl
1147        }
1148
1149        fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
1150            let unknown_upstream: Option<UpstreamTracking> = None;
1151            let ahead_of_upstream: Option<UpstreamTracking> = Some(
1152                UpstreamTrackingStatus {
1153                    ahead: 2,
1154                    behind: 0,
1155                }
1156                .into(),
1157            );
1158
1159            let not_ahead_or_behind_upstream: Option<UpstreamTracking> = Some(
1160                UpstreamTrackingStatus {
1161                    ahead: 0,
1162                    behind: 0,
1163                }
1164                .into(),
1165            );
1166
1167            fn branch(upstream: Option<UpstreamTracking>) -> Branch {
1168                Branch {
1169                    is_head: true,
1170                    name: "some-branch".into(),
1171                    upstream: upstream.map(|tracking| Upstream {
1172                        ref_name: "origin/some-branch".into(),
1173                        tracking,
1174                    }),
1175                    most_recent_commit: Some(CommitSummary {
1176                        sha: "abc123".into(),
1177                        subject: "Modify stuff".into(),
1178                        commit_timestamp: 1710932954,
1179                        has_parent: true,
1180                    }),
1181                }
1182            }
1183
1184            let no_repo_state = ProjectDiffEmptyState {
1185                no_repo: true,
1186                can_push_and_pull: false,
1187                focus_handle: None,
1188                current_branch: None,
1189            };
1190
1191            let no_changes_state = ProjectDiffEmptyState {
1192                no_repo: false,
1193                can_push_and_pull: true,
1194                focus_handle: None,
1195                current_branch: Some(branch(not_ahead_or_behind_upstream)),
1196            };
1197
1198            let ahead_of_upstream_state = ProjectDiffEmptyState {
1199                no_repo: false,
1200                can_push_and_pull: true,
1201                focus_handle: None,
1202                current_branch: Some(branch(ahead_of_upstream)),
1203            };
1204
1205            let unknown_upstream_state = ProjectDiffEmptyState {
1206                no_repo: false,
1207                can_push_and_pull: true,
1208                focus_handle: None,
1209                current_branch: Some(branch(unknown_upstream)),
1210            };
1211
1212            let (width, height) = (px(480.), px(320.));
1213
1214            Some(
1215                v_flex()
1216                    .gap_6()
1217                    .children(vec![
1218                        example_group(vec![
1219                            single_example(
1220                                "No Repo",
1221                                div()
1222                                    .w(width)
1223                                    .h(height)
1224                                    .child(no_repo_state)
1225                                    .into_any_element(),
1226                            ),
1227                            single_example(
1228                                "No Changes",
1229                                div()
1230                                    .w(width)
1231                                    .h(height)
1232                                    .child(no_changes_state)
1233                                    .into_any_element(),
1234                            ),
1235                            single_example(
1236                                "Unknown Upstream",
1237                                div()
1238                                    .w(width)
1239                                    .h(height)
1240                                    .child(unknown_upstream_state)
1241                                    .into_any_element(),
1242                            ),
1243                            single_example(
1244                                "Ahead of Remote",
1245                                div()
1246                                    .w(width)
1247                                    .h(height)
1248                                    .child(ahead_of_upstream_state)
1249                                    .into_any_element(),
1250                            ),
1251                        ])
1252                        .vertical(),
1253                    ])
1254                    .into_any_element(),
1255            )
1256        }
1257    }
1258}
1259
1260fn merge_anchor_ranges<'a>(
1261    left: impl 'a + Iterator<Item = Range<Anchor>>,
1262    right: impl 'a + Iterator<Item = Range<Anchor>>,
1263    snapshot: &'a language::BufferSnapshot,
1264) -> impl 'a + Iterator<Item = Range<Anchor>> {
1265    let mut left = left.fuse().peekable();
1266    let mut right = right.fuse().peekable();
1267
1268    std::iter::from_fn(move || {
1269        let Some(left_range) = left.peek() else {
1270            return right.next();
1271        };
1272        let Some(right_range) = right.peek() else {
1273            return left.next();
1274        };
1275
1276        let mut next_range = if left_range.start.cmp(&right_range.start, snapshot).is_lt() {
1277            left.next().unwrap()
1278        } else {
1279            right.next().unwrap()
1280        };
1281
1282        // Extend the basic range while there's overlap with a range from either stream.
1283        loop {
1284            if let Some(left_range) = left
1285                .peek()
1286                .filter(|range| range.start.cmp(&next_range.end, &snapshot).is_le())
1287                .cloned()
1288            {
1289                left.next();
1290                next_range.end = left_range.end;
1291            } else if let Some(right_range) = right
1292                .peek()
1293                .filter(|range| range.start.cmp(&next_range.end, &snapshot).is_le())
1294                .cloned()
1295            {
1296                right.next();
1297                next_range.end = right_range.end;
1298            } else {
1299                break;
1300            }
1301        }
1302
1303        Some(next_range)
1304    })
1305}
1306
1307#[cfg(not(target_os = "windows"))]
1308#[cfg(test)]
1309mod tests {
1310    use db::indoc;
1311    use editor::test::editor_test_context::{EditorTestContext, assert_state_with_diff};
1312    use gpui::TestAppContext;
1313    use project::FakeFs;
1314    use serde_json::json;
1315    use settings::SettingsStore;
1316    use std::path::Path;
1317    use unindent::Unindent as _;
1318    use util::path;
1319
1320    use super::*;
1321
1322    #[ctor::ctor]
1323    fn init_logger() {
1324        env_logger::init();
1325    }
1326
1327    fn init_test(cx: &mut TestAppContext) {
1328        cx.update(|cx| {
1329            let store = SettingsStore::test(cx);
1330            cx.set_global(store);
1331            theme::init(theme::LoadThemes::JustBase, cx);
1332            language::init(cx);
1333            Project::init_settings(cx);
1334            workspace::init_settings(cx);
1335            editor::init(cx);
1336            crate::init(cx);
1337        });
1338    }
1339
1340    #[gpui::test]
1341    async fn test_save_after_restore(cx: &mut TestAppContext) {
1342        init_test(cx);
1343
1344        let fs = FakeFs::new(cx.executor());
1345        fs.insert_tree(
1346            path!("/project"),
1347            json!({
1348                ".git": {},
1349                "foo.txt": "FOO\n",
1350            }),
1351        )
1352        .await;
1353        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1354        let (workspace, cx) =
1355            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1356        let diff = cx.new_window_entity(|window, cx| {
1357            ProjectDiff::new(project.clone(), workspace, window, cx)
1358        });
1359        cx.run_until_parked();
1360
1361        fs.set_head_for_repo(
1362            path!("/project/.git").as_ref(),
1363            &[("foo.txt".into(), "foo\n".into())],
1364        );
1365        fs.set_index_for_repo(
1366            path!("/project/.git").as_ref(),
1367            &[("foo.txt".into(), "foo\n".into())],
1368        );
1369        cx.run_until_parked();
1370
1371        let editor = diff.update(cx, |diff, _| diff.editor.clone());
1372        assert_state_with_diff(
1373            &editor,
1374            cx,
1375            &"
1376                - foo
1377                + ˇFOO
1378            "
1379            .unindent(),
1380        );
1381
1382        editor.update_in(cx, |editor, window, cx| {
1383            editor.git_restore(&Default::default(), window, cx);
1384        });
1385        cx.run_until_parked();
1386
1387        assert_state_with_diff(&editor, cx, &"ˇ".unindent());
1388
1389        let text = String::from_utf8(fs.read_file_sync("/project/foo.txt").unwrap()).unwrap();
1390        assert_eq!(text, "foo\n");
1391    }
1392
1393    #[gpui::test]
1394    async fn test_scroll_to_beginning_with_deletion(cx: &mut TestAppContext) {
1395        init_test(cx);
1396
1397        let fs = FakeFs::new(cx.executor());
1398        fs.insert_tree(
1399            path!("/project"),
1400            json!({
1401                ".git": {},
1402                "bar": "BAR\n",
1403                "foo": "FOO\n",
1404            }),
1405        )
1406        .await;
1407        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1408        let (workspace, cx) =
1409            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1410        let diff = cx.new_window_entity(|window, cx| {
1411            ProjectDiff::new(project.clone(), workspace, window, cx)
1412        });
1413        cx.run_until_parked();
1414
1415        fs.set_head_and_index_for_repo(
1416            path!("/project/.git").as_ref(),
1417            &[
1418                ("bar".into(), "bar\n".into()),
1419                ("foo".into(), "foo\n".into()),
1420            ],
1421        );
1422        cx.run_until_parked();
1423
1424        let editor = cx.update_window_entity(&diff, |diff, window, cx| {
1425            diff.move_to_path(
1426                PathKey::namespaced(TRACKED_NAMESPACE, Path::new("foo").into()),
1427                window,
1428                cx,
1429            );
1430            diff.editor.clone()
1431        });
1432        assert_state_with_diff(
1433            &editor,
1434            cx,
1435            &"
1436                - bar
1437                + BAR
1438
1439                - ˇfoo
1440                + FOO
1441            "
1442            .unindent(),
1443        );
1444
1445        let editor = cx.update_window_entity(&diff, |diff, window, cx| {
1446            diff.move_to_path(
1447                PathKey::namespaced(TRACKED_NAMESPACE, Path::new("bar").into()),
1448                window,
1449                cx,
1450            );
1451            diff.editor.clone()
1452        });
1453        assert_state_with_diff(
1454            &editor,
1455            cx,
1456            &"
1457                - ˇbar
1458                + BAR
1459
1460                - foo
1461                + FOO
1462            "
1463            .unindent(),
1464        );
1465    }
1466
1467    #[gpui::test]
1468    async fn test_hunks_after_restore_then_modify(cx: &mut TestAppContext) {
1469        init_test(cx);
1470
1471        let fs = FakeFs::new(cx.executor());
1472        fs.insert_tree(
1473            path!("/project"),
1474            json!({
1475                ".git": {},
1476                "foo": "modified\n",
1477            }),
1478        )
1479        .await;
1480        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1481        let (workspace, cx) =
1482            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1483        let buffer = project
1484            .update(cx, |project, cx| {
1485                project.open_local_buffer(path!("/project/foo"), cx)
1486            })
1487            .await
1488            .unwrap();
1489        let buffer_editor = cx.new_window_entity(|window, cx| {
1490            Editor::for_buffer(buffer, Some(project.clone()), window, cx)
1491        });
1492        let diff = cx.new_window_entity(|window, cx| {
1493            ProjectDiff::new(project.clone(), workspace, window, cx)
1494        });
1495        cx.run_until_parked();
1496
1497        fs.set_head_for_repo(
1498            path!("/project/.git").as_ref(),
1499            &[("foo".into(), "original\n".into())],
1500        );
1501        cx.run_until_parked();
1502
1503        let diff_editor = diff.update(cx, |diff, _| diff.editor.clone());
1504
1505        assert_state_with_diff(
1506            &diff_editor,
1507            cx,
1508            &"
1509                - original
1510                + ˇmodified
1511            "
1512            .unindent(),
1513        );
1514
1515        let prev_buffer_hunks =
1516            cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
1517                let snapshot = buffer_editor.snapshot(window, cx);
1518                let snapshot = &snapshot.buffer_snapshot;
1519                let prev_buffer_hunks = buffer_editor
1520                    .diff_hunks_in_ranges(&[editor::Anchor::min()..editor::Anchor::max()], snapshot)
1521                    .collect::<Vec<_>>();
1522                buffer_editor.git_restore(&Default::default(), window, cx);
1523                prev_buffer_hunks
1524            });
1525        assert_eq!(prev_buffer_hunks.len(), 1);
1526        cx.run_until_parked();
1527
1528        let new_buffer_hunks =
1529            cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
1530                let snapshot = buffer_editor.snapshot(window, cx);
1531                let snapshot = &snapshot.buffer_snapshot;
1532                buffer_editor
1533                    .diff_hunks_in_ranges(&[editor::Anchor::min()..editor::Anchor::max()], snapshot)
1534                    .collect::<Vec<_>>()
1535            });
1536        assert_eq!(new_buffer_hunks.as_slice(), &[]);
1537
1538        cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
1539            buffer_editor.set_text("different\n", window, cx);
1540            buffer_editor.save(false, project.clone(), window, cx)
1541        })
1542        .await
1543        .unwrap();
1544
1545        cx.run_until_parked();
1546
1547        cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
1548            buffer_editor.expand_all_diff_hunks(&Default::default(), window, cx);
1549        });
1550
1551        assert_state_with_diff(
1552            &buffer_editor,
1553            cx,
1554            &"
1555                - original
1556                + different
1557                  ˇ"
1558            .unindent(),
1559        );
1560
1561        assert_state_with_diff(
1562            &diff_editor,
1563            cx,
1564            &"
1565                - original
1566                + different
1567                  ˇ"
1568            .unindent(),
1569        );
1570    }
1571
1572    use crate::project_diff::{self, ProjectDiff};
1573
1574    #[gpui::test]
1575    async fn test_go_to_prev_hunk_multibuffer(cx: &mut TestAppContext) {
1576        init_test(cx);
1577
1578        let fs = FakeFs::new(cx.executor());
1579        fs.insert_tree(
1580            "/a",
1581            json!({
1582                ".git": {},
1583                "a.txt": "created\n",
1584                "b.txt": "really changed\n",
1585                "c.txt": "unchanged\n"
1586            }),
1587        )
1588        .await;
1589
1590        fs.set_git_content_for_repo(
1591            Path::new("/a/.git"),
1592            &[
1593                ("b.txt".into(), "before\n".to_string(), None),
1594                ("c.txt".into(), "unchanged\n".to_string(), None),
1595                ("d.txt".into(), "deleted\n".to_string(), None),
1596            ],
1597        );
1598
1599        let project = Project::test(fs, [Path::new("/a")], cx).await;
1600        let (workspace, cx) =
1601            cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1602
1603        cx.run_until_parked();
1604
1605        cx.focus(&workspace);
1606        cx.update(|window, cx| {
1607            window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
1608        });
1609
1610        cx.run_until_parked();
1611
1612        let item = workspace.update(cx, |workspace, cx| {
1613            workspace.active_item_as::<ProjectDiff>(cx).unwrap()
1614        });
1615        cx.focus(&item);
1616        let editor = item.update(cx, |item, _| item.editor.clone());
1617
1618        let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
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        cx.dispatch_action(editor::actions::GoToPreviousHunk);
1633
1634        cx.assert_excerpts_with_selections(indoc!(
1635            "
1636            [EXCERPT]
1637            before
1638            really changed
1639            [EXCERPT]
1640            ˇ[FOLDED]
1641            [EXCERPT]
1642            created
1643        "
1644        ));
1645
1646        cx.dispatch_action(editor::actions::GoToPreviousHunk);
1647
1648        cx.assert_excerpts_with_selections(indoc!(
1649            "
1650            [EXCERPT]
1651            ˇbefore
1652            really changed
1653            [EXCERPT]
1654            [FOLDED]
1655            [EXCERPT]
1656            created
1657        "
1658        ));
1659    }
1660
1661    #[gpui::test]
1662    async fn test_excerpts_splitting_after_restoring_the_middle_excerpt(cx: &mut TestAppContext) {
1663        init_test(cx);
1664
1665        let git_contents = indoc! {r#"
1666            #[rustfmt::skip]
1667            fn main() {
1668                let x = 0.0; // this line will be removed
1669                // 1
1670                // 2
1671                // 3
1672                let y = 0.0; // this line will be removed
1673                // 1
1674                // 2
1675                // 3
1676                let arr = [
1677                    0.0, // this line will be removed
1678                    0.0, // this line will be removed
1679                    0.0, // this line will be removed
1680                    0.0, // this line will be removed
1681                ];
1682            }
1683        "#};
1684        let buffer_contents = indoc! {"
1685            #[rustfmt::skip]
1686            fn main() {
1687                // 1
1688                // 2
1689                // 3
1690                // 1
1691                // 2
1692                // 3
1693                let arr = [
1694                ];
1695            }
1696        "};
1697
1698        let fs = FakeFs::new(cx.executor());
1699        fs.insert_tree(
1700            "/a",
1701            json!({
1702                ".git": {},
1703                "main.rs": buffer_contents,
1704            }),
1705        )
1706        .await;
1707
1708        fs.set_git_content_for_repo(
1709            Path::new("/a/.git"),
1710            &[("main.rs".into(), git_contents.to_owned(), None)],
1711        );
1712
1713        let project = Project::test(fs, [Path::new("/a")], cx).await;
1714        let (workspace, cx) =
1715            cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1716
1717        cx.run_until_parked();
1718
1719        cx.focus(&workspace);
1720        cx.update(|window, cx| {
1721            window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
1722        });
1723
1724        cx.run_until_parked();
1725
1726        let item = workspace.update(cx, |workspace, cx| {
1727            workspace.active_item_as::<ProjectDiff>(cx).unwrap()
1728        });
1729        cx.focus(&item);
1730        let editor = item.update(cx, |item, _| item.editor.clone());
1731
1732        let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
1733
1734        cx.assert_excerpts_with_selections(&format!("[EXCERPT]\nˇ{git_contents}"));
1735
1736        cx.dispatch_action(editor::actions::GoToHunk);
1737        cx.dispatch_action(editor::actions::GoToHunk);
1738        cx.dispatch_action(git::Restore);
1739        cx.dispatch_action(editor::actions::MoveToBeginning);
1740
1741        cx.assert_excerpts_with_selections(&format!("[EXCERPT]\nˇ{git_contents}"));
1742    }
1743}