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, cx| {
 478                this.pending_scroll.take();
 479                cx.notify();
 480            })?;
 481        }
 482
 483        Ok(())
 484    }
 485
 486    #[cfg(any(test, feature = "test-support"))]
 487    pub fn excerpt_paths(&self, cx: &App) -> Vec<String> {
 488        self.multibuffer
 489            .read(cx)
 490            .excerpt_paths()
 491            .map(|key| key.path().to_string_lossy().to_string())
 492            .collect()
 493    }
 494}
 495
 496impl EventEmitter<EditorEvent> for ProjectDiff {}
 497
 498impl Focusable for ProjectDiff {
 499    fn focus_handle(&self, cx: &App) -> FocusHandle {
 500        if self.multibuffer.read(cx).is_empty() {
 501            self.focus_handle.clone()
 502        } else {
 503            self.editor.focus_handle(cx)
 504        }
 505    }
 506}
 507
 508impl Item for ProjectDiff {
 509    type Event = EditorEvent;
 510
 511    fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
 512        Some(Icon::new(IconName::GitBranch).color(Color::Muted))
 513    }
 514
 515    fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
 516        Editor::to_item_events(event, f)
 517    }
 518
 519    fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 520        self.editor
 521            .update(cx, |editor, cx| editor.deactivated(window, cx));
 522    }
 523
 524    fn navigate(
 525        &mut self,
 526        data: Box<dyn Any>,
 527        window: &mut Window,
 528        cx: &mut Context<Self>,
 529    ) -> bool {
 530        self.editor
 531            .update(cx, |editor, cx| editor.navigate(data, window, cx))
 532    }
 533
 534    fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
 535        Some("Project Diff".into())
 536    }
 537
 538    fn tab_content(&self, params: TabContentParams, _window: &Window, _: &App) -> AnyElement {
 539        Label::new("Uncommitted Changes")
 540            .color(if params.selected {
 541                Color::Default
 542            } else {
 543                Color::Muted
 544            })
 545            .into_any_element()
 546    }
 547
 548    fn telemetry_event_text(&self) -> Option<&'static str> {
 549        Some("Project Diff Opened")
 550    }
 551
 552    fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
 553        Some(Box::new(self.editor.clone()))
 554    }
 555
 556    fn for_each_project_item(
 557        &self,
 558        cx: &App,
 559        f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
 560    ) {
 561        self.editor.for_each_project_item(cx, f)
 562    }
 563
 564    fn is_singleton(&self, _: &App) -> bool {
 565        false
 566    }
 567
 568    fn set_nav_history(
 569        &mut self,
 570        nav_history: ItemNavHistory,
 571        _: &mut Window,
 572        cx: &mut Context<Self>,
 573    ) {
 574        self.editor.update(cx, |editor, _| {
 575            editor.set_nav_history(Some(nav_history));
 576        });
 577    }
 578
 579    fn clone_on_split(
 580        &self,
 581        _workspace_id: Option<workspace::WorkspaceId>,
 582        window: &mut Window,
 583        cx: &mut Context<Self>,
 584    ) -> Option<Entity<Self>>
 585    where
 586        Self: Sized,
 587    {
 588        let workspace = self.workspace.upgrade()?;
 589        Some(cx.new(|cx| ProjectDiff::new(self.project.clone(), workspace, window, cx)))
 590    }
 591
 592    fn is_dirty(&self, cx: &App) -> bool {
 593        self.multibuffer.read(cx).is_dirty(cx)
 594    }
 595
 596    fn has_conflict(&self, cx: &App) -> bool {
 597        self.multibuffer.read(cx).has_conflict(cx)
 598    }
 599
 600    fn can_save(&self, _: &App) -> bool {
 601        true
 602    }
 603
 604    fn save(
 605        &mut self,
 606        format: bool,
 607        project: Entity<Project>,
 608        window: &mut Window,
 609        cx: &mut Context<Self>,
 610    ) -> Task<Result<()>> {
 611        self.editor.save(format, project, window, cx)
 612    }
 613
 614    fn save_as(
 615        &mut self,
 616        _: Entity<Project>,
 617        _: ProjectPath,
 618        _window: &mut Window,
 619        _: &mut Context<Self>,
 620    ) -> Task<Result<()>> {
 621        unreachable!()
 622    }
 623
 624    fn reload(
 625        &mut self,
 626        project: Entity<Project>,
 627        window: &mut Window,
 628        cx: &mut Context<Self>,
 629    ) -> Task<Result<()>> {
 630        self.editor.reload(project, window, cx)
 631    }
 632
 633    fn act_as_type<'a>(
 634        &'a self,
 635        type_id: TypeId,
 636        self_handle: &'a Entity<Self>,
 637        _: &'a App,
 638    ) -> Option<AnyView> {
 639        if type_id == TypeId::of::<Self>() {
 640            Some(self_handle.to_any())
 641        } else if type_id == TypeId::of::<Editor>() {
 642            Some(self.editor.to_any())
 643        } else {
 644            None
 645        }
 646    }
 647
 648    fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
 649        ToolbarItemLocation::PrimaryLeft
 650    }
 651
 652    fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
 653        self.editor.breadcrumbs(theme, cx)
 654    }
 655
 656    fn added_to_workspace(
 657        &mut self,
 658        workspace: &mut Workspace,
 659        window: &mut Window,
 660        cx: &mut Context<Self>,
 661    ) {
 662        self.editor.update(cx, |editor, cx| {
 663            editor.added_to_workspace(workspace, window, cx)
 664        });
 665    }
 666}
 667
 668impl Render for ProjectDiff {
 669    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 670        let is_empty = self.multibuffer.read(cx).is_empty();
 671
 672        let can_push_and_pull = crate::can_push_and_pull(&self.project, cx);
 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                el.child(
 684                    v_flex()
 685                        .gap_1()
 686                        .child(
 687                            h_flex()
 688                                .justify_around()
 689                                .child(Label::new("No uncommitted changes")),
 690                        )
 691                        .when(can_push_and_pull, |this_div| {
 692                            let keybinding_focus_handle = self.focus_handle(cx);
 693
 694                            this_div.when_some(self.current_branch.as_ref(), |this_div, branch| {
 695                                let remote_button = crate::render_remote_button(
 696                                    "project-diff-remote-button",
 697                                    branch,
 698                                    Some(keybinding_focus_handle.clone()),
 699                                    false,
 700                                );
 701
 702                                match remote_button {
 703                                    Some(button) => {
 704                                        this_div.child(h_flex().justify_around().child(button))
 705                                    }
 706                                    None => this_div.child(
 707                                        h_flex()
 708                                            .justify_around()
 709                                            .child(Label::new("Remote up to date")),
 710                                    ),
 711                                }
 712                            })
 713                        })
 714                        .map(|this| {
 715                            let keybinding_focus_handle = self.focus_handle(cx).clone();
 716
 717                            this.child(
 718                                h_flex().justify_around().mt_1().child(
 719                                    Button::new("project-diff-close-button", "Close")
 720                                        // .style(ButtonStyle::Transparent)
 721                                        .key_binding(KeyBinding::for_action_in(
 722                                            &CloseActiveItem::default(),
 723                                            &keybinding_focus_handle,
 724                                            window,
 725                                            cx,
 726                                        ))
 727                                        .on_click(move |_, window, cx| {
 728                                            window.focus(&keybinding_focus_handle);
 729                                            window.dispatch_action(
 730                                                Box::new(CloseActiveItem::default()),
 731                                                cx,
 732                                            );
 733                                        }),
 734                                ),
 735                            )
 736                        }),
 737                )
 738            })
 739            .when(!is_empty, |el| el.child(self.editor.clone()))
 740    }
 741}
 742
 743impl SerializableItem for ProjectDiff {
 744    fn serialized_item_kind() -> &'static str {
 745        "ProjectDiff"
 746    }
 747
 748    fn cleanup(
 749        _: workspace::WorkspaceId,
 750        _: Vec<workspace::ItemId>,
 751        _: &mut Window,
 752        _: &mut App,
 753    ) -> Task<Result<()>> {
 754        Task::ready(Ok(()))
 755    }
 756
 757    fn deserialize(
 758        _project: Entity<Project>,
 759        workspace: WeakEntity<Workspace>,
 760        _workspace_id: workspace::WorkspaceId,
 761        _item_id: workspace::ItemId,
 762        window: &mut Window,
 763        cx: &mut App,
 764    ) -> Task<Result<Entity<Self>>> {
 765        window.spawn(cx, |mut cx| async move {
 766            workspace.update_in(&mut cx, |workspace, window, cx| {
 767                let workspace_handle = cx.entity();
 768                cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx))
 769            })
 770        })
 771    }
 772
 773    fn serialize(
 774        &mut self,
 775        _workspace: &mut Workspace,
 776        _item_id: workspace::ItemId,
 777        _closing: bool,
 778        _window: &mut Window,
 779        _cx: &mut Context<Self>,
 780    ) -> Option<Task<Result<()>>> {
 781        None
 782    }
 783
 784    fn should_serialize(&self, _: &Self::Event) -> bool {
 785        false
 786    }
 787}
 788
 789pub struct ProjectDiffToolbar {
 790    project_diff: Option<WeakEntity<ProjectDiff>>,
 791    workspace: WeakEntity<Workspace>,
 792}
 793
 794impl ProjectDiffToolbar {
 795    pub fn new(workspace: &Workspace, _: &mut Context<Self>) -> Self {
 796        Self {
 797            project_diff: None,
 798            workspace: workspace.weak_handle(),
 799        }
 800    }
 801
 802    fn project_diff(&self, _: &App) -> Option<Entity<ProjectDiff>> {
 803        self.project_diff.as_ref()?.upgrade()
 804    }
 805    fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context<Self>) {
 806        if let Some(project_diff) = self.project_diff(cx) {
 807            project_diff.focus_handle(cx).focus(window);
 808        }
 809        let action = action.boxed_clone();
 810        cx.defer(move |cx| {
 811            cx.dispatch_action(action.as_ref());
 812        })
 813    }
 814    fn dispatch_panel_action(
 815        &self,
 816        action: &dyn Action,
 817        window: &mut Window,
 818        cx: &mut Context<Self>,
 819    ) {
 820        self.workspace
 821            .read_with(cx, |workspace, cx| {
 822                if let Some(panel) = workspace.panel::<GitPanel>(cx) {
 823                    panel.focus_handle(cx).focus(window)
 824                }
 825            })
 826            .ok();
 827        let action = action.boxed_clone();
 828        cx.defer(move |cx| {
 829            cx.dispatch_action(action.as_ref());
 830        })
 831    }
 832}
 833
 834impl EventEmitter<ToolbarItemEvent> for ProjectDiffToolbar {}
 835
 836impl ToolbarItemView for ProjectDiffToolbar {
 837    fn set_active_pane_item(
 838        &mut self,
 839        active_pane_item: Option<&dyn ItemHandle>,
 840        _: &mut Window,
 841        cx: &mut Context<Self>,
 842    ) -> ToolbarItemLocation {
 843        self.project_diff = active_pane_item
 844            .and_then(|item| item.act_as::<ProjectDiff>(cx))
 845            .map(|entity| entity.downgrade());
 846        if self.project_diff.is_some() {
 847            ToolbarItemLocation::PrimaryRight
 848        } else {
 849            ToolbarItemLocation::Hidden
 850        }
 851    }
 852
 853    fn pane_focus_update(
 854        &mut self,
 855        _pane_focused: bool,
 856        _window: &mut Window,
 857        _cx: &mut Context<Self>,
 858    ) {
 859    }
 860}
 861
 862struct ButtonStates {
 863    stage: bool,
 864    unstage: bool,
 865    prev_next: bool,
 866    selection: bool,
 867    stage_all: bool,
 868    unstage_all: bool,
 869}
 870
 871impl Render for ProjectDiffToolbar {
 872    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 873        let Some(project_diff) = self.project_diff(cx) else {
 874            return div();
 875        };
 876        let focus_handle = project_diff.focus_handle(cx);
 877        let button_states = project_diff.read(cx).button_states(cx);
 878
 879        h_group_xl()
 880            .my_neg_1()
 881            .items_center()
 882            .py_1()
 883            .pl_2()
 884            .pr_1()
 885            .flex_wrap()
 886            .justify_between()
 887            .child(
 888                h_group_sm()
 889                    .when(button_states.selection, |el| {
 890                        el.child(
 891                            Button::new("stage", "Toggle Staged")
 892                                .tooltip(Tooltip::for_action_title_in(
 893                                    "Toggle Staged",
 894                                    &ToggleStaged,
 895                                    &focus_handle,
 896                                ))
 897                                .disabled(!button_states.stage && !button_states.unstage)
 898                                .on_click(cx.listener(|this, _, window, cx| {
 899                                    this.dispatch_action(&ToggleStaged, window, cx)
 900                                })),
 901                        )
 902                    })
 903                    .when(!button_states.selection, |el| {
 904                        el.child(
 905                            Button::new("stage", "Stage")
 906                                .tooltip(Tooltip::for_action_title_in(
 907                                    "Stage and go to next hunk",
 908                                    &StageAndNext,
 909                                    &focus_handle,
 910                                ))
 911                                // don't actually disable the button so it's mashable
 912                                .color(if button_states.stage {
 913                                    Color::Default
 914                                } else {
 915                                    Color::Disabled
 916                                })
 917                                .on_click(cx.listener(|this, _, window, cx| {
 918                                    this.dispatch_action(&StageAndNext, window, cx)
 919                                })),
 920                        )
 921                        .child(
 922                            Button::new("unstage", "Unstage")
 923                                .tooltip(Tooltip::for_action_title_in(
 924                                    "Unstage and go to next hunk",
 925                                    &UnstageAndNext,
 926                                    &focus_handle,
 927                                ))
 928                                .color(if button_states.unstage {
 929                                    Color::Default
 930                                } else {
 931                                    Color::Disabled
 932                                })
 933                                .on_click(cx.listener(|this, _, window, cx| {
 934                                    this.dispatch_action(&UnstageAndNext, window, cx)
 935                                })),
 936                        )
 937                    }),
 938            )
 939            // n.b. the only reason these arrows are here is because we don't
 940            // support "undo" for staging so we need a way to go back.
 941            .child(
 942                h_group_sm()
 943                    .child(
 944                        IconButton::new("up", IconName::ArrowUp)
 945                            .shape(ui::IconButtonShape::Square)
 946                            .tooltip(Tooltip::for_action_title_in(
 947                                "Go to previous hunk",
 948                                &GoToPreviousHunk,
 949                                &focus_handle,
 950                            ))
 951                            .disabled(!button_states.prev_next)
 952                            .on_click(cx.listener(|this, _, window, cx| {
 953                                this.dispatch_action(&GoToPreviousHunk, window, cx)
 954                            })),
 955                    )
 956                    .child(
 957                        IconButton::new("down", IconName::ArrowDown)
 958                            .shape(ui::IconButtonShape::Square)
 959                            .tooltip(Tooltip::for_action_title_in(
 960                                "Go to next hunk",
 961                                &GoToHunk,
 962                                &focus_handle,
 963                            ))
 964                            .disabled(!button_states.prev_next)
 965                            .on_click(cx.listener(|this, _, window, cx| {
 966                                this.dispatch_action(&GoToHunk, window, cx)
 967                            })),
 968                    ),
 969            )
 970            .child(vertical_divider())
 971            .child(
 972                h_group_sm()
 973                    .when(
 974                        button_states.unstage_all && !button_states.stage_all,
 975                        |el| {
 976                            el.child(
 977                                Button::new("unstage-all", "Unstage All")
 978                                    .tooltip(Tooltip::for_action_title_in(
 979                                        "Unstage all changes",
 980                                        &UnstageAll,
 981                                        &focus_handle,
 982                                    ))
 983                                    .on_click(cx.listener(|this, _, window, cx| {
 984                                        this.dispatch_panel_action(&UnstageAll, window, cx)
 985                                    })),
 986                            )
 987                        },
 988                    )
 989                    .when(
 990                        !button_states.unstage_all || button_states.stage_all,
 991                        |el| {
 992                            el.child(
 993                                // todo make it so that changing to say "Unstaged"
 994                                // doesn't change the position.
 995                                div().child(
 996                                    Button::new("stage-all", "Stage All")
 997                                        .disabled(!button_states.stage_all)
 998                                        .tooltip(Tooltip::for_action_title_in(
 999                                            "Stage all changes",
1000                                            &StageAll,
1001                                            &focus_handle,
1002                                        ))
1003                                        .on_click(cx.listener(|this, _, window, cx| {
1004                                            this.dispatch_panel_action(&StageAll, window, cx)
1005                                        })),
1006                                ),
1007                            )
1008                        },
1009                    )
1010                    .child(
1011                        Button::new("commit", "Commit")
1012                            .tooltip(Tooltip::for_action_title_in(
1013                                "Commit",
1014                                &Commit,
1015                                &focus_handle,
1016                            ))
1017                            .on_click(cx.listener(|this, _, window, cx| {
1018                                this.dispatch_action(&Commit, window, cx);
1019                            })),
1020                    ),
1021            )
1022    }
1023}
1024
1025#[cfg(not(target_os = "windows"))]
1026#[cfg(test)]
1027mod tests {
1028    use std::path::Path;
1029
1030    use collections::HashMap;
1031    use db::indoc;
1032    use editor::test::editor_test_context::{assert_state_with_diff, EditorTestContext};
1033    use git::status::{StatusCode, TrackedStatus};
1034    use gpui::TestAppContext;
1035    use project::FakeFs;
1036    use serde_json::json;
1037    use settings::SettingsStore;
1038    use unindent::Unindent as _;
1039    use util::path;
1040
1041    use super::*;
1042
1043    #[ctor::ctor]
1044    fn init_logger() {
1045        env_logger::init();
1046    }
1047
1048    fn init_test(cx: &mut TestAppContext) {
1049        cx.update(|cx| {
1050            let store = SettingsStore::test(cx);
1051            cx.set_global(store);
1052            theme::init(theme::LoadThemes::JustBase, cx);
1053            language::init(cx);
1054            Project::init_settings(cx);
1055            workspace::init_settings(cx);
1056            editor::init(cx);
1057            crate::init(cx);
1058        });
1059    }
1060
1061    #[gpui::test]
1062    async fn test_save_after_restore(cx: &mut TestAppContext) {
1063        init_test(cx);
1064
1065        let fs = FakeFs::new(cx.executor());
1066        fs.insert_tree(
1067            path!("/project"),
1068            json!({
1069                ".git": {},
1070                "foo.txt": "FOO\n",
1071            }),
1072        )
1073        .await;
1074        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1075        let (workspace, cx) =
1076            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1077        let diff = cx.new_window_entity(|window, cx| {
1078            ProjectDiff::new(project.clone(), workspace, window, cx)
1079        });
1080        cx.run_until_parked();
1081
1082        fs.set_head_for_repo(
1083            path!("/project/.git").as_ref(),
1084            &[("foo.txt".into(), "foo\n".into())],
1085        );
1086        fs.set_index_for_repo(
1087            path!("/project/.git").as_ref(),
1088            &[("foo.txt".into(), "foo\n".into())],
1089        );
1090        fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
1091            state.statuses = HashMap::from_iter([(
1092                "foo.txt".into(),
1093                TrackedStatus {
1094                    index_status: StatusCode::Unmodified,
1095                    worktree_status: StatusCode::Modified,
1096                }
1097                .into(),
1098            )]);
1099        });
1100        cx.run_until_parked();
1101
1102        let editor = diff.update(cx, |diff, _| diff.editor.clone());
1103        assert_state_with_diff(
1104            &editor,
1105            cx,
1106            &"
1107                - foo
1108                + ˇFOO
1109            "
1110            .unindent(),
1111        );
1112
1113        editor.update_in(cx, |editor, window, cx| {
1114            editor.git_restore(&Default::default(), window, cx);
1115        });
1116        cx.run_until_parked();
1117
1118        assert_state_with_diff(&editor, cx, &"ˇ".unindent());
1119
1120        let text = String::from_utf8(fs.read_file_sync("/project/foo.txt").unwrap()).unwrap();
1121        assert_eq!(text, "foo\n");
1122    }
1123
1124    #[gpui::test]
1125    async fn test_scroll_to_beginning_with_deletion(cx: &mut TestAppContext) {
1126        init_test(cx);
1127
1128        let fs = FakeFs::new(cx.executor());
1129        fs.insert_tree(
1130            path!("/project"),
1131            json!({
1132                ".git": {},
1133                "bar": "BAR\n",
1134                "foo": "FOO\n",
1135            }),
1136        )
1137        .await;
1138        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1139        let (workspace, cx) =
1140            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1141        let diff = cx.new_window_entity(|window, cx| {
1142            ProjectDiff::new(project.clone(), workspace, window, cx)
1143        });
1144        cx.run_until_parked();
1145
1146        fs.set_head_for_repo(
1147            path!("/project/.git").as_ref(),
1148            &[
1149                ("bar".into(), "bar\n".into()),
1150                ("foo".into(), "foo\n".into()),
1151            ],
1152        );
1153        fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
1154            state.statuses = HashMap::from_iter([
1155                (
1156                    "bar".into(),
1157                    TrackedStatus {
1158                        index_status: StatusCode::Unmodified,
1159                        worktree_status: StatusCode::Modified,
1160                    }
1161                    .into(),
1162                ),
1163                (
1164                    "foo".into(),
1165                    TrackedStatus {
1166                        index_status: StatusCode::Unmodified,
1167                        worktree_status: StatusCode::Modified,
1168                    }
1169                    .into(),
1170                ),
1171            ]);
1172        });
1173        cx.run_until_parked();
1174
1175        let editor = cx.update_window_entity(&diff, |diff, window, cx| {
1176            diff.move_to_path(
1177                PathKey::namespaced(TRACKED_NAMESPACE, Path::new("foo").into()),
1178                window,
1179                cx,
1180            );
1181            diff.editor.clone()
1182        });
1183        assert_state_with_diff(
1184            &editor,
1185            cx,
1186            &"
1187                - bar
1188                + BAR
1189
1190                - ˇfoo
1191                + FOO
1192            "
1193            .unindent(),
1194        );
1195
1196        let editor = cx.update_window_entity(&diff, |diff, window, cx| {
1197            diff.move_to_path(
1198                PathKey::namespaced(TRACKED_NAMESPACE, Path::new("bar").into()),
1199                window,
1200                cx,
1201            );
1202            diff.editor.clone()
1203        });
1204        assert_state_with_diff(
1205            &editor,
1206            cx,
1207            &"
1208                - ˇbar
1209                + BAR
1210
1211                - foo
1212                + FOO
1213            "
1214            .unindent(),
1215        );
1216    }
1217
1218    #[gpui::test]
1219    async fn test_hunks_after_restore_then_modify(cx: &mut TestAppContext) {
1220        init_test(cx);
1221
1222        let fs = FakeFs::new(cx.executor());
1223        fs.insert_tree(
1224            path!("/project"),
1225            json!({
1226                ".git": {},
1227                "foo": "modified\n",
1228            }),
1229        )
1230        .await;
1231        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1232        let (workspace, cx) =
1233            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1234        let buffer = project
1235            .update(cx, |project, cx| {
1236                project.open_local_buffer(path!("/project/foo"), cx)
1237            })
1238            .await
1239            .unwrap();
1240        let buffer_editor = cx.new_window_entity(|window, cx| {
1241            Editor::for_buffer(buffer, Some(project.clone()), window, cx)
1242        });
1243        let diff = cx.new_window_entity(|window, cx| {
1244            ProjectDiff::new(project.clone(), workspace, window, cx)
1245        });
1246        cx.run_until_parked();
1247
1248        fs.set_head_for_repo(
1249            path!("/project/.git").as_ref(),
1250            &[("foo".into(), "original\n".into())],
1251        );
1252        fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
1253            state.statuses = HashMap::from_iter([(
1254                "foo".into(),
1255                TrackedStatus {
1256                    index_status: StatusCode::Unmodified,
1257                    worktree_status: StatusCode::Modified,
1258                }
1259                .into(),
1260            )]);
1261        });
1262        cx.run_until_parked();
1263
1264        let diff_editor = diff.update(cx, |diff, _| diff.editor.clone());
1265
1266        assert_state_with_diff(
1267            &diff_editor,
1268            cx,
1269            &"
1270                - original
1271                + ˇmodified
1272            "
1273            .unindent(),
1274        );
1275
1276        let prev_buffer_hunks =
1277            cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
1278                let snapshot = buffer_editor.snapshot(window, cx);
1279                let snapshot = &snapshot.buffer_snapshot;
1280                let prev_buffer_hunks = buffer_editor
1281                    .diff_hunks_in_ranges(&[editor::Anchor::min()..editor::Anchor::max()], snapshot)
1282                    .collect::<Vec<_>>();
1283                buffer_editor.git_restore(&Default::default(), window, cx);
1284                prev_buffer_hunks
1285            });
1286        assert_eq!(prev_buffer_hunks.len(), 1);
1287        cx.run_until_parked();
1288
1289        let new_buffer_hunks =
1290            cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
1291                let snapshot = buffer_editor.snapshot(window, cx);
1292                let snapshot = &snapshot.buffer_snapshot;
1293                let new_buffer_hunks = buffer_editor
1294                    .diff_hunks_in_ranges(&[editor::Anchor::min()..editor::Anchor::max()], snapshot)
1295                    .collect::<Vec<_>>();
1296                buffer_editor.git_restore(&Default::default(), window, cx);
1297                new_buffer_hunks
1298            });
1299        assert_eq!(new_buffer_hunks.as_slice(), &[]);
1300
1301        cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
1302            buffer_editor.set_text("different\n", window, cx);
1303            buffer_editor.save(false, project.clone(), window, cx)
1304        })
1305        .await
1306        .unwrap();
1307
1308        cx.run_until_parked();
1309
1310        assert_state_with_diff(
1311            &diff_editor,
1312            cx,
1313            &"
1314                - original
1315                + ˇdifferent
1316            "
1317            .unindent(),
1318        );
1319    }
1320
1321    use crate::project_diff::{self, ProjectDiff};
1322
1323    #[gpui::test]
1324    async fn test_go_to_prev_hunk_multibuffer(cx: &mut TestAppContext) {
1325        init_test(cx);
1326
1327        let fs = FakeFs::new(cx.executor());
1328        fs.insert_tree(
1329            "/a",
1330            json!({
1331                ".git":{},
1332                "a.txt": "created\n",
1333                "b.txt": "really changed\n",
1334                "c.txt": "unchanged\n"
1335            }),
1336        )
1337        .await;
1338
1339        fs.set_git_content_for_repo(
1340            Path::new("/a/.git"),
1341            &[
1342                ("b.txt".into(), "before\n".to_string(), None),
1343                ("c.txt".into(), "unchanged\n".to_string(), None),
1344                ("d.txt".into(), "deleted\n".to_string(), None),
1345            ],
1346        );
1347
1348        let project = Project::test(fs, [Path::new("/a")], cx).await;
1349        let (workspace, cx) =
1350            cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1351
1352        cx.run_until_parked();
1353
1354        cx.focus(&workspace);
1355        cx.update(|window, cx| {
1356            window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
1357        });
1358
1359        cx.run_until_parked();
1360
1361        let item = workspace.update(cx, |workspace, cx| {
1362            workspace.active_item_as::<ProjectDiff>(cx).unwrap()
1363        });
1364        cx.focus(&item);
1365        let editor = item.update(cx, |item, _| item.editor.clone());
1366
1367        let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
1368
1369        cx.assert_excerpts_with_selections(indoc!(
1370            "
1371            [EXCERPT]
1372            before
1373            really changed
1374            [EXCERPT]
1375            [FOLDED]
1376            [EXCERPT]
1377            ˇcreated
1378        "
1379        ));
1380
1381        cx.dispatch_action(editor::actions::GoToPreviousHunk);
1382
1383        cx.assert_excerpts_with_selections(indoc!(
1384            "
1385            [EXCERPT]
1386            before
1387            really changed
1388            [EXCERPT]
1389            ˇ[FOLDED]
1390            [EXCERPT]
1391            created
1392        "
1393        ));
1394
1395        cx.dispatch_action(editor::actions::GoToPreviousHunk);
1396
1397        cx.assert_excerpts_with_selections(indoc!(
1398            "
1399            [EXCERPT]
1400            ˇbefore
1401            really changed
1402            [EXCERPT]
1403            [FOLDED]
1404            [EXCERPT]
1405            created
1406        "
1407        ));
1408    }
1409}