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