From 724c9351968b5fab6f17fb2feec39039736423e0 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Wed, 23 Apr 2025 12:38:46 -0400 Subject: [PATCH] Highlight merge conflicts and provide for resolving them (#28065) TODO: - [x] Make it work in the project diff: - [x] Support non-singleton buffers - [x] Adjust excerpt boundaries to show full conflicts - [x] Write tests for conflict-related events and state management - [x] Prevent hunk buttons from appearing inside conflicts - [x] Make sure it works over SSH, collab - [x] Allow separate theming of markers Bonus: - [ ] Count of conflicts in toolbar - [ ] Keyboard-driven navigation and resolution - [ ] ~~Inlay hints to contextualize "ours"/"theirs"~~ Release Notes: - Implemented initial support for resolving merge conflicts. --------- Co-authored-by: Max Brunsfeld --- crates/agent/src/inline_assistant.rs | 4 +- crates/assistant/src/inline_assistant.rs | 4 +- crates/editor/src/editor.rs | 92 ++- crates/editor/src/element.rs | 42 +- crates/editor/src/items.rs | 2 +- crates/fs/src/fake_git_repo.rs | 22 +- crates/git/src/repository.rs | 2 +- crates/git_ui/src/conflict_view.rs | 473 +++++++++++++++ crates/git_ui/src/git_panel.rs | 4 +- crates/git_ui/src/git_ui.rs | 7 + crates/git_ui/src/project_diff.rs | 135 +++-- crates/go_to_line/src/go_to_line.rs | 8 +- crates/multi_buffer/src/multi_buffer.rs | 24 +- crates/multi_buffer/src/multi_buffer_tests.rs | 2 +- crates/outline/src/outline.rs | 6 +- crates/outline_panel/src/outline_panel.rs | 2 +- crates/project/src/git_store.rs | 339 +++++++++-- crates/project/src/git_store/conflict_set.rs | 560 ++++++++++++++++++ crates/project/src/project.rs | 5 +- crates/theme/src/default_colors.rs | 10 + crates/theme/src/fallback_themes.rs | 17 + crates/theme/src/schema.rs | 40 ++ crates/theme/src/styles/colors.rs | 8 + crates/vim/src/command.rs | 2 +- 24 files changed, 1626 insertions(+), 184 deletions(-) create mode 100644 crates/git_ui/src/conflict_view.rs create mode 100644 crates/project/src/git_store/conflict_set.rs diff --git a/crates/agent/src/inline_assistant.rs b/crates/agent/src/inline_assistant.rs index cca40e06b1..e8d626b82a 100644 --- a/crates/agent/src/inline_assistant.rs +++ b/crates/agent/src/inline_assistant.rs @@ -1328,7 +1328,7 @@ impl InlineAssistant { editor.highlight_rows::( row_range, cx.theme().status().info_background, - false, + Default::default(), cx, ); } @@ -1393,7 +1393,7 @@ impl InlineAssistant { editor.highlight_rows::( Anchor::min()..Anchor::max(), cx.theme().status().deleted_background, - false, + Default::default(), cx, ); editor diff --git a/crates/assistant/src/inline_assistant.rs b/crates/assistant/src/inline_assistant.rs index ea395c5dd4..179d3bf060 100644 --- a/crates/assistant/src/inline_assistant.rs +++ b/crates/assistant/src/inline_assistant.rs @@ -1226,7 +1226,7 @@ impl InlineAssistant { editor.highlight_rows::( row_range, cx.theme().status().info_background, - false, + Default::default(), cx, ); } @@ -1291,7 +1291,7 @@ impl InlineAssistant { editor.highlight_rows::( Anchor::min()..Anchor::max(), cx.theme().status().deleted_background, - false, + Default::default(), cx, ); editor diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index abaae331f5..e6d817ab38 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -269,6 +269,12 @@ enum DocumentHighlightWrite {} enum InputComposition {} enum SelectedTextHighlight {} +pub enum ConflictsOuter {} +pub enum ConflictsOurs {} +pub enum ConflictsTheirs {} +pub enum ConflictsOursMarker {} +pub enum ConflictsTheirsMarker {} + #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub enum Navigated { Yes, @@ -694,6 +700,10 @@ pub trait Addon: 'static { } fn to_any(&self) -> &dyn std::any::Any; + + fn to_any_mut(&mut self) -> Option<&mut dyn std::any::Any> { + None + } } /// A set of caret positions, registered when the editor was edited. @@ -1083,11 +1093,27 @@ impl SelectionHistory { } } +#[derive(Clone, Copy)] +pub struct RowHighlightOptions { + pub autoscroll: bool, + pub include_gutter: bool, +} + +impl Default for RowHighlightOptions { + fn default() -> Self { + Self { + autoscroll: Default::default(), + include_gutter: true, + } + } +} + struct RowHighlight { index: usize, range: Range, color: Hsla, - should_autoscroll: bool, + options: RowHighlightOptions, + type_id: TypeId, } #[derive(Clone, Debug)] @@ -5942,7 +5968,10 @@ impl Editor { self.highlight_rows::( target..target, cx.theme().colors().editor_highlighted_line_background, - true, + RowHighlightOptions { + autoscroll: true, + ..Default::default() + }, cx, ); self.request_autoscroll(Autoscroll::fit(), cx); @@ -13449,7 +13478,7 @@ impl Editor { start..end, highlight_color .unwrap_or_else(|| cx.theme().colors().editor_highlighted_line_background), - false, + Default::default(), cx, ); self.request_autoscroll(Autoscroll::center().for_anchor(start), cx); @@ -16765,7 +16794,7 @@ impl Editor { &mut self, range: Range, color: Hsla, - should_autoscroll: bool, + options: RowHighlightOptions, cx: &mut Context, ) { let snapshot = self.buffer().read(cx).snapshot(cx); @@ -16797,7 +16826,7 @@ impl Editor { merged = true; prev_highlight.index = index; prev_highlight.color = color; - prev_highlight.should_autoscroll = should_autoscroll; + prev_highlight.options = options; } } @@ -16808,7 +16837,8 @@ impl Editor { range: range.clone(), index, color, - should_autoscroll, + options, + type_id: TypeId::of::(), }, ); } @@ -16914,7 +16944,15 @@ impl Editor { used_highlight_orders.entry(row).or_insert(highlight.index); if highlight.index >= *used_index { *used_index = highlight.index; - unique_rows.insert(DisplayRow(row), highlight.color.into()); + unique_rows.insert( + DisplayRow(row), + LineHighlight { + include_gutter: highlight.options.include_gutter, + border: None, + background: highlight.color.into(), + type_id: Some(highlight.type_id), + }, + ); } } unique_rows @@ -16930,7 +16968,7 @@ impl Editor { .values() .flat_map(|highlighted_rows| highlighted_rows.iter()) .filter_map(|highlight| { - if highlight.should_autoscroll { + if highlight.options.autoscroll { Some(highlight.range.start.to_display_point(snapshot).row()) } else { None @@ -17405,13 +17443,19 @@ impl Editor { }); self.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx); } - multi_buffer::Event::ExcerptsRemoved { ids } => { + multi_buffer::Event::ExcerptsRemoved { + ids, + removed_buffer_ids, + } => { self.refresh_inlay_hints(InlayHintRefreshReason::ExcerptsRemoved(ids.clone()), cx); let buffer = self.buffer.read(cx); self.registered_buffers .retain(|buffer_id, _| buffer.buffer(*buffer_id).is_some()); jsx_tag_auto_close::refresh_enabled_in_any_buffer(self, multibuffer, cx); - cx.emit(EditorEvent::ExcerptsRemoved { ids: ids.clone() }) + cx.emit(EditorEvent::ExcerptsRemoved { + ids: ids.clone(), + removed_buffer_ids: removed_buffer_ids.clone(), + }) } multi_buffer::Event::ExcerptsEdited { excerpt_ids, @@ -18219,6 +18263,13 @@ impl Editor { .and_then(|item| item.to_any().downcast_ref::()) } + pub fn addon_mut(&mut self) -> Option<&mut T> { + let type_id = std::any::TypeId::of::(); + self.addons + .get_mut(&type_id) + .and_then(|item| item.to_any_mut()?.downcast_mut::()) + } + fn character_size(&self, window: &mut Window) -> gpui::Size { let text_layout_details = self.text_layout_details(window); let style = &text_layout_details.editor_style; @@ -19732,6 +19783,7 @@ pub enum EditorEvent { }, ExcerptsRemoved { ids: Vec, + removed_buffer_ids: Vec, }, BufferFoldToggled { ids: Vec, @@ -20672,24 +20724,8 @@ impl Render for MissingEditPredictionKeybindingTooltip { pub struct LineHighlight { pub background: Background, pub border: Option, -} - -impl From for LineHighlight { - fn from(hsla: Hsla) -> Self { - Self { - background: hsla.into(), - border: None, - } - } -} - -impl From for LineHighlight { - fn from(background: Background) -> Self { - Self { - background, - border: None, - } - } + pub include_gutter: bool, + pub type_id: Option, } fn render_diff_hunk_controls( diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 7d53caeba9..1649e0a694 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -1,6 +1,7 @@ use crate::{ ActiveDiagnostic, BlockId, COLUMNAR_SELECTION_MODIFIERS, CURSORS_VISIBLE_FOR, - ChunkRendererContext, ChunkReplacement, ContextMenuPlacement, CursorShape, CustomBlockId, + ChunkRendererContext, ChunkReplacement, ConflictsOurs, ConflictsOursMarker, ConflictsOuter, + ConflictsTheirs, ConflictsTheirsMarker, ContextMenuPlacement, CursorShape, CustomBlockId, DisplayDiffHunk, DisplayPoint, DisplayRow, DocumentHighlightRead, DocumentHighlightWrite, EditDisplayMode, Editor, EditorMode, EditorSettings, EditorSnapshot, EditorStyle, FILE_HEADER_HEIGHT, FocusedBlock, GutterDimensions, HalfPageDown, HalfPageUp, HandleInput, @@ -4036,6 +4037,7 @@ impl EditorElement { line_height: Pixels, scroll_pixel_position: gpui::Point, display_hunks: &[(DisplayDiffHunk, Option)], + highlighted_rows: &BTreeMap, editor: Entity, window: &mut Window, cx: &mut App, @@ -4064,6 +4066,22 @@ impl EditorElement { { continue; } + if highlighted_rows + .get(&display_row_range.start) + .and_then(|highlight| highlight.type_id) + .is_some_and(|type_id| { + [ + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + ] + .contains(&type_id) + }) + { + continue; + } let row_ix = (display_row_range.start - row_range.start).0 as usize; if row_infos[row_ix].diff_status.is_none() { continue; @@ -4258,14 +4276,21 @@ impl EditorElement { highlight_row_end: DisplayRow, highlight: crate::LineHighlight, edges| { + let mut origin_x = layout.hitbox.left(); + let mut width = layout.hitbox.size.width; + if !highlight.include_gutter { + origin_x += layout.gutter_hitbox.size.width; + width -= layout.gutter_hitbox.size.width; + } + let origin = point( - layout.hitbox.origin.x, + origin_x, layout.hitbox.origin.y + (highlight_row_start.as_f32() - scroll_top) * layout.position_map.line_height, ); let size = size( - layout.hitbox.size.width, + width, layout.position_map.line_height * highlight_row_end.next_row().minus(highlight_row_start) as f32, ); @@ -6789,10 +6814,16 @@ impl Element for EditorElement { } else { background_color.opacity(0.36) }), + include_gutter: true, + type_id: None, }; - let filled_highlight = - solid_background(background_color.opacity(hunk_opacity)).into(); + let filled_highlight = LineHighlight { + background: solid_background(background_color.opacity(hunk_opacity)), + border: None, + include_gutter: true, + type_id: None, + }; let background = if Self::diff_hunk_hollow(diff_status, cx) { hollow_highlight @@ -7551,6 +7582,7 @@ impl Element for EditorElement { line_height, scroll_pixel_position, &display_hunks, + &highlighted_rows, self.editor.clone(), window, cx, diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 9c12dab755..7d3f618f52 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -288,7 +288,7 @@ impl FollowableItem for Editor { } true } - EditorEvent::ExcerptsRemoved { ids } => { + EditorEvent::ExcerptsRemoved { ids, .. } => { update .deleted_excerpts .extend(ids.iter().map(ExcerptId::to_proto)); diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 83542e240d..ad5a2b0264 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -34,6 +34,7 @@ pub struct FakeGitRepositoryState { pub blames: HashMap, pub current_branch_name: Option, pub branches: HashSet, + pub merge_head_shas: Vec, pub simulated_index_write_error_message: Option, } @@ -47,12 +48,20 @@ impl FakeGitRepositoryState { blames: Default::default(), current_branch_name: Default::default(), branches: Default::default(), + merge_head_shas: Default::default(), simulated_index_write_error_message: Default::default(), } } } impl FakeGitRepository { + fn with_state(&self, write: bool, f: F) -> Result + where + F: FnOnce(&mut FakeGitRepositoryState) -> T, + { + self.fs.with_git_state(&self.dot_git_path, write, f) + } + fn with_state_async(&self, write: bool, f: F) -> BoxFuture<'static, Result> where F: 'static + Send + FnOnce(&mut FakeGitRepositoryState) -> Result, @@ -137,11 +146,18 @@ impl GitRepository for FakeGitRepository { } fn merge_head_shas(&self) -> Vec { - vec![] + self.with_state(false, |state| state.merge_head_shas.clone()) + .unwrap() } - fn show(&self, _commit: String) -> BoxFuture> { - unimplemented!() + fn show(&self, commit: String) -> BoxFuture> { + async { + Ok(CommitDetails { + sha: commit.into(), + ..Default::default() + }) + } + .boxed() } fn reset( diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index c490c53a7d..3e4db72edf 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -133,7 +133,7 @@ pub struct CommitSummary { pub has_parent: bool, } -#[derive(Clone, Debug, Hash, PartialEq, Eq)] +#[derive(Clone, Debug, Default, Hash, PartialEq, Eq)] pub struct CommitDetails { pub sha: SharedString, pub message: SharedString, diff --git a/crates/git_ui/src/conflict_view.rs b/crates/git_ui/src/conflict_view.rs new file mode 100644 index 0000000000..c869e57f7d --- /dev/null +++ b/crates/git_ui/src/conflict_view.rs @@ -0,0 +1,473 @@ +use collections::{HashMap, HashSet}; +use editor::{ + ConflictsOurs, ConflictsOursMarker, ConflictsOuter, ConflictsTheirs, ConflictsTheirsMarker, + Editor, EditorEvent, ExcerptId, MultiBuffer, RowHighlightOptions, + display_map::{BlockContext, BlockPlacement, BlockProperties, BlockStyle, CustomBlockId}, +}; +use gpui::{ + App, Context, Entity, InteractiveElement as _, ParentElement as _, Subscription, WeakEntity, +}; +use language::{Anchor, Buffer, BufferId}; +use project::{ConflictRegion, ConflictSet, ConflictSetUpdate}; +use std::{ops::Range, sync::Arc}; +use ui::{ + ActiveTheme, AnyElement, Element as _, StatefulInteractiveElement, Styled, + StyledTypography as _, div, h_flex, rems, +}; + +pub(crate) struct ConflictAddon { + buffers: HashMap, +} + +impl ConflictAddon { + pub(crate) fn conflict_set(&self, buffer_id: BufferId) -> Option> { + self.buffers + .get(&buffer_id) + .map(|entry| entry.conflict_set.clone()) + } +} + +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) { + // Only show conflict UI for singletons and in the project diff. + if !editor.buffer().read(cx).is_singleton() + && !editor.buffer().read(cx).all_diff_hunks_expanded() + { + return; + } + + editor.register_addon(ConflictAddon { + buffers: Default::default(), + }); + + let buffers = buffer.read(cx).all_buffers().clone(); + for buffer in buffers { + buffer_added(editor, buffer, cx); + } + + cx.subscribe(&cx.entity(), |editor, _, event, cx| match event { + EditorEvent::ExcerptsAdded { buffer, .. } => buffer_added(editor, buffer.clone(), cx), + EditorEvent::ExcerptsExpanded { ids } => { + let multibuffer = editor.buffer().read(cx).snapshot(cx); + for excerpt_id in ids { + let Some(buffer) = multibuffer.buffer_for_excerpt(*excerpt_id) else { + continue; + }; + let addon = editor.addon::().unwrap(); + let Some(conflict_set) = addon.conflict_set(buffer.remote_id()).clone() else { + return; + }; + excerpt_for_buffer_updated(editor, conflict_set, cx); + } + } + EditorEvent::ExcerptsRemoved { + removed_buffer_ids, .. + } => buffers_removed(editor, removed_buffer_ids, cx), + _ => {} + }) + .detach(); +} + +fn excerpt_for_buffer_updated( + editor: &mut Editor, + conflict_set: Entity, + cx: &mut Context, +) { + let conflicts_len = conflict_set.read(cx).snapshot().conflicts.len(); + conflicts_updated( + editor, + conflict_set, + &ConflictSetUpdate { + buffer_range: None, + old_range: 0..conflicts_len, + new_range: 0..conflicts_len, + }, + cx, + ); +} + +fn buffer_added(editor: &mut Editor, buffer: Entity, cx: &mut Context) { + let Some(project) = &editor.project else { + return; + }; + let git_store = project.read(cx).git_store().clone(); + + let buffer_conflicts = editor + .addon_mut::() + .unwrap() + .buffers + .entry(buffer.read(cx).remote_id()) + .or_insert_with(|| { + let conflict_set = git_store.update(cx, |git_store, cx| { + git_store.open_conflict_set(buffer.clone(), cx) + }); + 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, + ); +} + +fn buffers_removed(editor: &mut Editor, removed_buffer_ids: &[BufferId], cx: &mut Context) { + let mut removed_block_ids = HashSet::default(); + editor + .addon_mut::() + .unwrap() + .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); +} + +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 excerpts = multibuffer.excerpts_for_buffer(buffer_id, cx); + let Some(buffer_snapshot) = excerpts + .first() + .and_then(|(excerpt_id, _)| snapshot.buffer_for_excerpt(*excerpt_id)) + else { + return; + }; + + // Remove obsolete highlights and blocks + let conflict_addon = editor.addon_mut::().unwrap(); + if let Some(buffer_conflicts) = conflict_addon.buffers.get_mut(&buffer_id) { + let old_conflicts = buffer_conflicts.block_ids[event.old_range.clone()].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((excerpt_id, _)) = excerpts.iter().find(|(_, range)| { + let precedes_start = range + .context + .start + .cmp(&conflict_range.start, &buffer_snapshot) + .is_le(); + let follows_end = range + .context + .end + .cmp(&conflict_range.start, &buffer_snapshot) + .is_ge(); + precedes_start && follows_end + }) else { + continue; + }; + let excerpt_id = *excerpt_id; + let Some(range) = snapshot + .anchor_in_excerpt(excerpt_id, conflict_range.start) + .zip(snapshot.anchor_in_excerpt(excerpt_id, conflict_range.end)) + .map(|(start, end)| start..end) + else { + continue; + }; + removed_highlighted_ranges.push(range.clone()); + removed_block_ids.insert(block_id); + } + + 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 { + let Some((excerpt_id, _)) = excerpts.iter().find(|(_, range)| { + let precedes_start = range + .context + .start + .cmp(&conflict.range.start, &buffer_snapshot) + .is_le(); + let follows_end = range + .context + .end + .cmp(&conflict.range.start, &buffer_snapshot) + .is_ge(); + precedes_start && follows_end + }) else { + continue; + }; + let excerpt_id = *excerpt_id; + + update_conflict_highlighting(editor, conflict, &snapshot, excerpt_id, cx); + + let Some(anchor) = snapshot.anchor_in_excerpt(excerpt_id, conflict.range.start) else { + continue; + }; + + let editor_handle = editor_handle.clone(); + blocks.push(BlockProperties { + placement: BlockPlacement::Above(anchor), + height: Some(1), + style: BlockStyle::Fixed, + render: Arc::new({ + let conflict = conflict.clone(); + move |cx| render_conflict_buttons(&conflict, excerpt_id, editor_handle.clone(), cx) + }), + priority: 0, + }) + } + let new_block_ids = editor.insert_blocks(blocks, None, cx); + + let conflict_addon = editor.addon_mut::().unwrap(); + if let Some(buffer_conflicts) = conflict_addon.buffers.get_mut(&buffer_id) { + buffer_conflicts.block_ids.splice( + event.old_range.clone(), + new_conflicts + .iter() + .map(|conflict| conflict.range.clone()) + .zip(new_block_ids), + ); + } +} + +fn update_conflict_highlighting( + editor: &mut Editor, + conflict: &ConflictRegion, + buffer: &editor::MultiBufferSnapshot, + excerpt_id: editor::ExcerptId, + cx: &mut Context, +) { + log::debug!("update conflict highlighting for {conflict:?}"); + let theme = cx.theme().clone(); + let colors = theme.colors(); + let outer_start = buffer + .anchor_in_excerpt(excerpt_id, conflict.range.start) + .unwrap(); + let outer_end = buffer + .anchor_in_excerpt(excerpt_id, conflict.range.end) + .unwrap(); + let our_start = buffer + .anchor_in_excerpt(excerpt_id, conflict.ours.start) + .unwrap(); + let our_end = buffer + .anchor_in_excerpt(excerpt_id, conflict.ours.end) + .unwrap(); + let their_start = buffer + .anchor_in_excerpt(excerpt_id, conflict.theirs.start) + .unwrap(); + let their_end = buffer + .anchor_in_excerpt(excerpt_id, conflict.theirs.end) + .unwrap(); + + let ours_background = colors.version_control_conflict_ours_background; + let ours_marker = colors.version_control_conflict_ours_marker_background; + let theirs_background = colors.version_control_conflict_theirs_background; + let theirs_marker = colors.version_control_conflict_theirs_marker_background; + let divider_background = colors.version_control_conflict_divider_background; + + let options = RowHighlightOptions { + include_gutter: false, + ..Default::default() + }; + + // Prevent diff hunk highlighting within the entire conflict region. + editor.highlight_rows::( + outer_start..outer_end, + divider_background, + options, + cx, + ); + editor.highlight_rows::(our_start..our_end, ours_background, options, cx); + editor.highlight_rows::(outer_start..our_start, ours_marker, options, cx); + editor.highlight_rows::( + their_start..their_end, + theirs_background, + options, + cx, + ); + editor.highlight_rows::( + their_end..outer_end, + theirs_marker, + options, + cx, + ); +} + +fn render_conflict_buttons( + conflict: &ConflictRegion, + excerpt_id: ExcerptId, + editor: WeakEntity, + cx: &mut BlockContext, +) -> AnyElement { + h_flex() + .h(cx.line_height) + .items_end() + .ml(cx.gutter_dimensions.width) + .id(cx.block_id) + .gap_0p5() + .child( + div() + .id("ours") + .px_1() + .child("Take Ours") + .rounded_t(rems(0.2)) + .text_ui_sm(cx) + .hover(|this| this.bg(cx.theme().colors().element_background)) + .cursor_pointer() + .on_click({ + let editor = editor.clone(); + let conflict = conflict.clone(); + let ours = conflict.ours.clone(); + move |_, _, cx| { + resolve_conflict(editor.clone(), excerpt_id, &conflict, &[ours.clone()], cx) + } + }), + ) + .child( + div() + .id("theirs") + .px_1() + .child("Take Theirs") + .rounded_t(rems(0.2)) + .text_ui_sm(cx) + .hover(|this| this.bg(cx.theme().colors().element_background)) + .cursor_pointer() + .on_click({ + let editor = editor.clone(); + let conflict = conflict.clone(); + let theirs = conflict.theirs.clone(); + move |_, _, cx| { + resolve_conflict( + editor.clone(), + excerpt_id, + &conflict, + &[theirs.clone()], + cx, + ) + } + }), + ) + .child( + div() + .id("both") + .px_1() + .child("Take Both") + .rounded_t(rems(0.2)) + .text_ui_sm(cx) + .hover(|this| this.bg(cx.theme().colors().element_background)) + .cursor_pointer() + .on_click({ + let editor = editor.clone(); + let conflict = conflict.clone(); + let ours = conflict.ours.clone(); + let theirs = conflict.theirs.clone(); + move |_, _, cx| { + resolve_conflict( + editor.clone(), + excerpt_id, + &conflict, + &[ours.clone(), theirs.clone()], + cx, + ) + } + }), + ) + .into_any() +} + +fn resolve_conflict( + editor: WeakEntity, + excerpt_id: ExcerptId, + resolved_conflict: &ConflictRegion, + ranges: &[Range], + cx: &mut App, +) { + let Some(editor) = editor.upgrade() else { + return; + }; + + let multibuffer = editor.read(cx).buffer().read(cx); + let snapshot = multibuffer.snapshot(cx); + let Some(buffer) = resolved_conflict + .ours + .end + .buffer_id + .and_then(|buffer_id| multibuffer.buffer(buffer_id)) + else { + return; + }; + let buffer_snapshot = buffer.read(cx).snapshot(); + + resolved_conflict.resolve(buffer, ranges, cx); + + editor.update(cx, |editor, cx| { + let conflict_addon = editor.addon_mut::().unwrap(); + let Some(state) = conflict_addon.buffers.get_mut(&buffer_snapshot.remote_id()) else { + return; + }; + let Ok(ix) = state.block_ids.binary_search_by(|(range, _)| { + range + .start + .cmp(&resolved_conflict.range.start, &buffer_snapshot) + }) else { + return; + }; + let &(_, block_id) = &state.block_ids[ix]; + let start = snapshot + .anchor_in_excerpt(excerpt_id, resolved_conflict.range.start) + .unwrap(); + let end = snapshot + .anchor_in_excerpt(excerpt_id, resolved_conflict.range.end) + .unwrap(); + editor.remove_highlighted_rows::(vec![start..end], cx); + editor.remove_highlighted_rows::(vec![start..end], cx); + editor.remove_highlighted_rows::(vec![start..end], cx); + editor.remove_highlighted_rows::(vec![start..end], cx); + editor.remove_highlighted_rows::(vec![start..end], cx); + editor.remove_blocks(HashSet::from_iter([block_id]), None, cx); + }) +} diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index a1369af11d..6f1d57c7a2 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -447,7 +447,7 @@ impl GitPanel { .ok(); } GitStoreEvent::RepositoryUpdated(_, _, _) => {} - GitStoreEvent::JobsUpdated => {} + GitStoreEvent::JobsUpdated | GitStoreEvent::ConflictsUpdated => {} }, ) .detach(); @@ -1650,7 +1650,7 @@ impl GitPanel { if let Some(merge_message) = self .active_repository .as_ref() - .and_then(|repo| repo.read(cx).merge_message.as_ref()) + .and_then(|repo| repo.read(cx).merge.message.as_ref()) { return Some(merge_message.to_string()); } diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index ac0a1ef859..5dad63e0ed 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -3,6 +3,7 @@ use std::any::Any; use ::settings::Settings; use command_palette_hooks::CommandPaletteFilter; use commit_modal::CommitModal; +use editor::Editor; mod blame_ui; use git::{ repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus}, @@ -20,6 +21,7 @@ pub mod branch_picker; mod commit_modal; pub mod commit_tooltip; mod commit_view; +mod conflict_view; pub mod git_panel; mod git_panel_settings; pub mod onboarding; @@ -35,6 +37,11 @@ pub fn init(cx: &mut App) { editor::set_blame_renderer(blame_ui::GitBlameRenderer, cx); + cx.observe_new(|editor: &mut Editor, _, cx| { + conflict_view::register_editor(editor, editor.buffer().clone(), cx); + }) + .detach(); + cx.observe_new(|workspace: &mut Workspace, _, cx| { ProjectDiff::register(workspace, cx); CommitModal::register(workspace); diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index bd53543aad..eb93f97d3f 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -1,4 +1,5 @@ use crate::{ + conflict_view::ConflictAddon, git_panel::{GitPanel, GitPanelAddon, GitStatusEntry}, remote_button::{render_publish_button, render_push_button}, }; @@ -26,7 +27,10 @@ use project::{ Project, ProjectPath, git_store::{GitStore, GitStoreEvent, RepositoryEvent}, }; -use std::any::{Any, TypeId}; +use std::{ + any::{Any, TypeId}, + ops::Range, +}; use theme::ActiveTheme; use ui::{KeyBinding, Tooltip, prelude::*, vertical_divider}; use util::ResultExt as _; @@ -48,7 +52,6 @@ pub struct ProjectDiff { focus_handle: FocusHandle, update_needed: postage::watch::Sender<()>, pending_scroll: Option, - current_branch: Option, _task: Task>, _subscription: Subscription, } @@ -61,9 +64,9 @@ struct DiffBuffer { file_status: FileStatus, } -const CONFLICT_NAMESPACE: u32 = 0; -const TRACKED_NAMESPACE: u32 = 1; -const NEW_NAMESPACE: u32 = 2; +const CONFLICT_NAMESPACE: u32 = 1; +const TRACKED_NAMESPACE: u32 = 2; +const NEW_NAMESPACE: u32 = 3; impl ProjectDiff { pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context) { @@ -154,7 +157,8 @@ impl ProjectDiff { window, move |this, _git_store, event, _window, _cx| match event { GitStoreEvent::ActiveRepositoryChanged(_) - | GitStoreEvent::RepositoryUpdated(_, RepositoryEvent::Updated { .. }, true) => { + | GitStoreEvent::RepositoryUpdated(_, RepositoryEvent::Updated { .. }, true) + | GitStoreEvent::ConflictsUpdated => { *this.update_needed.borrow_mut() = (); } _ => {} @@ -178,7 +182,6 @@ impl ProjectDiff { multibuffer, pending_scroll: None, update_needed: send, - current_branch: None, _task: worker, _subscription: git_store_subscription, } @@ -395,11 +398,25 @@ impl ProjectDiff { let buffer = diff_buffer.buffer; let diff = diff_buffer.diff; + let conflict_addon = self + .editor + .read(cx) + .addon::() + .expect("project diff editor should have a conflict addon"); + let snapshot = buffer.read(cx).snapshot(); let diff = diff.read(cx); let diff_hunk_ranges = diff .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx) - .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot)) + .map(|diff_hunk| diff_hunk.buffer_range.clone()); + let conflicts = conflict_addon + .conflict_set(snapshot.remote_id()) + .map(|conflict_set| conflict_set.read(cx).snapshot().conflicts.clone()) + .unwrap_or_default(); + let conflicts = conflicts.iter().map(|conflict| conflict.range.clone()); + + let excerpt_ranges = merge_anchor_ranges(diff_hunk_ranges, conflicts, &snapshot) + .map(|range| range.to_point(&snapshot)) .collect::>(); let (was_empty, is_excerpt_newly_added) = self.multibuffer.update(cx, |multibuffer, cx| { @@ -407,7 +424,7 @@ impl ProjectDiff { let (_, is_newly_added) = multibuffer.set_excerpts_for_path( path_key.clone(), buffer, - diff_hunk_ranges, + excerpt_ranges, editor::DEFAULT_MULTIBUFFER_CONTEXT, cx, ); @@ -450,18 +467,6 @@ impl ProjectDiff { cx: &mut AsyncWindowContext, ) -> Result<()> { while let Some(_) = recv.next().await { - this.update(cx, |this, cx| { - let new_branch = this - .git_store - .read(cx) - .active_repository() - .and_then(|active_repository| active_repository.read(cx).branch.clone()); - if new_branch != this.current_branch { - this.current_branch = new_branch; - cx.notify(); - } - })?; - let buffers_to_load = this.update(cx, |this, cx| this.load_buffers(cx))?; for buffer_to_load in buffers_to_load { if let Some(buffer) = buffer_to_load.await.log_err() { @@ -1127,47 +1132,6 @@ impl RenderOnce for ProjectDiffEmptyState { } } -// .when(self.can_push_and_pull, |this| { -// let remote_button = crate::render_remote_button( -// "project-diff-remote-button", -// &branch, -// self.focus_handle.clone(), -// false, -// ); - -// match remote_button { -// Some(button) => { -// this.child(h_flex().justify_around().child(button)) -// } -// None => this.child( -// h_flex() -// .justify_around() -// .child(Label::new("Remote up to date")), -// ), -// } -// }), -// -// // .map(|this| { -// this.child(h_flex().justify_around().mt_1().child( -// Button::new("project-diff-close-button", "Close").when_some( -// self.focus_handle.clone(), -// |this, focus_handle| { -// this.key_binding(KeyBinding::for_action_in( -// &CloseActiveItem::default(), -// &focus_handle, -// window, -// cx, -// )) -// .on_click(move |_, window, cx| { -// window.focus(&focus_handle); -// window -// .dispatch_action(Box::new(CloseActiveItem::default()), cx); -// }) -// }, -// ), -// )) -// }), - mod preview { use git::repository::{ Branch, CommitSummary, Upstream, UpstreamTracking, UpstreamTrackingStatus, @@ -1293,6 +1257,53 @@ mod preview { } } +fn merge_anchor_ranges<'a>( + left: impl 'a + Iterator>, + right: impl 'a + Iterator>, + snapshot: &'a language::BufferSnapshot, +) -> impl 'a + Iterator> { + let mut left = left.fuse().peekable(); + let mut right = right.fuse().peekable(); + + std::iter::from_fn(move || { + let Some(left_range) = left.peek() else { + return right.next(); + }; + let Some(right_range) = right.peek() else { + return left.next(); + }; + + let mut next_range = if left_range.start.cmp(&right_range.start, snapshot).is_lt() { + left.next().unwrap() + } else { + right.next().unwrap() + }; + + // Extend the basic range while there's overlap with a range from either stream. + loop { + if let Some(left_range) = left + .peek() + .filter(|range| range.start.cmp(&next_range.end, &snapshot).is_le()) + .cloned() + { + left.next(); + next_range.end = left_range.end; + } else if let Some(right_range) = right + .peek() + .filter(|range| range.start.cmp(&next_range.end, &snapshot).is_le()) + .cloned() + { + right.next(); + next_range.end = right_range.end; + } else { + break; + } + } + + Some(next_range) + }) +} + #[cfg(not(target_os = "windows"))] #[cfg(test)] mod tests { diff --git a/crates/go_to_line/src/go_to_line.rs b/crates/go_to_line/src/go_to_line.rs index 83aaf5d861..bba9617975 100644 --- a/crates/go_to_line/src/go_to_line.rs +++ b/crates/go_to_line/src/go_to_line.rs @@ -2,7 +2,8 @@ pub mod cursor_position; use cursor_position::{LineIndicatorFormat, UserCaretPosition}; use editor::{ - Anchor, Editor, MultiBufferSnapshot, ToOffset, ToPoint, actions::Tab, scroll::Autoscroll, + Anchor, Editor, MultiBufferSnapshot, RowHighlightOptions, ToOffset, ToPoint, actions::Tab, + scroll::Autoscroll, }; use gpui::{ App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, Styled, @@ -180,7 +181,10 @@ impl GoToLine { editor.highlight_rows::( start..end, cx.theme().colors().editor_highlighted_line_background, - true, + RowHighlightOptions { + autoscroll: true, + ..Default::default() + }, cx, ); editor.request_autoscroll(Autoscroll::center(), cx); diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index e2be9f436e..fa763b81b8 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -95,6 +95,7 @@ pub enum Event { }, ExcerptsRemoved { ids: Vec, + removed_buffer_ids: Vec, }, ExcerptsExpanded { ids: Vec, @@ -2021,7 +2022,12 @@ impl MultiBuffer { pub fn clear(&mut self, cx: &mut Context) { self.sync(cx); let ids = self.excerpt_ids(); - self.buffers.borrow_mut().clear(); + let removed_buffer_ids = self + .buffers + .borrow_mut() + .drain() + .map(|(id, _)| id) + .collect(); self.excerpts_by_path.clear(); self.paths_by_excerpt.clear(); let mut snapshot = self.snapshot.borrow_mut(); @@ -2046,7 +2052,10 @@ impl MultiBuffer { singleton_buffer_edited: false, edited_buffer: None, }); - cx.emit(Event::ExcerptsRemoved { ids }); + cx.emit(Event::ExcerptsRemoved { + ids, + removed_buffer_ids, + }); cx.notify(); } @@ -2310,9 +2319,9 @@ impl MultiBuffer { new_excerpts.append(suffix, &()); drop(cursor); snapshot.excerpts = new_excerpts; - for buffer_id in removed_buffer_ids { - self.diffs.remove(&buffer_id); - snapshot.diffs.remove(&buffer_id); + for buffer_id in &removed_buffer_ids { + self.diffs.remove(buffer_id); + snapshot.diffs.remove(buffer_id); } if changed_trailing_excerpt { @@ -2325,7 +2334,10 @@ impl MultiBuffer { singleton_buffer_edited: false, edited_buffer: None, }); - cx.emit(Event::ExcerptsRemoved { ids }); + cx.emit(Event::ExcerptsRemoved { + ids, + removed_buffer_ids, + }); cx.notify(); } diff --git a/crates/multi_buffer/src/multi_buffer_tests.rs b/crates/multi_buffer/src/multi_buffer_tests.rs index 1aa5cfc2ac..ffee4ed5f1 100644 --- a/crates/multi_buffer/src/multi_buffer_tests.rs +++ b/crates/multi_buffer/src/multi_buffer_tests.rs @@ -635,7 +635,7 @@ fn test_excerpt_events(cx: &mut App) { predecessor, excerpts, } => follower.insert_excerpts_with_ids_after(predecessor, buffer, excerpts, cx), - Event::ExcerptsRemoved { ids } => follower.remove_excerpts(ids, cx), + Event::ExcerptsRemoved { ids, .. } => follower.remove_excerpts(ids, cx), Event::Edited { .. } => { *follower_edit_event_count.write() += 1; } diff --git a/crates/outline/src/outline.rs b/crates/outline/src/outline.rs index 2e4e504ba7..89ff20aaba 100644 --- a/crates/outline/src/outline.rs +++ b/crates/outline/src/outline.rs @@ -4,6 +4,7 @@ use std::{ sync::Arc, }; +use editor::RowHighlightOptions; use editor::{Anchor, AnchorRangeExt, Editor, scroll::Autoscroll}; use fuzzy::StringMatch; use gpui::{ @@ -171,7 +172,10 @@ impl OutlineViewDelegate { active_editor.highlight_rows::( outline_item.range.start..outline_item.range.end, cx.theme().colors().editor_highlighted_line_background, - true, + RowHighlightOptions { + autoscroll: true, + ..Default::default() + }, cx, ); active_editor.request_autoscroll(Autoscroll::center(), cx); diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index 648cd5ff3b..a89bba6a45 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -5028,7 +5028,7 @@ fn subscribe_for_editor_events( .extend(excerpts.iter().map(|&(excerpt_id, _)| excerpt_id)); outline_panel.update_fs_entries(editor.clone(), debounce, window, cx); } - EditorEvent::ExcerptsRemoved { ids } => { + EditorEvent::ExcerptsRemoved { ids, .. } => { let mut ids = ids.iter().collect::>(); for excerpts in outline_panel.excerpts.values_mut() { excerpts.retain(|excerpt_id, _| !ids.remove(excerpt_id)); diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 43bb3decec..f8e96d7730 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -1,3 +1,4 @@ +mod conflict_set; pub mod git_traversal; use crate::{ @@ -10,11 +11,12 @@ use askpass::AskPassDelegate; use buffer_diff::{BufferDiff, BufferDiffEvent}; use client::ProjectId; use collections::HashMap; +pub use conflict_set::{ConflictRegion, ConflictSet, ConflictSetSnapshot, ConflictSetUpdate}; use fs::Fs; use futures::{ - FutureExt as _, StreamExt as _, + FutureExt, StreamExt as _, channel::{mpsc, oneshot}, - future::{self, Shared}, + future::{self, Shared, try_join_all}, }; use git::{ BuildPermalinkParams, GitHostingProviderRegistry, WORK_DIRECTORY_REPO_PATH, @@ -74,7 +76,7 @@ pub struct GitStore { #[allow(clippy::type_complexity)] loading_diffs: HashMap<(BufferId, DiffKind), Shared, Arc>>>>, - diffs: HashMap>, + diffs: HashMap>, shared_diffs: HashMap>, _subscriptions: Vec, } @@ -85,12 +87,15 @@ struct SharedDiffs { uncommitted: Option>, } -struct BufferDiffState { +struct BufferGitState { unstaged_diff: Option>, uncommitted_diff: Option>, + conflict_set: Option>, recalculate_diff_task: Option>>, + reparse_conflict_markers_task: Option>>, language: Option>, language_registry: Option>, + conflict_updated_futures: Vec>, recalculating_tx: postage::watch::Sender, /// These operation counts are used to ensure that head and index text @@ -224,17 +229,26 @@ impl sum_tree::KeyedItem for StatusEntry { #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct RepositoryId(pub u64); +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct MergeDetails { + pub conflicted_paths: TreeSet, + pub message: Option, + pub apply_head: Option, + pub cherry_pick_head: Option, + pub merge_heads: Vec, + pub rebase_head: Option, + pub revert_head: Option, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct RepositorySnapshot { pub id: RepositoryId, - pub merge_message: Option, pub statuses_by_path: SumTree, pub work_directory_abs_path: Arc, pub branch: Option, pub head_commit: Option, - pub merge_conflicts: TreeSet, - pub merge_head_shas: Vec, pub scan_id: u64, + pub merge: MergeDetails, } type JobId = u64; @@ -297,6 +311,7 @@ pub enum GitStoreEvent { RepositoryRemoved(RepositoryId), IndexWriteError(anyhow::Error), JobsUpdated, + ConflictsUpdated, } impl EventEmitter for Repository {} @@ -681,10 +696,11 @@ impl GitStore { let text_snapshot = buffer.text_snapshot(); this.loading_diffs.remove(&(buffer_id, kind)); + let git_store = cx.weak_entity(); let diff_state = this .diffs .entry(buffer_id) - .or_insert_with(|| cx.new(|_| BufferDiffState::default())); + .or_insert_with(|| cx.new(|_| BufferGitState::new(git_store))); let diff = cx.new(|cx| BufferDiff::new(&text_snapshot, cx)); @@ -737,6 +753,62 @@ impl GitStore { diff_state.read(cx).uncommitted_diff.as_ref()?.upgrade() } + pub fn open_conflict_set( + &mut self, + buffer: Entity, + cx: &mut Context, + ) -> Entity { + log::debug!("open conflict set"); + let buffer_id = buffer.read(cx).remote_id(); + + if let Some(git_state) = self.diffs.get(&buffer_id) { + if let Some(conflict_set) = git_state + .read(cx) + .conflict_set + .as_ref() + .and_then(|weak| weak.upgrade()) + { + let conflict_set = conflict_set.clone(); + let buffer_snapshot = buffer.read(cx).text_snapshot(); + + git_state.update(cx, |state, cx| { + let _ = state.reparse_conflict_markers(buffer_snapshot, cx); + }); + + return conflict_set; + } + } + + let is_unmerged = self + .repository_and_path_for_buffer_id(buffer_id, cx) + .map_or(false, |(repo, path)| { + repo.read(cx) + .snapshot + .merge + .conflicted_paths + .contains(&path) + }); + let git_store = cx.weak_entity(); + let buffer_git_state = self + .diffs + .entry(buffer_id) + .or_insert_with(|| cx.new(|_| BufferGitState::new(git_store))); + let conflict_set = cx.new(|cx| ConflictSet::new(buffer_id, is_unmerged, cx)); + + self._subscriptions + .push(cx.subscribe(&conflict_set, |_, _, _, cx| { + cx.emit(GitStoreEvent::ConflictsUpdated); + })); + + buffer_git_state.update(cx, |state, cx| { + state.conflict_set = Some(conflict_set.downgrade()); + let buffer_snapshot = buffer.read(cx).text_snapshot(); + let _ = state.reparse_conflict_markers(buffer_snapshot, cx); + }); + + conflict_set + } + pub fn project_path_git_status( &self, project_path: &ProjectPath, @@ -1079,6 +1151,35 @@ impl GitStore { cx: &mut Context, ) { let id = repo.read(cx).id; + let merge_conflicts = repo.read(cx).snapshot.merge.conflicted_paths.clone(); + for (buffer_id, diff) in self.diffs.iter() { + if let Some((buffer_repo, repo_path)) = + self.repository_and_path_for_buffer_id(*buffer_id, cx) + { + if buffer_repo == repo { + diff.update(cx, |diff, cx| { + if let Some(conflict_set) = &diff.conflict_set { + let conflict_status_changed = + conflict_set.update(cx, |conflict_set, cx| { + let has_conflict = merge_conflicts.contains(&repo_path); + conflict_set.set_has_conflict(has_conflict, cx) + })?; + if conflict_status_changed { + let buffer_store = self.buffer_store.read(cx); + if let Some(buffer) = buffer_store.get(*buffer_id) { + let _ = diff.reparse_conflict_markers( + buffer.read(cx).text_snapshot(), + cx, + ); + } + } + } + anyhow::Ok(()) + }) + .ok(); + } + } + } cx.emit(GitStoreEvent::RepositoryUpdated( id, event.clone(), @@ -1218,9 +1319,15 @@ impl GitStore { if let Some(diff_state) = self.diffs.get_mut(&buffer.read(cx).remote_id()) { let buffer = buffer.read(cx).text_snapshot(); diff_state.update(cx, |diff_state, cx| { - diff_state.recalculate_diffs(buffer, cx); - futures.extend(diff_state.wait_for_recalculation()); + diff_state.recalculate_diffs(buffer.clone(), cx); + futures.extend(diff_state.wait_for_recalculation().map(FutureExt::boxed)); }); + futures.push(diff_state.update(cx, |diff_state, cx| { + diff_state + .reparse_conflict_markers(buffer, cx) + .map(|_| {}) + .boxed() + })); } } async move { @@ -2094,13 +2201,86 @@ impl GitStore { } } -impl BufferDiffState { +impl BufferGitState { + fn new(_git_store: WeakEntity) -> Self { + Self { + unstaged_diff: Default::default(), + uncommitted_diff: Default::default(), + recalculate_diff_task: Default::default(), + language: Default::default(), + language_registry: Default::default(), + recalculating_tx: postage::watch::channel_with(false).0, + hunk_staging_operation_count: 0, + hunk_staging_operation_count_as_of_write: 0, + head_text: Default::default(), + index_text: Default::default(), + head_changed: Default::default(), + index_changed: Default::default(), + language_changed: Default::default(), + conflict_updated_futures: Default::default(), + conflict_set: Default::default(), + reparse_conflict_markers_task: Default::default(), + } + } + fn buffer_language_changed(&mut self, buffer: Entity, cx: &mut Context) { self.language = buffer.read(cx).language().cloned(); self.language_changed = true; let _ = self.recalculate_diffs(buffer.read(cx).text_snapshot(), cx); } + fn reparse_conflict_markers( + &mut self, + buffer: text::BufferSnapshot, + cx: &mut Context, + ) -> oneshot::Receiver<()> { + let (tx, rx) = oneshot::channel(); + + let Some(conflict_set) = self + .conflict_set + .as_ref() + .and_then(|conflict_set| conflict_set.upgrade()) + else { + return rx; + }; + + let old_snapshot = conflict_set.read_with(cx, |conflict_set, _| { + if conflict_set.has_conflict { + Some(conflict_set.snapshot()) + } else { + None + } + }); + + if let Some(old_snapshot) = old_snapshot { + self.conflict_updated_futures.push(tx); + self.reparse_conflict_markers_task = Some(cx.spawn(async move |this, cx| { + let (snapshot, changed_range) = cx + .background_spawn(async move { + let new_snapshot = ConflictSet::parse(&buffer); + let changed_range = old_snapshot.compare(&new_snapshot, &buffer); + (new_snapshot, changed_range) + }) + .await; + this.update(cx, |this, cx| { + if let Some(conflict_set) = &this.conflict_set { + conflict_set + .update(cx, |conflict_set, cx| { + conflict_set.set_snapshot(snapshot, changed_range, cx); + }) + .ok(); + } + let futures = std::mem::take(&mut this.conflict_updated_futures); + for tx in futures { + tx.send(()).ok(); + } + }) + })) + } + + rx + } + fn unstaged_diff(&self) -> Option> { self.unstaged_diff.as_ref().and_then(|set| set.upgrade()) } @@ -2335,26 +2515,6 @@ impl BufferDiffState { } } -impl Default for BufferDiffState { - fn default() -> Self { - Self { - unstaged_diff: Default::default(), - uncommitted_diff: Default::default(), - recalculate_diff_task: Default::default(), - language: Default::default(), - language_registry: Default::default(), - recalculating_tx: postage::watch::channel_with(false).0, - hunk_staging_operation_count: 0, - hunk_staging_operation_count_as_of_write: 0, - head_text: Default::default(), - index_text: Default::default(), - head_changed: Default::default(), - index_changed: Default::default(), - language_changed: Default::default(), - } - } -} - fn make_remote_delegate( this: Entity, project_id: u64, @@ -2397,14 +2557,12 @@ impl RepositorySnapshot { fn empty(id: RepositoryId, work_directory_abs_path: Arc) -> Self { Self { id, - merge_message: None, statuses_by_path: Default::default(), work_directory_abs_path, branch: None, head_commit: None, - merge_conflicts: Default::default(), - merge_head_shas: Default::default(), scan_id: 0, + merge: Default::default(), } } @@ -2419,7 +2577,8 @@ impl RepositorySnapshot { .collect(), removed_statuses: Default::default(), current_merge_conflicts: self - .merge_conflicts + .merge + .conflicted_paths .iter() .map(|repo_path| repo_path.to_proto()) .collect(), @@ -2480,7 +2639,8 @@ impl RepositorySnapshot { updated_statuses, removed_statuses, current_merge_conflicts: self - .merge_conflicts + .merge + .conflicted_paths .iter() .map(|path| path.as_ref().to_proto()) .collect(), @@ -2515,7 +2675,7 @@ impl RepositorySnapshot { } pub fn has_conflict(&self, repo_path: &RepoPath) -> bool { - self.merge_conflicts.contains(repo_path) + self.merge.conflicted_paths.contains(repo_path) } /// This is the name that will be displayed in the repository selector for this repository. @@ -2529,7 +2689,77 @@ impl RepositorySnapshot { } } +impl MergeDetails { + async fn load( + backend: &Arc, + status: &SumTree, + prev_snapshot: &RepositorySnapshot, + ) -> Result<(MergeDetails, bool)> { + fn sha_eq<'a>( + l: impl IntoIterator, + r: impl IntoIterator, + ) -> bool { + l.into_iter() + .map(|commit| &commit.sha) + .eq(r.into_iter().map(|commit| &commit.sha)) + } + + let merge_heads = try_join_all( + backend + .merge_head_shas() + .into_iter() + .map(|sha| backend.show(sha)), + ) + .await?; + let cherry_pick_head = backend.show("CHERRY_PICK_HEAD".into()).await.ok(); + let rebase_head = backend.show("REBASE_HEAD".into()).await.ok(); + let revert_head = backend.show("REVERT_HEAD".into()).await.ok(); + let apply_head = backend.show("APPLY_HEAD".into()).await.ok(); + let message = backend.merge_message().await.map(SharedString::from); + let merge_heads_changed = !sha_eq( + merge_heads.as_slice(), + prev_snapshot.merge.merge_heads.as_slice(), + ) || !sha_eq( + cherry_pick_head.as_ref(), + prev_snapshot.merge.cherry_pick_head.as_ref(), + ) || !sha_eq( + apply_head.as_ref(), + prev_snapshot.merge.apply_head.as_ref(), + ) || !sha_eq( + rebase_head.as_ref(), + prev_snapshot.merge.rebase_head.as_ref(), + ) || !sha_eq( + revert_head.as_ref(), + prev_snapshot.merge.revert_head.as_ref(), + ); + let conflicted_paths = if merge_heads_changed { + TreeSet::from_ordered_entries( + status + .iter() + .filter(|entry| entry.status.is_conflicted()) + .map(|entry| entry.repo_path.clone()), + ) + } else { + prev_snapshot.merge.conflicted_paths.clone() + }; + let details = MergeDetails { + conflicted_paths, + message, + apply_head, + cherry_pick_head, + merge_heads, + rebase_head, + revert_head, + }; + Ok((details, merge_heads_changed)) + } +} + impl Repository { + pub fn snapshot(&self) -> RepositorySnapshot { + self.snapshot.clone() + } + fn local( id: RepositoryId, work_directory_abs_path: Arc, @@ -3731,7 +3961,7 @@ impl Repository { .as_ref() .map(proto_to_commit_details); - self.snapshot.merge_conflicts = conflicted_paths; + self.snapshot.merge.conflicted_paths = conflicted_paths; let edits = update .removed_statuses @@ -4321,16 +4551,6 @@ async fn compute_snapshot( let branches = backend.branches().await?; let branch = branches.into_iter().find(|branch| branch.is_head); let statuses = backend.status(&[WORK_DIRECTORY_REPO_PATH.clone()]).await?; - let merge_message = backend - .merge_message() - .await - .and_then(|msg| Some(msg.lines().nth(0)?.to_owned().into())); - let merge_head_shas = backend - .merge_head_shas() - .into_iter() - .map(SharedString::from) - .collect(); - let statuses_by_path = SumTree::from_iter( statuses .entries @@ -4341,47 +4561,36 @@ async fn compute_snapshot( }), &(), ); + let (merge_details, merge_heads_changed) = + MergeDetails::load(&backend, &statuses_by_path, &prev_snapshot).await?; - let merge_head_shas_changed = merge_head_shas != prev_snapshot.merge_head_shas; - - if merge_head_shas_changed + if merge_heads_changed || branch != prev_snapshot.branch || statuses_by_path != prev_snapshot.statuses_by_path { events.push(RepositoryEvent::Updated { full_scan: true }); } - let mut current_merge_conflicts = TreeSet::default(); - for (repo_path, status) in statuses.entries.iter() { - if status.is_conflicted() { - current_merge_conflicts.insert(repo_path.clone()); - } - } - // Cache merge conflict paths so they don't change from staging/unstaging, // until the merge heads change (at commit time, etc.). - let mut merge_conflicts = prev_snapshot.merge_conflicts.clone(); - if merge_head_shas_changed { - merge_conflicts = current_merge_conflicts; + if merge_heads_changed { events.push(RepositoryEvent::MergeHeadsChanged); } // Useful when branch is None in detached head state let head_commit = match backend.head_sha() { - Some(head_sha) => backend.show(head_sha).await.ok(), + Some(head_sha) => backend.show(head_sha).await.log_err(), None => None, }; let snapshot = RepositorySnapshot { id, - merge_message, statuses_by_path, work_directory_abs_path, scan_id: prev_snapshot.scan_id + 1, branch, head_commit, - merge_conflicts, - merge_head_shas, + merge: merge_details, }; Ok((snapshot, events)) diff --git a/crates/project/src/git_store/conflict_set.rs b/crates/project/src/git_store/conflict_set.rs new file mode 100644 index 0000000000..7590db599c --- /dev/null +++ b/crates/project/src/git_store/conflict_set.rs @@ -0,0 +1,560 @@ +use gpui::{App, Context, Entity, EventEmitter}; +use std::{cmp::Ordering, ops::Range, sync::Arc}; +use text::{Anchor, BufferId, OffsetRangeExt as _}; + +pub struct ConflictSet { + pub has_conflict: bool, + pub snapshot: ConflictSetSnapshot, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ConflictSetUpdate { + pub buffer_range: Option>, + pub old_range: Range, + pub new_range: Range, +} + +#[derive(Debug, Clone)] +pub struct ConflictSetSnapshot { + pub buffer_id: BufferId, + pub conflicts: Arc<[ConflictRegion]>, +} + +impl ConflictSetSnapshot { + pub fn conflicts_in_range( + &self, + range: Range, + buffer: &text::BufferSnapshot, + ) -> &[ConflictRegion] { + let start_ix = self + .conflicts + .binary_search_by(|conflict| { + conflict + .range + .end + .cmp(&range.start, buffer) + .then(Ordering::Greater) + }) + .unwrap_err(); + let end_ix = start_ix + + self.conflicts[start_ix..] + .binary_search_by(|conflict| { + conflict + .range + .start + .cmp(&range.end, buffer) + .then(Ordering::Less) + }) + .unwrap_err(); + &self.conflicts[start_ix..end_ix] + } + + pub fn compare(&self, other: &Self, buffer: &text::BufferSnapshot) -> ConflictSetUpdate { + let common_prefix_len = self + .conflicts + .iter() + .zip(other.conflicts.iter()) + .take_while(|(old, new)| old == new) + .count(); + let common_suffix_len = self.conflicts[common_prefix_len..] + .iter() + .rev() + .zip(other.conflicts[common_prefix_len..].iter().rev()) + .take_while(|(old, new)| old == new) + .count(); + let old_conflicts = + &self.conflicts[common_prefix_len..(self.conflicts.len() - common_suffix_len)]; + let new_conflicts = + &other.conflicts[common_prefix_len..(other.conflicts.len() - common_suffix_len)]; + let old_range = common_prefix_len..(common_prefix_len + old_conflicts.len()); + let new_range = common_prefix_len..(common_prefix_len + new_conflicts.len()); + let start = match (old_conflicts.first(), new_conflicts.first()) { + (None, None) => None, + (None, Some(conflict)) => Some(conflict.range.start), + (Some(conflict), None) => Some(conflict.range.start), + (Some(first), Some(second)) => Some(first.range.start.min(&second.range.start, buffer)), + }; + let end = match (old_conflicts.last(), new_conflicts.last()) { + (None, None) => None, + (None, Some(conflict)) => Some(conflict.range.end), + (Some(first), None) => Some(first.range.end), + (Some(first), Some(second)) => Some(first.range.end.max(&second.range.end, buffer)), + }; + ConflictSetUpdate { + buffer_range: start.zip(end).map(|(start, end)| start..end), + old_range, + new_range, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConflictRegion { + pub range: Range, + pub ours: Range, + pub theirs: Range, + pub base: Option>, +} + +impl ConflictRegion { + pub fn resolve( + &self, + buffer: Entity, + ranges: &[Range], + cx: &mut App, + ) { + let buffer_snapshot = buffer.read(cx).snapshot(); + let mut deletions = Vec::new(); + let empty = ""; + let outer_range = self.range.to_offset(&buffer_snapshot); + let mut offset = outer_range.start; + for kept_range in ranges { + let kept_range = kept_range.to_offset(&buffer_snapshot); + if kept_range.start > offset { + deletions.push((offset..kept_range.start, empty)); + } + offset = kept_range.end; + } + if outer_range.end > offset { + deletions.push((offset..outer_range.end, empty)); + } + + buffer.update(cx, |buffer, cx| { + buffer.edit(deletions, None, cx); + }); + } +} + +impl ConflictSet { + pub fn new(buffer_id: BufferId, has_conflict: bool, _: &mut Context) -> Self { + Self { + has_conflict, + snapshot: ConflictSetSnapshot { + buffer_id, + conflicts: Default::default(), + }, + } + } + + pub fn set_has_conflict(&mut self, has_conflict: bool, cx: &mut Context) -> bool { + if has_conflict != self.has_conflict { + self.has_conflict = has_conflict; + if !self.has_conflict { + cx.emit(ConflictSetUpdate { + buffer_range: None, + old_range: 0..self.snapshot.conflicts.len(), + new_range: 0..0, + }); + self.snapshot.conflicts = Default::default(); + } + true + } else { + false + } + } + + pub fn snapshot(&self) -> ConflictSetSnapshot { + self.snapshot.clone() + } + + pub fn set_snapshot( + &mut self, + snapshot: ConflictSetSnapshot, + update: ConflictSetUpdate, + cx: &mut Context, + ) { + self.snapshot = snapshot; + cx.emit(update); + } + + pub fn parse(buffer: &text::BufferSnapshot) -> ConflictSetSnapshot { + let mut conflicts = Vec::new(); + + let mut line_pos = 0; + let mut lines = buffer.text_for_range(0..buffer.len()).lines(); + + let mut conflict_start: Option = None; + let mut ours_start: Option = None; + let mut ours_end: Option = None; + let mut base_start: Option = None; + let mut base_end: Option = None; + let mut theirs_start: Option = None; + + while let Some(line) = lines.next() { + let line_end = line_pos + line.len(); + + if line.starts_with("<<<<<<< ") { + // If we see a new conflict marker while already parsing one, + // abandon the previous one and start a new one + conflict_start = Some(line_pos); + ours_start = Some(line_end + 1); + } else if line.starts_with("||||||| ") + && conflict_start.is_some() + && ours_start.is_some() + { + ours_end = Some(line_pos); + base_start = Some(line_end + 1); + } else if line.starts_with("=======") + && conflict_start.is_some() + && ours_start.is_some() + { + // Set ours_end if not already set (would be set if we have base markers) + if ours_end.is_none() { + ours_end = Some(line_pos); + } else if base_start.is_some() { + base_end = Some(line_pos); + } + theirs_start = Some(line_end + 1); + } else if line.starts_with(">>>>>>> ") + && conflict_start.is_some() + && ours_start.is_some() + && ours_end.is_some() + && theirs_start.is_some() + { + let theirs_end = line_pos; + let conflict_end = line_end + 1; + + let range = buffer.anchor_after(conflict_start.unwrap()) + ..buffer.anchor_before(conflict_end); + let ours = buffer.anchor_after(ours_start.unwrap()) + ..buffer.anchor_before(ours_end.unwrap()); + let theirs = + buffer.anchor_after(theirs_start.unwrap())..buffer.anchor_before(theirs_end); + + let base = base_start + .zip(base_end) + .map(|(start, end)| buffer.anchor_after(start)..buffer.anchor_before(end)); + + conflicts.push(ConflictRegion { + range, + ours, + theirs, + base, + }); + + conflict_start = None; + ours_start = None; + ours_end = None; + base_start = None; + base_end = None; + theirs_start = None; + } + + line_pos = line_end + 1; + } + + ConflictSetSnapshot { + conflicts: conflicts.into(), + buffer_id: buffer.remote_id(), + } + } +} + +impl EventEmitter for ConflictSet {} + +#[cfg(test)] +mod tests { + use std::sync::mpsc; + + use crate::{Project, project_settings::ProjectSettings}; + + use super::*; + use fs::FakeFs; + use git::status::{UnmergedStatus, UnmergedStatusCode}; + use gpui::{BackgroundExecutor, TestAppContext}; + use language::language_settings::AllLanguageSettings; + use serde_json::json; + use settings::Settings as _; + use text::{Buffer, BufferId, ToOffset as _}; + use unindent::Unindent as _; + use util::path; + use worktree::WorktreeSettings; + + #[test] + fn test_parse_conflicts_in_buffer() { + // Create a buffer with conflict markers + let test_content = r#" + This is some text before the conflict. + <<<<<<< HEAD + This is our version + ======= + This is their version + >>>>>>> branch-name + + Another conflict: + <<<<<<< HEAD + Our second change + ||||||| merged common ancestors + Original content + ======= + Their second change + >>>>>>> branch-name + "# + .unindent(); + + let buffer_id = BufferId::new(1).unwrap(); + let buffer = Buffer::new(0, buffer_id, test_content); + let snapshot = buffer.snapshot(); + + let conflict_snapshot = ConflictSet::parse(&snapshot); + assert_eq!(conflict_snapshot.conflicts.len(), 2); + + let first = &conflict_snapshot.conflicts[0]; + assert!(first.base.is_none()); + let our_text = snapshot + .text_for_range(first.ours.clone()) + .collect::(); + let their_text = snapshot + .text_for_range(first.theirs.clone()) + .collect::(); + assert_eq!(our_text, "This is our version\n"); + assert_eq!(their_text, "This is their version\n"); + + let second = &conflict_snapshot.conflicts[1]; + assert!(second.base.is_some()); + let our_text = snapshot + .text_for_range(second.ours.clone()) + .collect::(); + let their_text = snapshot + .text_for_range(second.theirs.clone()) + .collect::(); + let base_text = snapshot + .text_for_range(second.base.as_ref().unwrap().clone()) + .collect::(); + assert_eq!(our_text, "Our second change\n"); + assert_eq!(their_text, "Their second change\n"); + assert_eq!(base_text, "Original content\n"); + + // Test conflicts_in_range + let range = snapshot.anchor_before(0)..snapshot.anchor_before(snapshot.len()); + let conflicts_in_range = conflict_snapshot.conflicts_in_range(range, &snapshot); + assert_eq!(conflicts_in_range.len(), 2); + + // Test with a range that includes only the first conflict + let first_conflict_end = conflict_snapshot.conflicts[0].range.end; + let range = snapshot.anchor_before(0)..first_conflict_end; + let conflicts_in_range = conflict_snapshot.conflicts_in_range(range, &snapshot); + assert_eq!(conflicts_in_range.len(), 1); + + // Test with a range that includes only the second conflict + let second_conflict_start = conflict_snapshot.conflicts[1].range.start; + let range = second_conflict_start..snapshot.anchor_before(snapshot.len()); + let conflicts_in_range = conflict_snapshot.conflicts_in_range(range, &snapshot); + assert_eq!(conflicts_in_range.len(), 1); + + // Test with a range that doesn't include any conflicts + let range = buffer.anchor_after(first_conflict_end.to_offset(&buffer) + 1) + ..buffer.anchor_before(second_conflict_start.to_offset(&buffer) - 1); + let conflicts_in_range = conflict_snapshot.conflicts_in_range(range, &snapshot); + assert_eq!(conflicts_in_range.len(), 0); + } + + #[test] + fn test_nested_conflict_markers() { + // Create a buffer with nested conflict markers + let test_content = r#" + This is some text before the conflict. + <<<<<<< HEAD + This is our version + <<<<<<< HEAD + This is a nested conflict marker + ======= + This is their version in a nested conflict + >>>>>>> branch-nested + ======= + This is their version + >>>>>>> branch-name + "# + .unindent(); + + let buffer_id = BufferId::new(1).unwrap(); + let buffer = Buffer::new(0, buffer_id, test_content.to_string()); + let snapshot = buffer.snapshot(); + + let conflict_snapshot = ConflictSet::parse(&snapshot); + + assert_eq!(conflict_snapshot.conflicts.len(), 1); + + // The conflict should have our version, their version, but no base + let conflict = &conflict_snapshot.conflicts[0]; + assert!(conflict.base.is_none()); + + // Check that the nested conflict was detected correctly + let our_text = snapshot + .text_for_range(conflict.ours.clone()) + .collect::(); + assert_eq!(our_text, "This is a nested conflict marker\n"); + let their_text = snapshot + .text_for_range(conflict.theirs.clone()) + .collect::(); + assert_eq!(their_text, "This is their version in a nested conflict\n"); + } + + #[test] + fn test_conflicts_in_range() { + // Create a buffer with conflict markers + let test_content = r#" + one + <<<<<<< HEAD1 + two + ======= + three + >>>>>>> branch1 + four + five + <<<<<<< HEAD2 + six + ======= + seven + >>>>>>> branch2 + eight + nine + <<<<<<< HEAD3 + ten + ======= + eleven + >>>>>>> branch3 + twelve + <<<<<<< HEAD4 + thirteen + ======= + fourteen + >>>>>>> branch4 + fifteen + "# + .unindent(); + + let buffer_id = BufferId::new(1).unwrap(); + let buffer = Buffer::new(0, buffer_id, test_content.clone()); + let snapshot = buffer.snapshot(); + + let conflict_snapshot = ConflictSet::parse(&snapshot); + assert_eq!(conflict_snapshot.conflicts.len(), 4); + + let range = test_content.find("seven").unwrap()..test_content.find("eleven").unwrap(); + let range = buffer.anchor_before(range.start)..buffer.anchor_after(range.end); + assert_eq!( + conflict_snapshot.conflicts_in_range(range, &snapshot), + &conflict_snapshot.conflicts[1..=2] + ); + + let range = test_content.find("one").unwrap()..test_content.find("<<<<<<< HEAD2").unwrap(); + let range = buffer.anchor_before(range.start)..buffer.anchor_after(range.end); + assert_eq!( + conflict_snapshot.conflicts_in_range(range, &snapshot), + &conflict_snapshot.conflicts[0..=1] + ); + + let range = + test_content.find("eight").unwrap() - 1..test_content.find(">>>>>>> branch3").unwrap(); + let range = buffer.anchor_before(range.start)..buffer.anchor_after(range.end); + assert_eq!( + conflict_snapshot.conflicts_in_range(range, &snapshot), + &conflict_snapshot.conflicts[1..=2] + ); + + let range = test_content.find("thirteen").unwrap() - 1..test_content.len(); + let range = buffer.anchor_before(range.start)..buffer.anchor_after(range.end); + assert_eq!( + conflict_snapshot.conflicts_in_range(range, &snapshot), + &conflict_snapshot.conflicts[3..=3] + ); + } + + #[gpui::test] + async fn test_conflict_updates(executor: BackgroundExecutor, cx: &mut TestAppContext) { + env_logger::try_init().ok(); + cx.update(|cx| { + settings::init(cx); + WorktreeSettings::register(cx); + ProjectSettings::register(cx); + AllLanguageSettings::register(cx); + }); + let initial_text = " + one + two + three + four + five + " + .unindent(); + let fs = FakeFs::new(executor); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "a.txt": initial_text, + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let (git_store, buffer) = project.update(cx, |project, cx| { + ( + project.git_store().clone(), + project.open_local_buffer(path!("/project/a.txt"), cx), + ) + }); + let buffer = buffer.await.unwrap(); + let conflict_set = git_store.update(cx, |git_store, cx| { + git_store.open_conflict_set(buffer.clone(), cx) + }); + let (events_tx, events_rx) = mpsc::channel::(); + let _conflict_set_subscription = cx.update(|cx| { + cx.subscribe(&conflict_set, move |_, event, _| { + events_tx.send(event.clone()).ok(); + }) + }); + let conflicts_snapshot = conflict_set.update(cx, |conflict_set, _| conflict_set.snapshot()); + assert!(conflicts_snapshot.conflicts.is_empty()); + + buffer.update(cx, |buffer, cx| { + buffer.edit( + [ + (4..4, "<<<<<<< HEAD\n"), + (14..14, "=======\nTWO\n>>>>>>> branch\n"), + ], + None, + cx, + ); + }); + + cx.run_until_parked(); + events_rx.try_recv().expect_err( + "no conflicts should be registered as long as the file's status is unchanged", + ); + + fs.with_git_state(path!("/project/.git").as_ref(), true, |state| { + state.unmerged_paths.insert( + "a.txt".into(), + UnmergedStatus { + first_head: UnmergedStatusCode::Updated, + second_head: UnmergedStatusCode::Updated, + }, + ); + // Cause the repository to emit MergeHeadsChanged. + state.merge_head_shas = vec!["abc".into(), "def".into()] + }) + .unwrap(); + + cx.run_until_parked(); + let update = events_rx + .try_recv() + .expect("status change should trigger conflict parsing"); + assert_eq!(update.old_range, 0..0); + assert_eq!(update.new_range, 0..1); + + let conflict = conflict_set.update(cx, |conflict_set, _| { + conflict_set.snapshot().conflicts[0].clone() + }); + cx.update(|cx| { + conflict.resolve(buffer.clone(), &[conflict.theirs.clone()], cx); + }); + + cx.run_until_parked(); + let update = events_rx + .try_recv() + .expect("conflicts should be removed after resolution"); + assert_eq!(update.old_range, 0..1); + assert_eq!(update.new_range, 0..0); + } +} diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 070b235302..3c4b9e9658 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -29,7 +29,10 @@ pub mod search_history; mod yarn; use crate::git_store::GitStore; -pub use git_store::git_traversal::{ChildEntriesGitIter, GitEntry, GitEntryRef, GitTraversal}; +pub use git_store::{ + ConflictRegion, ConflictSet, ConflictSetSnapshot, ConflictSetUpdate, + git_traversal::{ChildEntriesGitIter, GitEntry, GitEntryRef, GitTraversal}, +}; use anyhow::{Context as _, Result, anyhow}; use buffer_store::{BufferStore, BufferStoreEvent}; diff --git a/crates/theme/src/default_colors.rs b/crates/theme/src/default_colors.rs index 0b7eeb4958..6eca890bdf 100644 --- a/crates/theme/src/default_colors.rs +++ b/crates/theme/src/default_colors.rs @@ -143,6 +143,11 @@ impl ThemeColors { version_control_renamed: MODIFIED_COLOR, version_control_conflict: orange().light().step_12(), version_control_ignored: gray().light().step_12(), + version_control_conflict_ours_background: green().light().step_10().alpha(0.5), + version_control_conflict_theirs_background: blue().light().step_10().alpha(0.5), + version_control_conflict_ours_marker_background: green().light().step_10().alpha(0.7), + version_control_conflict_theirs_marker_background: blue().light().step_10().alpha(0.7), + version_control_conflict_divider_background: Hsla::default(), } } @@ -258,6 +263,11 @@ impl ThemeColors { version_control_renamed: MODIFIED_COLOR, version_control_conflict: orange().dark().step_12(), version_control_ignored: gray().dark().step_12(), + version_control_conflict_ours_background: green().dark().step_10().alpha(0.5), + version_control_conflict_theirs_background: blue().dark().step_10().alpha(0.5), + version_control_conflict_ours_marker_background: green().dark().step_10().alpha(0.7), + version_control_conflict_theirs_marker_background: blue().dark().step_10().alpha(0.7), + version_control_conflict_divider_background: Hsla::default(), } } } diff --git a/crates/theme/src/fallback_themes.rs b/crates/theme/src/fallback_themes.rs index cc91895ca0..624cb3aabf 100644 --- a/crates/theme/src/fallback_themes.rs +++ b/crates/theme/src/fallback_themes.rs @@ -201,6 +201,23 @@ pub(crate) fn zed_default_dark() -> Theme { version_control_renamed: MODIFIED_COLOR, version_control_conflict: crate::orange().light().step_12(), version_control_ignored: crate::gray().light().step_12(), + version_control_conflict_ours_background: crate::green() + .light() + .step_12() + .alpha(0.5), + version_control_conflict_theirs_background: crate::blue() + .light() + .step_12() + .alpha(0.5), + version_control_conflict_ours_marker_background: crate::green() + .light() + .step_12() + .alpha(0.7), + version_control_conflict_theirs_marker_background: crate::blue() + .light() + .step_12() + .alpha(0.7), + version_control_conflict_divider_background: Hsla::default(), }, status: StatusColors { conflict: yellow, diff --git a/crates/theme/src/schema.rs b/crates/theme/src/schema.rs index b300a2737a..551308eafb 100644 --- a/crates/theme/src/schema.rs +++ b/crates/theme/src/schema.rs @@ -586,6 +586,26 @@ pub struct ThemeColorsContent { /// Ignored version control color. #[serde(rename = "version_control.ignored")] pub version_control_ignored: Option, + + /// Background color for row highlights of "ours" regions in merge conflicts. + #[serde(rename = "version_control.conflict.ours_background")] + pub version_control_conflict_ours_background: Option, + + /// Background color for row highlights of "theirs" regions in merge conflicts. + #[serde(rename = "version_control.conflict.theirs_background")] + pub version_control_conflict_theirs_background: Option, + + /// Background color for row highlights of "ours" conflict markers in merge conflicts. + #[serde(rename = "version_control.conflict.ours_marker_background")] + pub version_control_conflict_ours_marker_background: Option, + + /// Background color for row highlights of "theirs" conflict markers in merge conflicts. + #[serde(rename = "version_control.conflict.theirs_marker_background")] + pub version_control_conflict_theirs_marker_background: Option, + + /// Background color for row highlights of the "ours"/"theirs" divider in merge conflicts. + #[serde(rename = "version_control.conflict.divider_background")] + pub version_control_conflict_divider_background: Option, } impl ThemeColorsContent { @@ -1037,6 +1057,26 @@ impl ThemeColorsContent { .and_then(|color| try_parse_color(color).ok()) // Fall back to `conflict`, for backwards compatibility. .or(status_colors.ignored), + version_control_conflict_ours_background: self + .version_control_conflict_ours_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + version_control_conflict_theirs_background: self + .version_control_conflict_theirs_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + version_control_conflict_ours_marker_background: self + .version_control_conflict_ours_marker_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + version_control_conflict_theirs_marker_background: self + .version_control_conflict_theirs_marker_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + version_control_conflict_divider_background: self + .version_control_conflict_divider_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), } } } diff --git a/crates/theme/src/styles/colors.rs b/crates/theme/src/styles/colors.rs index 4e53341cde..0310ca698c 100644 --- a/crates/theme/src/styles/colors.rs +++ b/crates/theme/src/styles/colors.rs @@ -261,6 +261,14 @@ pub struct ThemeColors { pub version_control_conflict: Hsla, /// Represents an ignored entry in version control systems. pub version_control_ignored: Hsla, + + /// Represents the "ours" region of a merge conflict. + pub version_control_conflict_ours_background: Hsla, + /// Represents the "theirs" region of a merge conflict. + pub version_control_conflict_theirs_background: Hsla, + pub version_control_conflict_ours_marker_background: Hsla, + pub version_control_conflict_theirs_marker_background: Hsla, + pub version_control_conflict_divider_background: Hsla, } #[derive(EnumIter, Debug, Clone, Copy, AsRefStr)] diff --git a/crates/vim/src/command.rs b/crates/vim/src/command.rs index 0bba323571..1e55e5a9f4 100644 --- a/crates/vim/src/command.rs +++ b/crates/vim/src/command.rs @@ -1500,7 +1500,7 @@ impl ShellExec { editor.highlight_rows::( input_range.clone().unwrap(), cx.theme().status().unreachable_background, - false, + Default::default(), cx, );