use crate::{ branch_picker, diff_multibuffer::DiffMultibuffer, project_diff::{ self, CompareWithBranch, DeployBranchDiff, ReviewDiff, render_send_review_to_agent_button, }, }; use agent_settings::AgentSettings; use anyhow::{Context as _, Result, anyhow}; use editor::{ Addon, Editor, EditorEvent, RestoreOnlyDiffHunkDelegate, SplittableEditor, actions::SendReviewToAgent, }; use git::{repository::DiffType, status::FileStatus}; use gpui::{ Action, AnyElement, 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::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, TabContentParams}, 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, project: Entity, workspace: WeakEntity, _diff_event_subscription: Subscription, } struct BranchDiffAddon { branch_diff: Entity, } 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 { 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.register_action(Self::deploy_branch_diff); workspace.register_action(Self::compare_with_branch); workspace::register_serializable_item::(cx); } fn deploy_branch_diff( workspace: &mut Workspace, _: &DeployBranchDiff, window: &mut Window, cx: &mut Context, ) { 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?? .context("Could not determine default branch")?; workspace.update_in(cx, |workspace, window, cx| { Self::deploy_branch_diff_with_base_ref( workspace, project, intended_repo, base_ref, 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, ) { 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::(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, window, cx, ); }) .ok(); } }); workspace.toggle_modal(window, cx, |window, cx| { branch_picker::select_modal( workspace_handle, Some(repository), selected_branch, on_select, window, cx, ) }); } fn deploy_branch_diff_with_base_ref( workspace: &mut Workspace, project: Entity, intended_repo: Entity, base_ref: SharedString, window: &mut Window, cx: &mut Context, ) { let existing = workspace.items_of_type::(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 ) }); if let Some(existing) = existing { workspace.activate_item(&existing, true, true, window, cx); let needs_switch = existing.read(cx).repo(cx).map_or(true, |current| { current.read(cx).id != intended_repo.read(cx).id }); if needs_switch { existing.update(cx, |branch_diff, cx| { branch_diff.set_repo(Some(intended_repo), 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, 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, workspace: Entity, window: &mut Window, cx: &mut App, ) -> Task>> { 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| { cx.new(|cx| { Self::new_with_base_ref(project, workspace, base_ref, Some(repo), window, cx) }) }) }) } pub(crate) fn new_with_branch_base( project: Entity, workspace: Entity, base_ref: SharedString, repo: Entity, window: &mut Window, cx: &mut App, ) -> Task>> { window.spawn(cx, async move |cx| { cx.update(|window, cx| { cx.new(|cx| { Self::new_with_base_ref(project, workspace, base_ref, Some(repo), window, cx) }) }) }) } pub(crate) fn new_with_base_ref( project: Entity, workspace: Entity, base_ref: SharedString, repo: Option>, window: &mut Window, cx: &mut Context, ) -> Self { let branch_diff = cx.new(|cx| { let mut branch_diff = diff_buffer_list::DiffBufferList::new( DiffBase::Merge { base_ref }, project.clone(), window, cx, ); if repo.is_some() { branch_diff.set_repo(repo, cx); } branch_diff }); 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_delegate(Some(Arc::new(RestoreOnlyDiffHunkDelegate)), 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, project: Entity, workspace: Entity, cx: &mut Context, ) -> 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> { self.diff.read(cx).repo(cx) } pub(crate) fn set_repo(&mut self, repo: Option>, cx: &mut Context) { self.diff.update(cx, |diff, cx| diff.set_repo(repo, cx)); } fn set_merge_base(&mut self, base_ref: SharedString, cx: &mut Context) { 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) { 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 { self.diff.read(cx).editor().clone() } } impl EventEmitter 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 { 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.diff .update(cx, |diff, cx| diff.deactivated(window, cx)); } fn navigate( &mut self, data: Arc, window: &mut Window, cx: &mut Context, ) -> bool { self.diff .update(cx, |diff, cx| diff.navigate(data, window, cx)) } fn tab_tooltip_text(&self, cx: &App) -> Option { Some(self.tab_content_text(0, cx)) } fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement { Label::new(self.tab_content_text(0, cx)) .color(if params.selected { Color::Default } else { Color::Muted }) .into_any_element() } 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, cx: &App) -> Option> { 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 { self.diff.read(cx).active_project_path(cx) } fn set_nav_history( &mut self, nav_history: ItemNavHistory, _: &mut Window, cx: &mut Context, ) { 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, window: &mut Window, cx: &mut Context, ) -> Task>> 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(); Task::ready(Some(cx.new(|cx| { Self::new_with_base_ref(project, workspace, base_ref, repo, 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, window: &mut Window, cx: &mut Context, ) -> Task> { self.diff .update(cx, |diff, cx| diff.save(options, project, window, cx)) } fn save_as( &mut self, _: Entity, _: ProjectPath, _: &mut Window, _: &mut Context, ) -> Task> { unreachable!() } fn reload( &mut self, project: Entity, window: &mut Window, cx: &mut Context, ) -> Task> { 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, cx: &'a App, ) -> Option { if type_id == TypeId::of::() { Some(self_handle.clone().into()) } else if type_id == TypeId::of::() { Some(self.diff.clone().into()) } else if type_id == TypeId::of::() { Some( self.diff .read(cx) .editor() .read(cx) .rhs_editor() .clone() .into(), ) } else if type_id == TypeId::of::() { Some(self.diff.read(cx).editor().clone().into()) } else if type_id == TypeId::of::() { 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.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) -> 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, _: &mut Window, _: &mut App, ) -> Task> { Task::ready(Ok(())) } fn deserialize( project: Entity, workspace: WeakEntity, workspace_id: workspace::WorkspaceId, item_id: workspace::ItemId, window: &mut Window, cx: &mut App, ) -> Task>> { 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, window, cx)) }) }) } fn serialize( &mut self, workspace: &mut Workspace, item_id: workspace::ItemId, _closing: bool, _window: &mut Window, cx: &mut Context, ) -> Option>> { 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>, } impl BranchDiffToolbar { pub fn new(_cx: &mut Context) -> Self { Self { branch_diff: None } } fn branch_diff(&self, _: &App) -> Option> { self.branch_diff.as_ref()?.upgrade() } fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context) { 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 for BranchDiffToolbar {} impl ToolbarItemView for BranchDiffToolbar { fn set_active_pane_item( &mut self, active_pane_item: Option<&dyn ItemHandle>, _: &mut Window, cx: &mut Context, ) -> ToolbarItemLocation { self.branch_diff = active_pane_item .and_then(|item| item.act_as::(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, ) { } } impl Render for BranchDiffToolbar { fn render(&mut self, _: &mut Window, cx: &mut Context) -> 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 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 on_select = Arc::new( move |branch: git::repository::Branch, _window: &mut Window, cx: &mut App| { let base_ref: SharedString = branch.name().to_owned().into(); 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::(|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::(|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::>(); 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()); assert_state_with_diff( &editor, cx, &" - A + ˇC + new + created-in-head" .unindent(), ); let statuses: HashMap, Option> = 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, 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::(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::(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::>(); (active_base_ref, base_refs) }); base_refs.sort(); assert_eq!(active_base_ref, "origin/main"); assert_eq!(base_refs, vec!["origin/main", "topic"]); } }