Highlight merge conflicts and provide for resolving them (#28065)

Cole Miller and Max Brunsfeld created

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 <maxbrunsfeld@gmail.com>

Change summary

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, 1,626 insertions(+), 184 deletions(-)

Detailed changes

crates/agent/src/inline_assistant.rs 🔗

@@ -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

crates/assistant/src/inline_assistant.rs 🔗

@@ -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

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<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(

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<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,

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));

crates/fs/src/fake_git_repo.rs 🔗

@@ -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(

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,

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<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);
+    })
+}

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());
         }

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);

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<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 {

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::<GoToLineRowHighlights>(
                 start..end,
                 cx.theme().colors().editor_highlighted_line_background,
-                true,
+                RowHighlightOptions {
+                    autoscroll: true,
+                    ..Default::default()
+                },
                 cx,
             );
             editor.request_autoscroll(Autoscroll::center(), cx);

crates/multi_buffer/src/multi_buffer.rs 🔗

@@ -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();
     }
 

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;
                 }

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::<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);

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::<HashSet<_>>();
                     for excerpts in outline_panel.excerpts.values_mut() {
                         excerpts.retain(|excerpt_id, _| !ids.remove(excerpt_id));

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<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))

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<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);
+    }
+}

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};

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(),
         }
     }
 }

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,

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<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()),
         }
     }
 }

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)]

crates/vim/src/command.rs 🔗

@@ -1500,7 +1500,7 @@ impl ShellExec {
             editor.highlight_rows::<ShellExec>(
                 input_range.clone().unwrap(),
                 cx.theme().status().unreachable_background,
-                false,
+                Default::default(),
                 cx,
             );