Detailed changes
@@ -1328,7 +1328,7 @@ impl InlineAssistant {
editor.highlight_rows::<InlineAssist>(
row_range,
cx.theme().status().info_background,
- false,
+ Default::default(),
cx,
);
}
@@ -1393,7 +1393,7 @@ impl InlineAssistant {
editor.highlight_rows::<DeletedLines>(
Anchor::min()..Anchor::max(),
cx.theme().status().deleted_background,
- false,
+ Default::default(),
cx,
);
editor
@@ -1226,7 +1226,7 @@ impl InlineAssistant {
editor.highlight_rows::<InlineAssist>(
row_range,
cx.theme().status().info_background,
- false,
+ Default::default(),
cx,
);
}
@@ -1291,7 +1291,7 @@ impl InlineAssistant {
editor.highlight_rows::<DeletedLines>(
Anchor::min()..Anchor::max(),
cx.theme().status().deleted_background,
- false,
+ Default::default(),
cx,
);
editor
@@ -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<Anchor>,
color: Hsla,
- should_autoscroll: bool,
+ options: RowHighlightOptions,
+ type_id: TypeId,
}
#[derive(Clone, Debug)]
@@ -5942,7 +5968,10 @@ impl Editor {
self.highlight_rows::<EditPredictionPreview>(
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<Anchor>,
color: Hsla,
- should_autoscroll: bool,
+ options: RowHighlightOptions,
cx: &mut Context<Self>,
) {
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::<T>(),
},
);
}
@@ -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::<T>())
}
+ pub fn addon_mut<T: Addon>(&mut self) -> Option<&mut T> {
+ let type_id = std::any::TypeId::of::<T>();
+ self.addons
+ .get_mut(&type_id)
+ .and_then(|item| item.to_any_mut()?.downcast_mut::<T>())
+ }
+
fn character_size(&self, window: &mut Window) -> gpui::Size<Pixels> {
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<ExcerptId>,
+ removed_buffer_ids: Vec<BufferId>,
},
BufferFoldToggled {
ids: Vec<ExcerptId>,
@@ -20672,24 +20724,8 @@ impl Render for MissingEditPredictionKeybindingTooltip {
pub struct LineHighlight {
pub background: Background,
pub border: Option<gpui::Hsla>,
-}
-
-impl From<Hsla> for LineHighlight {
- fn from(hsla: Hsla) -> Self {
- Self {
- background: hsla.into(),
- border: None,
- }
- }
-}
-
-impl From<Background> for LineHighlight {
- fn from(background: Background) -> Self {
- Self {
- background,
- border: None,
- }
- }
+ pub include_gutter: bool,
+ pub type_id: Option<TypeId>,
}
fn render_diff_hunk_controls(
@@ -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<Pixels>,
display_hunks: &[(DisplayDiffHunk, Option<Hitbox>)],
+ highlighted_rows: &BTreeMap<DisplayRow, LineHighlight>,
editor: Entity<Editor>,
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::<ConflictsOuter>(),
+ TypeId::of::<ConflictsOursMarker>(),
+ TypeId::of::<ConflictsOurs>(),
+ TypeId::of::<ConflictsTheirs>(),
+ TypeId::of::<ConflictsTheirsMarker>(),
+ ]
+ .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,
@@ -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));
@@ -34,6 +34,7 @@ pub struct FakeGitRepositoryState {
pub blames: HashMap<RepoPath, Blame>,
pub current_branch_name: Option<String>,
pub branches: HashSet<String>,
+ pub merge_head_shas: Vec<String>,
pub simulated_index_write_error_message: Option<String>,
}
@@ -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<F, T>(&self, write: bool, f: F) -> Result<T>
+ where
+ F: FnOnce(&mut FakeGitRepositoryState) -> T,
+ {
+ self.fs.with_git_state(&self.dot_git_path, write, f)
+ }
+
fn with_state_async<F, T>(&self, write: bool, f: F) -> BoxFuture<'static, Result<T>>
where
F: 'static + Send + FnOnce(&mut FakeGitRepositoryState) -> Result<T>,
@@ -137,11 +146,18 @@ impl GitRepository for FakeGitRepository {
}
fn merge_head_shas(&self) -> Vec<String> {
- vec![]
+ self.with_state(false, |state| state.merge_head_shas.clone())
+ .unwrap()
}
- fn show(&self, _commit: String) -> BoxFuture<Result<CommitDetails>> {
- unimplemented!()
+ fn show(&self, commit: String) -> BoxFuture<Result<CommitDetails>> {
+ async {
+ Ok(CommitDetails {
+ sha: commit.into(),
+ ..Default::default()
+ })
+ }
+ .boxed()
}
fn reset(
@@ -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,
@@ -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<BufferId, BufferConflicts>,
+}
+
+impl ConflictAddon {
+ pub(crate) fn conflict_set(&self, buffer_id: BufferId) -> Option<Entity<ConflictSet>> {
+ self.buffers
+ .get(&buffer_id)
+ .map(|entry| entry.conflict_set.clone())
+ }
+}
+
+struct BufferConflicts {
+ block_ids: Vec<(Range<Anchor>, CustomBlockId)>,
+ conflict_set: Entity<ConflictSet>,
+ _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<MultiBuffer>, cx: &mut Context<Editor>) {
+ // 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::<ConflictAddon>().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<ConflictSet>,
+ cx: &mut Context<Editor>,
+) {
+ 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<Buffer>, cx: &mut Context<Editor>) {
+ let Some(project) = &editor.project else {
+ return;
+ };
+ let git_store = project.read(cx).git_store().clone();
+
+ let buffer_conflicts = editor
+ .addon_mut::<ConflictAddon>()
+ .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<Editor>) {
+ let mut removed_block_ids = HashSet::default();
+ editor
+ .addon_mut::<ConflictAddon>()
+ .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<ConflictSet>,
+ event: &ConflictSetUpdate,
+ cx: &mut Context<Editor>,
+) {
+ 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::<ConflictAddon>().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::<ConflictsOuter>(removed_highlighted_ranges.clone(), cx);
+ editor.remove_highlighted_rows::<ConflictsOurs>(removed_highlighted_ranges.clone(), cx);
+ editor
+ .remove_highlighted_rows::<ConflictsOursMarker>(removed_highlighted_ranges.clone(), cx);
+ editor.remove_highlighted_rows::<ConflictsTheirs>(removed_highlighted_ranges.clone(), cx);
+ editor.remove_highlighted_rows::<ConflictsTheirsMarker>(
+ 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::<ConflictAddon>().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<Editor>,
+) {
+ 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::<ConflictsOuter>(
+ outer_start..outer_end,
+ divider_background,
+ options,
+ cx,
+ );
+ editor.highlight_rows::<ConflictsOurs>(our_start..our_end, ours_background, options, cx);
+ editor.highlight_rows::<ConflictsOursMarker>(outer_start..our_start, ours_marker, options, cx);
+ editor.highlight_rows::<ConflictsTheirs>(
+ their_start..their_end,
+ theirs_background,
+ options,
+ cx,
+ );
+ editor.highlight_rows::<ConflictsTheirsMarker>(
+ their_end..outer_end,
+ theirs_marker,
+ options,
+ cx,
+ );
+}
+
+fn render_conflict_buttons(
+ conflict: &ConflictRegion,
+ excerpt_id: ExcerptId,
+ editor: WeakEntity<Editor>,
+ 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<Editor>,
+ excerpt_id: ExcerptId,
+ resolved_conflict: &ConflictRegion,
+ ranges: &[Range<Anchor>],
+ 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::<ConflictAddon>().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::<ConflictsOuter>(vec![start..end], cx);
+ editor.remove_highlighted_rows::<ConflictsOurs>(vec![start..end], cx);
+ editor.remove_highlighted_rows::<ConflictsTheirs>(vec![start..end], cx);
+ editor.remove_highlighted_rows::<ConflictsOursMarker>(vec![start..end], cx);
+ editor.remove_highlighted_rows::<ConflictsTheirsMarker>(vec![start..end], cx);
+ editor.remove_blocks(HashSet::from_iter([block_id]), None, cx);
+ })
+}
@@ -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());
}
@@ -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);
@@ -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<PathKey>,
- current_branch: Option<Branch>,
_task: Task<Result<()>>,
_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<Workspace>) {
@@ -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::<ConflictAddon>()
+ .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::<Vec<_>>();
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<Item = Range<Anchor>>,
+ right: impl 'a + Iterator<Item = Range<Anchor>>,
+ snapshot: &'a language::BufferSnapshot,
+) -> impl 'a + Iterator<Item = Range<Anchor>> {
+ 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 {
@@ -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::<GoToLineRowHighlights>(
start..end,
cx.theme().colors().editor_highlighted_line_background,
- true,
+ RowHighlightOptions {
+ autoscroll: true,
+ ..Default::default()
+ },
cx,
);
editor.request_autoscroll(Autoscroll::center(), cx);
@@ -95,6 +95,7 @@ pub enum Event {
},
ExcerptsRemoved {
ids: Vec<ExcerptId>,
+ removed_buffer_ids: Vec<BufferId>,
},
ExcerptsExpanded {
ids: Vec<ExcerptId>,
@@ -2021,7 +2022,12 @@ impl MultiBuffer {
pub fn clear(&mut self, cx: &mut Context<Self>) {
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();
}
@@ -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;
}
@@ -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::<OutlineRowHighlights>(
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);
@@ -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::<HashSet<_>>();
for excerpts in outline_panel.excerpts.values_mut() {
excerpts.retain(|excerpt_id, _| !ids.remove(excerpt_id));
@@ -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<Task<Result<Entity<BufferDiff>, Arc<anyhow::Error>>>>>,
- diffs: HashMap<BufferId, Entity<BufferDiffState>>,
+ diffs: HashMap<BufferId, Entity<BufferGitState>>,
shared_diffs: HashMap<proto::PeerId, HashMap<BufferId, SharedDiffs>>,
_subscriptions: Vec<Subscription>,
}
@@ -85,12 +87,15 @@ struct SharedDiffs {
uncommitted: Option<Entity<BufferDiff>>,
}
-struct BufferDiffState {
+struct BufferGitState {
unstaged_diff: Option<WeakEntity<BufferDiff>>,
uncommitted_diff: Option<WeakEntity<BufferDiff>>,
+ conflict_set: Option<WeakEntity<ConflictSet>>,
recalculate_diff_task: Option<Task<Result<()>>>,
+ reparse_conflict_markers_task: Option<Task<Result<()>>>,
language: Option<Arc<Language>>,
language_registry: Option<Arc<LanguageRegistry>>,
+ conflict_updated_futures: Vec<oneshot::Sender<()>>,
recalculating_tx: postage::watch::Sender<bool>,
/// 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<RepoPath>,
+ pub message: Option<SharedString>,
+ pub apply_head: Option<CommitDetails>,
+ pub cherry_pick_head: Option<CommitDetails>,
+ pub merge_heads: Vec<CommitDetails>,
+ pub rebase_head: Option<CommitDetails>,
+ pub revert_head: Option<CommitDetails>,
+}
+
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RepositorySnapshot {
pub id: RepositoryId,
- pub merge_message: Option<SharedString>,
pub statuses_by_path: SumTree<StatusEntry>,
pub work_directory_abs_path: Arc<Path>,
pub branch: Option<Branch>,
pub head_commit: Option<CommitDetails>,
- pub merge_conflicts: TreeSet<RepoPath>,
- pub merge_head_shas: Vec<SharedString>,
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<RepositoryEvent> 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<Buffer>,
+ cx: &mut Context<Self>,
+ ) -> Entity<ConflictSet> {
+ 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<Self>,
) {
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<GitStore>) -> 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<Buffer>, cx: &mut Context<Self>) {
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<Self>,
+ ) -> 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<Entity<BufferDiff>> {
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<GitStore>,
project_id: u64,
@@ -2397,14 +2557,12 @@ impl RepositorySnapshot {
fn empty(id: RepositoryId, work_directory_abs_path: Arc<Path>) -> 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<dyn GitRepository>,
+ status: &SumTree<StatusEntry>,
+ prev_snapshot: &RepositorySnapshot,
+ ) -> Result<(MergeDetails, bool)> {
+ fn sha_eq<'a>(
+ l: impl IntoIterator<Item = &'a CommitDetails>,
+ r: impl IntoIterator<Item = &'a CommitDetails>,
+ ) -> 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<Path>,
@@ -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))
@@ -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<Range<Anchor>>,
+ pub old_range: Range<usize>,
+ pub new_range: Range<usize>,
+}
+
+#[derive(Debug, Clone)]
+pub struct ConflictSetSnapshot {
+ pub buffer_id: BufferId,
+ pub conflicts: Arc<[ConflictRegion]>,
+}
+
+impl ConflictSetSnapshot {
+ pub fn conflicts_in_range(
+ &self,
+ range: Range<Anchor>,
+ 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<Anchor>,
+ pub ours: Range<Anchor>,
+ pub theirs: Range<Anchor>,
+ pub base: Option<Range<Anchor>>,
+}
+
+impl ConflictRegion {
+ pub fn resolve(
+ &self,
+ buffer: Entity<language::Buffer>,
+ ranges: &[Range<Anchor>],
+ 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 {
+ Self {
+ has_conflict,
+ snapshot: ConflictSetSnapshot {
+ buffer_id,
+ conflicts: Default::default(),
+ },
+ }
+ }
+
+ pub fn set_has_conflict(&mut self, has_conflict: bool, cx: &mut Context<Self>) -> 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>,
+ ) {
+ 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<usize> = None;
+ let mut ours_start: Option<usize> = None;
+ let mut ours_end: Option<usize> = None;
+ let mut base_start: Option<usize> = None;
+ let mut base_end: Option<usize> = None;
+ let mut theirs_start: Option<usize> = 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<ConflictSetUpdate> 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::<String>();
+ let their_text = snapshot
+ .text_for_range(first.theirs.clone())
+ .collect::<String>();
+ 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::<String>();
+ let their_text = snapshot
+ .text_for_range(second.theirs.clone())
+ .collect::<String>();
+ let base_text = snapshot
+ .text_for_range(second.base.as_ref().unwrap().clone())
+ .collect::<String>();
+ 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::<String>();
+ assert_eq!(our_text, "This is a nested conflict marker\n");
+ let their_text = snapshot
+ .text_for_range(conflict.theirs.clone())
+ .collect::<String>();
+ 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::<ConflictSetUpdate>();
+ 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);
+ }
+}
@@ -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};
@@ -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(),
}
}
}
@@ -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,
@@ -586,6 +586,26 @@ pub struct ThemeColorsContent {
/// Ignored version control color.
#[serde(rename = "version_control.ignored")]
pub version_control_ignored: Option<String>,
+
+ /// 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<String>,
+
+ /// 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<String>,
+
+ /// 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<String>,
+
+ /// 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<String>,
+
+ /// 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<String>,
}
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()),
}
}
}
@@ -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)]
@@ -1500,7 +1500,7 @@ impl ShellExec {
editor.highlight_rows::<ShellExec>(
input_range.clone().unwrap(),
cx.theme().status().unreachable_background,
- false,
+ Default::default(),
cx,
);