project_diff.rs

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