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