Skip to repository content699 lines · 25.2 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T02:36:05.041Z 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
conflict_view.rs
1use agent_settings::AgentSettings;
2use collections::{HashMap, HashSet};
3use editor::{
4 ConflictsOurs, ConflictsOursMarker, ConflictsOuter, ConflictsTheirs, ConflictsTheirsMarker,
5 Editor, MultiBuffer, RowHighlightOptions,
6 display_map::{BlockContext, BlockPlacement, BlockProperties, BlockStyle, CustomBlockId},
7};
8use gpui::{
9 App, ClickEvent, Context, Empty, Entity, InteractiveElement as _, ParentElement as _,
10 Subscription, Task, WeakEntity,
11};
12use language::{Anchor, Buffer, BufferId};
13use project::{
14 ConflictRegion, ConflictSet, ConflictSetUpdate, Project,
15 git_store::{GitStore, GitStoreEvent, RepositoryEvent},
16};
17use settings::Settings;
18use std::{ops::Range, sync::Arc};
19use ui::{ButtonLike, Divider, Tooltip, prelude::*};
20use util::debug_panic;
21use workspace::{HideStatusItem, StatusItemView, Workspace, item::ItemHandle};
22use zed_actions::agent::{
23 ConflictContent, ResolveConflictedFilesWithAgent, ResolveConflictsWithAgent,
24};
25
26pub(crate) struct ConflictAddon {
27 buffers: HashMap<BufferId, BufferConflicts>,
28}
29
30struct BufferConflicts {
31 block_ids: Vec<(Range<Anchor>, CustomBlockId)>,
32 conflict_set: Entity<ConflictSet>,
33 _subscription: Subscription,
34}
35
36impl editor::Addon for ConflictAddon {
37 fn to_any(&self) -> &dyn std::any::Any {
38 self
39 }
40
41 fn to_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
42 Some(self)
43 }
44}
45
46pub fn register_editor(editor: &mut Editor, buffer: Entity<MultiBuffer>, cx: &mut Context<Editor>) {
47 let is_singleton = editor.buffer().read(cx).is_singleton();
48 if !editor.mode().is_full()
49 || (!is_singleton && !editor.buffer().read(cx).all_diff_hunks_expanded())
50 || editor.read_only(cx)
51 {
52 return;
53 }
54
55 editor.register_addon(ConflictAddon {
56 buffers: Default::default(),
57 });
58
59 if is_singleton {
60 let buffers = buffer.read(cx).all_buffers();
61 for buffer in buffers {
62 open_conflict_set_for_buffer(editor, buffer, cx);
63 }
64 }
65}
66
67fn open_conflict_set_for_buffer(
68 _editor: &mut Editor,
69 buffer: Entity<Buffer>,
70 cx: &mut Context<Editor>,
71) {
72 let buffer = buffer.downgrade();
73
74 cx.spawn(async move |editor, cx| {
75 let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id())?;
76 if let Some(conflict_set) = editor.read_with(cx, |editor, _| {
77 editor
78 .addon::<ConflictAddon>()
79 .and_then(|addon| addon.buffers.get(&buffer_id))
80 .map(|buffer_conflicts| buffer_conflicts.conflict_set.clone())
81 })? {
82 editor.update(cx, |editor, cx| {
83 buffer_ranges_updated(editor, conflict_set, cx);
84 })?;
85 return anyhow::Ok(());
86 }
87
88 let Some(project) = editor.read_with(cx, |editor, _| editor.project().cloned())? else {
89 return anyhow::Ok(());
90 };
91 let git_store = project.read_with(cx, |project, _| project.git_store().clone());
92 let Some(buffer) = buffer.upgrade() else {
93 return Ok(());
94 };
95 let conflict_set = git_store
96 .update(cx, |git_store, cx| {
97 git_store.open_conflict_set(buffer.clone(), cx)
98 })
99 .await;
100 editor.update(cx, |editor, cx| {
101 buffer_ranges_updated(editor, conflict_set, cx);
102 })?;
103 Ok(())
104 })
105 .detach();
106}
107
108pub(crate) fn buffer_ranges_updated(
109 editor: &mut Editor,
110 conflict_set: Entity<ConflictSet>,
111 cx: &mut Context<Editor>,
112) {
113 let buffer_id = conflict_set.read(cx).snapshot.buffer_id;
114 if editor.buffer().read(cx).buffer(buffer_id).is_none() {
115 return;
116 }
117
118 let Some(conflict_addon) = editor.addon_mut::<ConflictAddon>() else {
119 return;
120 };
121 let buffer_conflicts = conflict_addon.buffers.entry(buffer_id).or_insert_with(|| {
122 let subscription = cx.subscribe(&conflict_set, conflicts_updated);
123 BufferConflicts {
124 block_ids: Vec::new(),
125 conflict_set: conflict_set.clone(),
126 _subscription: subscription,
127 }
128 });
129
130 let conflict_set = buffer_conflicts.conflict_set.clone();
131 let conflicts_len = conflict_set.read(cx).snapshot().conflicts.len();
132 let addon_conflicts_len = buffer_conflicts.block_ids.len();
133 conflicts_updated(
134 editor,
135 conflict_set,
136 &ConflictSetUpdate {
137 buffer_range: None,
138 old_range: 0..addon_conflicts_len,
139 new_range: 0..conflicts_len,
140 },
141 cx,
142 );
143}
144
145pub(crate) fn buffers_removed(
146 editor: &mut Editor,
147 removed_buffer_ids: &[BufferId],
148 cx: &mut Context<Editor>,
149) {
150 let mut removed_block_ids = HashSet::default();
151 let Some(conflict_addon) = editor.addon_mut::<ConflictAddon>() else {
152 return;
153 };
154 conflict_addon.buffers.retain(|buffer_id, buffer| {
155 if removed_buffer_ids.contains(buffer_id) {
156 removed_block_ids.extend(buffer.block_ids.iter().map(|(_, block_id)| *block_id));
157 false
158 } else {
159 true
160 }
161 });
162 editor.remove_blocks(removed_block_ids, None, cx);
163}
164
165#[ztracing::instrument(skip_all)]
166fn conflicts_updated(
167 editor: &mut Editor,
168 conflict_set: Entity<ConflictSet>,
169 event: &ConflictSetUpdate,
170 cx: &mut Context<Editor>,
171) {
172 let buffer_id = conflict_set.read(cx).snapshot.buffer_id;
173 let conflict_set = conflict_set.read(cx).snapshot();
174 let multibuffer = editor.buffer().read(cx);
175 let snapshot = multibuffer.snapshot(cx);
176 let old_range = {
177 let Some(conflict_addon) = editor.addon_mut::<ConflictAddon>() else {
178 return;
179 };
180 let Some(buffer_conflicts) = conflict_addon.buffers.get(&buffer_id) else {
181 return;
182 };
183 match buffer_conflicts.block_ids.get(event.old_range.clone()) {
184 Some(_) => Some(event.old_range.clone()),
185 None => {
186 debug_panic!(
187 "conflicts updated event old range is invalid for buffer conflicts view (block_ids len is {:?}, old_range is {:?})",
188 buffer_conflicts.block_ids.len(),
189 event.old_range,
190 );
191 if event.old_range.start <= event.old_range.end {
192 Some(
193 event.old_range.start.min(buffer_conflicts.block_ids.len())
194 ..event.old_range.end.min(buffer_conflicts.block_ids.len()),
195 )
196 } else {
197 None
198 }
199 }
200 }
201 };
202
203 // Remove obsolete highlights and blocks
204 let Some(conflict_addon) = editor.addon_mut::<ConflictAddon>() else {
205 return;
206 };
207 if let Some((buffer_conflicts, old_range)) = conflict_addon
208 .buffers
209 .get_mut(&buffer_id)
210 .zip(old_range.clone())
211 {
212 let old_conflicts = buffer_conflicts.block_ids[old_range].to_owned();
213 let mut removed_highlighted_ranges = Vec::new();
214 let mut removed_block_ids = HashSet::default();
215 for (conflict_range, block_id) in old_conflicts {
216 let Some(range) = snapshot.buffer_anchor_range_to_anchor_range(conflict_range) else {
217 continue;
218 };
219 removed_highlighted_ranges.push(range.clone());
220 removed_block_ids.insert(block_id);
221 }
222
223 editor.remove_gutter_highlights::<ConflictsOuter>(removed_highlighted_ranges.clone(), cx);
224
225 editor.remove_highlighted_rows::<ConflictsOuter>(removed_highlighted_ranges.clone(), cx);
226 editor.remove_highlighted_rows::<ConflictsOurs>(removed_highlighted_ranges.clone(), cx);
227 editor
228 .remove_highlighted_rows::<ConflictsOursMarker>(removed_highlighted_ranges.clone(), cx);
229 editor.remove_highlighted_rows::<ConflictsTheirs>(removed_highlighted_ranges.clone(), cx);
230 editor.remove_highlighted_rows::<ConflictsTheirsMarker>(
231 removed_highlighted_ranges.clone(),
232 cx,
233 );
234 editor.remove_blocks(removed_block_ids, None, cx);
235 }
236
237 // Add new highlights and blocks
238 let editor_handle = cx.weak_entity();
239 let new_conflicts = &conflict_set.conflicts[event.new_range.clone()];
240 let mut blocks = Vec::new();
241 for conflict in new_conflicts {
242 update_conflict_highlighting(editor, conflict, &snapshot, cx);
243
244 let Some(anchor) = snapshot.anchor_in_excerpt(conflict.range.start) else {
245 continue;
246 };
247
248 let editor_handle = editor_handle.clone();
249 blocks.push(BlockProperties {
250 placement: BlockPlacement::Above(anchor),
251 height: Some(1),
252 style: BlockStyle::Sticky,
253 render: Arc::new({
254 let conflict = conflict.clone();
255 move |cx| render_conflict_buttons(&conflict, editor_handle.clone(), cx)
256 }),
257 priority: 0,
258 })
259 }
260 let new_block_ids = editor.insert_blocks(blocks, None, cx);
261
262 let Some(conflict_addon) = editor.addon_mut::<ConflictAddon>() else {
263 return;
264 };
265 if let Some((buffer_conflicts, old_range)) =
266 conflict_addon.buffers.get_mut(&buffer_id).zip(old_range)
267 {
268 buffer_conflicts.block_ids.splice(
269 old_range,
270 new_conflicts
271 .iter()
272 .map(|conflict| conflict.range.clone())
273 .zip(new_block_ids),
274 );
275 }
276}
277
278#[ztracing::instrument(skip_all)]
279fn update_conflict_highlighting(
280 editor: &mut Editor,
281 conflict: &ConflictRegion,
282 buffer: &editor::MultiBufferSnapshot,
283 cx: &mut Context<Editor>,
284) -> Option<()> {
285 log::debug!("update conflict highlighting for {conflict:?}");
286
287 let outer = buffer.buffer_anchor_range_to_anchor_range(conflict.range.clone())?;
288 let ours = buffer.buffer_anchor_range_to_anchor_range(conflict.ours.clone())?;
289 let theirs = buffer.buffer_anchor_range_to_anchor_range(conflict.theirs.clone())?;
290
291 let ours_background = |cx: &App| cx.theme().colors().version_control_conflict_marker_ours;
292 let theirs_background = |cx: &App| cx.theme().colors().version_control_conflict_marker_theirs;
293
294 let options = RowHighlightOptions {
295 include_gutter: true,
296 ..Default::default()
297 };
298
299 editor.insert_gutter_highlight::<ConflictsOuter>(
300 outer.start..theirs.end,
301 |cx| cx.theme().colors().editor_background,
302 cx,
303 );
304
305 // Prevent diff hunk highlighting within the entire conflict region.
306 editor.highlight_rows::<ConflictsOuter>(outer.clone(), theirs_background, options, cx);
307 editor.highlight_rows::<ConflictsOurs>(ours.clone(), ours_background, options, cx);
308 editor.highlight_rows::<ConflictsOursMarker>(
309 outer.start..ours.start,
310 ours_background,
311 options,
312 cx,
313 );
314 editor.highlight_rows::<ConflictsTheirs>(theirs.clone(), theirs_background, options, cx);
315 editor.highlight_rows::<ConflictsTheirsMarker>(
316 theirs.end..outer.end,
317 theirs_background,
318 options,
319 cx,
320 );
321
322 Some(())
323}
324
325fn render_conflict_buttons(
326 conflict: &ConflictRegion,
327 editor: WeakEntity<Editor>,
328 cx: &mut BlockContext,
329) -> AnyElement {
330 let is_ai_enabled = AgentSettings::get_global(cx).enabled(cx);
331
332 h_flex()
333 .id(cx.block_id)
334 .h(cx.line_height)
335 .ml(cx.margins.gutter.width)
336 .gap_1()
337 .bg(cx.theme().colors().editor_background)
338 .child(
339 Button::new("head", format!("Use {}", conflict.ours_branch_name))
340 .label_size(LabelSize::Small)
341 .on_click({
342 let editor = editor.clone();
343 let conflict = conflict.clone();
344 let ours = conflict.ours.clone();
345 move |_, window, cx| {
346 resolve_conflict(
347 editor.clone(),
348 conflict.clone(),
349 vec![ours.clone()],
350 window,
351 cx,
352 )
353 .detach()
354 }
355 }),
356 )
357 .child(
358 Button::new("origin", format!("Use {}", conflict.theirs_branch_name))
359 .label_size(LabelSize::Small)
360 .on_click({
361 let editor = editor.clone();
362 let conflict = conflict.clone();
363 let theirs = conflict.theirs.clone();
364 move |_, window, cx| {
365 resolve_conflict(
366 editor.clone(),
367 conflict.clone(),
368 vec![theirs.clone()],
369 window,
370 cx,
371 )
372 .detach()
373 }
374 }),
375 )
376 .child(
377 Button::new("both", "Use Both")
378 .label_size(LabelSize::Small)
379 .on_click({
380 let editor = editor.clone();
381 let conflict = conflict.clone();
382 let ours = conflict.ours.clone();
383 let theirs = conflict.theirs.clone();
384 move |_, window, cx| {
385 resolve_conflict(
386 editor.clone(),
387 conflict.clone(),
388 vec![ours.clone(), theirs.clone()],
389 window,
390 cx,
391 )
392 .detach()
393 }
394 }),
395 )
396 .when(is_ai_enabled, |this| {
397 this.child(Divider::vertical()).child(
398 Button::new("resolve-with-agent", "Resolve with Agent")
399 .label_size(LabelSize::Small)
400 .start_icon(
401 Icon::new(IconName::OmegaAssistant)
402 .size(IconSize::Small)
403 .color(Color::Muted),
404 )
405 .on_click({
406 let conflict = conflict.clone();
407 move |_, window, cx| {
408 let content = editor
409 .update(cx, |editor, cx| {
410 let multibuffer = editor.buffer().read(cx);
411 let buffer_id = conflict.ours.end.buffer_id;
412 let buffer = multibuffer.buffer(buffer_id)?;
413 let buffer_read = buffer.read(cx);
414 let snapshot = buffer_read.snapshot();
415 let conflict_text = snapshot
416 .text_for_range(conflict.range.clone())
417 .collect::<String>();
418 let file_path = buffer_read
419 .file()
420 .and_then(|file| file.as_local())
421 .map(|f| f.abs_path(cx).to_string_lossy().to_string())
422 .unwrap_or_default();
423 Some(ConflictContent {
424 file_path,
425 conflict_text,
426 ours_branch_name: conflict.ours_branch_name.to_string(),
427 theirs_branch_name: conflict.theirs_branch_name.to_string(),
428 })
429 })
430 .ok()
431 .flatten();
432 if let Some(content) = content {
433 window.dispatch_action(
434 Box::new(ResolveConflictsWithAgent {
435 conflicts: vec![content],
436 }),
437 cx,
438 );
439 }
440 }
441 }),
442 )
443 })
444 .into_any()
445}
446
447fn collect_conflicted_file_paths(project: &Project, cx: &App) -> Vec<String> {
448 let git_store = project.git_store().read(cx);
449 let mut paths = Vec::new();
450
451 for repo in git_store.repositories().values() {
452 let snapshot = repo.read(cx).snapshot();
453 for (repo_path, _) in snapshot.merge.merge_heads_by_conflicted_path.iter() {
454 let is_currently_conflicted = snapshot
455 .status_for_path(repo_path)
456 .is_some_and(|entry| entry.status.is_conflicted());
457 if !is_currently_conflicted {
458 continue;
459 }
460 if let Some(project_path) = repo.read(cx).repo_path_to_project_path(repo_path, cx) {
461 paths.push(
462 project_path
463 .path
464 .as_std_path()
465 .to_string_lossy()
466 .to_string(),
467 );
468 }
469 }
470 }
471
472 paths
473}
474
475pub(crate) fn resolve_conflict(
476 editor: WeakEntity<Editor>,
477 resolved_conflict: ConflictRegion,
478 ranges: Vec<Range<Anchor>>,
479 window: &mut Window,
480 cx: &mut App,
481) -> Task<()> {
482 window.spawn(cx, async move |cx| {
483 editor
484 .update(cx, |editor, cx| {
485 let multibuffer = editor.buffer().clone();
486 let buffer_id = resolved_conflict.ours.end.buffer_id;
487 let buffer = multibuffer.read(cx).buffer(buffer_id)?;
488 resolved_conflict.resolve(buffer.clone(), &ranges, cx);
489 let conflict_addon = editor.addon_mut::<ConflictAddon>()?;
490 let snapshot = multibuffer.read(cx).snapshot(cx);
491 let buffer_snapshot = buffer.read(cx).snapshot();
492 let state = conflict_addon
493 .buffers
494 .get_mut(&buffer_snapshot.remote_id())?;
495 let ix = state
496 .block_ids
497 .binary_search_by(|(range, _)| {
498 range
499 .start
500 .cmp(&resolved_conflict.range.start, &buffer_snapshot)
501 })
502 .ok()?;
503 let &(_, block_id) = &state.block_ids[ix];
504 let range =
505 snapshot.buffer_anchor_range_to_anchor_range(resolved_conflict.range)?;
506
507 editor.remove_gutter_highlights::<ConflictsOuter>(vec![range.clone()], cx);
508
509 editor.remove_highlighted_rows::<ConflictsOuter>(vec![range.clone()], cx);
510 editor.remove_highlighted_rows::<ConflictsOurs>(vec![range.clone()], cx);
511 editor.remove_highlighted_rows::<ConflictsTheirs>(vec![range.clone()], cx);
512 editor.remove_highlighted_rows::<ConflictsOursMarker>(vec![range.clone()], cx);
513 editor.remove_highlighted_rows::<ConflictsTheirsMarker>(vec![range], cx);
514 editor.remove_blocks(HashSet::from_iter([block_id]), None, cx);
515 Some(())
516 })
517 .ok();
518 })
519}
520
521pub struct MergeConflictIndicator {
522 project: Entity<Project>,
523 conflicted_paths: Vec<String>,
524 last_shown_paths: HashSet<String>,
525 dismissed: bool,
526 _subscription: Subscription,
527}
528
529impl MergeConflictIndicator {
530 pub fn new(workspace: &Workspace, cx: &mut Context<Self>) -> Self {
531 let project = workspace.project().clone();
532 let git_store = project.read(cx).git_store().clone();
533
534 let subscription = cx.subscribe(&git_store, Self::on_git_store_event);
535
536 let conflicted_paths = collect_conflicted_file_paths(project.read(cx), cx);
537 let last_shown_paths: HashSet<String> = conflicted_paths.iter().cloned().collect();
538
539 Self {
540 project,
541 conflicted_paths,
542 last_shown_paths,
543 dismissed: false,
544 _subscription: subscription,
545 }
546 }
547
548 fn on_git_store_event(
549 &mut self,
550 _git_store: Entity<GitStore>,
551 event: &GitStoreEvent,
552 cx: &mut Context<Self>,
553 ) {
554 let conflicts_changed = matches!(
555 event,
556 GitStoreEvent::ConflictsUpdated
557 | GitStoreEvent::RepositoryUpdated(_, RepositoryEvent::StatusesChanged, _)
558 );
559
560 let agent_settings = AgentSettings::get_global(cx);
561 if !agent_settings.enabled(cx)
562 || !agent_settings.show_merge_conflict_indicator
563 || !conflicts_changed
564 {
565 return;
566 }
567
568 let project = self.project.read(cx);
569 if project.is_via_collab() {
570 return;
571 }
572
573 let paths = collect_conflicted_file_paths(project, cx);
574 let current_paths_set: HashSet<String> = paths.iter().cloned().collect();
575
576 if paths.is_empty() {
577 self.conflicted_paths.clear();
578 self.last_shown_paths.clear();
579 self.dismissed = false;
580 cx.notify();
581 } else if self.last_shown_paths != current_paths_set {
582 self.last_shown_paths = current_paths_set;
583 self.conflicted_paths = paths;
584 self.dismissed = false;
585 cx.notify();
586 }
587 }
588
589 fn resolve_with_agent(&mut self, window: &mut Window, cx: &mut Context<Self>) {
590 window.dispatch_action(
591 Box::new(ResolveConflictedFilesWithAgent {
592 conflicted_file_paths: self.conflicted_paths.clone(),
593 }),
594 cx,
595 );
596 self.dismissed = true;
597 cx.notify();
598 }
599
600 fn dismiss(&mut self, _: &ClickEvent, _window: &mut Window, cx: &mut Context<Self>) {
601 self.dismissed = true;
602 cx.notify();
603 }
604}
605
606impl Render for MergeConflictIndicator {
607 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
608 let agent_settings = AgentSettings::get_global(cx);
609 if !agent_settings.enabled(cx)
610 || !agent_settings.show_merge_conflict_indicator
611 || self.conflicted_paths.is_empty()
612 || self.dismissed
613 {
614 return Empty.into_any_element();
615 }
616
617 let file_count = self.conflicted_paths.len();
618
619 let message: SharedString = format!(
620 "Resolve Merge Conflict{} with Agent",
621 if file_count == 1 { "" } else { "s" }
622 )
623 .into();
624
625 let tooltip_label: SharedString = format!(
626 "Found {} {} across the codebase",
627 file_count,
628 if file_count == 1 {
629 "conflict"
630 } else {
631 "conflicts"
632 }
633 )
634 .into();
635
636 let border_color = cx.theme().colors().text_accent.opacity(0.2);
637
638 h_flex()
639 .h(rems_from_px(22.))
640 .rounded_sm()
641 .border_1()
642 .border_color(border_color)
643 .child(
644 ButtonLike::new("update-button")
645 .tab_index(0isize)
646 .aria_label(message.clone())
647 .child(
648 h_flex()
649 .h_full()
650 .gap_1()
651 .child(
652 Icon::new(IconName::GitMergeConflict)
653 .size(IconSize::Small)
654 .color(Color::Muted),
655 )
656 .child(Label::new(message).size(LabelSize::Small)),
657 )
658 .tooltip(move |_, cx| {
659 Tooltip::with_meta(
660 tooltip_label.clone(),
661 None,
662 "Click to Resolve with Agent",
663 cx,
664 )
665 })
666 .on_click(cx.listener(|this, _, window, cx| {
667 this.resolve_with_agent(window, cx);
668 })),
669 )
670 .child(
671 div().border_l_1().border_color(border_color).child(
672 IconButton::new("dismiss-merge-conflicts", IconName::Close)
673 .icon_size(IconSize::XSmall)
674 .on_click(cx.listener(Self::dismiss)),
675 ),
676 )
677 .into_any_element()
678 }
679}
680
681impl StatusItemView for MergeConflictIndicator {
682 fn set_active_pane_item(
683 &mut self,
684 _: Option<&dyn ItemHandle>,
685 _window: &mut Window,
686 _: &mut Context<Self>,
687 ) {
688 }
689
690 fn hide_setting(&self, _: &App) -> Option<HideStatusItem> {
691 Some(HideStatusItem::new(|settings| {
692 settings
693 .agent
694 .get_or_insert_default()
695 .show_merge_conflict_indicator = Some(false);
696 }))
697 }
698}
699