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