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