use agent_settings::AgentSettings; use collections::{HashMap, HashSet}; use editor::{ ConflictsOurs, ConflictsOursMarker, ConflictsOuter, ConflictsTheirs, ConflictsTheirsMarker, Editor, MultiBuffer, RowHighlightOptions, display_map::{BlockContext, BlockPlacement, BlockProperties, BlockStyle, CustomBlockId}, }; use gpui::{ App, ClickEvent, Context, Empty, Entity, InteractiveElement as _, ParentElement as _, Subscription, Task, WeakEntity, }; use language::{Anchor, Buffer, BufferId}; use project::{ ConflictRegion, ConflictSet, ConflictSetUpdate, Project, git_store::{GitStore, GitStoreEvent, RepositoryEvent}, }; use settings::Settings; use std::{ops::Range, sync::Arc}; use ui::{ButtonLike, Divider, Tooltip, prelude::*}; use util::debug_panic; use workspace::{HideStatusItem, StatusItemView, Workspace, item::ItemHandle}; use zed_actions::agent::{ ConflictContent, ResolveConflictedFilesWithAgent, ResolveConflictsWithAgent, }; pub(crate) struct ConflictAddon { buffers: HashMap, } struct BufferConflicts { block_ids: Vec<(Range, CustomBlockId)>, conflict_set: Entity, _subscription: Subscription, } impl editor::Addon for ConflictAddon { fn to_any(&self) -> &dyn std::any::Any { self } fn to_any_mut(&mut self) -> Option<&mut dyn std::any::Any> { Some(self) } } pub fn register_editor(editor: &mut Editor, buffer: Entity, cx: &mut Context) { let is_singleton = editor.buffer().read(cx).is_singleton(); if !editor.mode().is_full() || (!is_singleton && !editor.buffer().read(cx).all_diff_hunks_expanded()) || editor.read_only(cx) { return; } editor.register_addon(ConflictAddon { buffers: Default::default(), }); if is_singleton { let buffers = buffer.read(cx).all_buffers(); for buffer in buffers { open_conflict_set_for_buffer(editor, buffer, cx); } } } fn open_conflict_set_for_buffer( _editor: &mut Editor, buffer: Entity, cx: &mut Context, ) { let buffer = buffer.downgrade(); cx.spawn(async move |editor, cx| { let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id())?; if let Some(conflict_set) = editor.read_with(cx, |editor, _| { editor .addon::() .and_then(|addon| addon.buffers.get(&buffer_id)) .map(|buffer_conflicts| buffer_conflicts.conflict_set.clone()) })? { editor.update(cx, |editor, cx| { buffer_ranges_updated(editor, conflict_set, cx); })?; return anyhow::Ok(()); } let Some(project) = editor.read_with(cx, |editor, _| editor.project().cloned())? else { return anyhow::Ok(()); }; let git_store = project.read_with(cx, |project, _| project.git_store().clone()); let Some(buffer) = buffer.upgrade() else { return Ok(()); }; let conflict_set = git_store .update(cx, |git_store, cx| { git_store.open_conflict_set(buffer.clone(), cx) }) .await; editor.update(cx, |editor, cx| { buffer_ranges_updated(editor, conflict_set, cx); })?; Ok(()) }) .detach(); } pub(crate) fn buffer_ranges_updated( editor: &mut Editor, conflict_set: Entity, cx: &mut Context, ) { let buffer_id = conflict_set.read(cx).snapshot.buffer_id; if editor.buffer().read(cx).buffer(buffer_id).is_none() { return; } let Some(conflict_addon) = editor.addon_mut::() else { return; }; let buffer_conflicts = conflict_addon.buffers.entry(buffer_id).or_insert_with(|| { let subscription = cx.subscribe(&conflict_set, conflicts_updated); BufferConflicts { block_ids: Vec::new(), conflict_set: conflict_set.clone(), _subscription: subscription, } }); let conflict_set = buffer_conflicts.conflict_set.clone(); let conflicts_len = conflict_set.read(cx).snapshot().conflicts.len(); let addon_conflicts_len = buffer_conflicts.block_ids.len(); conflicts_updated( editor, conflict_set, &ConflictSetUpdate { buffer_range: None, old_range: 0..addon_conflicts_len, new_range: 0..conflicts_len, }, cx, ); } pub(crate) fn buffers_removed( editor: &mut Editor, removed_buffer_ids: &[BufferId], cx: &mut Context, ) { let mut removed_block_ids = HashSet::default(); let Some(conflict_addon) = editor.addon_mut::() else { return; }; conflict_addon.buffers.retain(|buffer_id, buffer| { if removed_buffer_ids.contains(buffer_id) { removed_block_ids.extend(buffer.block_ids.iter().map(|(_, block_id)| *block_id)); false } else { true } }); editor.remove_blocks(removed_block_ids, None, cx); } #[ztracing::instrument(skip_all)] fn conflicts_updated( editor: &mut Editor, conflict_set: Entity, event: &ConflictSetUpdate, cx: &mut Context, ) { let buffer_id = conflict_set.read(cx).snapshot.buffer_id; let conflict_set = conflict_set.read(cx).snapshot(); let multibuffer = editor.buffer().read(cx); let snapshot = multibuffer.snapshot(cx); let old_range = { let Some(conflict_addon) = editor.addon_mut::() else { return; }; let Some(buffer_conflicts) = conflict_addon.buffers.get(&buffer_id) else { return; }; match buffer_conflicts.block_ids.get(event.old_range.clone()) { Some(_) => Some(event.old_range.clone()), None => { debug_panic!( "conflicts updated event old range is invalid for buffer conflicts view (block_ids len is {:?}, old_range is {:?})", buffer_conflicts.block_ids.len(), event.old_range, ); if event.old_range.start <= event.old_range.end { Some( event.old_range.start.min(buffer_conflicts.block_ids.len()) ..event.old_range.end.min(buffer_conflicts.block_ids.len()), ) } else { None } } } }; // Remove obsolete highlights and blocks let Some(conflict_addon) = editor.addon_mut::() else { return; }; if let Some((buffer_conflicts, old_range)) = conflict_addon .buffers .get_mut(&buffer_id) .zip(old_range.clone()) { let old_conflicts = buffer_conflicts.block_ids[old_range].to_owned(); let mut removed_highlighted_ranges = Vec::new(); let mut removed_block_ids = HashSet::default(); for (conflict_range, block_id) in old_conflicts { let Some(range) = snapshot.buffer_anchor_range_to_anchor_range(conflict_range) else { continue; }; removed_highlighted_ranges.push(range.clone()); removed_block_ids.insert(block_id); } editor.remove_gutter_highlights::(removed_highlighted_ranges.clone(), cx); editor.remove_highlighted_rows::(removed_highlighted_ranges.clone(), cx); editor.remove_highlighted_rows::(removed_highlighted_ranges.clone(), cx); editor .remove_highlighted_rows::(removed_highlighted_ranges.clone(), cx); editor.remove_highlighted_rows::(removed_highlighted_ranges.clone(), cx); editor.remove_highlighted_rows::( removed_highlighted_ranges.clone(), cx, ); editor.remove_blocks(removed_block_ids, None, cx); } // Add new highlights and blocks let editor_handle = cx.weak_entity(); let new_conflicts = &conflict_set.conflicts[event.new_range.clone()]; let mut blocks = Vec::new(); for conflict in new_conflicts { update_conflict_highlighting(editor, conflict, &snapshot, cx); let Some(anchor) = snapshot.anchor_in_excerpt(conflict.range.start) else { continue; }; let editor_handle = editor_handle.clone(); blocks.push(BlockProperties { placement: BlockPlacement::Above(anchor), height: Some(1), style: BlockStyle::Sticky, render: Arc::new({ let conflict = conflict.clone(); move |cx| render_conflict_buttons(&conflict, editor_handle.clone(), cx) }), priority: 0, }) } let new_block_ids = editor.insert_blocks(blocks, None, cx); let Some(conflict_addon) = editor.addon_mut::() else { return; }; if let Some((buffer_conflicts, old_range)) = conflict_addon.buffers.get_mut(&buffer_id).zip(old_range) { buffer_conflicts.block_ids.splice( old_range, new_conflicts .iter() .map(|conflict| conflict.range.clone()) .zip(new_block_ids), ); } } #[ztracing::instrument(skip_all)] fn update_conflict_highlighting( editor: &mut Editor, conflict: &ConflictRegion, buffer: &editor::MultiBufferSnapshot, cx: &mut Context, ) -> Option<()> { log::debug!("update conflict highlighting for {conflict:?}"); let outer = buffer.buffer_anchor_range_to_anchor_range(conflict.range.clone())?; let ours = buffer.buffer_anchor_range_to_anchor_range(conflict.ours.clone())?; let theirs = buffer.buffer_anchor_range_to_anchor_range(conflict.theirs.clone())?; let ours_background = |cx: &App| cx.theme().colors().version_control_conflict_marker_ours; let theirs_background = |cx: &App| cx.theme().colors().version_control_conflict_marker_theirs; let options = RowHighlightOptions { include_gutter: true, ..Default::default() }; editor.insert_gutter_highlight::( outer.start..theirs.end, |cx| cx.theme().colors().editor_background, cx, ); // Prevent diff hunk highlighting within the entire conflict region. editor.highlight_rows::(outer.clone(), theirs_background, options, cx); editor.highlight_rows::(ours.clone(), ours_background, options, cx); editor.highlight_rows::( outer.start..ours.start, ours_background, options, cx, ); editor.highlight_rows::(theirs.clone(), theirs_background, options, cx); editor.highlight_rows::( theirs.end..outer.end, theirs_background, options, cx, ); Some(()) } fn render_conflict_buttons( conflict: &ConflictRegion, editor: WeakEntity, cx: &mut BlockContext, ) -> AnyElement { let is_ai_enabled = AgentSettings::get_global(cx).enabled(cx); h_flex() .id(cx.block_id) .h(cx.line_height) .ml(cx.margins.gutter.width) .gap_1() .bg(cx.theme().colors().editor_background) .child( Button::new("head", format!("Use {}", conflict.ours_branch_name)) .label_size(LabelSize::Small) .on_click({ let editor = editor.clone(); let conflict = conflict.clone(); let ours = conflict.ours.clone(); move |_, window, cx| { resolve_conflict( editor.clone(), conflict.clone(), vec![ours.clone()], window, cx, ) .detach() } }), ) .child( Button::new("origin", format!("Use {}", conflict.theirs_branch_name)) .label_size(LabelSize::Small) .on_click({ let editor = editor.clone(); let conflict = conflict.clone(); let theirs = conflict.theirs.clone(); move |_, window, cx| { resolve_conflict( editor.clone(), conflict.clone(), vec![theirs.clone()], window, cx, ) .detach() } }), ) .child( Button::new("both", "Use Both") .label_size(LabelSize::Small) .on_click({ let editor = editor.clone(); let conflict = conflict.clone(); let ours = conflict.ours.clone(); let theirs = conflict.theirs.clone(); move |_, window, cx| { resolve_conflict( editor.clone(), conflict.clone(), vec![ours.clone(), theirs.clone()], window, cx, ) .detach() } }), ) .when(is_ai_enabled, |this| { this.child(Divider::vertical()).child( Button::new("resolve-with-agent", "Resolve with Agent") .label_size(LabelSize::Small) .start_icon( Icon::new(IconName::ZedAssistant) .size(IconSize::Small) .color(Color::Muted), ) .on_click({ let conflict = conflict.clone(); move |_, window, cx| { let content = editor .update(cx, |editor, cx| { let multibuffer = editor.buffer().read(cx); let buffer_id = conflict.ours.end.buffer_id; let buffer = multibuffer.buffer(buffer_id)?; let buffer_read = buffer.read(cx); let snapshot = buffer_read.snapshot(); let conflict_text = snapshot .text_for_range(conflict.range.clone()) .collect::(); let file_path = buffer_read .file() .and_then(|file| file.as_local()) .map(|f| f.abs_path(cx).to_string_lossy().to_string()) .unwrap_or_default(); Some(ConflictContent { file_path, conflict_text, ours_branch_name: conflict.ours_branch_name.to_string(), theirs_branch_name: conflict.theirs_branch_name.to_string(), }) }) .ok() .flatten(); if let Some(content) = content { window.dispatch_action( Box::new(ResolveConflictsWithAgent { conflicts: vec![content], }), cx, ); } } }), ) }) .into_any() } fn collect_conflicted_file_paths(project: &Project, cx: &App) -> Vec { let git_store = project.git_store().read(cx); let mut paths = Vec::new(); for repo in git_store.repositories().values() { let snapshot = repo.read(cx).snapshot(); for (repo_path, _) in snapshot.merge.merge_heads_by_conflicted_path.iter() { let is_currently_conflicted = snapshot .status_for_path(repo_path) .is_some_and(|entry| entry.status.is_conflicted()); if !is_currently_conflicted { continue; } if let Some(project_path) = repo.read(cx).repo_path_to_project_path(repo_path, cx) { paths.push( project_path .path .as_std_path() .to_string_lossy() .to_string(), ); } } } paths } pub(crate) fn resolve_conflict( editor: WeakEntity, resolved_conflict: ConflictRegion, ranges: Vec>, window: &mut Window, cx: &mut App, ) -> Task<()> { window.spawn(cx, async move |cx| { editor .update(cx, |editor, cx| { let multibuffer = editor.buffer().clone(); let buffer_id = resolved_conflict.ours.end.buffer_id; let buffer = multibuffer.read(cx).buffer(buffer_id)?; resolved_conflict.resolve(buffer.clone(), &ranges, cx); let conflict_addon = editor.addon_mut::()?; let snapshot = multibuffer.read(cx).snapshot(cx); let buffer_snapshot = buffer.read(cx).snapshot(); let state = conflict_addon .buffers .get_mut(&buffer_snapshot.remote_id())?; let ix = state .block_ids .binary_search_by(|(range, _)| { range .start .cmp(&resolved_conflict.range.start, &buffer_snapshot) }) .ok()?; let &(_, block_id) = &state.block_ids[ix]; let range = snapshot.buffer_anchor_range_to_anchor_range(resolved_conflict.range)?; editor.remove_gutter_highlights::(vec![range.clone()], cx); editor.remove_highlighted_rows::(vec![range.clone()], cx); editor.remove_highlighted_rows::(vec![range.clone()], cx); editor.remove_highlighted_rows::(vec![range.clone()], cx); editor.remove_highlighted_rows::(vec![range.clone()], cx); editor.remove_highlighted_rows::(vec![range], cx); editor.remove_blocks(HashSet::from_iter([block_id]), None, cx); Some(()) }) .ok(); }) } pub struct MergeConflictIndicator { project: Entity, conflicted_paths: Vec, last_shown_paths: HashSet, dismissed: bool, _subscription: Subscription, } impl MergeConflictIndicator { pub fn new(workspace: &Workspace, cx: &mut Context) -> Self { let project = workspace.project().clone(); let git_store = project.read(cx).git_store().clone(); let subscription = cx.subscribe(&git_store, Self::on_git_store_event); let conflicted_paths = collect_conflicted_file_paths(project.read(cx), cx); let last_shown_paths: HashSet = conflicted_paths.iter().cloned().collect(); Self { project, conflicted_paths, last_shown_paths, dismissed: false, _subscription: subscription, } } fn on_git_store_event( &mut self, _git_store: Entity, event: &GitStoreEvent, cx: &mut Context, ) { let conflicts_changed = matches!( event, GitStoreEvent::ConflictsUpdated | GitStoreEvent::RepositoryUpdated(_, RepositoryEvent::StatusesChanged, _) ); let agent_settings = AgentSettings::get_global(cx); if !agent_settings.enabled(cx) || !agent_settings.show_merge_conflict_indicator || !conflicts_changed { return; } let project = self.project.read(cx); if project.is_via_collab() { return; } let paths = collect_conflicted_file_paths(project, cx); let current_paths_set: HashSet = paths.iter().cloned().collect(); if paths.is_empty() { self.conflicted_paths.clear(); self.last_shown_paths.clear(); self.dismissed = false; cx.notify(); } else if self.last_shown_paths != current_paths_set { self.last_shown_paths = current_paths_set; self.conflicted_paths = paths; self.dismissed = false; cx.notify(); } } fn resolve_with_agent(&mut self, window: &mut Window, cx: &mut Context) { window.dispatch_action( Box::new(ResolveConflictedFilesWithAgent { conflicted_file_paths: self.conflicted_paths.clone(), }), cx, ); self.dismissed = true; cx.notify(); } fn dismiss(&mut self, _: &ClickEvent, _window: &mut Window, cx: &mut Context) { self.dismissed = true; cx.notify(); } } impl Render for MergeConflictIndicator { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let agent_settings = AgentSettings::get_global(cx); if !agent_settings.enabled(cx) || !agent_settings.show_merge_conflict_indicator || self.conflicted_paths.is_empty() || self.dismissed { return Empty.into_any_element(); } let file_count = self.conflicted_paths.len(); let message: SharedString = format!( "Resolve Merge Conflict{} with Agent", if file_count == 1 { "" } else { "s" } ) .into(); let tooltip_label: SharedString = format!( "Found {} {} across the codebase", file_count, if file_count == 1 { "conflict" } else { "conflicts" } ) .into(); let border_color = cx.theme().colors().text_accent.opacity(0.2); h_flex() .h(rems_from_px(22.)) .rounded_sm() .border_1() .border_color(border_color) .child( ButtonLike::new("update-button") .tab_index(0isize) .aria_label(message.clone()) .child( h_flex() .h_full() .gap_1() .child( Icon::new(IconName::GitMergeConflict) .size(IconSize::Small) .color(Color::Muted), ) .child(Label::new(message).size(LabelSize::Small)), ) .tooltip(move |_, cx| { Tooltip::with_meta( tooltip_label.clone(), None, "Click to Resolve with Agent", cx, ) }) .on_click(cx.listener(|this, _, window, cx| { this.resolve_with_agent(window, cx); })), ) .child( div().border_l_1().border_color(border_color).child( IconButton::new("dismiss-merge-conflicts", IconName::Close) .icon_size(IconSize::XSmall) .on_click(cx.listener(Self::dismiss)), ), ) .into_any_element() } } impl StatusItemView for MergeConflictIndicator { fn set_active_pane_item( &mut self, _: Option<&dyn ItemHandle>, _window: &mut Window, _: &mut Context, ) { } fn hide_setting(&self, _: &App) -> Option { Some(HideStatusItem::new(|settings| { settings .agent .get_or_insert_default() .show_merge_conflict_indicator = Some(false); })) } }