conflict_view.rs

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