project_diff.rs

   1use crate::{
   2    conflict_view::ConflictAddon,
   3    git_panel::{GitPanel, GitPanelAddon, GitStatusEntry},
   4    git_panel_settings::GitPanelSettings,
   5    remote_button::{render_publish_button, render_push_button},
   6};
   7use anyhow::Result;
   8use buffer_diff::{BufferDiff, DiffHunkSecondaryStatus};
   9use collections::HashSet;
  10use editor::{
  11    Editor, EditorEvent, SelectionEffects,
  12    actions::{GoToHunk, GoToPreviousHunk},
  13    scroll::Autoscroll,
  14};
  15use futures::StreamExt;
  16use git::{
  17    Commit, StageAll, StageAndNext, ToggleStaged, UnstageAll, UnstageAndNext,
  18    repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus},
  19    status::FileStatus,
  20};
  21use gpui::{
  22    Action, AnyElement, AnyView, App, AppContext as _, AsyncWindowContext, Entity, EventEmitter,
  23    FocusHandle, Focusable, Render, Subscription, Task, WeakEntity, actions,
  24};
  25use language::{Anchor, Buffer, Capability, OffsetRangeExt};
  26use multi_buffer::{MultiBuffer, PathKey};
  27use project::{
  28    Project, ProjectPath,
  29    git_store::{GitStore, GitStoreEvent, RepositoryEvent},
  30};
  31use settings::{Settings, SettingsStore};
  32use std::any::{Any, TypeId};
  33use std::ops::Range;
  34use theme::ActiveTheme;
  35use ui::{KeyBinding, Tooltip, prelude::*, vertical_divider};
  36use util::ResultExt as _;
  37use workspace::{
  38    CloseActiveItem, ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation,
  39    ToolbarItemView, Workspace,
  40    item::{BreadcrumbText, Item, ItemEvent, ItemHandle, SaveOptions, TabContentParams},
  41    searchable::SearchableItemHandle,
  42};
  43
  44actions!(git, [Diff, Add]);
  45
  46pub struct ProjectDiff {
  47    project: Entity<Project>,
  48    multibuffer: Entity<MultiBuffer>,
  49    editor: Entity<Editor>,
  50    git_store: Entity<GitStore>,
  51    workspace: WeakEntity<Workspace>,
  52    focus_handle: FocusHandle,
  53    update_needed: postage::watch::Sender<()>,
  54    pending_scroll: Option<PathKey>,
  55    _task: Task<Result<()>>,
  56    _subscription: Subscription,
  57}
  58
  59#[derive(Debug)]
  60struct DiffBuffer {
  61    path_key: PathKey,
  62    buffer: Entity<Buffer>,
  63    diff: Entity<BufferDiff>,
  64    file_status: FileStatus,
  65}
  66
  67const CONFLICT_NAMESPACE: u32 = 1;
  68const TRACKED_NAMESPACE: u32 = 2;
  69const NEW_NAMESPACE: u32 = 3;
  70
  71impl ProjectDiff {
  72    pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context<Workspace>) {
  73        workspace.register_action(Self::deploy);
  74        workspace.register_action(|workspace, _: &Add, window, cx| {
  75            Self::deploy(workspace, &Diff, window, cx);
  76        });
  77        workspace::register_serializable_item::<ProjectDiff>(cx);
  78    }
  79
  80    fn deploy(
  81        workspace: &mut Workspace,
  82        _: &Diff,
  83        window: &mut Window,
  84        cx: &mut Context<Workspace>,
  85    ) {
  86        Self::deploy_at(workspace, None, window, cx)
  87    }
  88
  89    pub fn deploy_at(
  90        workspace: &mut Workspace,
  91        entry: Option<GitStatusEntry>,
  92        window: &mut Window,
  93        cx: &mut Context<Workspace>,
  94    ) {
  95        telemetry::event!(
  96            "Git Diff Opened",
  97            source = if entry.is_some() {
  98                "Git Panel"
  99            } else {
 100                "Action"
 101            }
 102        );
 103        let project_diff = if let Some(existing) = workspace.item_of_type::<Self>(cx) {
 104            workspace.activate_item(&existing, true, true, window, cx);
 105            existing
 106        } else {
 107            let workspace_handle = cx.entity();
 108            let project_diff =
 109                cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx));
 110            workspace.add_item_to_active_pane(
 111                Box::new(project_diff.clone()),
 112                None,
 113                true,
 114                window,
 115                cx,
 116            );
 117            project_diff
 118        };
 119        if let Some(entry) = entry {
 120            project_diff.update(cx, |project_diff, cx| {
 121                project_diff.move_to_entry(entry, window, cx);
 122            })
 123        }
 124    }
 125
 126    pub fn autoscroll(&self, cx: &mut Context<Self>) {
 127        self.editor.update(cx, |editor, cx| {
 128            editor.request_autoscroll(Autoscroll::fit(), cx);
 129        })
 130    }
 131
 132    fn new(
 133        project: Entity<Project>,
 134        workspace: Entity<Workspace>,
 135        window: &mut Window,
 136        cx: &mut Context<Self>,
 137    ) -> Self {
 138        let focus_handle = cx.focus_handle();
 139        let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite));
 140
 141        let editor = cx.new(|cx| {
 142            let mut diff_display_editor =
 143                Editor::for_multibuffer(multibuffer.clone(), Some(project.clone()), window, cx);
 144            diff_display_editor.disable_diagnostics(cx);
 145            diff_display_editor.set_expand_all_diff_hunks(cx);
 146            diff_display_editor.register_addon(GitPanelAddon {
 147                workspace: workspace.downgrade(),
 148            });
 149            diff_display_editor
 150        });
 151        window.defer(cx, {
 152            let workspace = workspace.clone();
 153            let editor = editor.clone();
 154            move |window, cx| {
 155                workspace.update(cx, |workspace, cx| {
 156                    editor.update(cx, |editor, cx| {
 157                        editor.added_to_workspace(workspace, window, cx);
 158                    })
 159                });
 160            }
 161        });
 162        cx.subscribe_in(&editor, window, Self::handle_editor_event)
 163            .detach();
 164
 165        let git_store = project.read(cx).git_store().clone();
 166        let git_store_subscription = cx.subscribe_in(
 167            &git_store,
 168            window,
 169            move |this, _git_store, event, _window, _cx| match event {
 170                GitStoreEvent::ActiveRepositoryChanged(_)
 171                | GitStoreEvent::RepositoryUpdated(_, RepositoryEvent::Updated { .. }, true)
 172                | GitStoreEvent::ConflictsUpdated => {
 173                    *this.update_needed.borrow_mut() = ();
 174                }
 175                _ => {}
 176            },
 177        );
 178
 179        let mut was_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
 180        let mut was_collapse_untracked_diff =
 181            GitPanelSettings::get_global(cx).collapse_untracked_diff;
 182        cx.observe_global::<SettingsStore>(move |this, cx| {
 183            let is_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
 184            let is_collapse_untracked_diff =
 185                GitPanelSettings::get_global(cx).collapse_untracked_diff;
 186            if is_sort_by_path != was_sort_by_path
 187                || is_collapse_untracked_diff != was_collapse_untracked_diff
 188            {
 189                *this.update_needed.borrow_mut() = ();
 190            }
 191            was_sort_by_path = is_sort_by_path;
 192            was_collapse_untracked_diff = is_collapse_untracked_diff;
 193        })
 194        .detach();
 195
 196        let (mut send, recv) = postage::watch::channel::<()>();
 197        let worker = window.spawn(cx, {
 198            let this = cx.weak_entity();
 199            async |cx| Self::handle_status_updates(this, recv, cx).await
 200        });
 201        // Kick off a refresh immediately
 202        *send.borrow_mut() = ();
 203
 204        Self {
 205            project,
 206            git_store: git_store.clone(),
 207            workspace: workspace.downgrade(),
 208            focus_handle,
 209            editor,
 210            multibuffer,
 211            pending_scroll: None,
 212            update_needed: send,
 213            _task: worker,
 214            _subscription: git_store_subscription,
 215        }
 216    }
 217
 218    pub fn move_to_entry(
 219        &mut self,
 220        entry: GitStatusEntry,
 221        window: &mut Window,
 222        cx: &mut Context<Self>,
 223    ) {
 224        let Some(git_repo) = self.git_store.read(cx).active_repository() else {
 225            return;
 226        };
 227        let repo = git_repo.read(cx);
 228
 229        let namespace = if repo.had_conflict_on_last_merge_head_change(&entry.repo_path) {
 230            CONFLICT_NAMESPACE
 231        } else if entry.status.is_created() {
 232            NEW_NAMESPACE
 233        } else {
 234            TRACKED_NAMESPACE
 235        };
 236
 237        let path_key = PathKey::namespaced(namespace, entry.repo_path.0.clone());
 238
 239        self.move_to_path(path_key, window, cx)
 240    }
 241
 242    pub fn active_path(&self, cx: &App) -> Option<ProjectPath> {
 243        let editor = self.editor.read(cx);
 244        let position = editor.selections.newest_anchor().head();
 245        let multi_buffer = editor.buffer().read(cx);
 246        let (_, buffer, _) = multi_buffer.excerpt_containing(position, cx)?;
 247
 248        let file = buffer.read(cx).file()?;
 249        Some(ProjectPath {
 250            worktree_id: file.worktree_id(cx),
 251            path: file.path().clone(),
 252        })
 253    }
 254
 255    fn move_to_path(&mut self, path_key: PathKey, window: &mut Window, cx: &mut Context<Self>) {
 256        if let Some(position) = self.multibuffer.read(cx).location_for_path(&path_key, cx) {
 257            self.editor.update(cx, |editor, cx| {
 258                editor.change_selections(
 259                    SelectionEffects::scroll(Autoscroll::focused()),
 260                    window,
 261                    cx,
 262                    |s| {
 263                        s.select_ranges([position..position]);
 264                    },
 265                )
 266            });
 267        } else {
 268            self.pending_scroll = Some(path_key);
 269        }
 270    }
 271
 272    fn button_states(&self, cx: &App) -> ButtonStates {
 273        let editor = self.editor.read(cx);
 274        let snapshot = self.multibuffer.read(cx).snapshot(cx);
 275        let prev_next = snapshot.diff_hunks().skip(1).next().is_some();
 276        let mut selection = true;
 277
 278        let mut ranges = editor
 279            .selections
 280            .disjoint_anchor_ranges()
 281            .collect::<Vec<_>>();
 282        if !ranges.iter().any(|range| range.start != range.end) {
 283            selection = false;
 284            if let Some((excerpt_id, buffer, range)) = self.editor.read(cx).active_excerpt(cx) {
 285                ranges = vec![multi_buffer::Anchor::range_in_buffer(
 286                    excerpt_id,
 287                    buffer.read(cx).remote_id(),
 288                    range,
 289                )];
 290            } else {
 291                ranges = Vec::default();
 292            }
 293        }
 294        let mut has_staged_hunks = false;
 295        let mut has_unstaged_hunks = false;
 296        for hunk in editor.diff_hunks_in_ranges(&ranges, &snapshot) {
 297            match hunk.secondary_status {
 298                DiffHunkSecondaryStatus::HasSecondaryHunk
 299                | DiffHunkSecondaryStatus::SecondaryHunkAdditionPending => {
 300                    has_unstaged_hunks = true;
 301                }
 302                DiffHunkSecondaryStatus::OverlapsWithSecondaryHunk => {
 303                    has_staged_hunks = true;
 304                    has_unstaged_hunks = true;
 305                }
 306                DiffHunkSecondaryStatus::NoSecondaryHunk
 307                | DiffHunkSecondaryStatus::SecondaryHunkRemovalPending => {
 308                    has_staged_hunks = true;
 309                }
 310            }
 311        }
 312        let mut stage_all = false;
 313        let mut unstage_all = false;
 314        self.workspace
 315            .read_with(cx, |workspace, cx| {
 316                if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
 317                    let git_panel = git_panel.read(cx);
 318                    stage_all = git_panel.can_stage_all();
 319                    unstage_all = git_panel.can_unstage_all();
 320                }
 321            })
 322            .ok();
 323
 324        return ButtonStates {
 325            stage: has_unstaged_hunks,
 326            unstage: has_staged_hunks,
 327            prev_next,
 328            selection,
 329            stage_all,
 330            unstage_all,
 331        };
 332    }
 333
 334    fn handle_editor_event(
 335        &mut self,
 336        editor: &Entity<Editor>,
 337        event: &EditorEvent,
 338        window: &mut Window,
 339        cx: &mut Context<Self>,
 340    ) {
 341        match event {
 342            EditorEvent::SelectionsChanged { local: true } => {
 343                let Some(project_path) = self.active_path(cx) else {
 344                    return;
 345                };
 346                self.workspace
 347                    .update(cx, |workspace, cx| {
 348                        if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
 349                            git_panel.update(cx, |git_panel, cx| {
 350                                git_panel.select_entry_by_path(project_path, window, cx)
 351                            })
 352                        }
 353                    })
 354                    .ok();
 355            }
 356            _ => {}
 357        }
 358        if editor.focus_handle(cx).contains_focused(window, cx) {
 359            if self.multibuffer.read(cx).is_empty() {
 360                self.focus_handle.focus(window)
 361            }
 362        }
 363    }
 364
 365    fn load_buffers(&mut self, cx: &mut Context<Self>) -> Vec<Task<Result<DiffBuffer>>> {
 366        let Some(repo) = self.git_store.read(cx).active_repository() else {
 367            self.multibuffer.update(cx, |multibuffer, cx| {
 368                multibuffer.clear(cx);
 369            });
 370            return vec![];
 371        };
 372
 373        let mut previous_paths = self.multibuffer.read(cx).paths().collect::<HashSet<_>>();
 374
 375        let mut result = vec![];
 376        repo.update(cx, |repo, cx| {
 377            for entry in repo.cached_status() {
 378                if !entry.status.has_changes() {
 379                    continue;
 380                }
 381                let Some(project_path) = repo.repo_path_to_project_path(&entry.repo_path, cx)
 382                else {
 383                    continue;
 384                };
 385                let namespace = if GitPanelSettings::get_global(cx).sort_by_path {
 386                    TRACKED_NAMESPACE
 387                } else if repo.had_conflict_on_last_merge_head_change(&entry.repo_path) {
 388                    CONFLICT_NAMESPACE
 389                } else if entry.status.is_created() {
 390                    NEW_NAMESPACE
 391                } else {
 392                    TRACKED_NAMESPACE
 393                };
 394                let path_key = PathKey::namespaced(namespace, entry.repo_path.0.clone());
 395
 396                previous_paths.remove(&path_key);
 397                let load_buffer = self
 398                    .project
 399                    .update(cx, |project, cx| project.open_buffer(project_path, cx));
 400
 401                let project = self.project.clone();
 402                result.push(cx.spawn(async move |_, cx| {
 403                    let buffer = load_buffer.await?;
 404                    let changes = project
 405                        .update(cx, |project, cx| {
 406                            project.open_uncommitted_diff(buffer.clone(), cx)
 407                        })?
 408                        .await?;
 409                    Ok(DiffBuffer {
 410                        path_key,
 411                        buffer,
 412                        diff: changes,
 413                        file_status: entry.status,
 414                    })
 415                }));
 416            }
 417        });
 418        self.multibuffer.update(cx, |multibuffer, cx| {
 419            for path in previous_paths {
 420                multibuffer.remove_excerpts_for_path(path, cx);
 421            }
 422        });
 423        result
 424    }
 425
 426    fn register_buffer(
 427        &mut self,
 428        diff_buffer: DiffBuffer,
 429        window: &mut Window,
 430        cx: &mut Context<Self>,
 431    ) {
 432        let path_key = diff_buffer.path_key;
 433        let buffer = diff_buffer.buffer;
 434        let diff = diff_buffer.diff;
 435
 436        let conflict_addon = self
 437            .editor
 438            .read(cx)
 439            .addon::<ConflictAddon>()
 440            .expect("project diff editor should have a conflict addon");
 441
 442        let snapshot = buffer.read(cx).snapshot();
 443        let diff = diff.read(cx);
 444        let diff_hunk_ranges = diff
 445            .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx)
 446            .map(|diff_hunk| diff_hunk.buffer_range.clone());
 447        let conflicts = conflict_addon
 448            .conflict_set(snapshot.remote_id())
 449            .map(|conflict_set| conflict_set.read(cx).snapshot().conflicts.clone())
 450            .unwrap_or_default();
 451        let conflicts = conflicts.iter().map(|conflict| conflict.range.clone());
 452
 453        let excerpt_ranges = merge_anchor_ranges(diff_hunk_ranges, conflicts, &snapshot)
 454            .map(|range| range.to_point(&snapshot))
 455            .collect::<Vec<_>>();
 456
 457        let (was_empty, is_excerpt_newly_added) = self.multibuffer.update(cx, |multibuffer, cx| {
 458            let was_empty = multibuffer.is_empty();
 459            let (_, is_newly_added) = multibuffer.set_excerpts_for_path(
 460                path_key.clone(),
 461                buffer,
 462                excerpt_ranges,
 463                editor::DEFAULT_MULTIBUFFER_CONTEXT,
 464                cx,
 465            );
 466            (was_empty, is_newly_added)
 467        });
 468
 469        self.editor.update(cx, |editor, cx| {
 470            if was_empty {
 471                editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| {
 472                    // TODO select the very beginning (possibly inside a deletion)
 473                    selections.select_ranges([0..0])
 474                });
 475            }
 476            if is_excerpt_newly_added
 477                && (diff_buffer.file_status.is_deleted()
 478                    || (diff_buffer.file_status.is_untracked()
 479                        && GitPanelSettings::get_global(cx).collapse_untracked_diff))
 480            {
 481                editor.fold_buffer(snapshot.text.remote_id(), cx)
 482            }
 483        });
 484
 485        if self.multibuffer.read(cx).is_empty()
 486            && self
 487                .editor
 488                .read(cx)
 489                .focus_handle(cx)
 490                .contains_focused(window, cx)
 491        {
 492            self.focus_handle.focus(window);
 493        } else if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() {
 494            self.editor.update(cx, |editor, cx| {
 495                editor.focus_handle(cx).focus(window);
 496            });
 497        }
 498        if self.pending_scroll.as_ref() == Some(&path_key) {
 499            self.move_to_path(path_key, window, cx);
 500        }
 501    }
 502
 503    pub async fn handle_status_updates(
 504        this: WeakEntity<Self>,
 505        mut recv: postage::watch::Receiver<()>,
 506        cx: &mut AsyncWindowContext,
 507    ) -> Result<()> {
 508        while let Some(_) = recv.next().await {
 509            let buffers_to_load = this.update(cx, |this, cx| this.load_buffers(cx))?;
 510            for buffer_to_load in buffers_to_load {
 511                if let Some(buffer) = buffer_to_load.await.log_err() {
 512                    cx.update(|window, cx| {
 513                        this.update(cx, |this, cx| this.register_buffer(buffer, window, cx))
 514                            .ok();
 515                    })?;
 516                }
 517            }
 518            this.update(cx, |this, cx| {
 519                this.pending_scroll.take();
 520                cx.notify();
 521            })?;
 522        }
 523
 524        Ok(())
 525    }
 526
 527    #[cfg(any(test, feature = "test-support"))]
 528    pub fn excerpt_paths(&self, cx: &App) -> Vec<String> {
 529        self.multibuffer
 530            .read(cx)
 531            .excerpt_paths()
 532            .map(|key| key.path().to_string_lossy().to_string())
 533            .collect()
 534    }
 535}
 536
 537impl EventEmitter<EditorEvent> for ProjectDiff {}
 538
 539impl Focusable for ProjectDiff {
 540    fn focus_handle(&self, cx: &App) -> FocusHandle {
 541        if self.multibuffer.read(cx).is_empty() {
 542            self.focus_handle.clone()
 543        } else {
 544            self.editor.focus_handle(cx)
 545        }
 546    }
 547}
 548
 549impl Item for ProjectDiff {
 550    type Event = EditorEvent;
 551
 552    fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
 553        Some(Icon::new(IconName::GitBranch).color(Color::Muted))
 554    }
 555
 556    fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
 557        Editor::to_item_events(event, f)
 558    }
 559
 560    fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 561        self.editor
 562            .update(cx, |editor, cx| editor.deactivated(window, cx));
 563    }
 564
 565    fn navigate(
 566        &mut self,
 567        data: Box<dyn Any>,
 568        window: &mut Window,
 569        cx: &mut Context<Self>,
 570    ) -> bool {
 571        self.editor
 572            .update(cx, |editor, cx| editor.navigate(data, window, cx))
 573    }
 574
 575    fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
 576        Some("Project Diff".into())
 577    }
 578
 579    fn tab_content(&self, params: TabContentParams, _window: &Window, _: &App) -> AnyElement {
 580        Label::new("Uncommitted Changes")
 581            .color(if params.selected {
 582                Color::Default
 583            } else {
 584                Color::Muted
 585            })
 586            .into_any_element()
 587    }
 588
 589    fn tab_content_text(&self, _detail: usize, _: &App) -> SharedString {
 590        "Uncommitted Changes".into()
 591    }
 592
 593    fn telemetry_event_text(&self) -> Option<&'static str> {
 594        Some("Project Diff Opened")
 595    }
 596
 597    fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
 598        Some(Box::new(self.editor.clone()))
 599    }
 600
 601    fn for_each_project_item(
 602        &self,
 603        cx: &App,
 604        f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
 605    ) {
 606        self.editor.for_each_project_item(cx, f)
 607    }
 608
 609    fn is_singleton(&self, _: &App) -> bool {
 610        false
 611    }
 612
 613    fn set_nav_history(
 614        &mut self,
 615        nav_history: ItemNavHistory,
 616        _: &mut Window,
 617        cx: &mut Context<Self>,
 618    ) {
 619        self.editor.update(cx, |editor, _| {
 620            editor.set_nav_history(Some(nav_history));
 621        });
 622    }
 623
 624    fn clone_on_split(
 625        &self,
 626        _workspace_id: Option<workspace::WorkspaceId>,
 627        window: &mut Window,
 628        cx: &mut Context<Self>,
 629    ) -> Option<Entity<Self>>
 630    where
 631        Self: Sized,
 632    {
 633        let workspace = self.workspace.upgrade()?;
 634        Some(cx.new(|cx| ProjectDiff::new(self.project.clone(), workspace, window, cx)))
 635    }
 636
 637    fn is_dirty(&self, cx: &App) -> bool {
 638        self.multibuffer.read(cx).is_dirty(cx)
 639    }
 640
 641    fn has_conflict(&self, cx: &App) -> bool {
 642        self.multibuffer.read(cx).has_conflict(cx)
 643    }
 644
 645    fn can_save(&self, _: &App) -> bool {
 646        true
 647    }
 648
 649    fn save(
 650        &mut self,
 651        options: SaveOptions,
 652        project: Entity<Project>,
 653        window: &mut Window,
 654        cx: &mut Context<Self>,
 655    ) -> Task<Result<()>> {
 656        self.editor.save(options, project, window, cx)
 657    }
 658
 659    fn save_as(
 660        &mut self,
 661        _: Entity<Project>,
 662        _: ProjectPath,
 663        _window: &mut Window,
 664        _: &mut Context<Self>,
 665    ) -> Task<Result<()>> {
 666        unreachable!()
 667    }
 668
 669    fn reload(
 670        &mut self,
 671        project: Entity<Project>,
 672        window: &mut Window,
 673        cx: &mut Context<Self>,
 674    ) -> Task<Result<()>> {
 675        self.editor.reload(project, window, cx)
 676    }
 677
 678    fn act_as_type<'a>(
 679        &'a self,
 680        type_id: TypeId,
 681        self_handle: &'a Entity<Self>,
 682        _: &'a App,
 683    ) -> Option<AnyView> {
 684        if type_id == TypeId::of::<Self>() {
 685            Some(self_handle.to_any())
 686        } else if type_id == TypeId::of::<Editor>() {
 687            Some(self.editor.to_any())
 688        } else {
 689            None
 690        }
 691    }
 692
 693    fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
 694        ToolbarItemLocation::PrimaryLeft
 695    }
 696
 697    fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
 698        self.editor.breadcrumbs(theme, cx)
 699    }
 700
 701    fn added_to_workspace(
 702        &mut self,
 703        workspace: &mut Workspace,
 704        window: &mut Window,
 705        cx: &mut Context<Self>,
 706    ) {
 707        self.editor.update(cx, |editor, cx| {
 708            editor.added_to_workspace(workspace, window, cx)
 709        });
 710    }
 711}
 712
 713impl Render for ProjectDiff {
 714    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 715        let is_empty = self.multibuffer.read(cx).is_empty();
 716
 717        div()
 718            .track_focus(&self.focus_handle)
 719            .key_context(if is_empty { "EmptyPane" } else { "GitDiff" })
 720            .bg(cx.theme().colors().editor_background)
 721            .flex()
 722            .items_center()
 723            .justify_center()
 724            .size_full()
 725            .when(is_empty, |el| {
 726                let remote_button = if let Some(panel) = self
 727                    .workspace
 728                    .upgrade()
 729                    .and_then(|workspace| workspace.read(cx).panel::<GitPanel>(cx))
 730                {
 731                    panel.update(cx, |panel, cx| panel.render_remote_button(cx))
 732                } else {
 733                    None
 734                };
 735                let keybinding_focus_handle = self.focus_handle(cx).clone();
 736                el.child(
 737                    v_flex()
 738                        .gap_1()
 739                        .child(
 740                            h_flex()
 741                                .justify_around()
 742                                .child(Label::new("No uncommitted changes")),
 743                        )
 744                        .map(|el| match remote_button {
 745                            Some(button) => el.child(h_flex().justify_around().child(button)),
 746                            None => el.child(
 747                                h_flex()
 748                                    .justify_around()
 749                                    .child(Label::new("Remote up to date")),
 750                            ),
 751                        })
 752                        .child(
 753                            h_flex().justify_around().mt_1().child(
 754                                Button::new("project-diff-close-button", "Close")
 755                                    // .style(ButtonStyle::Transparent)
 756                                    .key_binding(KeyBinding::for_action_in(
 757                                        &CloseActiveItem::default(),
 758                                        &keybinding_focus_handle,
 759                                        window,
 760                                        cx,
 761                                    ))
 762                                    .on_click(move |_, window, cx| {
 763                                        window.focus(&keybinding_focus_handle);
 764                                        window.dispatch_action(
 765                                            Box::new(CloseActiveItem::default()),
 766                                            cx,
 767                                        );
 768                                    }),
 769                            ),
 770                        ),
 771                )
 772            })
 773            .when(!is_empty, |el| el.child(self.editor.clone()))
 774    }
 775}
 776
 777impl SerializableItem for ProjectDiff {
 778    fn serialized_item_kind() -> &'static str {
 779        "ProjectDiff"
 780    }
 781
 782    fn cleanup(
 783        _: workspace::WorkspaceId,
 784        _: Vec<workspace::ItemId>,
 785        _: &mut Window,
 786        _: &mut App,
 787    ) -> Task<Result<()>> {
 788        Task::ready(Ok(()))
 789    }
 790
 791    fn deserialize(
 792        _project: Entity<Project>,
 793        workspace: WeakEntity<Workspace>,
 794        _workspace_id: workspace::WorkspaceId,
 795        _item_id: workspace::ItemId,
 796        window: &mut Window,
 797        cx: &mut App,
 798    ) -> Task<Result<Entity<Self>>> {
 799        window.spawn(cx, async move |cx| {
 800            workspace.update_in(cx, |workspace, window, cx| {
 801                let workspace_handle = cx.entity();
 802                cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx))
 803            })
 804        })
 805    }
 806
 807    fn serialize(
 808        &mut self,
 809        _workspace: &mut Workspace,
 810        _item_id: workspace::ItemId,
 811        _closing: bool,
 812        _window: &mut Window,
 813        _cx: &mut Context<Self>,
 814    ) -> Option<Task<Result<()>>> {
 815        None
 816    }
 817
 818    fn should_serialize(&self, _: &Self::Event) -> bool {
 819        false
 820    }
 821}
 822
 823pub struct ProjectDiffToolbar {
 824    project_diff: Option<WeakEntity<ProjectDiff>>,
 825    workspace: WeakEntity<Workspace>,
 826}
 827
 828impl ProjectDiffToolbar {
 829    pub fn new(workspace: &Workspace, _: &mut Context<Self>) -> Self {
 830        Self {
 831            project_diff: None,
 832            workspace: workspace.weak_handle(),
 833        }
 834    }
 835
 836    fn project_diff(&self, _: &App) -> Option<Entity<ProjectDiff>> {
 837        self.project_diff.as_ref()?.upgrade()
 838    }
 839
 840    fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context<Self>) {
 841        if let Some(project_diff) = self.project_diff(cx) {
 842            project_diff.focus_handle(cx).focus(window);
 843        }
 844        let action = action.boxed_clone();
 845        cx.defer(move |cx| {
 846            cx.dispatch_action(action.as_ref());
 847        })
 848    }
 849
 850    fn stage_all(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 851        self.workspace
 852            .update(cx, |workspace, cx| {
 853                if let Some(panel) = workspace.panel::<GitPanel>(cx) {
 854                    panel.update(cx, |panel, cx| {
 855                        panel.stage_all(&Default::default(), window, cx);
 856                    });
 857                }
 858            })
 859            .ok();
 860    }
 861
 862    fn unstage_all(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 863        self.workspace
 864            .update(cx, |workspace, cx| {
 865                let Some(panel) = workspace.panel::<GitPanel>(cx) else {
 866                    return;
 867                };
 868                panel.update(cx, |panel, cx| {
 869                    panel.unstage_all(&Default::default(), window, cx);
 870                });
 871            })
 872            .ok();
 873    }
 874}
 875
 876impl EventEmitter<ToolbarItemEvent> for ProjectDiffToolbar {}
 877
 878impl ToolbarItemView for ProjectDiffToolbar {
 879    fn set_active_pane_item(
 880        &mut self,
 881        active_pane_item: Option<&dyn ItemHandle>,
 882        _: &mut Window,
 883        cx: &mut Context<Self>,
 884    ) -> ToolbarItemLocation {
 885        self.project_diff = active_pane_item
 886            .and_then(|item| item.act_as::<ProjectDiff>(cx))
 887            .map(|entity| entity.downgrade());
 888        if self.project_diff.is_some() {
 889            ToolbarItemLocation::PrimaryRight
 890        } else {
 891            ToolbarItemLocation::Hidden
 892        }
 893    }
 894
 895    fn pane_focus_update(
 896        &mut self,
 897        _pane_focused: bool,
 898        _window: &mut Window,
 899        _cx: &mut Context<Self>,
 900    ) {
 901    }
 902}
 903
 904struct ButtonStates {
 905    stage: bool,
 906    unstage: bool,
 907    prev_next: bool,
 908    selection: bool,
 909    stage_all: bool,
 910    unstage_all: bool,
 911}
 912
 913impl Render for ProjectDiffToolbar {
 914    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 915        let Some(project_diff) = self.project_diff(cx) else {
 916            return div();
 917        };
 918        let focus_handle = project_diff.focus_handle(cx);
 919        let button_states = project_diff.read(cx).button_states(cx);
 920
 921        h_group_xl()
 922            .my_neg_1()
 923            .py_1()
 924            .items_center()
 925            .flex_wrap()
 926            .justify_between()
 927            .child(
 928                h_group_sm()
 929                    .when(button_states.selection, |el| {
 930                        el.child(
 931                            Button::new("stage", "Toggle Staged")
 932                                .tooltip(Tooltip::for_action_title_in(
 933                                    "Toggle Staged",
 934                                    &ToggleStaged,
 935                                    &focus_handle,
 936                                ))
 937                                .disabled(!button_states.stage && !button_states.unstage)
 938                                .on_click(cx.listener(|this, _, window, cx| {
 939                                    this.dispatch_action(&ToggleStaged, window, cx)
 940                                })),
 941                        )
 942                    })
 943                    .when(!button_states.selection, |el| {
 944                        el.child(
 945                            Button::new("stage", "Stage")
 946                                .tooltip(Tooltip::for_action_title_in(
 947                                    "Stage and go to next hunk",
 948                                    &StageAndNext,
 949                                    &focus_handle,
 950                                ))
 951                                .on_click(cx.listener(|this, _, window, cx| {
 952                                    this.dispatch_action(&StageAndNext, window, cx)
 953                                })),
 954                        )
 955                        .child(
 956                            Button::new("unstage", "Unstage")
 957                                .tooltip(Tooltip::for_action_title_in(
 958                                    "Unstage and go to next hunk",
 959                                    &UnstageAndNext,
 960                                    &focus_handle,
 961                                ))
 962                                .on_click(cx.listener(|this, _, window, cx| {
 963                                    this.dispatch_action(&UnstageAndNext, window, cx)
 964                                })),
 965                        )
 966                    }),
 967            )
 968            // n.b. the only reason these arrows are here is because we don't
 969            // support "undo" for staging so we need a way to go back.
 970            .child(
 971                h_group_sm()
 972                    .child(
 973                        IconButton::new("up", IconName::ArrowUp)
 974                            .shape(ui::IconButtonShape::Square)
 975                            .tooltip(Tooltip::for_action_title_in(
 976                                "Go to previous hunk",
 977                                &GoToPreviousHunk,
 978                                &focus_handle,
 979                            ))
 980                            .disabled(!button_states.prev_next)
 981                            .on_click(cx.listener(|this, _, window, cx| {
 982                                this.dispatch_action(&GoToPreviousHunk, window, cx)
 983                            })),
 984                    )
 985                    .child(
 986                        IconButton::new("down", IconName::ArrowDown)
 987                            .shape(ui::IconButtonShape::Square)
 988                            .tooltip(Tooltip::for_action_title_in(
 989                                "Go to next hunk",
 990                                &GoToHunk,
 991                                &focus_handle,
 992                            ))
 993                            .disabled(!button_states.prev_next)
 994                            .on_click(cx.listener(|this, _, window, cx| {
 995                                this.dispatch_action(&GoToHunk, window, cx)
 996                            })),
 997                    ),
 998            )
 999            .child(vertical_divider())
1000            .child(
1001                h_group_sm()
1002                    .when(
1003                        button_states.unstage_all && !button_states.stage_all,
1004                        |el| {
1005                            el.child(
1006                                Button::new("unstage-all", "Unstage All")
1007                                    .tooltip(Tooltip::for_action_title_in(
1008                                        "Unstage all changes",
1009                                        &UnstageAll,
1010                                        &focus_handle,
1011                                    ))
1012                                    .on_click(cx.listener(|this, _, window, cx| {
1013                                        this.unstage_all(window, cx)
1014                                    })),
1015                            )
1016                        },
1017                    )
1018                    .when(
1019                        !button_states.unstage_all || button_states.stage_all,
1020                        |el| {
1021                            el.child(
1022                                // todo make it so that changing to say "Unstaged"
1023                                // doesn't change the position.
1024                                div().child(
1025                                    Button::new("stage-all", "Stage All")
1026                                        .disabled(!button_states.stage_all)
1027                                        .tooltip(Tooltip::for_action_title_in(
1028                                            "Stage all changes",
1029                                            &StageAll,
1030                                            &focus_handle,
1031                                        ))
1032                                        .on_click(cx.listener(|this, _, window, cx| {
1033                                            this.stage_all(window, cx)
1034                                        })),
1035                                ),
1036                            )
1037                        },
1038                    )
1039                    .child(
1040                        Button::new("commit", "Commit")
1041                            .tooltip(Tooltip::for_action_title_in(
1042                                "Commit",
1043                                &Commit,
1044                                &focus_handle,
1045                            ))
1046                            .on_click(cx.listener(|this, _, window, cx| {
1047                                this.dispatch_action(&Commit, window, cx);
1048                            })),
1049                    ),
1050            )
1051    }
1052}
1053
1054#[derive(IntoElement, RegisterComponent)]
1055pub struct ProjectDiffEmptyState {
1056    pub no_repo: bool,
1057    pub can_push_and_pull: bool,
1058    pub focus_handle: Option<FocusHandle>,
1059    pub current_branch: Option<Branch>,
1060    // has_pending_commits: bool,
1061    // ahead_of_remote: bool,
1062    // no_git_repository: bool,
1063}
1064
1065impl RenderOnce for ProjectDiffEmptyState {
1066    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
1067        let status_against_remote = |ahead_by: usize, behind_by: usize| -> bool {
1068            match self.current_branch {
1069                Some(Branch {
1070                    upstream:
1071                        Some(Upstream {
1072                            tracking:
1073                                UpstreamTracking::Tracked(UpstreamTrackingStatus {
1074                                    ahead, behind, ..
1075                                }),
1076                            ..
1077                        }),
1078                    ..
1079                }) if (ahead > 0) == (ahead_by > 0) && (behind > 0) == (behind_by > 0) => true,
1080                _ => false,
1081            }
1082        };
1083
1084        let change_count = |current_branch: &Branch| -> (usize, usize) {
1085            match current_branch {
1086                Branch {
1087                    upstream:
1088                        Some(Upstream {
1089                            tracking:
1090                                UpstreamTracking::Tracked(UpstreamTrackingStatus {
1091                                    ahead, behind, ..
1092                                }),
1093                            ..
1094                        }),
1095                    ..
1096                } => (*ahead as usize, *behind as usize),
1097                _ => (0, 0),
1098            }
1099        };
1100
1101        let not_ahead_or_behind = status_against_remote(0, 0);
1102        let ahead_of_remote = status_against_remote(1, 0);
1103        let branch_not_on_remote = if let Some(branch) = self.current_branch.as_ref() {
1104            branch.upstream.is_none()
1105        } else {
1106            false
1107        };
1108
1109        let has_branch_container = |branch: &Branch| {
1110            h_flex()
1111                .max_w(px(420.))
1112                .bg(cx.theme().colors().text.opacity(0.05))
1113                .border_1()
1114                .border_color(cx.theme().colors().border)
1115                .rounded_sm()
1116                .gap_8()
1117                .px_6()
1118                .py_4()
1119                .map(|this| {
1120                    if ahead_of_remote {
1121                        let ahead_count = change_count(branch).0;
1122                        let ahead_string = format!("{} Commits Ahead", ahead_count);
1123                        this.child(
1124                            v_flex()
1125                                .child(Headline::new(ahead_string).size(HeadlineSize::Small))
1126                                .child(
1127                                    Label::new(format!("Push your changes to {}", branch.name()))
1128                                        .color(Color::Muted),
1129                                ),
1130                        )
1131                        .child(div().child(render_push_button(
1132                            self.focus_handle,
1133                            "push".into(),
1134                            ahead_count as u32,
1135                        )))
1136                    } else if branch_not_on_remote {
1137                        this.child(
1138                            v_flex()
1139                                .child(Headline::new("Publish Branch").size(HeadlineSize::Small))
1140                                .child(
1141                                    Label::new(format!("Create {} on remote", branch.name()))
1142                                        .color(Color::Muted),
1143                                ),
1144                        )
1145                        .child(
1146                            div().child(render_publish_button(self.focus_handle, "publish".into())),
1147                        )
1148                    } else {
1149                        this.child(Label::new("Remote status unknown").color(Color::Muted))
1150                    }
1151                })
1152        };
1153
1154        v_flex().size_full().items_center().justify_center().child(
1155            v_flex()
1156                .gap_1()
1157                .when(self.no_repo, |this| {
1158                    // TODO: add git init
1159                    this.text_center()
1160                        .child(Label::new("No Repository").color(Color::Muted))
1161                })
1162                .map(|this| {
1163                    if not_ahead_or_behind && self.current_branch.is_some() {
1164                        this.text_center()
1165                            .child(Label::new("No Changes").color(Color::Muted))
1166                    } else {
1167                        this.when_some(self.current_branch.as_ref(), |this, branch| {
1168                            this.child(has_branch_container(&branch))
1169                        })
1170                    }
1171                }),
1172        )
1173    }
1174}
1175
1176mod preview {
1177    use git::repository::{
1178        Branch, CommitSummary, Upstream, UpstreamTracking, UpstreamTrackingStatus,
1179    };
1180    use ui::prelude::*;
1181
1182    use super::ProjectDiffEmptyState;
1183
1184    // View this component preview using `workspace: open component-preview`
1185    impl Component for ProjectDiffEmptyState {
1186        fn scope() -> ComponentScope {
1187            ComponentScope::VersionControl
1188        }
1189
1190        fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
1191            let unknown_upstream: Option<UpstreamTracking> = None;
1192            let ahead_of_upstream: Option<UpstreamTracking> = Some(
1193                UpstreamTrackingStatus {
1194                    ahead: 2,
1195                    behind: 0,
1196                }
1197                .into(),
1198            );
1199
1200            let not_ahead_or_behind_upstream: Option<UpstreamTracking> = Some(
1201                UpstreamTrackingStatus {
1202                    ahead: 0,
1203                    behind: 0,
1204                }
1205                .into(),
1206            );
1207
1208            fn branch(upstream: Option<UpstreamTracking>) -> Branch {
1209                Branch {
1210                    is_head: true,
1211                    ref_name: "some-branch".into(),
1212                    upstream: upstream.map(|tracking| Upstream {
1213                        ref_name: "origin/some-branch".into(),
1214                        tracking,
1215                    }),
1216                    most_recent_commit: Some(CommitSummary {
1217                        sha: "abc123".into(),
1218                        subject: "Modify stuff".into(),
1219                        commit_timestamp: 1710932954,
1220                        has_parent: true,
1221                    }),
1222                }
1223            }
1224
1225            let no_repo_state = ProjectDiffEmptyState {
1226                no_repo: true,
1227                can_push_and_pull: false,
1228                focus_handle: None,
1229                current_branch: None,
1230            };
1231
1232            let no_changes_state = ProjectDiffEmptyState {
1233                no_repo: false,
1234                can_push_and_pull: true,
1235                focus_handle: None,
1236                current_branch: Some(branch(not_ahead_or_behind_upstream)),
1237            };
1238
1239            let ahead_of_upstream_state = ProjectDiffEmptyState {
1240                no_repo: false,
1241                can_push_and_pull: true,
1242                focus_handle: None,
1243                current_branch: Some(branch(ahead_of_upstream)),
1244            };
1245
1246            let unknown_upstream_state = ProjectDiffEmptyState {
1247                no_repo: false,
1248                can_push_and_pull: true,
1249                focus_handle: None,
1250                current_branch: Some(branch(unknown_upstream)),
1251            };
1252
1253            let (width, height) = (px(480.), px(320.));
1254
1255            Some(
1256                v_flex()
1257                    .gap_6()
1258                    .children(vec![
1259                        example_group(vec![
1260                            single_example(
1261                                "No Repo",
1262                                div()
1263                                    .w(width)
1264                                    .h(height)
1265                                    .child(no_repo_state)
1266                                    .into_any_element(),
1267                            ),
1268                            single_example(
1269                                "No Changes",
1270                                div()
1271                                    .w(width)
1272                                    .h(height)
1273                                    .child(no_changes_state)
1274                                    .into_any_element(),
1275                            ),
1276                            single_example(
1277                                "Unknown Upstream",
1278                                div()
1279                                    .w(width)
1280                                    .h(height)
1281                                    .child(unknown_upstream_state)
1282                                    .into_any_element(),
1283                            ),
1284                            single_example(
1285                                "Ahead of Remote",
1286                                div()
1287                                    .w(width)
1288                                    .h(height)
1289                                    .child(ahead_of_upstream_state)
1290                                    .into_any_element(),
1291                            ),
1292                        ])
1293                        .vertical(),
1294                    ])
1295                    .into_any_element(),
1296            )
1297        }
1298    }
1299}
1300
1301fn merge_anchor_ranges<'a>(
1302    left: impl 'a + Iterator<Item = Range<Anchor>>,
1303    right: impl 'a + Iterator<Item = Range<Anchor>>,
1304    snapshot: &'a language::BufferSnapshot,
1305) -> impl 'a + Iterator<Item = Range<Anchor>> {
1306    let mut left = left.fuse().peekable();
1307    let mut right = right.fuse().peekable();
1308
1309    std::iter::from_fn(move || {
1310        let Some(left_range) = left.peek() else {
1311            return right.next();
1312        };
1313        let Some(right_range) = right.peek() else {
1314            return left.next();
1315        };
1316
1317        let mut next_range = if left_range.start.cmp(&right_range.start, snapshot).is_lt() {
1318            left.next().unwrap()
1319        } else {
1320            right.next().unwrap()
1321        };
1322
1323        // Extend the basic range while there's overlap with a range from either stream.
1324        loop {
1325            if let Some(left_range) = left
1326                .peek()
1327                .filter(|range| range.start.cmp(&next_range.end, &snapshot).is_le())
1328                .cloned()
1329            {
1330                left.next();
1331                next_range.end = left_range.end;
1332            } else if let Some(right_range) = right
1333                .peek()
1334                .filter(|range| range.start.cmp(&next_range.end, &snapshot).is_le())
1335                .cloned()
1336            {
1337                right.next();
1338                next_range.end = right_range.end;
1339            } else {
1340                break;
1341            }
1342        }
1343
1344        Some(next_range)
1345    })
1346}
1347
1348#[cfg(not(target_os = "windows"))]
1349#[cfg(test)]
1350mod tests {
1351    use db::indoc;
1352    use editor::test::editor_test_context::{EditorTestContext, assert_state_with_diff};
1353    use git::status::{UnmergedStatus, UnmergedStatusCode};
1354    use gpui::TestAppContext;
1355    use project::FakeFs;
1356    use serde_json::json;
1357    use settings::SettingsStore;
1358    use std::path::Path;
1359    use unindent::Unindent as _;
1360    use util::path;
1361
1362    use super::*;
1363
1364    #[ctor::ctor]
1365    fn init_logger() {
1366        zlog::init_test();
1367    }
1368
1369    fn init_test(cx: &mut TestAppContext) {
1370        cx.update(|cx| {
1371            let store = SettingsStore::test(cx);
1372            cx.set_global(store);
1373            theme::init(theme::LoadThemes::JustBase, cx);
1374            language::init(cx);
1375            Project::init_settings(cx);
1376            workspace::init_settings(cx);
1377            editor::init(cx);
1378            crate::init(cx);
1379        });
1380    }
1381
1382    #[gpui::test]
1383    async fn test_save_after_restore(cx: &mut TestAppContext) {
1384        init_test(cx);
1385
1386        let fs = FakeFs::new(cx.executor());
1387        fs.insert_tree(
1388            path!("/project"),
1389            json!({
1390                ".git": {},
1391                "foo.txt": "FOO\n",
1392            }),
1393        )
1394        .await;
1395        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1396        let (workspace, cx) =
1397            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1398        let diff = cx.new_window_entity(|window, cx| {
1399            ProjectDiff::new(project.clone(), workspace, window, cx)
1400        });
1401        cx.run_until_parked();
1402
1403        fs.set_head_for_repo(
1404            path!("/project/.git").as_ref(),
1405            &[("foo.txt".into(), "foo\n".into())],
1406            "deadbeef",
1407        );
1408        fs.set_index_for_repo(
1409            path!("/project/.git").as_ref(),
1410            &[("foo.txt".into(), "foo\n".into())],
1411        );
1412        cx.run_until_parked();
1413
1414        let editor = diff.read_with(cx, |diff, _| diff.editor.clone());
1415        assert_state_with_diff(
1416            &editor,
1417            cx,
1418            &"
1419                - foo
1420                + ˇFOO
1421            "
1422            .unindent(),
1423        );
1424
1425        editor.update_in(cx, |editor, window, cx| {
1426            editor.git_restore(&Default::default(), window, cx);
1427        });
1428        cx.run_until_parked();
1429
1430        assert_state_with_diff(&editor, cx, &"ˇ".unindent());
1431
1432        let text = String::from_utf8(fs.read_file_sync("/project/foo.txt").unwrap()).unwrap();
1433        assert_eq!(text, "foo\n");
1434    }
1435
1436    #[gpui::test]
1437    async fn test_scroll_to_beginning_with_deletion(cx: &mut TestAppContext) {
1438        init_test(cx);
1439
1440        let fs = FakeFs::new(cx.executor());
1441        fs.insert_tree(
1442            path!("/project"),
1443            json!({
1444                ".git": {},
1445                "bar": "BAR\n",
1446                "foo": "FOO\n",
1447            }),
1448        )
1449        .await;
1450        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1451        let (workspace, cx) =
1452            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1453        let diff = cx.new_window_entity(|window, cx| {
1454            ProjectDiff::new(project.clone(), workspace, window, cx)
1455        });
1456        cx.run_until_parked();
1457
1458        fs.set_head_and_index_for_repo(
1459            path!("/project/.git").as_ref(),
1460            &[
1461                ("bar".into(), "bar\n".into()),
1462                ("foo".into(), "foo\n".into()),
1463            ],
1464        );
1465        cx.run_until_parked();
1466
1467        let editor = cx.update_window_entity(&diff, |diff, window, cx| {
1468            diff.move_to_path(
1469                PathKey::namespaced(TRACKED_NAMESPACE, Path::new("foo").into()),
1470                window,
1471                cx,
1472            );
1473            diff.editor.clone()
1474        });
1475        assert_state_with_diff(
1476            &editor,
1477            cx,
1478            &"
1479                - bar
1480                + BAR
1481
1482                - ˇfoo
1483                + FOO
1484            "
1485            .unindent(),
1486        );
1487
1488        let editor = cx.update_window_entity(&diff, |diff, window, cx| {
1489            diff.move_to_path(
1490                PathKey::namespaced(TRACKED_NAMESPACE, Path::new("bar").into()),
1491                window,
1492                cx,
1493            );
1494            diff.editor.clone()
1495        });
1496        assert_state_with_diff(
1497            &editor,
1498            cx,
1499            &"
1500                - ˇbar
1501                + BAR
1502
1503                - foo
1504                + FOO
1505            "
1506            .unindent(),
1507        );
1508    }
1509
1510    #[gpui::test]
1511    async fn test_hunks_after_restore_then_modify(cx: &mut TestAppContext) {
1512        init_test(cx);
1513
1514        let fs = FakeFs::new(cx.executor());
1515        fs.insert_tree(
1516            path!("/project"),
1517            json!({
1518                ".git": {},
1519                "foo": "modified\n",
1520            }),
1521        )
1522        .await;
1523        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1524        let (workspace, cx) =
1525            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1526        let buffer = project
1527            .update(cx, |project, cx| {
1528                project.open_local_buffer(path!("/project/foo"), cx)
1529            })
1530            .await
1531            .unwrap();
1532        let buffer_editor = cx.new_window_entity(|window, cx| {
1533            Editor::for_buffer(buffer, Some(project.clone()), window, cx)
1534        });
1535        let diff = cx.new_window_entity(|window, cx| {
1536            ProjectDiff::new(project.clone(), workspace, window, cx)
1537        });
1538        cx.run_until_parked();
1539
1540        fs.set_head_for_repo(
1541            path!("/project/.git").as_ref(),
1542            &[("foo".into(), "original\n".into())],
1543            "deadbeef",
1544        );
1545        cx.run_until_parked();
1546
1547        let diff_editor = diff.read_with(cx, |diff, _| diff.editor.clone());
1548
1549        assert_state_with_diff(
1550            &diff_editor,
1551            cx,
1552            &"
1553                - original
1554                + ˇmodified
1555            "
1556            .unindent(),
1557        );
1558
1559        let prev_buffer_hunks =
1560            cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
1561                let snapshot = buffer_editor.snapshot(window, cx);
1562                let snapshot = &snapshot.buffer_snapshot;
1563                let prev_buffer_hunks = buffer_editor
1564                    .diff_hunks_in_ranges(&[editor::Anchor::min()..editor::Anchor::max()], snapshot)
1565                    .collect::<Vec<_>>();
1566                buffer_editor.git_restore(&Default::default(), window, cx);
1567                prev_buffer_hunks
1568            });
1569        assert_eq!(prev_buffer_hunks.len(), 1);
1570        cx.run_until_parked();
1571
1572        let new_buffer_hunks =
1573            cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
1574                let snapshot = buffer_editor.snapshot(window, cx);
1575                let snapshot = &snapshot.buffer_snapshot;
1576                buffer_editor
1577                    .diff_hunks_in_ranges(&[editor::Anchor::min()..editor::Anchor::max()], snapshot)
1578                    .collect::<Vec<_>>()
1579            });
1580        assert_eq!(new_buffer_hunks.as_slice(), &[]);
1581
1582        cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
1583            buffer_editor.set_text("different\n", window, cx);
1584            buffer_editor.save(
1585                SaveOptions {
1586                    format: false,
1587                    autosave: false,
1588                },
1589                project.clone(),
1590                window,
1591                cx,
1592            )
1593        })
1594        .await
1595        .unwrap();
1596
1597        cx.run_until_parked();
1598
1599        cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
1600            buffer_editor.expand_all_diff_hunks(&Default::default(), window, cx);
1601        });
1602
1603        assert_state_with_diff(
1604            &buffer_editor,
1605            cx,
1606            &"
1607                - original
1608                + different
1609                  ˇ"
1610            .unindent(),
1611        );
1612
1613        assert_state_with_diff(
1614            &diff_editor,
1615            cx,
1616            &"
1617                - original
1618                + different
1619                  ˇ"
1620            .unindent(),
1621        );
1622    }
1623
1624    use crate::{
1625        conflict_view::resolve_conflict,
1626        project_diff::{self, ProjectDiff},
1627    };
1628
1629    #[gpui::test]
1630    async fn test_go_to_prev_hunk_multibuffer(cx: &mut TestAppContext) {
1631        init_test(cx);
1632
1633        let fs = FakeFs::new(cx.executor());
1634        fs.insert_tree(
1635            "/a",
1636            json!({
1637                ".git": {},
1638                "a.txt": "created\n",
1639                "b.txt": "really changed\n",
1640                "c.txt": "unchanged\n"
1641            }),
1642        )
1643        .await;
1644
1645        fs.set_git_content_for_repo(
1646            Path::new("/a/.git"),
1647            &[
1648                ("b.txt".into(), "before\n".to_string(), None),
1649                ("c.txt".into(), "unchanged\n".to_string(), None),
1650                ("d.txt".into(), "deleted\n".to_string(), None),
1651            ],
1652        );
1653
1654        let project = Project::test(fs, [Path::new("/a")], cx).await;
1655        let (workspace, cx) =
1656            cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1657
1658        cx.run_until_parked();
1659
1660        cx.focus(&workspace);
1661        cx.update(|window, cx| {
1662            window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
1663        });
1664
1665        cx.run_until_parked();
1666
1667        let item = workspace.update(cx, |workspace, cx| {
1668            workspace.active_item_as::<ProjectDiff>(cx).unwrap()
1669        });
1670        cx.focus(&item);
1671        let editor = item.read_with(cx, |item, _| item.editor.clone());
1672
1673        let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
1674
1675        cx.assert_excerpts_with_selections(indoc!(
1676            "
1677            [EXCERPT]
1678            before
1679            really changed
1680            [EXCERPT]
1681            [FOLDED]
1682            [EXCERPT]
1683            ˇcreated
1684        "
1685        ));
1686
1687        cx.dispatch_action(editor::actions::GoToPreviousHunk);
1688
1689        cx.assert_excerpts_with_selections(indoc!(
1690            "
1691            [EXCERPT]
1692            before
1693            really changed
1694            [EXCERPT]
1695            ˇ[FOLDED]
1696            [EXCERPT]
1697            created
1698        "
1699        ));
1700
1701        cx.dispatch_action(editor::actions::GoToPreviousHunk);
1702
1703        cx.assert_excerpts_with_selections(indoc!(
1704            "
1705            [EXCERPT]
1706            ˇbefore
1707            really changed
1708            [EXCERPT]
1709            [FOLDED]
1710            [EXCERPT]
1711            created
1712        "
1713        ));
1714    }
1715
1716    #[gpui::test]
1717    async fn test_excerpts_splitting_after_restoring_the_middle_excerpt(cx: &mut TestAppContext) {
1718        init_test(cx);
1719
1720        let git_contents = indoc! {r#"
1721            #[rustfmt::skip]
1722            fn main() {
1723                let x = 0.0; // this line will be removed
1724                // 1
1725                // 2
1726                // 3
1727                let y = 0.0; // this line will be removed
1728                // 1
1729                // 2
1730                // 3
1731                let arr = [
1732                    0.0, // this line will be removed
1733                    0.0, // this line will be removed
1734                    0.0, // this line will be removed
1735                    0.0, // this line will be removed
1736                ];
1737            }
1738        "#};
1739        let buffer_contents = indoc! {"
1740            #[rustfmt::skip]
1741            fn main() {
1742                // 1
1743                // 2
1744                // 3
1745                // 1
1746                // 2
1747                // 3
1748                let arr = [
1749                ];
1750            }
1751        "};
1752
1753        let fs = FakeFs::new(cx.executor());
1754        fs.insert_tree(
1755            "/a",
1756            json!({
1757                ".git": {},
1758                "main.rs": buffer_contents,
1759            }),
1760        )
1761        .await;
1762
1763        fs.set_git_content_for_repo(
1764            Path::new("/a/.git"),
1765            &[("main.rs".into(), git_contents.to_owned(), None)],
1766        );
1767
1768        let project = Project::test(fs, [Path::new("/a")], cx).await;
1769        let (workspace, cx) =
1770            cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1771
1772        cx.run_until_parked();
1773
1774        cx.focus(&workspace);
1775        cx.update(|window, cx| {
1776            window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
1777        });
1778
1779        cx.run_until_parked();
1780
1781        let item = workspace.update(cx, |workspace, cx| {
1782            workspace.active_item_as::<ProjectDiff>(cx).unwrap()
1783        });
1784        cx.focus(&item);
1785        let editor = item.read_with(cx, |item, _| item.editor.clone());
1786
1787        let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
1788
1789        cx.assert_excerpts_with_selections(&format!("[EXCERPT]\nˇ{git_contents}"));
1790
1791        cx.dispatch_action(editor::actions::GoToHunk);
1792        cx.dispatch_action(editor::actions::GoToHunk);
1793        cx.dispatch_action(git::Restore);
1794        cx.dispatch_action(editor::actions::MoveToBeginning);
1795
1796        cx.assert_excerpts_with_selections(&format!("[EXCERPT]\nˇ{git_contents}"));
1797    }
1798
1799    #[gpui::test]
1800    async fn test_saving_resolved_conflicts(cx: &mut TestAppContext) {
1801        init_test(cx);
1802
1803        let fs = FakeFs::new(cx.executor());
1804        fs.insert_tree(
1805            path!("/project"),
1806            json!({
1807                ".git": {},
1808                "foo": "<<<<<<< x\nours\n=======\ntheirs\n>>>>>>> y\n",
1809            }),
1810        )
1811        .await;
1812        fs.set_status_for_repo(
1813            Path::new(path!("/project/.git")),
1814            &[(
1815                Path::new("foo"),
1816                UnmergedStatus {
1817                    first_head: UnmergedStatusCode::Updated,
1818                    second_head: UnmergedStatusCode::Updated,
1819                }
1820                .into(),
1821            )],
1822        );
1823        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1824        let (workspace, cx) =
1825            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1826        let diff = cx.new_window_entity(|window, cx| {
1827            ProjectDiff::new(project.clone(), workspace, window, cx)
1828        });
1829        cx.run_until_parked();
1830
1831        cx.update(|window, cx| {
1832            let editor = diff.read(cx).editor.clone();
1833            let excerpt_ids = editor.read(cx).buffer().read(cx).excerpt_ids();
1834            assert_eq!(excerpt_ids.len(), 1);
1835            let excerpt_id = excerpt_ids[0];
1836            let buffer = editor
1837                .read(cx)
1838                .buffer()
1839                .read(cx)
1840                .all_buffers()
1841                .into_iter()
1842                .next()
1843                .unwrap();
1844            let buffer_id = buffer.read(cx).remote_id();
1845            let conflict_set = diff
1846                .read(cx)
1847                .editor
1848                .read(cx)
1849                .addon::<ConflictAddon>()
1850                .unwrap()
1851                .conflict_set(buffer_id)
1852                .unwrap();
1853            assert!(conflict_set.read(cx).has_conflict);
1854            let snapshot = conflict_set.read(cx).snapshot();
1855            assert_eq!(snapshot.conflicts.len(), 1);
1856
1857            let ours_range = snapshot.conflicts[0].ours.clone();
1858
1859            resolve_conflict(
1860                editor.downgrade(),
1861                excerpt_id,
1862                snapshot.conflicts[0].clone(),
1863                vec![ours_range],
1864                window,
1865                cx,
1866            )
1867        })
1868        .await;
1869
1870        let contents = fs.read_file_sync(path!("/project/foo")).unwrap();
1871        let contents = String::from_utf8(contents).unwrap();
1872        assert_eq!(contents, "ours\n");
1873    }
1874}