conflict_view.rs

  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                    .start_icon(
457                        Icon::new(IconName::ZedAssistant)
458                            .size(IconSize::Small)
459                            .color(Color::Muted),
460                    )
461                    .on_click({
462                        let conflict = conflict.clone();
463                        move |_, window, cx| {
464                            let content = editor
465                                .update(cx, |editor, cx| {
466                                    let multibuffer = editor.buffer().read(cx);
467                                    let buffer_id = conflict.ours.end.buffer_id?;
468                                    let buffer = multibuffer.buffer(buffer_id)?;
469                                    let buffer_read = buffer.read(cx);
470                                    let snapshot = buffer_read.snapshot();
471                                    let conflict_text = snapshot
472                                        .text_for_range(conflict.range.clone())
473                                        .collect::<String>();
474                                    let file_path = buffer_read
475                                        .file()
476                                        .and_then(|file| file.as_local())
477                                        .map(|f| f.abs_path(cx).to_string_lossy().to_string())
478                                        .unwrap_or_default();
479                                    Some(ConflictContent {
480                                        file_path,
481                                        conflict_text,
482                                        ours_branch_name: conflict.ours_branch_name.to_string(),
483                                        theirs_branch_name: conflict.theirs_branch_name.to_string(),
484                                    })
485                                })
486                                .ok()
487                                .flatten();
488                            if let Some(content) = content {
489                                window.dispatch_action(
490                                    Box::new(ResolveConflictsWithAgent {
491                                        conflicts: vec![content],
492                                    }),
493                                    cx,
494                                );
495                            }
496                        }
497                    }),
498            )
499        })
500        .into_any()
501}
502
503struct MergeConflictNotification;
504
505fn merge_conflict_notification_id() -> NotificationId {
506    NotificationId::unique::<MergeConflictNotification>()
507}
508
509fn collect_conflicted_file_paths(workspace: &Workspace, cx: &App) -> Vec<String> {
510    let project = workspace.project().read(cx);
511    let git_store = project.git_store().read(cx);
512    let mut paths = Vec::new();
513
514    for repo in git_store.repositories().values() {
515        let snapshot = repo.read(cx).snapshot();
516        for (repo_path, _) in snapshot.merge.merge_heads_by_conflicted_path.iter() {
517            if let Some(project_path) = repo.read(cx).repo_path_to_project_path(repo_path, cx) {
518                paths.push(
519                    project_path
520                        .path
521                        .as_std_path()
522                        .to_string_lossy()
523                        .to_string(),
524                );
525            }
526        }
527    }
528
529    paths
530}
531
532pub(crate) fn register_conflict_notification(
533    workspace: &mut Workspace,
534    cx: &mut Context<Workspace>,
535) {
536    let git_store = workspace.project().read(cx).git_store().clone();
537
538    let last_shown_paths: Rc<RefCell<HashSet<String>>> = Rc::new(RefCell::new(HashSet::default()));
539
540    cx.subscribe(&git_store, move |workspace, _git_store, event, cx| {
541        let conflicts_changed = matches!(
542            event,
543            GitStoreEvent::ConflictsUpdated
544                | GitStoreEvent::RepositoryUpdated(_, RepositoryEvent::StatusesChanged, _)
545        );
546        if !AgentSettings::get_global(cx).enabled || !conflicts_changed {
547            return;
548        }
549
550        let paths = collect_conflicted_file_paths(workspace, cx);
551        let notification_id = merge_conflict_notification_id();
552        let current_paths_set: HashSet<String> = paths.iter().cloned().collect();
553
554        if paths.is_empty() {
555            last_shown_paths.borrow_mut().clear();
556            workspace.dismiss_notification(&notification_id, cx);
557        } else if *last_shown_paths.borrow() != current_paths_set {
558            // Only show the notification if the set of conflicted paths has changed.
559            // This prevents re-showing after the user dismisses it while working on the same conflicts.
560            *last_shown_paths.borrow_mut() = current_paths_set;
561            let file_count = paths.len();
562            workspace.show_notification(notification_id, cx, |cx| {
563                cx.new(|cx| {
564                    let message = if file_count == 1 {
565                        "1 file has unresolved merge conflicts".to_string()
566                    } else {
567                        format!("{file_count} files have unresolved merge conflicts")
568                    };
569
570                    MessageNotification::new(message, cx)
571                        .primary_message("Resolve with Agent")
572                        .primary_icon(IconName::ZedAssistant)
573                        .primary_icon_color(Color::Muted)
574                        .primary_on_click({
575                            let paths = paths.clone();
576                            move |window, cx| {
577                                window.dispatch_action(
578                                    Box::new(ResolveConflictedFilesWithAgent {
579                                        conflicted_file_paths: paths.clone(),
580                                    }),
581                                    cx,
582                                );
583                                cx.emit(DismissEvent);
584                            }
585                        })
586                })
587            });
588        }
589    })
590    .detach();
591}
592
593pub(crate) fn resolve_conflict(
594    editor: WeakEntity<Editor>,
595    excerpt_id: ExcerptId,
596    resolved_conflict: ConflictRegion,
597    ranges: Vec<Range<Anchor>>,
598    window: &mut Window,
599    cx: &mut App,
600) -> Task<()> {
601    window.spawn(cx, async move |cx| {
602        let Some((workspace, project, multibuffer, buffer)) = editor
603            .update(cx, |editor, cx| {
604                let workspace = editor.workspace()?;
605                let project = editor.project()?.clone();
606                let multibuffer = editor.buffer().clone();
607                let buffer_id = resolved_conflict.ours.end.buffer_id?;
608                let buffer = multibuffer.read(cx).buffer(buffer_id)?;
609                resolved_conflict.resolve(buffer.clone(), &ranges, cx);
610                let conflict_addon = editor.addon_mut::<ConflictAddon>().unwrap();
611                let snapshot = multibuffer.read(cx).snapshot(cx);
612                let buffer_snapshot = buffer.read(cx).snapshot();
613                let state = conflict_addon
614                    .buffers
615                    .get_mut(&buffer_snapshot.remote_id())?;
616                let ix = state
617                    .block_ids
618                    .binary_search_by(|(range, _)| {
619                        range
620                            .start
621                            .cmp(&resolved_conflict.range.start, &buffer_snapshot)
622                    })
623                    .ok()?;
624                let &(_, block_id) = &state.block_ids[ix];
625                let range =
626                    snapshot.anchor_range_in_excerpt(excerpt_id, resolved_conflict.range)?;
627
628                editor.remove_gutter_highlights::<ConflictsOuter>(vec![range.clone()], cx);
629
630                editor.remove_highlighted_rows::<ConflictsOuter>(vec![range.clone()], cx);
631                editor.remove_highlighted_rows::<ConflictsOurs>(vec![range.clone()], cx);
632                editor.remove_highlighted_rows::<ConflictsTheirs>(vec![range.clone()], cx);
633                editor.remove_highlighted_rows::<ConflictsOursMarker>(vec![range.clone()], cx);
634                editor.remove_highlighted_rows::<ConflictsTheirsMarker>(vec![range], cx);
635                editor.remove_blocks(HashSet::from_iter([block_id]), None, cx);
636                Some((workspace, project, multibuffer, buffer))
637            })
638            .ok()
639            .flatten()
640        else {
641            return;
642        };
643        let save = project.update(cx, |project, cx| {
644            if multibuffer.read(cx).all_diff_hunks_expanded() {
645                project.save_buffer(buffer.clone(), cx)
646            } else {
647                Task::ready(Ok(()))
648            }
649        });
650        if save.await.log_err().is_none() {
651            let open_path = maybe!({
652                let path = buffer.read_with(cx, |buffer, cx| buffer.project_path(cx))?;
653                workspace
654                    .update_in(cx, |workspace, window, cx| {
655                        workspace.open_path_preview(path, None, false, false, false, window, cx)
656                    })
657                    .ok()
658            });
659
660            if let Some(open_path) = open_path {
661                open_path.await.log_err();
662            }
663        }
664    })
665}