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