use crate::{ conflict_view, git_panel::{GitPanel, GitStatusEntry}, git_panel_settings::GitPanelSettings, }; use anyhow::Result; use buffer_diff::BufferDiff; use collections::{HashMap, HashSet}; use editor::{ EditorEvent, EditorSettings, SelectionEffects, SplittableEditor, actions::GoToHunk, multibuffer_context_lines, scroll::Autoscroll, }; use futures_lite::future::yield_now; use git::{repository::RepoPath, status::FileStatus}; use gpui::{ App, AppContext as _, AsyncWindowContext, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, Subscription, Task, WeakEntity, }; use language::{Anchor, Buffer, BufferId, Capability, OffsetRangeExt}; use multi_buffer::{MultiBuffer, PathKey}; use project::{ ConflictSet, Project, ProjectPath, git_store::{ Repository, diff_buffer_list::{self, BranchDiffEvent, DiffBase}, }, }; use settings::{GitPanelGroupBy, GitPanelSortBy, Settings, SettingsStore}; use std::{collections::BTreeMap, sync::Arc}; use theme::ActiveTheme; use ui::{CommonAnimationExt as _, KeyBinding, prelude::*}; use util::{ResultExt as _, rel_path::RelPath}; use workspace::{ CloseActiveItem, ItemNavHistory, Workspace, item::{Item, SaveOptions}, }; use ztracing::instrument; struct BufferSubscriptions { _diff: Entity, display_buffer: Entity, _diff_subscription: Subscription, _conflict_set: Option>, _conflict_set_subscription: Option, } pub struct DiffMultibuffer { multibuffer: Entity, branch_diff: Entity, editor: Entity, buffer_subscriptions: HashMap, workspace: WeakEntity, focus_handle: FocusHandle, pending_scroll: Option, review_comment_count: usize, empty_label: SharedString, _task: Task>, _subscription: Subscription, } impl DiffMultibuffer { pub(crate) fn new( branch_diff: Entity, multibuffer_capability: Capability, empty_label: impl Into, configure_editor: impl FnOnce(&mut SplittableEditor, &mut Context) + 'static, project: Entity, workspace: Entity, window: &mut Window, cx: &mut Context, ) -> Self { let focus_handle = cx.focus_handle(); let multibuffer = cx.new(|cx| { let mut multibuffer = MultiBuffer::new(multibuffer_capability); multibuffer.set_all_diff_hunks_expanded(cx); multibuffer }); let editor = cx.new(|cx| { let mut diff_display_editor = SplittableEditor::new( EditorSettings::get_global(cx).diff_view_style, multibuffer.clone(), project.clone(), workspace.clone(), window, cx, ); configure_editor(&mut diff_display_editor, cx); diff_display_editor.rhs_editor().update(cx, |editor, cx| { editor.set_show_diff_review_button(true, cx); }); diff_display_editor }); let editor_subscription = cx.subscribe_in(&editor, window, Self::handle_editor_event); let primary_editor = editor.read(cx).rhs_editor().clone(); let review_comment_subscription = cx.subscribe(&primary_editor, |this, _editor, event: &EditorEvent, cx| { if let EditorEvent::ReviewCommentsChanged { total_count } = event { this.review_comment_count = *total_count; cx.notify(); } }); let branch_diff_subscription = cx.subscribe_in( &branch_diff, window, move |this, _git_store, event, window, cx| match event { BranchDiffEvent::FileListChanged => { this._task = window.spawn(cx, { let this = cx.weak_entity(); async |cx| Self::refresh(this, cx).await }) } BranchDiffEvent::DiffBaseChanged => { this.pending_scroll.take(); this._task = window.spawn(cx, { let this = cx.weak_entity(); async |cx| Self::refresh(this, cx).await }) } }, ); let mut was_sort_by = GitPanelSettings::get_global(cx).sort_by; let mut was_group_by = GitPanelSettings::get_global(cx).group_by; let mut was_tree_view = GitPanelSettings::get_global(cx).tree_view; let mut was_collapse_untracked_diff = GitPanelSettings::get_global(cx).collapse_untracked_diff; cx.observe_global_in::(window, move |this, window, cx| { let settings = GitPanelSettings::get_global(cx); let sort_by = settings.sort_by; let group_by = settings.group_by; let tree_view = settings.tree_view; let is_collapse_untracked_diff = settings.collapse_untracked_diff; if sort_by != was_sort_by || group_by != was_group_by || tree_view != was_tree_view || is_collapse_untracked_diff != was_collapse_untracked_diff { this._task = { window.spawn(cx, { let this = cx.weak_entity(); async |cx| Self::refresh(this, cx).await }) } } was_sort_by = sort_by; was_group_by = group_by; was_tree_view = tree_view; was_collapse_untracked_diff = is_collapse_untracked_diff; }) .detach(); let task = window.spawn(cx, { let this = cx.weak_entity(); async |cx| Self::refresh(this, cx).await }); Self { workspace: workspace.downgrade(), branch_diff, focus_handle, editor, multibuffer, buffer_subscriptions: Default::default(), pending_scroll: None, review_comment_count: 0, empty_label: empty_label.into(), _task: task, _subscription: Subscription::join( branch_diff_subscription, Subscription::join(editor_subscription, review_comment_subscription), ), } } pub(crate) fn diff_base<'a>(&'a self, cx: &'a App) -> &'a DiffBase { self.branch_diff.read(cx).diff_base() } pub(crate) fn branch_diff(&self) -> &Entity { &self.branch_diff } pub(crate) fn repo(&self, cx: &App) -> Option> { self.branch_diff.read(cx).repo().cloned() } pub(crate) fn set_repo(&mut self, repo: Option>, cx: &mut Context) { self.branch_diff.update(cx, |branch_diff, cx| { branch_diff.set_repo(repo, cx); }); } pub(crate) fn is_dirty(&self, cx: &App) -> bool { self.multibuffer.read(cx).is_dirty(cx) } pub(crate) fn has_conflict(&self, cx: &App) -> bool { self.multibuffer.read(cx).has_conflict(cx) } pub(crate) fn multibuffer(&self) -> &Entity { &self.multibuffer } pub(crate) fn move_to_entry( &mut self, entry: GitStatusEntry, window: &mut Window, cx: &mut Context, ) { let Some(git_repo) = self.branch_diff.read(cx).repo() else { return; }; let repo = git_repo.read(cx); let path_key = project_diff_path_key(repo, &entry.repo_path, entry.status, cx); self.move_to_path(path_key, window, cx) } pub(crate) fn move_to_project_path( &mut self, project_path: &ProjectPath, window: &mut Window, cx: &mut Context, ) { let Some(git_repo) = self.branch_diff.read(cx).repo() else { return; }; let Some(repo_path) = git_repo .read(cx) .project_path_to_repo_path(project_path, cx) else { return; }; let status = git_repo .read(cx) .status_for_path(&repo_path) .map(|entry| entry.status) .unwrap_or(FileStatus::Untracked); let path_key = project_diff_path_key(&git_repo.read(cx), &repo_path, status, cx); self.move_to_path(path_key, window, cx) } pub(crate) fn move_to_beginning(&mut self, window: &mut Window, cx: &mut Context) { self.editor.update(cx, |editor, cx| { editor.rhs_editor().update(cx, |editor, cx| { editor.change_selections(Default::default(), window, cx, |s| { s.select_ranges(vec![multi_buffer::Anchor::Min..multi_buffer::Anchor::Min]); }); }); }); } pub(crate) fn move_to_path( &mut self, path_key: PathKey, window: &mut Window, cx: &mut Context, ) { if let Some(position) = self.multibuffer.read(cx).location_for_path(&path_key, cx) { self.editor.update(cx, |editor, cx| { editor.rhs_editor().update(cx, |editor, cx| { editor.change_selections( SelectionEffects::scroll(Autoscroll::focused()), window, cx, |s| { s.select_ranges([position..position]); }, ) }) }); } else { self.pending_scroll = Some(path_key); } } pub(crate) fn autoscroll(&self, cx: &mut Context) { self.editor.update(cx, |editor, cx| { editor.rhs_editor().update(cx, |editor, cx| { editor.request_autoscroll(Autoscroll::fit(), cx); }) }) } pub(crate) fn calculate_changed_lines(&self, cx: &App) -> (u32, u32) { self.multibuffer.read(cx).snapshot(cx).total_changed_lines() } /// Returns the total count of review comments across all hunks/files. pub(crate) fn total_review_comment_count(&self) -> usize { self.review_comment_count } /// Returns a reference to the splittable editor. pub(crate) fn editor(&self) -> &Entity { &self.editor } pub(crate) fn selected_ranges( &self, cx: &App, ) -> (bool, Vec>) { let editor = self.editor.read(cx).rhs_editor().read(cx); let snapshot = self.multibuffer.read(cx).snapshot(cx); let mut selection = true; let mut ranges = editor .selections .disjoint_anchor_ranges() .collect::>(); if !ranges.iter().any(|range| range.start != range.end) { selection = false; let anchor = editor.selections.newest_anchor().head(); if let Some((_, excerpt_range)) = snapshot.excerpt_containing(anchor..anchor) && let Some(range) = snapshot .anchor_in_buffer(excerpt_range.context.start) .zip(snapshot.anchor_in_buffer(excerpt_range.context.end)) .map(|(start, end)| start..end) { ranges = vec![range]; } else { ranges = Vec::default(); }; } (selection, ranges) } /// Ranges for a toolbar stage/unstage action: the selection, or the cursor /// (a zero-width range that resolves to the single hunk under it) when /// there is no selection. Unlike [`Self::selected_ranges`], this never /// widens to the whole excerpt, so actions affect one hunk at a time. fn hunk_action_ranges(&self, cx: &App) -> Vec> { self.editor .read(cx) .rhs_editor() .read(cx) .selections .disjoint_anchor_ranges() .collect() } pub(crate) fn stage_or_unstage_selected_hunks( &mut self, stage: bool, move_to_next: bool, window: &mut Window, cx: &mut Context, ) { let editor = self.editor.read(cx).rhs_editor().clone(); let ranges = self.hunk_action_ranges(cx); // Route through the editor's delegated stage path, the same path taken // by the hunk buttons (on either side of a split) and the keyboard. // For staging, dirty buffers are saved first, exactly as they are when // staging from the uncommitted diff or a normal editor. editor.update(cx, |editor, cx| { editor.stage_or_unstage_diff_hunks(stage, ranges, window, cx); }); if move_to_next { editor .focus_handle(cx) .dispatch_action(&GoToHunk, window, cx); } } pub(crate) fn restore_selected_hunks( &mut self, move_to_next: bool, window: &mut Window, cx: &mut Context, ) { let editor = self.editor.read(cx).rhs_editor().clone(); let ranges = self.hunk_action_ranges(cx); editor.update(cx, |editor, cx| { let snapshot = editor.buffer().read(cx).snapshot(cx); let hunks: Vec<_> = editor.diff_hunks_in_ranges(&ranges, &snapshot).collect(); if !hunks.is_empty() { editor.apply_restore(hunks, window, cx); } }); if move_to_next { editor .focus_handle(cx) .dispatch_action(&GoToHunk, window, cx); } } fn handle_editor_event( &mut self, editor: &Entity, event: &EditorEvent, window: &mut Window, cx: &mut Context, ) { match event { EditorEvent::SelectionsChanged { local: true } => { // Only follow the git panel selection from the view the user is // actually interacting with. Background (non-active) diff views // refresh on their own and must not hijack the panel selection. if !editor.focus_handle(cx).contains_focused(window, cx) { return; } cx.emit(event.clone()); let Some(project_path) = self.active_project_path(cx) else { return; }; self.workspace .update(cx, |workspace, cx| { if let Some(git_panel) = workspace.panel::(cx) { git_panel.update(cx, |git_panel, cx| { git_panel.select_entry_by_path(project_path, window, cx) }) } }) .ok(); } EditorEvent::Saved => { self._task = cx.spawn_in(window, async move |this, cx| Self::refresh(this, cx).await); } _ => {} } if editor.focus_handle(cx).contains_focused(window, cx) && self.multibuffer.read(cx).is_empty() { self.focus_handle.focus(window, cx) } } #[instrument(skip_all)] fn register_buffer( &mut self, repo_path: RepoPath, path_key: PathKey, file_status: FileStatus, display_buffer: Entity, main_buffer: Entity, diff: Entity, conflict_set: Option>, window: &mut Window, cx: &mut Context, ) -> Option { let diff_subscription = cx.subscribe_in(&diff, window, { let repo_path = repo_path.clone(); let path_key = path_key.clone(); let display_buffer = display_buffer.clone(); let main_buffer = main_buffer.clone(); let diff = diff.clone(); let conflict_set = conflict_set.clone(); move |this, _, event, window, cx| match event { buffer_diff::BufferDiffEvent::DiffChanged(_) => { this.buffer_ranges_changed( repo_path.clone(), path_key.clone(), file_status, display_buffer.clone(), main_buffer.clone(), diff.clone(), conflict_set.clone(), window, cx, ); } buffer_diff::BufferDiffEvent::BaseTextChanged => {} } }); let conflict_set_subscription = conflict_set.as_ref().map(|conflict_set| { cx.subscribe_in(conflict_set, window, { let repo_path = repo_path.clone(); let path_key = path_key.clone(); let display_buffer = display_buffer.clone(); let main_buffer = main_buffer.clone(); let diff = diff.clone(); let conflict_set = Some(conflict_set.clone()); move |this, _, _, window, cx| { this.buffer_ranges_changed( repo_path.clone(), path_key.clone(), file_status, display_buffer.clone(), main_buffer.clone(), diff.clone(), conflict_set.clone(), window, cx, ) } }) }); self.buffer_subscriptions.insert( repo_path, BufferSubscriptions { _diff: diff.clone(), display_buffer: display_buffer.clone(), _diff_subscription: diff_subscription, _conflict_set: conflict_set.clone(), _conflict_set_subscription: conflict_set_subscription, }, ); let snapshot = display_buffer.read(cx).snapshot(); let diff_snapshot = diff.read(cx).snapshot(cx); let excerpt_ranges = { let diff_hunk_ranges = diff_snapshot .hunks_intersecting_range( Anchor::min_max_range_for_buffer(snapshot.remote_id()), &snapshot, ) .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot)); let conflict_ranges = conflict_set.as_ref().and_then(|conflict_set| { let conflicts = conflict_set.read(cx).snapshot(); let conflicts = conflicts .conflicts .iter() .map(|conflict| conflict.range.to_point(&snapshot)) .collect::>(); (!conflicts.is_empty()).then_some(conflicts) }); conflict_ranges.unwrap_or_else(|| diff_hunk_ranges.collect()) }; let buffer_id = snapshot.text.remote_id(); let mut needs_fold = false; let (was_empty, is_excerpt_newly_added) = self.editor.update(cx, |editor, cx| { let was_empty = editor.rhs_editor().read(cx).buffer().read(cx).is_empty(); let is_newly_added = editor.update_excerpts_for_path( path_key.clone(), display_buffer, excerpt_ranges, multibuffer_context_lines(cx), diff, cx, ); if let Some(conflict_set) = conflict_set { editor.rhs_editor().update(cx, |editor, cx| { conflict_view::buffer_ranges_updated(editor, conflict_set, cx); }); } (was_empty, is_newly_added) }); self.editor.update(cx, |editor, cx| { editor.rhs_editor().update(cx, |editor, cx| { if was_empty { editor.change_selections( SelectionEffects::no_scroll(), window, cx, |selections| { selections.select_ranges([ multi_buffer::Anchor::Min..multi_buffer::Anchor::Min ]) }, ); } if is_excerpt_newly_added && (file_status.is_deleted() || (file_status.is_untracked() && GitPanelSettings::get_global(cx).collapse_untracked_diff)) { needs_fold = true; } }) }); if self.multibuffer.read(cx).is_empty() && self .editor .read(cx) .focus_handle(cx) .contains_focused(window, cx) { self.focus_handle.focus(window, cx); } else if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() { self.editor.update(cx, |editor, cx| { editor.focus_handle(cx).focus(window, cx); }); } if self.pending_scroll.as_ref() == Some(&path_key) { self.move_to_path(path_key, window, cx); } needs_fold.then_some(buffer_id) } fn buffer_ranges_changed( &mut self, repo_path: RepoPath, path_key: PathKey, file_status: FileStatus, display_buffer: Entity, main_buffer: Entity, diff: Entity, conflict_set: Option>, window: &mut Window, cx: &mut Context, ) { if display_buffer.read(cx).is_dirty() { return; } self.register_buffer( repo_path, path_key, file_status, display_buffer, main_buffer, diff, conflict_set, window, cx, ); } #[instrument(skip(this, cx))] pub(crate) async fn refresh(this: WeakEntity, cx: &mut AsyncWindowContext) -> Result<()> { let entries = this.update(cx, |this, cx| { let (repo, buffers_to_load) = this.branch_diff.update(cx, |branch_diff, cx| { let load_buffers = branch_diff.load_buffers(cx); (branch_diff.repo().cloned(), load_buffers) }); let mut previous_paths = this .multibuffer .read(cx) .snapshot(cx) .buffers_with_paths() .map(|(buffer_snapshot, path_key)| (path_key.clone(), buffer_snapshot.remote_id())) .collect::>(); let mut entries = BTreeMap::new(); let mut live_repo_paths = HashSet::default(); if let Some(repo) = repo { let repo = repo.read(cx); for diff_buffer in buffers_to_load { live_repo_paths.insert(diff_buffer.repo_path.clone()); let path_key = project_diff_path_key( &repo, &diff_buffer.repo_path, diff_buffer.file_status, cx, ); previous_paths.remove(&path_key); entries.insert(path_key, diff_buffer); } } let repo_path_by_display_id = this .buffer_subscriptions .iter() .map(|(repo_path, sub)| { (sub.display_buffer.read(cx).remote_id(), repo_path.clone()) }) .collect::>(); this.editor.update(cx, |editor, cx| { for (path, buffer_id) in previous_paths { if let Some(repo_path) = repo_path_by_display_id.get(&buffer_id) { this.buffer_subscriptions.remove(repo_path); } editor.rhs_editor().update(cx, |editor, cx| { conflict_view::buffers_removed(editor, &[buffer_id], cx); }); let _span = ztracing::info_span!("remove_excerpts_for_path"); _span.enter(); editor.remove_excerpts_for_path(path, cx); } }); this.buffer_subscriptions .retain(|repo_path, _| live_repo_paths.contains(repo_path)); entries })?; let mut buffers_to_fold = Vec::new(); for (path_key, entry) in entries { if let Some(loaded_buffer) = entry.load.await.log_err() { // We might be lagging behind enough that all future entry.load futures are no longer pending. // If that is the case, this task will never yield, starving the foreground thread of execution time. yield_now().await; cx.update(|window, cx| { this.update(cx, |this, cx| { if let Some(buffer_id) = this.register_buffer( entry.repo_path, path_key, entry.file_status, loaded_buffer.display_buffer, loaded_buffer.main_buffer, loaded_buffer.diff, loaded_buffer.conflict_set, window, cx, ) { buffers_to_fold.push(buffer_id); } }) .ok(); })?; } } this.update(cx, |this, cx| { if !buffers_to_fold.is_empty() { this.editor.update(cx, |editor, cx| { editor .rhs_editor() .update(cx, |editor, cx| editor.fold_buffers(buffers_to_fold, cx)); }); } this.pending_scroll.take(); cx.notify(); })?; Ok(()) } pub(crate) fn active_project_path(&self, cx: &App) -> Option { let editor = self.editor.read(cx).focused_editor().read(cx); let focused_multibuffer = editor.buffer().read(cx); let position = editor.selections.newest_anchor().head(); let focused_snapshot = focused_multibuffer.snapshot(cx); let (focused_anchor, _) = focused_snapshot.anchor_to_buffer_anchor(position)?; let path = focused_snapshot.path_for_buffer(focused_anchor.buffer_id)?; let multibuffer = self.multibuffer.read(cx); let position = multibuffer.location_for_path(path, cx)?; let snapshot = multibuffer.snapshot(cx); let (text_anchor, _) = snapshot.anchor_to_buffer_anchor(position)?; let buffer = multibuffer.buffer(text_anchor.buffer_id)?; let file = buffer.read(cx).file()?; Some(ProjectPath { worktree_id: file.worktree_id(cx), path: file.path().clone(), }) } pub(crate) fn added_to_workspace( &mut self, workspace: &mut Workspace, window: &mut Window, cx: &mut Context, ) { self.editor.update(cx, |editor, cx| { editor.added_to_workspace(workspace, window, cx) }); } pub(crate) fn deactivated(&mut self, window: &mut Window, cx: &mut Context) { self.editor.update(cx, |editor, cx| { editor.rhs_editor().update(cx, |primary_editor, cx| { primary_editor.deactivated(window, cx); }) }); } pub(crate) fn navigate( &mut self, data: Arc, window: &mut Window, cx: &mut Context, ) -> bool { self.editor.update(cx, |editor, cx| { editor.rhs_editor().update(cx, |primary_editor, cx| { primary_editor.navigate(data, window, cx) }) }) } pub(crate) fn set_nav_history(&mut self, nav_history: ItemNavHistory, cx: &mut Context) { self.editor.update(cx, |editor, cx| { editor.rhs_editor().update(cx, |primary_editor, _| { primary_editor.set_nav_history(Some(nav_history)); }) }); } pub(crate) fn for_each_project_item( &self, cx: &App, f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem), ) { self.editor .read(cx) .rhs_editor() .read(cx) .for_each_project_item(cx, f) } pub(crate) fn save( &mut self, options: SaveOptions, project: Entity, window: &mut Window, cx: &mut Context, ) -> Task> { self.editor.update(cx, |editor, cx| { editor.rhs_editor().update(cx, |primary_editor, cx| { primary_editor.save(options, project, window, cx) }) }) } pub(crate) fn reload( &mut self, project: Entity, window: &mut Window, cx: &mut Context, ) -> Task> { self.editor.update(cx, |editor, cx| { editor.rhs_editor().update(cx, |primary_editor, cx| { primary_editor.reload(project, window, cx) }) }) } #[cfg(any(test, feature = "test-support"))] pub fn excerpt_paths(&self, cx: &App) -> Vec> { let snapshot = self .editor() .read(cx) .rhs_editor() .read(cx) .buffer() .read(cx) .snapshot(cx); snapshot .excerpts() .map(|excerpt| { snapshot .path_for_buffer(excerpt.context.start.buffer_id) .unwrap() .path .clone() }) .collect() } /// Returns the real (worktree-relative) path of each excerpted buffer, in /// the order the excerpts appear in the multibuffer. Unlike /// [`Self::excerpt_paths`], this resolves the buffer's actual `File` rather /// than the (possibly synthetic) `PathKey` path used for sorting. #[cfg(any(test, feature = "test-support"))] pub fn excerpt_file_paths(&self, cx: &App) -> Vec { let multibuffer = self .editor() .read(cx) .rhs_editor() .read(cx) .buffer() .clone(); let snapshot = multibuffer.read(cx).snapshot(cx); let mut result = Vec::new(); let mut last_buffer_id = None; for excerpt in snapshot.excerpts() { let buffer_id = excerpt.context.start.buffer_id; if last_buffer_id == Some(buffer_id) { continue; } last_buffer_id = Some(buffer_id); if let Some(buffer) = multibuffer.read(cx).buffer(buffer_id) && let Some(file) = buffer.read(cx).file() { result.push(file.path().as_unix_str().to_string()); } } result } } impl EventEmitter for DiffMultibuffer {} impl Focusable for DiffMultibuffer { fn focus_handle(&self, cx: &App) -> FocusHandle { if self.multibuffer.read(cx).is_empty() { self.focus_handle.clone() } else { self.editor.focus_handle(cx) } } } impl Render for DiffMultibuffer { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let is_empty = self.multibuffer.read(cx).is_empty(); let is_loading = self.branch_diff.read(cx).is_tree_base_loading() || !self._task.is_ready(); let empty_label = self.empty_label.clone(); div() .track_focus(&self.focus_handle) .key_context(if is_empty { "EmptyPane" } else { "GitDiff" }) .bg(cx.theme().colors().editor_background) .flex() .items_center() .justify_center() .size_full() .when(is_empty && is_loading, |el| { let rems = TextSize::Large.rems(cx); el.child( Icon::new(IconName::LoadCircle) .size(IconSize::Custom(rems)) .color(Color::Accent) .with_rotate_animation(3) .into_any_element(), ) }) .when(is_empty && !is_loading, |el| { let remote_button = if let Some(panel) = self .workspace .upgrade() .and_then(|workspace| workspace.read(cx).panel::(cx)) { panel.update(cx, |panel, cx| panel.render_remote_button(cx)) } else { None }; let keybinding_focus_handle = self.focus_handle(cx); el.child( v_flex() .gap_1() .child(h_flex().justify_around().child(Label::new(empty_label))) .map(|el| match remote_button { Some(button) => el.child(h_flex().justify_around().child(button)), None => el.child( h_flex() .justify_around() .child(Label::new("Remote up to date")), ), }) .child( h_flex().justify_around().mt_1().child( Button::new("project-diff-close-button", "Close") .key_binding(KeyBinding::for_action_in( &CloseActiveItem::default(), &keybinding_focus_handle, cx, )) .on_click(move |_, window, cx| { window.focus(&keybinding_focus_handle, cx); window.dispatch_action( Box::new(CloseActiveItem::default()), cx, ); }), ), ), ) }) .when(!is_empty, |el| el.child(self.editor.clone())) } } const CONFLICT_SORT_PREFIX: u64 = 1; const TRACKED_SORT_PREFIX: u64 = 2; const NEW_SORT_PREFIX: u64 = 3; /// Computes a stable [`PathKey`] for a buffer in the project diff. /// /// The key is an intrinsic function of the file's own repo path and status; it /// never depends on which other buffers happen to be present in the /// multibuffer. This is required because the multibuffer uses the path key both /// to order excerpts and to identify which excerpts belong to a given buffer, so /// a key that shifted as files were added or removed would break that identity. /// /// Status grouping is encoded in the `sort_prefix`, and the within-group order /// is encoded in the (possibly synthetic) path so that `PathKey`'s natural /// ordering reproduces the git panel's order. The path here is only ever used /// for sorting and multibuffer identity; the path shown in the UI comes from the /// buffer's own `File`. pub(crate) fn project_diff_path_key( repo: &Repository, repo_path: &RepoPath, status: FileStatus, cx: &App, ) -> PathKey { let settings = GitPanelSettings::get_global(cx); let sort_prefix = if settings.group_by != GitPanelGroupBy::Status { TRACKED_SORT_PREFIX } else if repo.had_conflict_on_last_merge_head_change(repo_path) { CONFLICT_SORT_PREFIX } else if status.is_created() { NEW_SORT_PREFIX } else { TRACKED_SORT_PREFIX }; let path = project_diff_sort_path(repo_path, settings.tree_view, settings.sort_by); PathKey::with_sort_prefix(sort_prefix, path) } fn project_diff_sort_path( repo_path: &RelPath, tree_view: bool, sort_by: GitPanelSortBy, ) -> Arc { if tree_view { tree_sort_path(repo_path) } else { match sort_by { GitPanelSortBy::Path => repo_path.into_arc(), GitPanelSortBy::Name => name_sort_path(repo_path), } } } /// Builds a synthetic path that sorts by file name first, falling back to the /// full path to keep the key unique per file. fn name_sort_path(repo_path: &RelPath) -> Arc { let Some(file_name) = repo_path.file_name() else { return repo_path.into_arc(); }; let synthetic = format!("{}/{}", file_name, repo_path.as_unix_str()); RelPath::from_unix_str(&synthetic) .map(|path| path.into_arc()) .unwrap_or_else(|_| repo_path.into_arc()) } /// Builds a synthetic path whose natural component-wise ordering reproduces a /// folder-first tree order. Each directory component is prefixed with a NUL /// byte, which can never appear in a real path component and sorts before every /// printable character, so at each level directories sort before files. fn tree_sort_path(repo_path: &RelPath) -> Arc { let components: Vec<&str> = repo_path.components().collect(); if components.len() <= 1 { return repo_path.into_arc(); } let last = components.len() - 1; let mut synthetic = String::new(); for (index, component) in components.into_iter().enumerate() { if index > 0 { synthetic.push('/'); } if index < last { synthetic.push('\0'); } synthetic.push_str(component); } RelPath::from_unix_str(&synthetic) .map(|path| path.into_arc()) .unwrap_or_else(|_| repo_path.into_arc()) }