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