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