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                    .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(&notification_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}