# 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
1273 lines
44 KiB
Rust
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"]);
|
|
}
|
|
}
|