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, 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, ProjectItem as _,
15 git_store::{GitStore, GitStoreEvent, RepositoryEvent},
16};
17use settings::Settings;
18use std::{ops::Range, sync::Arc};
19use ui::{ButtonLike, Divider, Tooltip, prelude::*};
20use util::{ResultExt as _, debug_panic, maybe};
21use workspace::{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
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 resolve_conflict(
437 editor: WeakEntity<Editor>,
438 resolved_conflict: ConflictRegion,
439 ranges: Vec<Range<Anchor>>,
440 window: &mut Window,
441 cx: &mut App,
442) -> Task<()> {
443 window.spawn(cx, async move |cx| {
444 let Some((workspace, project, multibuffer, buffer)) = editor
445 .update(cx, |editor, cx| {
446 let workspace = editor.workspace()?;
447 let project = editor.project()?.clone();
448 let multibuffer = editor.buffer().clone();
449 let buffer_id = resolved_conflict.ours.end.buffer_id;
450 let buffer = multibuffer.read(cx).buffer(buffer_id)?;
451 resolved_conflict.resolve(buffer.clone(), &ranges, cx);
452 let conflict_addon = editor.addon_mut::<ConflictAddon>().unwrap();
453 let snapshot = multibuffer.read(cx).snapshot(cx);
454 let buffer_snapshot = buffer.read(cx).snapshot();
455 let state = conflict_addon
456 .buffers
457 .get_mut(&buffer_snapshot.remote_id())?;
458 let ix = state
459 .block_ids
460 .binary_search_by(|(range, _)| {
461 range
462 .start
463 .cmp(&resolved_conflict.range.start, &buffer_snapshot)
464 })
465 .ok()?;
466 let &(_, block_id) = &state.block_ids[ix];
467 let range =
468 snapshot.buffer_anchor_range_to_anchor_range(resolved_conflict.range)?;
469
470 editor.remove_gutter_highlights::<ConflictsOuter>(vec![range.clone()], cx);
471
472 editor.remove_highlighted_rows::<ConflictsOuter>(vec![range.clone()], cx);
473 editor.remove_highlighted_rows::<ConflictsOurs>(vec![range.clone()], cx);
474 editor.remove_highlighted_rows::<ConflictsTheirs>(vec![range.clone()], cx);
475 editor.remove_highlighted_rows::<ConflictsOursMarker>(vec![range.clone()], cx);
476 editor.remove_highlighted_rows::<ConflictsTheirsMarker>(vec![range], cx);
477 editor.remove_blocks(HashSet::from_iter([block_id]), None, cx);
478 Some((workspace, project, multibuffer, buffer))
479 })
480 .ok()
481 .flatten()
482 else {
483 return;
484 };
485 let save = project.update(cx, |project, cx| {
486 if multibuffer.read(cx).all_diff_hunks_expanded() {
487 project.save_buffer(buffer.clone(), cx)
488 } else {
489 Task::ready(Ok(()))
490 }
491 });
492 if save.await.log_err().is_none() {
493 let open_path = maybe!({
494 let path = buffer.read_with(cx, |buffer, cx| buffer.project_path(cx))?;
495 workspace
496 .update_in(cx, |workspace, window, cx| {
497 workspace.open_path_preview(path, None, false, false, false, window, cx)
498 })
499 .ok()
500 });
501
502 if let Some(open_path) = open_path {
503 open_path.await.log_err();
504 }
505 }
506 })
507}
508
509pub struct MergeConflictIndicator {
510 project: Entity<Project>,
511 conflicted_paths: Vec<String>,
512 last_shown_paths: HashSet<String>,
513 dismissed: bool,
514 _subscription: Subscription,
515}
516
517impl MergeConflictIndicator {
518 pub fn new(workspace: &Workspace, cx: &mut Context<Self>) -> Self {
519 let project = workspace.project().clone();
520 let git_store = project.read(cx).git_store().clone();
521
522 let subscription = cx.subscribe(&git_store, Self::on_git_store_event);
523
524 let conflicted_paths = collect_conflicted_file_paths(project.read(cx), cx);
525 let last_shown_paths: HashSet<String> = conflicted_paths.iter().cloned().collect();
526
527 Self {
528 project,
529 conflicted_paths,
530 last_shown_paths,
531 dismissed: false,
532 _subscription: subscription,
533 }
534 }
535
536 fn on_git_store_event(
537 &mut self,
538 _git_store: Entity<GitStore>,
539 event: &GitStoreEvent,
540 cx: &mut Context<Self>,
541 ) {
542 let conflicts_changed = matches!(
543 event,
544 GitStoreEvent::ConflictsUpdated
545 | GitStoreEvent::RepositoryUpdated(_, RepositoryEvent::StatusesChanged, _)
546 );
547
548 let agent_settings = AgentSettings::get_global(cx);
549 if !agent_settings.enabled(cx)
550 || !agent_settings.show_merge_conflict_indicator
551 || !conflicts_changed
552 {
553 return;
554 }
555
556 let project = self.project.read(cx);
557 if project.is_via_collab() {
558 return;
559 }
560
561 let paths = collect_conflicted_file_paths(project, cx);
562 let current_paths_set: HashSet<String> = paths.iter().cloned().collect();
563
564 if paths.is_empty() {
565 self.conflicted_paths.clear();
566 self.last_shown_paths.clear();
567 self.dismissed = false;
568 cx.notify();
569 } else if self.last_shown_paths != current_paths_set {
570 self.last_shown_paths = current_paths_set;
571 self.conflicted_paths = paths;
572 self.dismissed = false;
573 cx.notify();
574 }
575 }
576
577 fn resolve_with_agent(&mut self, window: &mut Window, cx: &mut Context<Self>) {
578 window.dispatch_action(
579 Box::new(ResolveConflictedFilesWithAgent {
580 conflicted_file_paths: self.conflicted_paths.clone(),
581 }),
582 cx,
583 );
584 self.dismissed = true;
585 cx.notify();
586 }
587
588 fn dismiss(&mut self, _: &ClickEvent, _window: &mut Window, cx: &mut Context<Self>) {
589 self.dismissed = true;
590 cx.notify();
591 }
592}
593
594impl Render for MergeConflictIndicator {
595 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
596 let agent_settings = AgentSettings::get_global(cx);
597 if !agent_settings.enabled(cx)
598 || !agent_settings.show_merge_conflict_indicator
599 || self.conflicted_paths.is_empty()
600 || self.dismissed
601 {
602 return Empty.into_any_element();
603 }
604
605 let file_count = self.conflicted_paths.len();
606
607 let message: SharedString = format!(
608 "Resolve Merge Conflict{} with Agent",
609 if file_count == 1 { "" } else { "s" }
610 )
611 .into();
612
613 let tooltip_label: SharedString = format!(
614 "Found {} {} across the codebase",
615 file_count,
616 if file_count == 1 {
617 "conflict"
618 } else {
619 "conflicts"
620 }
621 )
622 .into();
623
624 let border_color = cx.theme().colors().text_accent.opacity(0.2);
625
626 h_flex()
627 .h(rems_from_px(22.))
628 .rounded_sm()
629 .border_1()
630 .border_color(border_color)
631 .child(
632 ButtonLike::new("update-button")
633 .child(
634 h_flex()
635 .h_full()
636 .gap_1()
637 .child(
638 Icon::new(IconName::GitMergeConflict)
639 .size(IconSize::Small)
640 .color(Color::Muted),
641 )
642 .child(Label::new(message).size(LabelSize::Small)),
643 )
644 .tooltip(move |_, cx| {
645 Tooltip::with_meta(
646 tooltip_label.clone(),
647 None,
648 "Click to Resolve with Agent",
649 cx,
650 )
651 })
652 .on_click(cx.listener(|this, _, window, cx| {
653 this.resolve_with_agent(window, cx);
654 })),
655 )
656 .child(
657 div().border_l_1().border_color(border_color).child(
658 IconButton::new("dismiss-merge-conflicts", IconName::Close)
659 .icon_size(IconSize::XSmall)
660 .on_click(cx.listener(Self::dismiss)),
661 ),
662 )
663 .into_any_element()
664 }
665}
666
667impl StatusItemView for MergeConflictIndicator {
668 fn set_active_pane_item(
669 &mut self,
670 _: Option<&dyn ItemHandle>,
671 _window: &mut Window,
672 _: &mut Context<Self>,
673 ) {
674 }
675}