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