Files
zed/crates/git_ui/src/branch_diff.rs
T
Ben Kunkle aecaacdb44 git: Move diff hunk operations onto buffer diffs (#63556)
# Objective

Keep Git-specific diff-base knowledge in `GitStore`, where Git
operations are defined.

Related to FR-135.

## Solution

Give each `BufferDiff` its supported hunk operations. Keep editor hooks
limited to rendering custom controls.

## Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content adheres to Zed's UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and
[icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

---

Release Notes:

- N/A
2026-09-01 15:52:33 +00:00

1273 lines
44 KiB
Rust

use crate::{
branch_picker,
diff_multibuffer::DiffMultibuffer,
project_diff::{
self, CompareWithBranch, DeployBranchDiff, ProjectDiff, ReviewDiff,
render_send_review_to_agent_button,
},
};
use agent_settings::AgentSettings;
use anyhow::{Context as _, Result, anyhow};
use editor::{
Addon, Editor, EditorEvent, HiddenDiffHunkRenderer, SplittableEditor,
actions::SendReviewToAgent,
};
use git::{repository::DiffType, status::FileStatus};
use gpui::{
Action, App, AppContext as _, Entity, EventEmitter, FocusHandle, Focusable, Render,
SharedString, Subscription, Task, WeakEntity,
};
use language::{BufferId, Capability};
use project::{
Project, ProjectPath,
git_store::{
Repository,
diff_buffer_list::{self, DiffBase},
},
};
use settings::{GitDiffBaseSetting, Settings};
use std::{
any::{Any, TypeId},
sync::Arc,
};
use ui::{DiffStat, Divider, PopoverMenu, Tooltip, prelude::*};
use workspace::{
ItemHandle, ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation,
ToolbarItemView, Workspace,
item::{Item, ItemEvent, SaveOptions},
notifications::NotifyTaskExt,
searchable::SearchableItemHandle,
};
use zed_actions::agent::ReviewBranchDiff;
/// The workspace item for a branch (merge-base) diff: "Changes since {branch}".
/// It wraps a single [`DiffMultibuffer`] over [`DiffBase::Merge`] and delegates
/// the [`Item`] surface to it. The merge base can be changed in place via the
/// [`BranchDiffToolbar`]'s branch picker, which reloads without reconfiguring
/// the editor (the merge styling is identical for every base ref).
pub struct BranchDiff {
diff: Entity<DiffMultibuffer>,
project: Entity<Project>,
workspace: WeakEntity<Workspace>,
_diff_event_subscription: Subscription,
}
struct BranchDiffAddon {
branch_diff: Entity<diff_buffer_list::DiffBufferList>,
}
impl Addon for BranchDiffAddon {
fn to_any(&self) -> &dyn std::any::Any {
self
}
fn override_status_for_buffer_id(&self, buffer_id: BufferId, cx: &App) -> Option<FileStatus> {
self.branch_diff
.read(cx)
.status_for_buffer_id(buffer_id, cx)
}
}
impl BranchDiff {
pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context<Workspace>) {
workspace.register_action(|workspace, _: &DeployBranchDiff, window, cx| {
Self::deploy_branch_diff(workspace, window, cx)
});
workspace.register_action(Self::compare_with_branch);
workspace::register_serializable_item::<Self>(cx);
}
pub(crate) fn deploy_branch_diff(
workspace: &mut Workspace,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
telemetry::event!("Git Branch Diff Opened");
let project = workspace.project().clone();
let Some(intended_repo) = project.read(cx).active_repository(cx) else {
let workspace = cx.entity().downgrade();
window
.spawn(cx, async |_cx| {
let result: Result<()> = Err(anyhow!("No active repository"));
result
})
.detach_and_notify_err(workspace, window, cx);
return;
};
let default_branch = intended_repo.update(cx, |repo, _| repo.default_branch(true));
let workspace = cx.entity();
let workspace_weak = workspace.downgrade();
window
.spawn(cx, async move |cx| {
let base_ref = default_branch.await??;
workspace.update_in(cx, |workspace, window, cx| {
let git_store = project.read(cx).git_store().clone();
let Some(base_ref) = base_ref else {
ProjectDiff::deploy_at(workspace, None, window, cx);
return;
};
let branch_diff =
if git_store.read(cx).diff_base() == GitDiffBaseSetting::DefaultBranch {
Some(git_store.update(cx, |git_store, cx| {
git_store.ensure_display_diff(
intended_repo.clone(),
base_ref.clone(),
cx,
)
}))
} else {
None
};
Self::deploy_branch_diff_with_base_ref(
workspace,
project,
intended_repo,
base_ref,
branch_diff,
window,
cx,
);
})?;
anyhow::Ok(())
})
.detach_and_notify_err(workspace_weak, window, cx);
}
fn compare_with_branch(
workspace: &mut Workspace,
_: &CompareWithBranch,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
let project = workspace.project().clone();
let Some(repository) = project.read(cx).active_repository(cx) else {
let workspace = cx.entity().downgrade();
window
.spawn(cx, async |_cx| {
let result: Result<()> = Err(anyhow!("No active repository"));
result
})
.detach_and_notify_err(workspace, window, cx);
return;
};
let selected_branch = workspace.active_item_as::<Self>(cx).and_then(|item| {
match item.read(cx).diff_base(cx) {
DiffBase::Merge { base_ref } => Some(base_ref.clone()),
DiffBase::Head | DiffBase::Index | DiffBase::Staged => None,
}
});
let workspace_handle = workspace.weak_handle();
let on_select = Arc::new({
let repository = repository.clone();
let workspace = workspace_handle.clone();
move |branch: git::repository::Branch, window: &mut Window, cx: &mut App| {
let base_ref: SharedString = branch.name().to_owned().into();
workspace
.update(cx, |workspace, cx| {
Self::deploy_branch_diff_with_base_ref(
workspace,
project.clone(),
repository.clone(),
base_ref,
None,
window,
cx,
);
})
.ok();
}
});
workspace.toggle_modal(window, cx, |window, cx| {
branch_picker::select_modal(
workspace_handle,
Some(repository),
selected_branch,
on_select,
window,
cx,
)
});
}
pub(crate) fn deploy_branch_diff_with_base_ref(
workspace: &mut Workspace,
project: Entity<Project>,
intended_repo: Entity<Repository>,
base_ref: SharedString,
branch_diff: Option<Entity<diff_buffer_list::DiffBufferList>>,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
let existing = workspace.items_of_type::<Self>(cx).find(|item| {
let item = item.read(cx);
matches!(
item.diff_base(cx),
DiffBase::Merge { base_ref: existing_base_ref } if existing_base_ref == &base_ref
) && item
.repo(cx)
.is_some_and(|repo| repo.read(cx).id == intended_repo.read(cx).id)
&& branch_diff.as_ref().is_none_or(|expected| {
item.diff.read(cx).branch_diff().entity_id() == expected.entity_id()
})
});
if let Some(existing) = existing {
workspace.activate_item(&existing, true, true, window, cx);
return;
}
let workspace = cx.entity();
let workspace_weak = workspace.downgrade();
window
.spawn(cx, async move |cx| {
let this = cx
.update(|window, cx| {
Self::new_with_branch_base(
project,
workspace.clone(),
base_ref,
intended_repo,
branch_diff,
window,
cx,
)
})?
.await?;
workspace
.update_in(cx, |workspace, window, cx| {
workspace.add_item_to_active_pane(Box::new(this), None, true, window, cx);
})
.ok();
anyhow::Ok(())
})
.detach_and_notify_err(workspace_weak, window, cx);
}
#[cfg(any(test, feature = "test-support"))]
pub fn new_with_default_branch(
project: Entity<Project>,
workspace: Entity<Workspace>,
window: &mut Window,
cx: &mut App,
) -> Task<Result<Entity<Self>>> {
let Some(repo) = project.read(cx).git_store().read(cx).active_repository() else {
return Task::ready(Err(anyhow!("No active repository")));
};
let main_branch = repo.update(cx, |repo, _| repo.default_branch(true));
window.spawn(cx, async move |cx| {
let base_ref = main_branch
.await??
.context("Could not determine default branch")?;
cx.update(|window, cx| {
let git_store = project.read(cx).git_store().clone();
let branch_diff =
if git_store.read(cx).diff_base() == GitDiffBaseSetting::DefaultBranch {
Some(git_store.update(cx, |git_store, cx| {
git_store.ensure_display_diff(repo.clone(), base_ref.clone(), cx)
}))
} else {
None
};
cx.new(|cx| {
Self::new_with_base_ref(
project,
workspace,
base_ref,
Some(repo),
branch_diff,
window,
cx,
)
})
})
})
}
pub(crate) fn new_with_branch_base(
project: Entity<Project>,
workspace: Entity<Workspace>,
base_ref: SharedString,
repo: Entity<Repository>,
branch_diff: Option<Entity<diff_buffer_list::DiffBufferList>>,
window: &mut Window,
cx: &mut App,
) -> Task<Result<Entity<Self>>> {
window.spawn(cx, async move |cx| {
cx.update(|window, cx| {
cx.new(|cx| {
Self::new_with_base_ref(
project,
workspace,
base_ref,
Some(repo),
branch_diff,
window,
cx,
)
})
})
})
}
pub(crate) fn new_with_base_ref(
project: Entity<Project>,
workspace: Entity<Workspace>,
base_ref: SharedString,
repo: Option<Entity<Repository>>,
branch_diff: Option<Entity<diff_buffer_list::DiffBufferList>>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let branch_diff = branch_diff.unwrap_or_else(|| {
let git_store = project.read(cx).git_store().clone();
cx.new(|cx| {
diff_buffer_list::DiffBufferList::new(
DiffBase::Merge { base_ref },
git_store,
repo,
cx,
)
})
});
let branch_diff_for_addon = branch_diff.clone();
let diff = cx.new(|cx| {
DiffMultibuffer::new(
branch_diff,
Capability::ReadWrite,
"No changes",
move |editor, cx| {
editor.set_diff_hunk_renderer(Some(Arc::new(HiddenDiffHunkRenderer)), cx);
editor.rhs_editor().update(cx, move |rhs_editor, _cx| {
rhs_editor.set_read_only(false);
rhs_editor.register_addon(BranchDiffAddon {
branch_diff: branch_diff_for_addon,
});
});
},
project.clone(),
workspace.clone(),
window,
cx,
)
});
Self::from_diff(diff, project, workspace, cx)
}
fn from_diff(
diff: Entity<DiffMultibuffer>,
project: Entity<Project>,
workspace: Entity<Workspace>,
cx: &mut Context<Self>,
) -> Self {
let diff_event_subscription = cx.subscribe(&diff, |_, _, event: &EditorEvent, cx| {
cx.emit(event.clone())
});
Self {
diff,
project,
workspace: workspace.downgrade(),
_diff_event_subscription: diff_event_subscription,
}
}
pub(crate) fn diff_base<'a>(&'a self, cx: &'a App) -> &'a DiffBase {
self.diff.read(cx).diff_base(cx)
}
pub(crate) fn repo(&self, cx: &App) -> Option<Entity<Repository>> {
self.diff.read(cx).repo(cx)
}
fn set_merge_base(&mut self, base_ref: SharedString, cx: &mut Context<Self>) {
self.diff.update(cx, |diff, cx| {
diff.branch_diff().update(cx, |branch_diff, cx| {
branch_diff.set_diff_base(DiffBase::Merge { base_ref }, cx);
});
});
}
fn review_diff(&mut self, _: &ReviewDiff, window: &mut Window, cx: &mut Context<Self>) {
let DiffBase::Merge { base_ref } = self.diff_base(cx).clone() else {
return;
};
let Some(repo) = self.repo(cx) else {
return;
};
let diff_receiver = repo.update(cx, |repo, cx| {
repo.diff(
DiffType::MergeBase {
base_ref: base_ref.clone(),
},
cx,
)
});
let workspace = self.workspace.clone();
window
.spawn(cx, {
let workspace = workspace.clone();
async move |cx| {
let diff_text = diff_receiver.await??;
if let Some(workspace) = workspace.upgrade() {
workspace.update_in(cx, |_workspace, window, cx| {
window.dispatch_action(
ReviewBranchDiff {
diff_text: diff_text.into(),
base_ref,
}
.boxed_clone(),
cx,
);
})?;
}
anyhow::Ok(())
}
})
.detach_and_notify_err(workspace, window, cx);
}
#[cfg(any(test, feature = "test-support"))]
pub fn editor(&self, cx: &App) -> Entity<SplittableEditor> {
self.diff.read(cx).editor().clone()
}
}
impl EventEmitter<EditorEvent> for BranchDiff {}
impl Focusable for BranchDiff {
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.diff.read(cx).focus_handle(cx)
}
}
impl Item for BranchDiff {
type Event = EditorEvent;
fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
Some(Icon::new(IconName::GitBranch).color(Color::Muted))
}
fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) {
Editor::to_item_events(event, f)
}
fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.diff
.update(cx, |diff, cx| diff.deactivated(window, cx));
}
fn navigate(
&mut self,
data: Arc<dyn Any + Send>,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
self.diff
.update(cx, |diff, cx| diff.navigate(data, window, cx))
}
fn tab_tooltip_text(&self, cx: &App) -> Option<SharedString> {
Some(self.tab_content_text(0, cx))
}
fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
match self.diff_base(cx) {
DiffBase::Merge { base_ref } => format!("Changes since {}", base_ref).into(),
DiffBase::Head | DiffBase::Index | DiffBase::Staged => "Changes".into(),
}
}
fn telemetry_event_text(&self) -> Option<&'static str> {
Some("Branch Diff Opened")
}
fn as_searchable(&self, _: &Entity<Self>, cx: &App) -> Option<Box<dyn SearchableItemHandle>> {
Some(Box::new(self.diff.read(cx).editor().clone()))
}
fn for_each_project_item(
&self,
cx: &App,
f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
) {
self.diff.read(cx).for_each_project_item(cx, f);
}
fn active_project_path(&self, cx: &App) -> Option<ProjectPath> {
self.diff.read(cx).active_project_path(cx)
}
fn set_nav_history(
&mut self,
nav_history: ItemNavHistory,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.diff
.update(cx, |diff, cx| diff.set_nav_history(nav_history, cx));
}
fn can_split(&self) -> bool {
true
}
fn clone_on_split(
&self,
_workspace_id: Option<workspace::WorkspaceId>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Option<Entity<Self>>>
where
Self: Sized,
{
let Some(workspace) = self.workspace.upgrade() else {
return Task::ready(None);
};
let DiffBase::Merge { base_ref } = self.diff_base(cx).clone() else {
return Task::ready(None);
};
let repo = self.repo(cx);
let project = self.project.clone();
let branch_diff = self.diff.read(cx).branch_diff().clone();
Task::ready(Some(cx.new(|cx| {
Self::new_with_base_ref(
project,
workspace,
base_ref,
repo,
Some(branch_diff),
window,
cx,
)
})))
}
fn is_dirty(&self, cx: &App) -> bool {
self.diff.read(cx).is_dirty(cx)
}
fn has_conflict(&self, cx: &App) -> bool {
self.diff.read(cx).has_conflict(cx)
}
fn can_save(&self, _cx: &App) -> bool {
true
}
fn save(
&mut self,
options: SaveOptions,
project: Entity<Project>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
self.diff
.update(cx, |diff, cx| diff.save(options, project, window, cx))
}
fn save_as(
&mut self,
_: Entity<Project>,
_: ProjectPath,
_: &mut Window,
_: &mut Context<Self>,
) -> Task<Result<()>> {
unreachable!()
}
fn reload(
&mut self,
project: Entity<Project>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
self.diff
.update(cx, |diff, cx| diff.reload(project, window, cx))
}
fn act_as_type<'a>(
&'a self,
type_id: TypeId,
self_handle: &'a Entity<Self>,
cx: &'a App,
) -> Option<gpui::AnyEntity> {
if type_id == TypeId::of::<Self>() {
Some(self_handle.clone().into())
} else if type_id == TypeId::of::<DiffMultibuffer>() {
Some(self.diff.clone().into())
} else if type_id == TypeId::of::<Editor>() {
Some(
self.diff
.read(cx)
.editor()
.read(cx)
.rhs_editor()
.clone()
.into(),
)
} else if type_id == TypeId::of::<SplittableEditor>() {
Some(self.diff.read(cx).editor().clone().into())
} else if type_id == TypeId::of::<diff_buffer_list::DiffBufferList>() {
Some(self.diff.read(cx).branch_diff().clone().into())
} else {
None
}
}
fn added_to_workspace(
&mut self,
workspace: &mut Workspace,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.diff.update(cx, |diff, cx| {
diff.added_to_workspace(workspace, window, cx)
});
}
}
impl Render for BranchDiff {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.on_action(cx.listener(Self::review_diff))
.child(self.diff.clone())
}
}
impl SerializableItem for BranchDiff {
fn serialized_item_kind() -> &'static str {
"BranchDiff"
}
fn cleanup(
_: workspace::WorkspaceId,
_: Vec<workspace::ItemId>,
_: &mut Window,
_: &mut App,
) -> Task<Result<()>> {
Task::ready(Ok(()))
}
fn deserialize(
project: Entity<Project>,
workspace: WeakEntity<Workspace>,
workspace_id: workspace::WorkspaceId,
item_id: workspace::ItemId,
window: &mut Window,
cx: &mut App,
) -> Task<Result<Entity<Self>>> {
let db = project_diff::persistence::ProjectDiffDb::global(cx);
window.spawn(cx, async move |cx| {
let diff_base = db.get_project_diff_base(item_id, workspace_id)?;
let DiffBase::Merge { base_ref } = diff_base else {
anyhow::bail!("expected a merge base for a branch diff");
};
let workspace = workspace.upgrade().context("workspace gone")?;
cx.update(|window, cx| {
cx.new(|cx| {
Self::new_with_base_ref(project, workspace, base_ref, None, None, window, cx)
})
})
})
}
fn serialize(
&mut self,
workspace: &mut Workspace,
item_id: workspace::ItemId,
_closing: bool,
cx: &mut Context<Self>,
) -> Option<Task<Result<()>>> {
let workspace_id = workspace.database_id()?;
let DiffBase::Merge { base_ref } = self.diff_base(cx).clone() else {
return None;
};
let diff_base = DiffBase::Merge { base_ref };
let db = project_diff::persistence::ProjectDiffDb::global(cx);
Some(cx.background_spawn(async move {
db.save_project_diff_base(item_id, workspace_id, diff_base)
.await
}))
}
fn should_serialize(&self, _: &Self::Event) -> bool {
false
}
}
pub struct BranchDiffToolbar {
branch_diff: Option<WeakEntity<BranchDiff>>,
}
impl BranchDiffToolbar {
pub fn new(_cx: &mut Context<Self>) -> Self {
Self { branch_diff: None }
}
fn branch_diff(&self, _: &App) -> Option<Entity<BranchDiff>> {
self.branch_diff.as_ref()?.upgrade()
}
fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context<Self>) {
if let Some(branch_diff) = self.branch_diff(cx) {
branch_diff.focus_handle(cx).focus(window, cx);
}
let action = action.boxed_clone();
cx.defer(move |cx| {
cx.dispatch_action(action.as_ref());
})
}
}
impl EventEmitter<ToolbarItemEvent> for BranchDiffToolbar {}
impl ToolbarItemView for BranchDiffToolbar {
fn set_active_pane_item(
&mut self,
active_pane_item: Option<&dyn ItemHandle>,
_: &mut Window,
cx: &mut Context<Self>,
) -> ToolbarItemLocation {
self.branch_diff = active_pane_item
.and_then(|item| item.act_as::<BranchDiff>(cx))
.map(|entity| entity.downgrade());
if self.branch_diff.is_some() {
ToolbarItemLocation::PrimaryRight
} else {
ToolbarItemLocation::Hidden
}
}
fn pane_focus_update(
&mut self,
_pane_focused: bool,
_window: &mut Window,
_cx: &mut Context<Self>,
) {
}
}
impl Render for BranchDiffToolbar {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let Some(branch_diff) = self.branch_diff(cx) else {
return div();
};
let focus_handle = branch_diff.focus_handle(cx);
let review_count = branch_diff
.read(cx)
.diff
.read(cx)
.total_review_comment_count();
let (additions, deletions) = branch_diff
.read(cx)
.diff
.read(cx)
.calculate_changed_lines(cx);
let diff_base = branch_diff.read(cx).diff_base(cx).clone();
let DiffBase::Merge { base_ref } = diff_base else {
return div();
};
let selected_base_ref = base_ref.clone();
let base_ref_label = format!("Base: {base_ref}");
let repository = branch_diff.read(cx).repo(cx);
let workspace = branch_diff.read(cx).workspace.clone();
let project = branch_diff.read(cx).project.clone();
let buffers = branch_diff.read(cx).diff.read(cx).branch_diff().clone();
let uses_display_diff = repository.as_ref().is_some_and(|repository| {
project
.read(cx)
.git_store()
.read(cx)
.display_diff_for_repo(repository.read(cx).id)
.is_some_and(|display_diff| display_diff.entity_id() == buffers.entity_id())
});
let view_for_picker = branch_diff.downgrade();
let is_multibuffer_empty = branch_diff
.read(cx)
.diff
.read(cx)
.multibuffer()
.read(cx)
.is_empty();
let is_ai_enabled = AgentSettings::get_global(cx).enabled(cx);
let show_review_button = !is_multibuffer_empty && is_ai_enabled;
h_flex()
.my_neg_1()
.py_1()
.gap_1p5()
.flex_wrap()
.justify_between()
.when(!is_multibuffer_empty, |this| {
this.child(DiffStat::new(
"branch-diff-stat",
additions as usize,
deletions as usize,
))
})
.child(Divider::vertical().ml_1())
.child(
PopoverMenu::new("branch-diff-base-branch-picker")
.menu(move |window, cx| {
let view_for_picker = view_for_picker.clone();
let workspace_for_select = workspace.clone();
let repository_for_select = repository.clone();
let project = project.clone();
let on_select = Arc::new(
move |branch: git::repository::Branch,
window: &mut Window,
cx: &mut App| {
let base_ref: SharedString = branch.name().to_owned().into();
if uses_display_diff {
let Some(repository) = repository_for_select.clone() else {
return;
};
workspace_for_select
.update(cx, |workspace, cx| {
BranchDiff::deploy_branch_diff_with_base_ref(
workspace,
project.clone(),
repository,
base_ref,
None,
window,
cx,
);
})
.ok();
} else {
view_for_picker
.update(cx, |branch_diff, cx| {
branch_diff.set_merge_base(base_ref, cx);
cx.notify();
})
.ok();
}
},
);
Some(branch_picker::select_popover(
workspace.clone(),
repository.clone(),
Some(selected_base_ref.clone()),
on_select,
window,
cx,
))
})
.trigger_with_tooltip(
Button::new("branch-diff-base-branch", base_ref_label).end_icon(
Icon::new(IconName::ChevronDown)
.size(IconSize::XSmall)
.color(Color::Muted),
),
Tooltip::text("Select Base Branch"),
),
)
.when(show_review_button, |this| {
let focus_handle = focus_handle.clone();
this.child(Divider::vertical()).child(
Button::new("review-diff", "Review Diff")
.start_icon(
Icon::new(IconName::ZedAssistant)
.size(IconSize::Small)
.color(Color::Muted),
)
.tooltip(move |_, cx| {
Tooltip::with_meta_in(
"Review Diff",
Some(&ReviewDiff),
"Send this diff for your last agent to review.",
&focus_handle,
cx,
)
})
.on_click(cx.listener(|this, _, window, cx| {
this.dispatch_action(&ReviewDiff, window, cx);
})),
)
})
.when(review_count > 0, |this| {
this.child(Divider::vertical()).child(
render_send_review_to_agent_button(review_count, &focus_handle).on_click(
cx.listener(|this, _, window, cx| {
this.dispatch_action(&SendReviewToAgent, window, cx)
}),
),
)
})
}
}
#[cfg(test)]
mod tests {
use anyhow::anyhow;
use collections::HashMap;
use editor::test::editor_test_context::assert_state_with_diff;
use git::status::{FileStatus, TrackedStatus, UnmergedStatus, UnmergedStatusCode};
use gpui::TestAppContext;
use project::FakeFs;
use serde_json::json;
use settings::{DiffViewStyle, SettingsStore};
use std::path::Path;
use std::sync::Arc;
use unindent::Unindent as _;
use util::{
path,
rel_path::{RelPath, rel_path},
};
use workspace::MultiWorkspace;
use super::*;
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let store = SettingsStore::test(cx);
cx.set_global(store);
cx.update_global::<SettingsStore, _>(|store, cx| {
store.update_user_settings(cx, |settings| {
settings.editor.diff_view_style = Some(DiffViewStyle::Unified);
});
});
theme_settings::init(theme::LoadThemes::JustBase, cx);
editor::init(cx);
crate::init(cx);
});
}
#[gpui::test(iterations = 50)]
async fn test_split_diff_conflict_path_transition_with_dirty_buffer_invalid_anchor_panics(
cx: &mut TestAppContext,
) {
init_test(cx);
cx.update(|cx| {
cx.update_global::<SettingsStore, _>(|store, cx| {
store.update_user_settings(cx, |settings| {
settings.editor.diff_view_style = Some(DiffViewStyle::Split);
});
});
});
let build_conflict_text: fn(usize) -> String = |tag: usize| {
let mut lines = (0..80)
.map(|line_index| format!("line {line_index}"))
.collect::<Vec<_>>();
for offset in [5usize, 20, 37, 61] {
lines[offset] = format!("base-{tag}-line-{offset}");
}
format!("{}\n", lines.join("\n"))
};
let initial_conflict_text = build_conflict_text(0);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
".git": {},
"helper.txt": "same\n",
"conflict.txt": initial_conflict_text,
}),
)
.await;
fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
state
.refs
.insert("MERGE_HEAD".into(), "conflict-head".into());
})
.unwrap();
fs.set_status_for_repo(
path!("/project/.git").as_ref(),
&[(
"conflict.txt",
FileStatus::Unmerged(UnmergedStatus {
first_head: UnmergedStatusCode::Updated,
second_head: UnmergedStatusCode::Updated,
}),
)],
);
fs.set_merge_base_content_for_repo(
path!("/project/.git").as_ref(),
&[
("conflict.txt", build_conflict_text(1)),
("helper.txt", "same\n".to_string()),
],
);
let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let _branch_diff = cx
.update(|window, cx| {
BranchDiff::new_with_default_branch(project.clone(), workspace, window, cx)
})
.await
.unwrap();
cx.run_until_parked();
let buffer = project
.update(cx, |project, cx| {
project.open_local_buffer(path!("/project/conflict.txt"), cx)
})
.await
.unwrap();
buffer.update(cx, |buffer, cx| buffer.edit([(0..0, "dirty\n")], None, cx));
assert!(buffer.read_with(cx, |buffer, _| buffer.is_dirty()));
cx.run_until_parked();
cx.update(|window, cx| {
let fs = fs.clone();
window
.spawn(cx, async move |cx| {
cx.background_executor().simulate_random_delay().await;
fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
state.refs.insert("HEAD".into(), "head-1".into());
state.refs.remove("MERGE_HEAD");
})
.unwrap();
fs.set_status_for_repo(
path!("/project/.git").as_ref(),
&[
(
"conflict.txt",
FileStatus::Tracked(TrackedStatus {
index_status: git::status::StatusCode::Modified,
worktree_status: git::status::StatusCode::Modified,
}),
),
(
"helper.txt",
FileStatus::Tracked(TrackedStatus {
index_status: git::status::StatusCode::Modified,
worktree_status: git::status::StatusCode::Modified,
}),
),
],
);
// FakeFs assigns deterministic OIDs by entry position; flipping order churns
// conflict diff identity without reaching into view internals.
fs.set_merge_base_content_for_repo(
path!("/project/.git").as_ref(),
&[
("helper.txt", "helper-base\n".to_string()),
("conflict.txt", build_conflict_text(2)),
],
);
})
.detach();
});
cx.update(|window, cx| {
let buffer = buffer.clone();
window
.spawn(cx, async move |cx| {
cx.background_executor().simulate_random_delay().await;
for edit_index in 0..10 {
if edit_index > 0 {
cx.background_executor().simulate_random_delay().await;
}
buffer.update(cx, |buffer, cx| {
let len = buffer.len();
if edit_index % 2 == 0 {
buffer.edit(
[(0..0, format!("status-burst-head-{edit_index}\n"))],
None,
cx,
);
} else {
buffer.edit(
[(len..len, format!("status-burst-tail-{edit_index}\n"))],
None,
cx,
);
}
});
}
})
.detach();
});
cx.run_until_parked();
}
#[gpui::test]
async fn test_branch_diff(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
".git": {},
"a.txt": "C",
"b.txt": "new",
"c.txt": "in-merge-base-and-work-tree",
"d.txt": "created-in-head",
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let diff = cx
.update(|window, cx| {
BranchDiff::new_with_default_branch(project.clone(), workspace, window, cx)
})
.await
.unwrap();
cx.run_until_parked();
fs.set_head_for_repo(
Path::new(path!("/project/.git")),
&[("a.txt", "B".into()), ("d.txt", "created-in-head".into())],
"sha",
);
fs.set_merge_base_content_for_repo(
Path::new(path!("/project/.git")),
&[
("a.txt", "A".into()),
("c.txt", "in-merge-base-and-work-tree".into()),
],
);
cx.run_until_parked();
let editor = diff.read_with(cx, |diff, cx| diff.editor(cx).read(cx).rhs_editor().clone());
let expected_diff = "
- A
+ C
+ ˇnew
+ created-in-head"
.unindent();
assert_state_with_diff(&editor, cx, &expected_diff);
cx.update(|window, cx| {
editor.focus_handle(cx).focus(window, cx);
window.dispatch_action(git::Restore.boxed_clone(), cx);
});
cx.run_until_parked();
assert_state_with_diff(&editor, cx, &expected_diff);
let statuses: HashMap<Arc<RelPath>, Option<FileStatus>> =
editor.update(cx, |editor, cx| {
editor
.buffer()
.read(cx)
.all_buffers()
.iter()
.map(|buffer| {
(
buffer.read(cx).file().unwrap().path().clone(),
editor.status_for_buffer_id(buffer.read(cx).remote_id(), cx),
)
})
.collect()
});
assert_eq!(
statuses,
HashMap::from_iter([
(
rel_path("a.txt").into_arc(),
Some(FileStatus::Tracked(TrackedStatus {
index_status: git::status::StatusCode::Modified,
worktree_status: git::status::StatusCode::Modified
}))
),
(rel_path("b.txt").into_arc(), Some(FileStatus::Untracked)),
(
rel_path("d.txt").into_arc(),
Some(FileStatus::Tracked(TrackedStatus {
index_status: git::status::StatusCode::Added,
worktree_status: git::status::StatusCode::Added
}))
)
])
);
}
#[gpui::test]
async fn test_branch_diff_action_matches_existing_item_by_base_ref(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
".git": {},
"a.txt": "changed",
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let target_branch_diff = cx
.update(|window, cx| {
let Some(repository) = project.read(cx).active_repository(cx) else {
return Task::ready(Err(anyhow!("No active repository")));
};
BranchDiff::new_with_branch_base(
project.clone(),
workspace.clone(),
"topic".into(),
repository,
None,
window,
cx,
)
})
.await
.unwrap();
workspace.update_in(cx, |workspace, window, cx| {
workspace.add_item_to_active_pane(
Box::new(target_branch_diff.clone()),
None,
true,
window,
cx,
);
});
cx.run_until_parked();
cx.focus(&workspace);
cx.update(|window, cx| {
window.dispatch_action(DeployBranchDiff.boxed_clone(), cx);
});
cx.run_until_parked();
let (active_base_ref, mut base_refs) = workspace.update(cx, |workspace, cx| {
let active_item = workspace.active_item_as::<BranchDiff>(cx).unwrap();
let active_base_ref = match active_item.read(cx).diff_base(cx) {
DiffBase::Merge { base_ref } => base_ref.to_string(),
DiffBase::Head | DiffBase::Index | DiffBase::Staged => {
panic!("expected active item to be a branch diff")
}
};
let base_refs = workspace
.items_of_type::<BranchDiff>(cx)
.filter_map(|item| match item.read(cx).diff_base(cx) {
DiffBase::Merge { base_ref } => Some(base_ref.to_string()),
DiffBase::Head | DiffBase::Index | DiffBase::Staged => None,
})
.collect::<Vec<_>>();
(active_base_ref, base_refs)
});
base_refs.sort();
assert_eq!(active_base_ref, "origin/main");
assert_eq!(base_refs, vec!["origin/main", "topic"]);
}
}