Closes https://github.com/zed-industries/zed/issues/62909 Closes https://github.com/zed-industries/zed/issues/57396 Release Notes: - Fixed project panel reveal not working in git diff-related multi buffers
1045 lines
38 KiB
Rust
1045 lines
38 KiB
Rust
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<BufferDiff>,
|
|
display_buffer: Entity<Buffer>,
|
|
_diff_subscription: Subscription,
|
|
_conflict_set: Option<Entity<ConflictSet>>,
|
|
_conflict_set_subscription: Option<Subscription>,
|
|
}
|
|
|
|
pub struct DiffMultibuffer {
|
|
multibuffer: Entity<MultiBuffer>,
|
|
branch_diff: Entity<diff_buffer_list::DiffBufferList>,
|
|
editor: Entity<SplittableEditor>,
|
|
buffer_subscriptions: HashMap<RepoPath, BufferSubscriptions>,
|
|
workspace: WeakEntity<Workspace>,
|
|
focus_handle: FocusHandle,
|
|
pending_scroll: Option<PathKey>,
|
|
review_comment_count: usize,
|
|
empty_label: SharedString,
|
|
_task: Task<Result<()>>,
|
|
_subscription: Subscription,
|
|
}
|
|
|
|
impl DiffMultibuffer {
|
|
pub(crate) fn new(
|
|
branch_diff: Entity<diff_buffer_list::DiffBufferList>,
|
|
multibuffer_capability: Capability,
|
|
empty_label: impl Into<SharedString>,
|
|
configure_editor: impl FnOnce(&mut SplittableEditor, &mut Context<SplittableEditor>) + 'static,
|
|
project: Entity<Project>,
|
|
workspace: Entity<Workspace>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> 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::<SettingsStore>(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<diff_buffer_list::DiffBufferList> {
|
|
&self.branch_diff
|
|
}
|
|
|
|
pub(crate) fn repo(&self, cx: &App) -> Option<Entity<Repository>> {
|
|
self.branch_diff.read(cx).repo().cloned()
|
|
}
|
|
|
|
pub(crate) fn set_repo(&mut self, repo: Option<Entity<Repository>>, cx: &mut Context<Self>) {
|
|
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<MultiBuffer> {
|
|
&self.multibuffer
|
|
}
|
|
|
|
pub(crate) fn move_to_entry(
|
|
&mut self,
|
|
entry: GitStatusEntry,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
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<Self>,
|
|
) {
|
|
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>) {
|
|
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<Self>,
|
|
) {
|
|
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>) {
|
|
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<SplittableEditor> {
|
|
&self.editor
|
|
}
|
|
|
|
pub(crate) fn selected_ranges(
|
|
&self,
|
|
cx: &App,
|
|
) -> (bool, Vec<std::ops::Range<multi_buffer::Anchor>>) {
|
|
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::<Vec<_>>();
|
|
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<std::ops::Range<multi_buffer::Anchor>> {
|
|
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<Self>,
|
|
) {
|
|
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<Self>,
|
|
) {
|
|
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<SplittableEditor>,
|
|
event: &EditorEvent,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
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::<GitPanel>(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<Buffer>,
|
|
main_buffer: Entity<Buffer>,
|
|
diff: Entity<BufferDiff>,
|
|
conflict_set: Option<Entity<ConflictSet>>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Option<BufferId> {
|
|
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::<Vec<_>>();
|
|
(!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<Buffer>,
|
|
main_buffer: Entity<Buffer>,
|
|
diff: Entity<BufferDiff>,
|
|
conflict_set: Option<Entity<ConflictSet>>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
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<Self>, 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::<HashMap<_, _>>();
|
|
|
|
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::<HashMap<_, _>>();
|
|
|
|
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<ProjectPath> {
|
|
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>,
|
|
) {
|
|
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>) {
|
|
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<dyn std::any::Any + Send>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> 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>) {
|
|
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<Project>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Task<Result<()>> {
|
|
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<Project>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Task<Result<()>> {
|
|
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<std::sync::Arc<util::rel_path::RelPath>> {
|
|
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<String> {
|
|
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<EditorEvent> 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<Self>) -> 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::<GitPanel>(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<RelPath> {
|
|
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<RelPath> {
|
|
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<RelPath> {
|
|
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())
|
|
}
|