Skip to repository content1200 lines · 41.4 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T03:31:35.358Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
branch_diff.rs
1use crate::{
2 branch_picker,
3 diff_multibuffer::DiffMultibuffer,
4 project_diff::{
5 self, CompareWithBranch, DeployBranchDiff, ReviewDiff, render_send_review_to_agent_button,
6 },
7};
8use agent_settings::AgentSettings;
9use anyhow::{Context as _, Result, anyhow};
10use editor::{
11 Addon, Editor, EditorEvent, RestoreOnlyDiffHunkDelegate, SplittableEditor,
12 actions::SendReviewToAgent,
13};
14use git::{repository::DiffType, status::FileStatus};
15use gpui::{
16 Action, AnyElement, App, AppContext as _, Entity, EventEmitter, FocusHandle, Focusable, Render,
17 SharedString, Subscription, Task, WeakEntity,
18};
19use language::{BufferId, Capability};
20use project::{
21 Project, ProjectPath,
22 git_store::{
23 Repository,
24 diff_buffer_list::{self, DiffBase},
25 },
26};
27use settings::Settings;
28use std::{
29 any::{Any, TypeId},
30 sync::Arc,
31};
32use ui::{DiffStat, Divider, PopoverMenu, Tooltip, prelude::*};
33use workspace::{
34 ItemHandle, ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation,
35 ToolbarItemView, Workspace,
36 item::{Item, ItemEvent, SaveOptions, TabContentParams},
37 notifications::NotifyTaskExt,
38 searchable::SearchableItemHandle,
39};
40use zed_actions::agent::ReviewBranchDiff;
41
42/// The workspace item for a branch (merge-base) diff: "Changes since {branch}".
43/// It wraps a single [`DiffMultibuffer`] over [`DiffBase::Merge`] and delegates
44/// the [`Item`] surface to it. The merge base can be changed in place via the
45/// [`BranchDiffToolbar`]'s branch picker, which reloads without reconfiguring
46/// the editor (the merge styling is identical for every base ref).
47pub struct BranchDiff {
48 diff: Entity<DiffMultibuffer>,
49 project: Entity<Project>,
50 workspace: WeakEntity<Workspace>,
51 _diff_event_subscription: Subscription,
52}
53
54struct BranchDiffAddon {
55 branch_diff: Entity<diff_buffer_list::DiffBufferList>,
56}
57
58impl Addon for BranchDiffAddon {
59 fn to_any(&self) -> &dyn std::any::Any {
60 self
61 }
62
63 fn override_status_for_buffer_id(&self, buffer_id: BufferId, cx: &App) -> Option<FileStatus> {
64 self.branch_diff
65 .read(cx)
66 .status_for_buffer_id(buffer_id, cx)
67 }
68}
69
70impl BranchDiff {
71 pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context<Workspace>) {
72 workspace.register_action(Self::deploy_branch_diff);
73 workspace.register_action(Self::compare_with_branch);
74 workspace::register_serializable_item::<Self>(cx);
75 }
76
77 fn deploy_branch_diff(
78 workspace: &mut Workspace,
79 _: &DeployBranchDiff,
80 window: &mut Window,
81 cx: &mut Context<Workspace>,
82 ) {
83 telemetry::event!("Git Branch Diff Opened");
84 let project = workspace.project().clone();
85 let Some(intended_repo) = project.read(cx).active_repository(cx) else {
86 let workspace = cx.entity().downgrade();
87 window
88 .spawn(cx, async |_cx| {
89 let result: Result<()> = Err(anyhow!("No active repository"));
90 result
91 })
92 .detach_and_notify_err(workspace, window, cx);
93 return;
94 };
95
96 let default_branch = intended_repo.update(cx, |repo, _| repo.default_branch(true));
97 let workspace = cx.entity();
98 let workspace_weak = workspace.downgrade();
99 window
100 .spawn(cx, async move |cx| {
101 let base_ref = default_branch
102 .await??
103 .context("Could not determine default branch")?;
104
105 workspace.update_in(cx, |workspace, window, cx| {
106 Self::deploy_branch_diff_with_base_ref(
107 workspace,
108 project,
109 intended_repo,
110 base_ref,
111 window,
112 cx,
113 );
114 })?;
115
116 anyhow::Ok(())
117 })
118 .detach_and_notify_err(workspace_weak, window, cx);
119 }
120
121 fn compare_with_branch(
122 workspace: &mut Workspace,
123 _: &CompareWithBranch,
124 window: &mut Window,
125 cx: &mut Context<Workspace>,
126 ) {
127 let project = workspace.project().clone();
128 let Some(repository) = project.read(cx).active_repository(cx) else {
129 let workspace = cx.entity().downgrade();
130 window
131 .spawn(cx, async |_cx| {
132 let result: Result<()> = Err(anyhow!("No active repository"));
133 result
134 })
135 .detach_and_notify_err(workspace, window, cx);
136 return;
137 };
138 let selected_branch = workspace.active_item_as::<Self>(cx).and_then(|item| {
139 match item.read(cx).diff_base(cx) {
140 DiffBase::Merge { base_ref } => Some(base_ref.clone()),
141 DiffBase::Head | DiffBase::Index | DiffBase::Staged => None,
142 }
143 });
144 let workspace_handle = workspace.weak_handle();
145 let on_select = Arc::new({
146 let repository = repository.clone();
147 let workspace = workspace_handle.clone();
148 move |branch: git::repository::Branch, window: &mut Window, cx: &mut App| {
149 let base_ref: SharedString = branch.name().to_owned().into();
150 workspace
151 .update(cx, |workspace, cx| {
152 Self::deploy_branch_diff_with_base_ref(
153 workspace,
154 project.clone(),
155 repository.clone(),
156 base_ref,
157 window,
158 cx,
159 );
160 })
161 .ok();
162 }
163 });
164
165 workspace.toggle_modal(window, cx, |window, cx| {
166 branch_picker::select_modal(
167 workspace_handle,
168 Some(repository),
169 selected_branch,
170 on_select,
171 window,
172 cx,
173 )
174 });
175 }
176
177 fn deploy_branch_diff_with_base_ref(
178 workspace: &mut Workspace,
179 project: Entity<Project>,
180 intended_repo: Entity<Repository>,
181 base_ref: SharedString,
182 window: &mut Window,
183 cx: &mut Context<Workspace>,
184 ) {
185 let existing = workspace.items_of_type::<Self>(cx).find(|item| {
186 let item = item.read(cx);
187 matches!(
188 item.diff_base(cx),
189 DiffBase::Merge { base_ref: existing_base_ref } if existing_base_ref == &base_ref
190 )
191 });
192 if let Some(existing) = existing {
193 workspace.activate_item(&existing, true, true, window, cx);
194
195 let needs_switch = existing.read(cx).repo(cx).map_or(true, |current| {
196 current.read(cx).id != intended_repo.read(cx).id
197 });
198
199 if needs_switch {
200 existing.update(cx, |branch_diff, cx| {
201 branch_diff.set_repo(Some(intended_repo), cx);
202 });
203 }
204
205 return;
206 }
207
208 let workspace = cx.entity();
209 let workspace_weak = workspace.downgrade();
210 window
211 .spawn(cx, async move |cx| {
212 let this = cx
213 .update(|window, cx| {
214 Self::new_with_branch_base(
215 project,
216 workspace.clone(),
217 base_ref,
218 intended_repo,
219 window,
220 cx,
221 )
222 })?
223 .await?;
224 workspace
225 .update_in(cx, |workspace, window, cx| {
226 workspace.add_item_to_active_pane(Box::new(this), None, true, window, cx);
227 })
228 .ok();
229 anyhow::Ok(())
230 })
231 .detach_and_notify_err(workspace_weak, window, cx);
232 }
233
234 #[cfg(any(test, feature = "test-support"))]
235 pub fn new_with_default_branch(
236 project: Entity<Project>,
237 workspace: Entity<Workspace>,
238 window: &mut Window,
239 cx: &mut App,
240 ) -> Task<Result<Entity<Self>>> {
241 let Some(repo) = project.read(cx).git_store().read(cx).active_repository() else {
242 return Task::ready(Err(anyhow!("No active repository")));
243 };
244 let main_branch = repo.update(cx, |repo, _| repo.default_branch(true));
245 window.spawn(cx, async move |cx| {
246 let base_ref = main_branch
247 .await??
248 .context("Could not determine default branch")?;
249 cx.update(|window, cx| {
250 cx.new(|cx| {
251 Self::new_with_base_ref(project, workspace, base_ref, Some(repo), window, cx)
252 })
253 })
254 })
255 }
256
257 pub(crate) fn new_with_branch_base(
258 project: Entity<Project>,
259 workspace: Entity<Workspace>,
260 base_ref: SharedString,
261 repo: Entity<Repository>,
262 window: &mut Window,
263 cx: &mut App,
264 ) -> Task<Result<Entity<Self>>> {
265 window.spawn(cx, async move |cx| {
266 cx.update(|window, cx| {
267 cx.new(|cx| {
268 Self::new_with_base_ref(project, workspace, base_ref, Some(repo), window, cx)
269 })
270 })
271 })
272 }
273
274 pub(crate) fn new_with_base_ref(
275 project: Entity<Project>,
276 workspace: Entity<Workspace>,
277 base_ref: SharedString,
278 repo: Option<Entity<Repository>>,
279 window: &mut Window,
280 cx: &mut Context<Self>,
281 ) -> Self {
282 let branch_diff = cx.new(|cx| {
283 let mut branch_diff = diff_buffer_list::DiffBufferList::new(
284 DiffBase::Merge { base_ref },
285 project.clone(),
286 window,
287 cx,
288 );
289 if repo.is_some() {
290 branch_diff.set_repo(repo, cx);
291 }
292 branch_diff
293 });
294 let branch_diff_for_addon = branch_diff.clone();
295 let diff = cx.new(|cx| {
296 DiffMultibuffer::new(
297 branch_diff,
298 Capability::ReadWrite,
299 "No changes",
300 move |editor, cx| {
301 editor.set_diff_hunk_delegate(Some(Arc::new(RestoreOnlyDiffHunkDelegate)), cx);
302 editor.rhs_editor().update(cx, move |rhs_editor, _cx| {
303 rhs_editor.set_read_only(false);
304 rhs_editor.register_addon(BranchDiffAddon {
305 branch_diff: branch_diff_for_addon,
306 });
307 });
308 },
309 project.clone(),
310 workspace.clone(),
311 window,
312 cx,
313 )
314 });
315 Self::from_diff(diff, project, workspace, cx)
316 }
317
318 fn from_diff(
319 diff: Entity<DiffMultibuffer>,
320 project: Entity<Project>,
321 workspace: Entity<Workspace>,
322 cx: &mut Context<Self>,
323 ) -> Self {
324 let diff_event_subscription = cx.subscribe(&diff, |_, _, event: &EditorEvent, cx| {
325 cx.emit(event.clone())
326 });
327 Self {
328 diff,
329 project,
330 workspace: workspace.downgrade(),
331 _diff_event_subscription: diff_event_subscription,
332 }
333 }
334
335 pub(crate) fn diff_base<'a>(&'a self, cx: &'a App) -> &'a DiffBase {
336 self.diff.read(cx).diff_base(cx)
337 }
338
339 pub(crate) fn repo(&self, cx: &App) -> Option<Entity<Repository>> {
340 self.diff.read(cx).repo(cx)
341 }
342
343 pub(crate) fn set_repo(&mut self, repo: Option<Entity<Repository>>, cx: &mut Context<Self>) {
344 self.diff.update(cx, |diff, cx| diff.set_repo(repo, cx));
345 }
346
347 fn set_merge_base(&mut self, base_ref: SharedString, cx: &mut Context<Self>) {
348 self.diff.update(cx, |diff, cx| {
349 diff.branch_diff().update(cx, |branch_diff, cx| {
350 branch_diff.set_diff_base(DiffBase::Merge { base_ref }, cx);
351 });
352 });
353 }
354
355 fn review_diff(&mut self, _: &ReviewDiff, window: &mut Window, cx: &mut Context<Self>) {
356 let DiffBase::Merge { base_ref } = self.diff_base(cx).clone() else {
357 return;
358 };
359 let Some(repo) = self.repo(cx) else {
360 return;
361 };
362
363 let diff_receiver = repo.update(cx, |repo, cx| {
364 repo.diff(
365 DiffType::MergeBase {
366 base_ref: base_ref.clone(),
367 },
368 cx,
369 )
370 });
371
372 let workspace = self.workspace.clone();
373 window
374 .spawn(cx, {
375 let workspace = workspace.clone();
376 async move |cx| {
377 let diff_text = diff_receiver.await??;
378
379 if let Some(workspace) = workspace.upgrade() {
380 workspace.update_in(cx, |_workspace, window, cx| {
381 window.dispatch_action(
382 ReviewBranchDiff {
383 diff_text: diff_text.into(),
384 base_ref,
385 }
386 .boxed_clone(),
387 cx,
388 );
389 })?;
390 }
391
392 anyhow::Ok(())
393 }
394 })
395 .detach_and_notify_err(workspace, window, cx);
396 }
397
398 #[cfg(any(test, feature = "test-support"))]
399 pub fn editor(&self, cx: &App) -> Entity<SplittableEditor> {
400 self.diff.read(cx).editor().clone()
401 }
402}
403
404impl EventEmitter<EditorEvent> for BranchDiff {}
405
406impl Focusable for BranchDiff {
407 fn focus_handle(&self, cx: &App) -> FocusHandle {
408 self.diff.read(cx).focus_handle(cx)
409 }
410}
411
412impl Item for BranchDiff {
413 type Event = EditorEvent;
414
415 fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
416 Some(Icon::new(IconName::GitBranch).color(Color::Muted))
417 }
418
419 fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) {
420 Editor::to_item_events(event, f)
421 }
422
423 fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
424 self.diff
425 .update(cx, |diff, cx| diff.deactivated(window, cx));
426 }
427
428 fn navigate(
429 &mut self,
430 data: Arc<dyn Any + Send>,
431 window: &mut Window,
432 cx: &mut Context<Self>,
433 ) -> bool {
434 self.diff
435 .update(cx, |diff, cx| diff.navigate(data, window, cx))
436 }
437
438 fn tab_tooltip_text(&self, cx: &App) -> Option<SharedString> {
439 Some(self.tab_content_text(0, cx))
440 }
441
442 fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
443 Label::new(self.tab_content_text(0, cx))
444 .color(if params.selected {
445 Color::Default
446 } else {
447 Color::Muted
448 })
449 .into_any_element()
450 }
451
452 fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
453 match self.diff_base(cx) {
454 DiffBase::Merge { base_ref } => format!("Changes since {}", base_ref).into(),
455 DiffBase::Head | DiffBase::Index | DiffBase::Staged => "Changes".into(),
456 }
457 }
458
459 fn telemetry_event_text(&self) -> Option<&'static str> {
460 Some("Branch Diff Opened")
461 }
462
463 fn as_searchable(&self, _: &Entity<Self>, cx: &App) -> Option<Box<dyn SearchableItemHandle>> {
464 Some(Box::new(self.diff.read(cx).editor().clone()))
465 }
466
467 fn for_each_project_item(
468 &self,
469 cx: &App,
470 f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
471 ) {
472 self.diff.read(cx).for_each_project_item(cx, f);
473 }
474
475 fn active_project_path(&self, cx: &App) -> Option<ProjectPath> {
476 self.diff.read(cx).active_project_path(cx)
477 }
478
479 fn set_nav_history(
480 &mut self,
481 nav_history: ItemNavHistory,
482 _: &mut Window,
483 cx: &mut Context<Self>,
484 ) {
485 self.diff
486 .update(cx, |diff, cx| diff.set_nav_history(nav_history, cx));
487 }
488
489 fn can_split(&self) -> bool {
490 true
491 }
492
493 fn clone_on_split(
494 &self,
495 _workspace_id: Option<workspace::WorkspaceId>,
496 window: &mut Window,
497 cx: &mut Context<Self>,
498 ) -> Task<Option<Entity<Self>>>
499 where
500 Self: Sized,
501 {
502 let Some(workspace) = self.workspace.upgrade() else {
503 return Task::ready(None);
504 };
505 let DiffBase::Merge { base_ref } = self.diff_base(cx).clone() else {
506 return Task::ready(None);
507 };
508 let repo = self.repo(cx);
509 let project = self.project.clone();
510 Task::ready(Some(cx.new(|cx| {
511 Self::new_with_base_ref(project, workspace, base_ref, repo, window, cx)
512 })))
513 }
514
515 fn is_dirty(&self, cx: &App) -> bool {
516 self.diff.read(cx).is_dirty(cx)
517 }
518
519 fn has_conflict(&self, cx: &App) -> bool {
520 self.diff.read(cx).has_conflict(cx)
521 }
522
523 fn can_save(&self, _cx: &App) -> bool {
524 true
525 }
526
527 fn save(
528 &mut self,
529 options: SaveOptions,
530 project: Entity<Project>,
531 window: &mut Window,
532 cx: &mut Context<Self>,
533 ) -> Task<Result<()>> {
534 self.diff
535 .update(cx, |diff, cx| diff.save(options, project, window, cx))
536 }
537
538 fn save_as(
539 &mut self,
540 _: Entity<Project>,
541 _: ProjectPath,
542 _: &mut Window,
543 _: &mut Context<Self>,
544 ) -> Task<Result<()>> {
545 unreachable!()
546 }
547
548 fn reload(
549 &mut self,
550 project: Entity<Project>,
551 window: &mut Window,
552 cx: &mut Context<Self>,
553 ) -> Task<Result<()>> {
554 self.diff
555 .update(cx, |diff, cx| diff.reload(project, window, cx))
556 }
557
558 fn act_as_type<'a>(
559 &'a self,
560 type_id: TypeId,
561 self_handle: &'a Entity<Self>,
562 cx: &'a App,
563 ) -> Option<gpui::AnyEntity> {
564 if type_id == TypeId::of::<Self>() {
565 Some(self_handle.clone().into())
566 } else if type_id == TypeId::of::<DiffMultibuffer>() {
567 Some(self.diff.clone().into())
568 } else if type_id == TypeId::of::<Editor>() {
569 Some(
570 self.diff
571 .read(cx)
572 .editor()
573 .read(cx)
574 .rhs_editor()
575 .clone()
576 .into(),
577 )
578 } else if type_id == TypeId::of::<SplittableEditor>() {
579 Some(self.diff.read(cx).editor().clone().into())
580 } else if type_id == TypeId::of::<diff_buffer_list::DiffBufferList>() {
581 Some(self.diff.read(cx).branch_diff().clone().into())
582 } else {
583 None
584 }
585 }
586
587 fn added_to_workspace(
588 &mut self,
589 workspace: &mut Workspace,
590 window: &mut Window,
591 cx: &mut Context<Self>,
592 ) {
593 self.diff.update(cx, |diff, cx| {
594 diff.added_to_workspace(workspace, window, cx)
595 });
596 }
597}
598
599impl Render for BranchDiff {
600 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
601 div()
602 .size_full()
603 .on_action(cx.listener(Self::review_diff))
604 .child(self.diff.clone())
605 }
606}
607
608impl SerializableItem for BranchDiff {
609 fn serialized_item_kind() -> &'static str {
610 "BranchDiff"
611 }
612
613 fn cleanup(
614 _: workspace::WorkspaceId,
615 _: Vec<workspace::ItemId>,
616 _: &mut Window,
617 _: &mut App,
618 ) -> Task<Result<()>> {
619 Task::ready(Ok(()))
620 }
621
622 fn deserialize(
623 project: Entity<Project>,
624 workspace: WeakEntity<Workspace>,
625 workspace_id: workspace::WorkspaceId,
626 item_id: workspace::ItemId,
627 window: &mut Window,
628 cx: &mut App,
629 ) -> Task<Result<Entity<Self>>> {
630 let db = project_diff::persistence::ProjectDiffDb::global(cx);
631 window.spawn(cx, async move |cx| {
632 let diff_base = db.get_project_diff_base(item_id, workspace_id)?;
633 let DiffBase::Merge { base_ref } = diff_base else {
634 anyhow::bail!("expected a merge base for a branch diff");
635 };
636 let workspace = workspace.upgrade().context("workspace gone")?;
637 cx.update(|window, cx| {
638 cx.new(|cx| Self::new_with_base_ref(project, workspace, base_ref, None, window, cx))
639 })
640 })
641 }
642
643 fn serialize(
644 &mut self,
645 workspace: &mut Workspace,
646 item_id: workspace::ItemId,
647 _closing: bool,
648 _window: &mut Window,
649 cx: &mut Context<Self>,
650 ) -> Option<Task<Result<()>>> {
651 let workspace_id = workspace.database_id()?;
652 let DiffBase::Merge { base_ref } = self.diff_base(cx).clone() else {
653 return None;
654 };
655 let diff_base = DiffBase::Merge { base_ref };
656 let db = project_diff::persistence::ProjectDiffDb::global(cx);
657 Some(cx.background_spawn(async move {
658 db.save_project_diff_base(item_id, workspace_id, diff_base)
659 .await
660 }))
661 }
662
663 fn should_serialize(&self, _: &Self::Event) -> bool {
664 false
665 }
666}
667
668pub struct BranchDiffToolbar {
669 branch_diff: Option<WeakEntity<BranchDiff>>,
670}
671
672impl BranchDiffToolbar {
673 pub fn new(_cx: &mut Context<Self>) -> Self {
674 Self { branch_diff: None }
675 }
676
677 fn branch_diff(&self, _: &App) -> Option<Entity<BranchDiff>> {
678 self.branch_diff.as_ref()?.upgrade()
679 }
680
681 fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context<Self>) {
682 if let Some(branch_diff) = self.branch_diff(cx) {
683 branch_diff.focus_handle(cx).focus(window, cx);
684 }
685 let action = action.boxed_clone();
686 cx.defer(move |cx| {
687 cx.dispatch_action(action.as_ref());
688 })
689 }
690}
691
692impl EventEmitter<ToolbarItemEvent> for BranchDiffToolbar {}
693
694impl ToolbarItemView for BranchDiffToolbar {
695 fn set_active_pane_item(
696 &mut self,
697 active_pane_item: Option<&dyn ItemHandle>,
698 _: &mut Window,
699 cx: &mut Context<Self>,
700 ) -> ToolbarItemLocation {
701 self.branch_diff = active_pane_item
702 .and_then(|item| item.act_as::<BranchDiff>(cx))
703 .map(|entity| entity.downgrade());
704 if self.branch_diff.is_some() {
705 ToolbarItemLocation::PrimaryRight
706 } else {
707 ToolbarItemLocation::Hidden
708 }
709 }
710
711 fn pane_focus_update(
712 &mut self,
713 _pane_focused: bool,
714 _window: &mut Window,
715 _cx: &mut Context<Self>,
716 ) {
717 }
718}
719
720impl Render for BranchDiffToolbar {
721 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
722 let Some(branch_diff) = self.branch_diff(cx) else {
723 return div();
724 };
725 let focus_handle = branch_diff.focus_handle(cx);
726 let review_count = branch_diff
727 .read(cx)
728 .diff
729 .read(cx)
730 .total_review_comment_count();
731 let (additions, deletions) = branch_diff
732 .read(cx)
733 .diff
734 .read(cx)
735 .calculate_changed_lines(cx);
736 let diff_base = branch_diff.read(cx).diff_base(cx).clone();
737 let DiffBase::Merge { base_ref } = diff_base else {
738 return div();
739 };
740 let selected_base_ref = base_ref.clone();
741 let base_ref_label = format!("Base: {base_ref}");
742 let repository = branch_diff.read(cx).repo(cx);
743 let workspace = branch_diff.read(cx).workspace.clone();
744 let view_for_picker = branch_diff.downgrade();
745
746 let is_multibuffer_empty = branch_diff
747 .read(cx)
748 .diff
749 .read(cx)
750 .multibuffer()
751 .read(cx)
752 .is_empty();
753 let is_ai_enabled = AgentSettings::get_global(cx).enabled(cx);
754
755 let show_review_button = !is_multibuffer_empty && is_ai_enabled;
756
757 h_flex()
758 .my_neg_1()
759 .py_1()
760 .gap_1p5()
761 .flex_wrap()
762 .justify_between()
763 .when(!is_multibuffer_empty, |this| {
764 this.child(DiffStat::new(
765 "branch-diff-stat",
766 additions as usize,
767 deletions as usize,
768 ))
769 })
770 .child(Divider::vertical().ml_1())
771 .child(
772 PopoverMenu::new("branch-diff-base-branch-picker")
773 .menu(move |window, cx| {
774 let view_for_picker = view_for_picker.clone();
775 let on_select = Arc::new(
776 move |branch: git::repository::Branch,
777 _window: &mut Window,
778 cx: &mut App| {
779 let base_ref: SharedString = branch.name().to_owned().into();
780 view_for_picker
781 .update(cx, |branch_diff, cx| {
782 branch_diff.set_merge_base(base_ref, cx);
783 cx.notify();
784 })
785 .ok();
786 },
787 );
788
789 Some(branch_picker::select_popover(
790 workspace.clone(),
791 repository.clone(),
792 Some(selected_base_ref.clone()),
793 on_select,
794 window,
795 cx,
796 ))
797 })
798 .trigger_with_tooltip(
799 Button::new("branch-diff-base-branch", base_ref_label).end_icon(
800 Icon::new(IconName::ChevronDown)
801 .size(IconSize::XSmall)
802 .color(Color::Muted),
803 ),
804 Tooltip::text("Select Base Branch"),
805 ),
806 )
807 .when(show_review_button, |this| {
808 let focus_handle = focus_handle.clone();
809 this.child(Divider::vertical()).child(
810 Button::new("review-diff", "Review Diff")
811 .start_icon(
812 Icon::new(IconName::OmegaAssistant)
813 .size(IconSize::Small)
814 .color(Color::Muted),
815 )
816 .tooltip(move |_, cx| {
817 Tooltip::with_meta_in(
818 "Review Diff",
819 Some(&ReviewDiff),
820 "Send this diff for your last agent to review.",
821 &focus_handle,
822 cx,
823 )
824 })
825 .on_click(cx.listener(|this, _, window, cx| {
826 this.dispatch_action(&ReviewDiff, window, cx);
827 })),
828 )
829 })
830 .when(review_count > 0, |this| {
831 this.child(Divider::vertical()).child(
832 render_send_review_to_agent_button(review_count, &focus_handle).on_click(
833 cx.listener(|this, _, window, cx| {
834 this.dispatch_action(&SendReviewToAgent, window, cx)
835 }),
836 ),
837 )
838 })
839 }
840}
841
842#[cfg(test)]
843mod tests {
844 use anyhow::anyhow;
845 use collections::HashMap;
846 use editor::test::editor_test_context::assert_state_with_diff;
847 use git::status::{FileStatus, TrackedStatus, UnmergedStatus, UnmergedStatusCode};
848 use gpui::TestAppContext;
849 use project::FakeFs;
850 use serde_json::json;
851 use settings::{DiffViewStyle, SettingsStore};
852 use std::path::Path;
853 use std::sync::Arc;
854 use unindent::Unindent as _;
855 use util::{
856 path,
857 rel_path::{RelPath, rel_path},
858 };
859 use workspace::MultiWorkspace;
860
861 use super::*;
862
863 fn init_test(cx: &mut TestAppContext) {
864 cx.update(|cx| {
865 let store = SettingsStore::test(cx);
866 cx.set_global(store);
867 cx.update_global::<SettingsStore, _>(|store, cx| {
868 store.update_user_settings(cx, |settings| {
869 settings.editor.diff_view_style = Some(DiffViewStyle::Unified);
870 });
871 });
872 theme_settings::init(theme::LoadThemes::JustBase, cx);
873 editor::init(cx);
874 crate::init(cx);
875 });
876 }
877
878 #[gpui::test(iterations = 50)]
879 async fn test_split_diff_conflict_path_transition_with_dirty_buffer_invalid_anchor_panics(
880 cx: &mut TestAppContext,
881 ) {
882 init_test(cx);
883
884 cx.update(|cx| {
885 cx.update_global::<SettingsStore, _>(|store, cx| {
886 store.update_user_settings(cx, |settings| {
887 settings.editor.diff_view_style = Some(DiffViewStyle::Split);
888 });
889 });
890 });
891
892 let build_conflict_text: fn(usize) -> String = |tag: usize| {
893 let mut lines = (0..80)
894 .map(|line_index| format!("line {line_index}"))
895 .collect::<Vec<_>>();
896 for offset in [5usize, 20, 37, 61] {
897 lines[offset] = format!("base-{tag}-line-{offset}");
898 }
899 format!("{}\n", lines.join("\n"))
900 };
901 let initial_conflict_text = build_conflict_text(0);
902 let fs = FakeFs::new(cx.executor());
903 fs.insert_tree(
904 path!("/project"),
905 json!({
906 ".git": {},
907 "helper.txt": "same\n",
908 "conflict.txt": initial_conflict_text,
909 }),
910 )
911 .await;
912 fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
913 state
914 .refs
915 .insert("MERGE_HEAD".into(), "conflict-head".into());
916 })
917 .unwrap();
918 fs.set_status_for_repo(
919 path!("/project/.git").as_ref(),
920 &[(
921 "conflict.txt",
922 FileStatus::Unmerged(UnmergedStatus {
923 first_head: UnmergedStatusCode::Updated,
924 second_head: UnmergedStatusCode::Updated,
925 }),
926 )],
927 );
928 fs.set_merge_base_content_for_repo(
929 path!("/project/.git").as_ref(),
930 &[
931 ("conflict.txt", build_conflict_text(1)),
932 ("helper.txt", "same\n".to_string()),
933 ],
934 );
935
936 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
937 let (multi_workspace, cx) =
938 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
939 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
940 let _branch_diff = cx
941 .update(|window, cx| {
942 BranchDiff::new_with_default_branch(project.clone(), workspace, window, cx)
943 })
944 .await
945 .unwrap();
946 cx.run_until_parked();
947
948 let buffer = project
949 .update(cx, |project, cx| {
950 project.open_local_buffer(path!("/project/conflict.txt"), cx)
951 })
952 .await
953 .unwrap();
954 buffer.update(cx, |buffer, cx| buffer.edit([(0..0, "dirty\n")], None, cx));
955 assert!(buffer.read_with(cx, |buffer, _| buffer.is_dirty()));
956 cx.run_until_parked();
957
958 cx.update(|window, cx| {
959 let fs = fs.clone();
960 window
961 .spawn(cx, async move |cx| {
962 cx.background_executor().simulate_random_delay().await;
963 fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
964 state.refs.insert("HEAD".into(), "head-1".into());
965 state.refs.remove("MERGE_HEAD");
966 })
967 .unwrap();
968 fs.set_status_for_repo(
969 path!("/project/.git").as_ref(),
970 &[
971 (
972 "conflict.txt",
973 FileStatus::Tracked(TrackedStatus {
974 index_status: git::status::StatusCode::Modified,
975 worktree_status: git::status::StatusCode::Modified,
976 }),
977 ),
978 (
979 "helper.txt",
980 FileStatus::Tracked(TrackedStatus {
981 index_status: git::status::StatusCode::Modified,
982 worktree_status: git::status::StatusCode::Modified,
983 }),
984 ),
985 ],
986 );
987 // FakeFs assigns deterministic OIDs by entry position; flipping order churns
988 // conflict diff identity without reaching into view internals.
989 fs.set_merge_base_content_for_repo(
990 path!("/project/.git").as_ref(),
991 &[
992 ("helper.txt", "helper-base\n".to_string()),
993 ("conflict.txt", build_conflict_text(2)),
994 ],
995 );
996 })
997 .detach();
998 });
999
1000 cx.update(|window, cx| {
1001 let buffer = buffer.clone();
1002 window
1003 .spawn(cx, async move |cx| {
1004 cx.background_executor().simulate_random_delay().await;
1005 for edit_index in 0..10 {
1006 if edit_index > 0 {
1007 cx.background_executor().simulate_random_delay().await;
1008 }
1009 buffer.update(cx, |buffer, cx| {
1010 let len = buffer.len();
1011 if edit_index % 2 == 0 {
1012 buffer.edit(
1013 [(0..0, format!("status-burst-head-{edit_index}\n"))],
1014 None,
1015 cx,
1016 );
1017 } else {
1018 buffer.edit(
1019 [(len..len, format!("status-burst-tail-{edit_index}\n"))],
1020 None,
1021 cx,
1022 );
1023 }
1024 });
1025 }
1026 })
1027 .detach();
1028 });
1029
1030 cx.run_until_parked();
1031 }
1032
1033 #[gpui::test]
1034 async fn test_branch_diff(cx: &mut TestAppContext) {
1035 init_test(cx);
1036
1037 let fs = FakeFs::new(cx.executor());
1038 fs.insert_tree(
1039 path!("/project"),
1040 json!({
1041 ".git": {},
1042 "a.txt": "C",
1043 "b.txt": "new",
1044 "c.txt": "in-merge-base-and-work-tree",
1045 "d.txt": "created-in-head",
1046 }),
1047 )
1048 .await;
1049 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1050 let (multi_workspace, cx) =
1051 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1052 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1053 let diff = cx
1054 .update(|window, cx| {
1055 BranchDiff::new_with_default_branch(project.clone(), workspace, window, cx)
1056 })
1057 .await
1058 .unwrap();
1059 cx.run_until_parked();
1060
1061 fs.set_head_for_repo(
1062 Path::new(path!("/project/.git")),
1063 &[("a.txt", "B".into()), ("d.txt", "created-in-head".into())],
1064 "sha",
1065 );
1066 fs.set_merge_base_content_for_repo(
1067 Path::new(path!("/project/.git")),
1068 &[
1069 ("a.txt", "A".into()),
1070 ("c.txt", "in-merge-base-and-work-tree".into()),
1071 ],
1072 );
1073 cx.run_until_parked();
1074
1075 let editor = diff.read_with(cx, |diff, cx| diff.editor(cx).read(cx).rhs_editor().clone());
1076
1077 assert_state_with_diff(
1078 &editor,
1079 cx,
1080 &"
1081 - A
1082 + ˇC
1083 + new
1084 + created-in-head"
1085 .unindent(),
1086 );
1087
1088 let statuses: HashMap<Arc<RelPath>, Option<FileStatus>> =
1089 editor.update(cx, |editor, cx| {
1090 editor
1091 .buffer()
1092 .read(cx)
1093 .all_buffers()
1094 .iter()
1095 .map(|buffer| {
1096 (
1097 buffer.read(cx).file().unwrap().path().clone(),
1098 editor.status_for_buffer_id(buffer.read(cx).remote_id(), cx),
1099 )
1100 })
1101 .collect()
1102 });
1103
1104 assert_eq!(
1105 statuses,
1106 HashMap::from_iter([
1107 (
1108 rel_path("a.txt").into_arc(),
1109 Some(FileStatus::Tracked(TrackedStatus {
1110 index_status: git::status::StatusCode::Modified,
1111 worktree_status: git::status::StatusCode::Modified
1112 }))
1113 ),
1114 (rel_path("b.txt").into_arc(), Some(FileStatus::Untracked)),
1115 (
1116 rel_path("d.txt").into_arc(),
1117 Some(FileStatus::Tracked(TrackedStatus {
1118 index_status: git::status::StatusCode::Added,
1119 worktree_status: git::status::StatusCode::Added
1120 }))
1121 )
1122 ])
1123 );
1124 }
1125
1126 #[gpui::test]
1127 async fn test_branch_diff_action_matches_existing_item_by_base_ref(cx: &mut TestAppContext) {
1128 init_test(cx);
1129
1130 let fs = FakeFs::new(cx.executor());
1131 fs.insert_tree(
1132 path!("/project"),
1133 json!({
1134 ".git": {},
1135 "a.txt": "changed",
1136 }),
1137 )
1138 .await;
1139 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1140 let (multi_workspace, cx) =
1141 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1142 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1143
1144 let target_branch_diff = cx
1145 .update(|window, cx| {
1146 let Some(repository) = project.read(cx).active_repository(cx) else {
1147 return Task::ready(Err(anyhow!("No active repository")));
1148 };
1149 BranchDiff::new_with_branch_base(
1150 project.clone(),
1151 workspace.clone(),
1152 "topic".into(),
1153 repository,
1154 window,
1155 cx,
1156 )
1157 })
1158 .await
1159 .unwrap();
1160 workspace.update_in(cx, |workspace, window, cx| {
1161 workspace.add_item_to_active_pane(
1162 Box::new(target_branch_diff.clone()),
1163 None,
1164 true,
1165 window,
1166 cx,
1167 );
1168 });
1169 cx.run_until_parked();
1170
1171 cx.focus(&workspace);
1172 cx.update(|window, cx| {
1173 window.dispatch_action(DeployBranchDiff.boxed_clone(), cx);
1174 });
1175 cx.run_until_parked();
1176
1177 let (active_base_ref, mut base_refs) = workspace.update(cx, |workspace, cx| {
1178 let active_item = workspace.active_item_as::<BranchDiff>(cx).unwrap();
1179 let active_base_ref = match active_item.read(cx).diff_base(cx) {
1180 DiffBase::Merge { base_ref } => base_ref.to_string(),
1181 DiffBase::Head | DiffBase::Index | DiffBase::Staged => {
1182 panic!("expected active item to be a branch diff")
1183 }
1184 };
1185 let base_refs = workspace
1186 .items_of_type::<BranchDiff>(cx)
1187 .filter_map(|item| match item.read(cx).diff_base(cx) {
1188 DiffBase::Merge { base_ref } => Some(base_ref.to_string()),
1189 DiffBase::Head | DiffBase::Index | DiffBase::Staged => None,
1190 })
1191 .collect::<Vec<_>>();
1192 (active_base_ref, base_refs)
1193 });
1194 base_refs.sort();
1195
1196 assert_eq!(active_base_ref, "origin/main");
1197 assert_eq!(base_refs, vec!["origin/main", "topic"]);
1198 }
1199}
1200