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