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