Basic side-by-side diff implementation (#43586)

Release Notes:

- N/A

---------

Co-authored-by: cameron <cameron.studdstreet@gmail.com>
Co-authored-by: Cameron <cameron@zed.dev>
This commit is contained in:
Cole Miller
2025-11-30 22:45:01 -05:00
committed by GitHub
co-authored by cameron Cameron
parent ca6e64d451
commit 2e00f40c54
29 changed files with 1915 additions and 518 deletions
+137 -88
View File
@@ -8,7 +8,7 @@ use anyhow::{Context as _, Result, anyhow};
use buffer_diff::{BufferDiff, DiffHunkSecondaryStatus};
use collections::{HashMap, HashSet};
use editor::{
Addon, Editor, EditorEvent, SelectionEffects,
Addon, Editor, EditorEvent, SelectionEffects, SplittableEditor,
actions::{GoToHunk, GoToPreviousHunk},
multibuffer_context_lines,
scroll::Autoscroll,
@@ -56,7 +56,8 @@ actions!(
Add,
/// Shows the diff between the working directory and your default
/// branch (typically main or master).
BranchDiff
BranchDiff,
LeaderAndFollower,
]
);
@@ -64,7 +65,7 @@ pub struct ProjectDiff {
project: Entity<Project>,
multibuffer: Entity<MultiBuffer>,
branch_diff: Entity<branch_diff::BranchDiff>,
editor: Entity<Editor>,
editor: Entity<SplittableEditor>,
buffer_diff_subscriptions: HashMap<Arc<RelPath>, (Entity<BufferDiff>, Subscription)>,
workspace: WeakEntity<Workspace>,
focus_handle: FocusHandle,
@@ -172,7 +173,9 @@ impl ProjectDiff {
pub fn autoscroll(&self, cx: &mut Context<Self>) {
self.editor.update(cx, |editor, cx| {
editor.request_autoscroll(Autoscroll::fit(), cx);
editor.primary_editor().update(cx, |editor, cx| {
editor.request_autoscroll(Autoscroll::fit(), cx);
})
})
}
@@ -226,44 +229,44 @@ impl ProjectDiff {
cx: &mut Context<Self>,
) -> Self {
let focus_handle = cx.focus_handle();
let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite));
let multibuffer = cx.new(|cx| {
let mut multibuffer = MultiBuffer::new(Capability::ReadWrite);
multibuffer.set_all_diff_hunks_expanded(cx);
multibuffer
});
let editor = cx.new(|cx| {
let mut diff_display_editor =
Editor::for_multibuffer(multibuffer.clone(), Some(project.clone()), window, cx);
diff_display_editor.disable_diagnostics(cx);
diff_display_editor.set_expand_all_diff_hunks(cx);
match branch_diff.read(cx).diff_base() {
DiffBase::Head => {
diff_display_editor.register_addon(GitPanelAddon {
workspace: workspace.downgrade(),
});
}
DiffBase::Merge { .. } => {
diff_display_editor.register_addon(BranchDiffAddon {
branch_diff: branch_diff.clone(),
});
diff_display_editor.start_temporary_diff_override();
diff_display_editor.set_render_diff_hunk_controls(
Arc::new(|_, _, _, _, _, _, _, _| gpui::Empty.into_any_element()),
cx,
);
//
}
}
let diff_display_editor = SplittableEditor::new_unsplit(
multibuffer.clone(),
project.clone(),
workspace.clone(),
window,
cx,
);
diff_display_editor
});
window.defer(cx, {
let workspace = workspace.clone();
let editor = editor.clone();
move |window, cx| {
workspace.update(cx, |workspace, cx| {
editor.update(cx, |editor, cx| {
editor.added_to_workspace(workspace, window, cx);
})
.primary_editor()
.update(cx, |editor, cx| {
editor.disable_diagnostics(cx);
match branch_diff.read(cx).diff_base() {
DiffBase::Head => {
editor.register_addon(GitPanelAddon {
workspace: workspace.downgrade(),
});
}
DiffBase::Merge { .. } => {
editor.register_addon(BranchDiffAddon {
branch_diff: branch_diff.clone(),
});
editor.start_temporary_diff_override();
editor.set_render_diff_hunk_controls(
Arc::new(|_, _, _, _, _, _, _, _| gpui::Empty.into_any_element()),
cx,
);
}
}
});
}
diff_display_editor
});
cx.subscribe_in(&editor, window, Self::handle_editor_event)
.detach();
@@ -343,7 +346,7 @@ impl ProjectDiff {
}
pub fn active_path(&self, cx: &App) -> Option<ProjectPath> {
let editor = self.editor.read(cx);
let editor = self.editor.read(cx).last_selected_editor().read(cx);
let position = editor.selections.newest_anchor().head();
let multi_buffer = editor.buffer().read(cx);
let (_, buffer, _) = multi_buffer.excerpt_containing(position, cx)?;
@@ -358,14 +361,16 @@ impl ProjectDiff {
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.change_selections(
SelectionEffects::scroll(Autoscroll::focused()),
window,
cx,
|s| {
s.select_ranges([position..position]);
},
)
editor.primary_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);
@@ -373,7 +378,7 @@ impl ProjectDiff {
}
fn button_states(&self, cx: &App) -> ButtonStates {
let editor = self.editor.read(cx);
let editor = self.editor.read(cx).primary_editor().read(cx);
let snapshot = self.multibuffer.read(cx).snapshot(cx);
let prev_next = snapshot.diff_hunks().nth(1).is_some();
let mut selection = true;
@@ -384,7 +389,13 @@ impl ProjectDiff {
.collect::<Vec<_>>();
if !ranges.iter().any(|range| range.start != range.end) {
selection = false;
if let Some((excerpt_id, _, range)) = self.editor.read(cx).active_excerpt(cx) {
if let Some((excerpt_id, _, range)) = self
.editor
.read(cx)
.primary_editor()
.read(cx)
.active_excerpt(cx)
{
ranges = vec![multi_buffer::Anchor::range_in_buffer(excerpt_id, range)];
} else {
ranges = Vec::default();
@@ -432,7 +443,7 @@ impl ProjectDiff {
fn handle_editor_event(
&mut self,
editor: &Entity<Editor>,
editor: &Entity<SplittableEditor>,
event: &EditorEvent,
window: &mut Window,
cx: &mut Context<Self>,
@@ -476,9 +487,12 @@ impl ProjectDiff {
self.buffer_diff_subscriptions
.insert(path_key.path.clone(), (diff.clone(), subscription));
// TODO(split-diff) we shouldn't have a conflict addon when split
let conflict_addon = self
.editor
.read(cx)
.primary_editor()
.read(cx)
.addon::<ConflictAddon>()
.expect("project diff editor should have a conflict addon");
@@ -518,20 +532,27 @@ impl ProjectDiff {
});
self.editor.update(cx, |editor, cx| {
if was_empty {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| {
// TODO select the very beginning (possibly inside a deletion)
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))
{
editor.fold_buffer(snapshot.text.remote_id(), cx)
}
editor.primary_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))
{
editor.fold_buffer(snapshot.text.remote_id(), cx)
}
})
});
if self.multibuffer.read(cx).is_empty()
@@ -650,8 +671,11 @@ impl Item for ProjectDiff {
}
fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.editor
.update(cx, |editor, cx| editor.deactivated(window, cx));
self.editor.update(cx, |editor, cx| {
editor.primary_editor().update(cx, |primary_editor, cx| {
primary_editor.deactivated(window, cx);
})
});
}
fn navigate(
@@ -660,8 +684,11 @@ impl Item for ProjectDiff {
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
self.editor
.update(cx, |editor, cx| editor.navigate(data, window, cx))
self.editor.update(cx, |editor, cx| {
editor.primary_editor().update(cx, |primary_editor, cx| {
primary_editor.navigate(data, window, cx)
})
})
}
fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
@@ -689,8 +716,9 @@ impl Item for ProjectDiff {
Some("Project Diff Opened")
}
fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
Some(Box::new(self.editor.clone()))
fn as_searchable(&self, _: &Entity<Self>, cx: &App) -> Option<Box<dyn SearchableItemHandle>> {
// TODO(split-diff) SplitEditor should be searchable
Some(Box::new(self.editor.read(cx).primary_editor().clone()))
}
fn for_each_project_item(
@@ -698,7 +726,11 @@ impl Item for ProjectDiff {
cx: &App,
f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
) {
self.editor.for_each_project_item(cx, f)
self.editor
.read(cx)
.primary_editor()
.read(cx)
.for_each_project_item(cx, f)
}
fn set_nav_history(
@@ -707,8 +739,10 @@ impl Item for ProjectDiff {
_: &mut Window,
cx: &mut Context<Self>,
) {
self.editor.update(cx, |editor, _| {
editor.set_nav_history(Some(nav_history));
self.editor.update(cx, |editor, cx| {
editor.primary_editor().update(cx, |primary_editor, _| {
primary_editor.set_nav_history(Some(nav_history));
})
});
}
@@ -752,7 +786,11 @@ impl Item for ProjectDiff {
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
self.editor.save(options, project, window, cx)
self.editor.update(cx, |editor, cx| {
editor.primary_editor().update(cx, |primary_editor, cx| {
primary_editor.save(options, project, window, cx)
})
})
}
fn save_as(
@@ -771,19 +809,23 @@ impl Item for ProjectDiff {
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
self.editor.reload(project, window, cx)
self.editor.update(cx, |editor, cx| {
editor.primary_editor().update(cx, |primary_editor, cx| {
primary_editor.reload(project, window, cx)
})
})
}
fn act_as_type<'a>(
&'a self,
type_id: TypeId,
self_handle: &'a Entity<Self>,
_: &'a App,
cx: &'a App,
) -> Option<gpui::AnyEntity> {
if type_id == TypeId::of::<Self>() {
Some(self_handle.clone().into())
} else if type_id == TypeId::of::<Editor>() {
Some(self.editor.clone().into())
Some(self.editor.read(cx).primary_editor().clone().into())
} else {
None
}
@@ -794,7 +836,11 @@ impl Item for ProjectDiff {
}
fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
self.editor.breadcrumbs(theme, cx)
self.editor
.read(cx)
.last_selected_editor()
.read(cx)
.breadcrumbs(theme, cx)
}
fn added_to_workspace(
@@ -1629,7 +1675,7 @@ mod tests {
);
cx.run_until_parked();
let editor = diff.read_with(cx, |diff, _| diff.editor.clone());
let editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).primary_editor().clone());
assert_state_with_diff(
&editor,
cx,
@@ -1685,7 +1731,7 @@ mod tests {
window,
cx,
);
diff.editor.clone()
diff.editor.read(cx).primary_editor().clone()
});
assert_state_with_diff(
&editor,
@@ -1706,7 +1752,7 @@ mod tests {
window,
cx,
);
diff.editor.clone()
diff.editor.read(cx).primary_editor().clone()
});
assert_state_with_diff(
&editor,
@@ -1759,7 +1805,8 @@ mod tests {
);
cx.run_until_parked();
let diff_editor = diff.read_with(cx, |diff, _| diff.editor.clone());
let diff_editor =
diff.read_with(cx, |diff, cx| diff.editor.read(cx).primary_editor().clone());
assert_state_with_diff(
&diff_editor,
@@ -1883,7 +1930,7 @@ mod tests {
workspace.active_item_as::<ProjectDiff>(cx).unwrap()
});
cx.focus(&item);
let editor = item.read_with(cx, |item, _| item.editor.clone());
let editor = item.read_with(cx, |item, cx| item.editor.read(cx).primary_editor().clone());
let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
@@ -1997,7 +2044,7 @@ mod tests {
workspace.active_item_as::<ProjectDiff>(cx).unwrap()
});
cx.focus(&item);
let editor = item.read_with(cx, |item, _| item.editor.clone());
let editor = item.read_with(cx, |item, cx| item.editor.read(cx).primary_editor().clone());
let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
@@ -2044,7 +2091,7 @@ mod tests {
cx.run_until_parked();
cx.update(|window, cx| {
let editor = diff.read(cx).editor.clone();
let editor = diff.read(cx).editor.read(cx).primary_editor().clone();
let excerpt_ids = editor.read(cx).buffer().read(cx).excerpt_ids();
assert_eq!(excerpt_ids.len(), 1);
let excerpt_id = excerpt_ids[0];
@@ -2061,6 +2108,8 @@ mod tests {
.read(cx)
.editor
.read(cx)
.primary_editor()
.read(cx)
.addon::<ConflictAddon>()
.unwrap()
.conflict_set(buffer_id)
@@ -2144,7 +2193,7 @@ mod tests {
);
cx.run_until_parked();
let editor = diff.read_with(cx, |diff, _| diff.editor.clone());
let editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).primary_editor().clone());
assert_state_with_diff(
&editor,
@@ -2255,7 +2304,7 @@ mod tests {
);
cx.run_until_parked();
let editor = diff.read_with(cx, |diff, _| diff.editor.clone());
let editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).primary_editor().clone());
assert_state_with_diff(
&editor,
@@ -2349,7 +2398,7 @@ mod tests {
workspace.active_item_as::<ProjectDiff>(cx).unwrap()
});
cx.focus(&item);
let editor = item.read_with(cx, |item, _| item.editor.clone());
let editor = item.read_with(cx, |item, cx| item.editor.read(cx).primary_editor().clone());
fs.set_head_and_index_for_repo(
Path::new(path!("/project/.git")),