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