Basic side-by-side diff implementation (#43586)

Cole Miller , cameron , and Cameron created

Release Notes:

- N/A

---------

Co-authored-by: cameron <cameron.studdstreet@gmail.com>
Co-authored-by: Cameron <cameron@zed.dev>

Change summary

Cargo.lock                                    |   1 
crates/agent_ui/src/agent_diff.rs             |   2 
crates/agent_ui/src/text_thread_editor.rs     |   6 
crates/buffer_diff/src/buffer_diff.rs         | 124 +++
crates/collab_ui/src/channel_view.rs          |   2 
crates/debugger_tools/src/dap_log.rs          |   6 
crates/debugger_ui/src/stack_trace_view.rs    |   2 
crates/diagnostics/src/diagnostics.rs         |   2 
crates/editor/Cargo.toml                      |   1 
crates/editor/src/display_map/wrap_map.rs     |   1 
crates/editor/src/editor.rs                   |  10 
crates/editor/src/editor_tests.rs             | 206 +++++
crates/editor/src/element.rs                  |   3 
crates/editor/src/items.rs                    |   6 
crates/editor/src/split.rs                    | 262 +++++++
crates/git_ui/src/commit_view.rs              |   2 
crates/git_ui/src/file_diff_view.rs           |   2 
crates/git_ui/src/project_diff.rs             | 225 +++--
crates/git_ui/src/text_diff_view.rs           |   2 
crates/language_tools/src/lsp_log_view.rs     |   6 
crates/multi_buffer/src/multi_buffer.rs       | 540 ++++++++++---
crates/multi_buffer/src/multi_buffer_tests.rs | 786 +++++++++++++++-----
crates/multi_buffer/src/path_key.rs           |   5 
crates/repl/src/notebook/notebook_ui.rs       |   2 
crates/rope/src/point.rs                      |   9 
crates/search/src/project_search.rs           |   2 
crates/terminal_view/src/terminal_view.rs     |   6 
crates/workspace/src/item.rs                  |   4 
typos.toml                                    |   2 
29 files changed, 1,758 insertions(+), 469 deletions(-)

Detailed changes

Cargo.lock πŸ”—

@@ -5367,6 +5367,7 @@ dependencies = [
  "db",
  "edit_prediction",
  "emojis",
+ "feature_flags",
  "file_icons",
  "fs",
  "futures 0.3.31",

crates/agent_ui/src/agent_diff.rs πŸ”—

@@ -493,7 +493,7 @@ impl Item for AgentDiffPane {
         Some("Assistant Diff Opened")
     }
 
-    fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
+    fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
         Some(Box::new(self.editor.clone()))
     }
 

crates/agent_ui/src/text_thread_editor.rs πŸ”—

@@ -2556,7 +2556,11 @@ impl Item for TextThreadEditor {
         Some(self.title(cx).to_string().into())
     }
 
-    fn as_searchable(&self, handle: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
+    fn as_searchable(
+        &self,
+        handle: &Entity<Self>,
+        _: &App,
+    ) -> Option<Box<dyn SearchableItemHandle>> {
         Some(Box::new(handle.clone()))
     }
 

crates/buffer_diff/src/buffer_diff.rs πŸ”—

@@ -1,7 +1,7 @@
 use futures::channel::oneshot;
 use git2::{DiffLineType as GitDiffLineType, DiffOptions as GitOptions, Patch as GitPatch};
 use gpui::{App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Task, TaskLabel};
-use language::{Language, LanguageRegistry};
+use language::{BufferRow, Language, LanguageRegistry};
 use rope::Rope;
 use std::{
     cmp::Ordering,
@@ -11,7 +11,7 @@ use std::{
     sync::{Arc, LazyLock},
 };
 use sum_tree::SumTree;
-use text::{Anchor, Bias, BufferId, OffsetRangeExt, Point, ToOffset as _};
+use text::{Anchor, Bias, BufferId, OffsetRangeExt, Point, ToOffset as _, ToPoint as _};
 use util::ResultExt;
 
 pub static CALCULATE_DIFF_TASK: LazyLock<TaskLabel> = LazyLock::new(TaskLabel::new);
@@ -88,6 +88,7 @@ struct PendingHunk {
 #[derive(Debug, Clone)]
 pub struct DiffHunkSummary {
     buffer_range: Range<Anchor>,
+    diff_base_byte_range: Range<usize>,
 }
 
 impl sum_tree::Item for InternalDiffHunk {
@@ -96,6 +97,7 @@ impl sum_tree::Item for InternalDiffHunk {
     fn summary(&self, _cx: &text::BufferSnapshot) -> Self::Summary {
         DiffHunkSummary {
             buffer_range: self.buffer_range.clone(),
+            diff_base_byte_range: self.diff_base_byte_range.clone(),
         }
     }
 }
@@ -106,6 +108,7 @@ impl sum_tree::Item for PendingHunk {
     fn summary(&self, _cx: &text::BufferSnapshot) -> Self::Summary {
         DiffHunkSummary {
             buffer_range: self.buffer_range.clone(),
+            diff_base_byte_range: self.diff_base_byte_range.clone(),
         }
     }
 }
@@ -116,6 +119,7 @@ impl sum_tree::Summary for DiffHunkSummary {
     fn zero(_cx: Self::Context<'_>) -> Self {
         DiffHunkSummary {
             buffer_range: Anchor::MIN..Anchor::MIN,
+            diff_base_byte_range: 0..0,
         }
     }
 
@@ -125,6 +129,15 @@ impl sum_tree::Summary for DiffHunkSummary {
             .start
             .min(&other.buffer_range.start, buffer);
         self.buffer_range.end = *self.buffer_range.end.max(&other.buffer_range.end, buffer);
+
+        self.diff_base_byte_range.start = self
+            .diff_base_byte_range
+            .start
+            .min(other.diff_base_byte_range.start);
+        self.diff_base_byte_range.end = self
+            .diff_base_byte_range
+            .end
+            .max(other.diff_base_byte_range.end);
     }
 }
 
@@ -305,6 +318,54 @@ impl BufferDiffSnapshot {
         let (new_id, new_empty) = (right.remote_id(), right.is_empty());
         new_id == old_id || (new_empty && old_empty)
     }
+
+    pub fn row_to_base_text_row(&self, row: BufferRow, buffer: &text::BufferSnapshot) -> u32 {
+        // TODO(split-diff) expose a parameter to reuse a cursor to avoid repeatedly seeking from the start
+
+        // Find the last hunk that starts before this position.
+        let mut cursor = self.inner.hunks.cursor::<DiffHunkSummary>(buffer);
+        let position = buffer.anchor_before(Point::new(row, 0));
+        cursor.seek(&position, Bias::Left);
+        if cursor
+            .item()
+            .is_none_or(|hunk| hunk.buffer_range.start.cmp(&position, buffer).is_gt())
+        {
+            cursor.prev();
+        }
+
+        let unclipped_point = if let Some(hunk) = cursor.item()
+            && hunk.buffer_range.start.cmp(&position, buffer).is_le()
+        {
+            let mut unclipped_point = cursor
+                .end()
+                .diff_base_byte_range
+                .end
+                .to_point(self.base_text());
+            if position.cmp(&cursor.end().buffer_range.end, buffer).is_ge() {
+                unclipped_point +=
+                    Point::new(row, 0) - cursor.end().buffer_range.end.to_point(buffer);
+            }
+            // Move the cursor so that at the next step we can clip with the start of the next hunk.
+            cursor.next();
+            unclipped_point
+        } else {
+            // Position is before the added region for the first hunk.
+            debug_assert!(self.inner.hunks.first().is_none_or(|first_hunk| {
+                position.cmp(&first_hunk.buffer_range.start, buffer).is_le()
+            }));
+            Point::new(row, 0)
+        };
+
+        let max_point = if let Some(next_hunk) = cursor.item() {
+            next_hunk
+                .diff_base_byte_range
+                .start
+                .to_point(self.base_text())
+        } else {
+            self.base_text().max_point()
+        };
+        unclipped_point.min(max_point).row
+    }
 }
 
 impl BufferDiffInner {
@@ -946,6 +1007,7 @@ impl BufferDiff {
         if self.secondary_diff.is_some() {
             self.inner.pending_hunks = SumTree::from_summary(DiffHunkSummary {
                 buffer_range: Anchor::min_min_range_for_buffer(self.buffer_id),
+                diff_base_byte_range: 0..0,
             });
             cx.emit(BufferDiffEvent::DiffChanged {
                 changed_range: Some(Anchor::min_max_range_for_buffer(self.buffer_id)),
@@ -2240,4 +2302,62 @@ mod tests {
             hunks = found_hunks;
         }
     }
+
+    #[gpui::test]
+    async fn test_row_to_base_text_row(cx: &mut TestAppContext) {
+        let base_text = "
+            zero
+            one
+            two
+            three
+            four
+            five
+            six
+            seven
+            eight
+        "
+        .unindent();
+        let buffer_text = "
+            zero
+            ONE
+            two
+            NINE
+            five
+            seven
+        "
+        .unindent();
+
+        //   zero
+        // - one
+        // + ONE
+        //   two
+        // - three
+        // - four
+        // + NINE
+        //   five
+        // - six
+        //   seven
+        // + eight
+
+        let buffer = Buffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), buffer_text);
+        let buffer_snapshot = buffer.snapshot();
+        let diff = BufferDiffSnapshot::new_sync(buffer_snapshot.clone(), base_text, cx);
+        let expected_results = [
+            // don't format me
+            (0, 0),
+            (1, 2),
+            (2, 2),
+            (3, 5),
+            (4, 5),
+            (5, 7),
+            (6, 9),
+        ];
+        for (buffer_row, expected) in expected_results {
+            assert_eq!(
+                diff.row_to_base_text_row(buffer_row, &buffer_snapshot),
+                expected,
+                "{buffer_row}"
+            );
+        }
+    }
 }

crates/collab_ui/src/channel_view.rs πŸ”—

@@ -541,7 +541,7 @@ impl Item for ChannelView {
         })
     }
 
-    fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
+    fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
         Some(Box::new(self.editor.clone()))
     }
 

crates/debugger_tools/src/dap_log.rs πŸ”—

@@ -998,7 +998,11 @@ impl Item for DapLogView {
         None
     }
 
-    fn as_searchable(&self, handle: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
+    fn as_searchable(
+        &self,
+        handle: &Entity<Self>,
+        _: &App,
+    ) -> Option<Box<dyn SearchableItemHandle>> {
         Some(Box::new(handle.clone()))
     }
 }

crates/debugger_ui/src/stack_trace_view.rs πŸ”—

@@ -428,7 +428,7 @@ impl Item for StackTraceView {
         }
     }
 
-    fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
+    fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
         Some(Box::new(self.editor.clone()))
     }
 

crates/diagnostics/src/diagnostics.rs πŸ”—

@@ -890,7 +890,7 @@ impl Item for ProjectDiagnosticsEditor {
         }
     }
 
-    fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
+    fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
         Some(Box::new(self.editor.clone()))
     }
 

crates/editor/Cargo.toml πŸ”—

@@ -41,6 +41,7 @@ dap.workspace = true
 db.workspace = true
 buffer_diff.workspace = true
 emojis.workspace = true
+feature_flags.workspace = true
 file_icons.workspace = true
 futures.workspace = true
 fuzzy.workspace = true

crates/editor/src/editor.rs πŸ”—

@@ -36,6 +36,7 @@ mod persistence;
 mod rust_analyzer_ext;
 pub mod scroll;
 mod selections_collection;
+mod split;
 pub mod tasks;
 
 #[cfg(test)]
@@ -69,6 +70,7 @@ pub use multi_buffer::{
     MultiBufferOffset, MultiBufferOffsetUtf16, MultiBufferSnapshot, PathKey, RowInfo, ToOffset,
     ToPoint,
 };
+pub use split::SplittableEditor;
 pub use text::Bias;
 
 use ::git::{
@@ -1198,6 +1200,7 @@ pub struct Editor {
     applicable_language_settings: HashMap<Option<LanguageName>, LanguageSettings>,
     accent_overrides: Vec<SharedString>,
     fetched_tree_sitter_chunks: HashMap<ExcerptId, HashSet<Range<BufferRow>>>,
+    use_base_text_line_numbers: bool,
 }
 
 fn debounce_value(debounce_ms: u64) -> Option<Duration> {
@@ -1637,7 +1640,7 @@ pub(crate) struct FocusedBlock {
     focus_handle: WeakFocusHandle,
 }
 
-#[derive(Clone)]
+#[derive(Clone, Debug)]
 enum JumpData {
     MultiBufferRow {
         row: MultiBufferRow,
@@ -2344,6 +2347,7 @@ impl Editor {
             applicable_language_settings: HashMap::default(),
             accent_overrides: Vec::new(),
             fetched_tree_sitter_chunks: HashMap::default(),
+            use_base_text_line_numbers: false,
         };
 
         if is_minimap {
@@ -19204,6 +19208,10 @@ impl Editor {
         self.display_map.read(cx).fold_placeholder.clone()
     }
 
+    pub fn set_use_base_text_line_numbers(&mut self, show: bool, _cx: &mut Context<Self>) {
+        self.use_base_text_line_numbers = show;
+    }
+
     pub fn set_expand_all_diff_hunks(&mut self, cx: &mut App) {
         self.buffer.update(cx, |buffer, cx| {
             buffer.set_all_diff_hunks_expanded(cx);

crates/editor/src/editor_tests.rs πŸ”—

@@ -35,7 +35,9 @@ use language_settings::Formatter;
 use languages::markdown_lang;
 use languages::rust_lang;
 use lsp::CompletionParams;
-use multi_buffer::{IndentGuide, MultiBufferOffset, MultiBufferOffsetUtf16, PathKey};
+use multi_buffer::{
+    IndentGuide, MultiBufferFilterMode, MultiBufferOffset, MultiBufferOffsetUtf16, PathKey,
+};
 use parking_lot::Mutex;
 use pretty_assertions::{assert_eq, assert_ne};
 use project::{
@@ -28199,6 +28201,208 @@ async fn test_multibuffer_selections_with_folding(cx: &mut TestAppContext) {
         "});
 }
 
+#[gpui::test]
+async fn test_filtered_editor_pair(cx: &mut gpui::TestAppContext) {
+    init_test(cx, |_| {});
+    let mut leader_cx = EditorTestContext::new(cx).await;
+
+    let diff_base = indoc!(
+        r#"
+        one
+        two
+        three
+        four
+        five
+        six
+        "#
+    );
+
+    let initial_state = indoc!(
+        r#"
+        Λ‡one
+        two
+        THREE
+        four
+        five
+        six
+        "#
+    );
+
+    leader_cx.set_state(initial_state);
+
+    leader_cx.set_head_text(&diff_base);
+    leader_cx.run_until_parked();
+
+    let follower = leader_cx.update_multibuffer(|leader, cx| {
+        leader.set_filter_mode(Some(MultiBufferFilterMode::KeepInsertions));
+        leader.set_all_diff_hunks_expanded(cx);
+        leader.get_or_create_follower(cx)
+    });
+    follower.update(cx, |follower, cx| {
+        follower.set_filter_mode(Some(MultiBufferFilterMode::KeepDeletions));
+        follower.set_all_diff_hunks_expanded(cx);
+    });
+
+    let follower_editor =
+        leader_cx.new_window_entity(|window, cx| build_editor(follower, window, cx));
+    // leader_cx.window.focus(&follower_editor.focus_handle(cx));
+
+    let mut follower_cx = EditorTestContext::for_editor_in(follower_editor, &mut leader_cx).await;
+    cx.run_until_parked();
+
+    leader_cx.assert_editor_state(initial_state);
+    follower_cx.assert_editor_state(indoc! {
+        r#"
+        Λ‡one
+        two
+        three
+        four
+        five
+        six
+        "#
+    });
+
+    follower_cx.editor(|editor, _window, cx| {
+        assert!(editor.read_only(cx));
+    });
+
+    leader_cx.update_editor(|editor, _window, cx| {
+        editor.edit([(Point::new(4, 0)..Point::new(5, 0), "FIVE\n")], cx);
+    });
+    cx.run_until_parked();
+
+    leader_cx.assert_editor_state(indoc! {
+        r#"
+        Λ‡one
+        two
+        THREE
+        four
+        FIVE
+        six
+        "#
+    });
+
+    follower_cx.assert_editor_state(indoc! {
+        r#"
+        Λ‡one
+        two
+        three
+        four
+        five
+        six
+        "#
+    });
+
+    leader_cx.update_editor(|editor, _window, cx| {
+        editor.edit([(Point::new(6, 0)..Point::new(6, 0), "SEVEN")], cx);
+    });
+    cx.run_until_parked();
+
+    leader_cx.assert_editor_state(indoc! {
+        r#"
+        Λ‡one
+        two
+        THREE
+        four
+        FIVE
+        six
+        SEVEN"#
+    });
+
+    follower_cx.assert_editor_state(indoc! {
+        r#"
+        Λ‡one
+        two
+        three
+        four
+        five
+        six
+        "#
+    });
+
+    leader_cx.update_editor(|editor, window, cx| {
+        editor.move_down(&MoveDown, window, cx);
+        editor.refresh_selected_text_highlights(true, window, cx);
+    });
+    leader_cx.run_until_parked();
+}
+
+#[gpui::test]
+async fn test_filtered_editor_pair_complex(cx: &mut gpui::TestAppContext) {
+    init_test(cx, |_| {});
+    let base_text = "base\n";
+    let buffer_text = "buffer\n";
+
+    let buffer1 = cx.new(|cx| Buffer::local(buffer_text, cx));
+    let diff1 = cx.new(|cx| BufferDiff::new_with_base_text(base_text, &buffer1, cx));
+
+    let extra_buffer_1 = cx.new(|cx| Buffer::local("dummy text 1\n", cx));
+    let extra_diff_1 = cx.new(|cx| BufferDiff::new_with_base_text("", &extra_buffer_1, cx));
+    let extra_buffer_2 = cx.new(|cx| Buffer::local("dummy text 2\n", cx));
+    let extra_diff_2 = cx.new(|cx| BufferDiff::new_with_base_text("", &extra_buffer_2, cx));
+
+    let leader = cx.new(|cx| {
+        let mut leader = MultiBuffer::new(Capability::ReadWrite);
+        leader.set_all_diff_hunks_expanded(cx);
+        leader.set_filter_mode(Some(MultiBufferFilterMode::KeepInsertions));
+        leader
+    });
+    let follower = leader.update(cx, |leader, cx| leader.get_or_create_follower(cx));
+    follower.update(cx, |follower, _| {
+        follower.set_filter_mode(Some(MultiBufferFilterMode::KeepDeletions));
+    });
+
+    leader.update(cx, |leader, cx| {
+        leader.insert_excerpts_after(
+            ExcerptId::min(),
+            extra_buffer_2.clone(),
+            vec![ExcerptRange::new(text::Anchor::MIN..text::Anchor::MAX)],
+            cx,
+        );
+        leader.add_diff(extra_diff_2.clone(), cx);
+
+        leader.insert_excerpts_after(
+            ExcerptId::min(),
+            extra_buffer_1.clone(),
+            vec![ExcerptRange::new(text::Anchor::MIN..text::Anchor::MAX)],
+            cx,
+        );
+        leader.add_diff(extra_diff_1.clone(), cx);
+
+        leader.insert_excerpts_after(
+            ExcerptId::min(),
+            buffer1.clone(),
+            vec![ExcerptRange::new(text::Anchor::MIN..text::Anchor::MAX)],
+            cx,
+        );
+        leader.add_diff(diff1.clone(), cx);
+    });
+
+    cx.run_until_parked();
+    let mut cx = cx.add_empty_window();
+
+    let leader_editor = cx
+        .new_window_entity(|window, cx| Editor::for_multibuffer(leader.clone(), None, window, cx));
+    let follower_editor = cx.new_window_entity(|window, cx| {
+        Editor::for_multibuffer(follower.clone(), None, window, cx)
+    });
+
+    let mut leader_cx = EditorTestContext::for_editor_in(leader_editor.clone(), &mut cx).await;
+    leader_cx.assert_editor_state(indoc! {"
+       Λ‡buffer
+
+       dummy text 1
+
+       dummy text 2
+    "});
+    let mut follower_cx = EditorTestContext::for_editor_in(follower_editor.clone(), &mut cx).await;
+    follower_cx.assert_editor_state(indoc! {"
+        Λ‡base
+
+
+    "});
+}
+
 #[gpui::test]
 async fn test_multibuffer_scroll_cursor_top_margin(cx: &mut TestAppContext) {
     init_test(cx, |_| {});

crates/editor/src/element.rs πŸ”—

@@ -3274,6 +3274,8 @@ impl EditorElement {
             line_number.clear();
             let non_relative_number = if relative.wrapped() {
                 row_info.buffer_row.or(row_info.wrapped_buffer_row)? + 1
+            } else if self.editor.read(cx).use_base_text_line_numbers {
+                row_info.base_text_row?.0 + 1
             } else {
                 row_info.buffer_row? + 1
             };
@@ -3282,6 +3284,7 @@ impl EditorElement {
                 && row_info
                     .diff_status
                     .is_some_and(|status| status.is_deleted())
+                && !self.editor.read(cx).use_base_text_line_numbers
             {
                 return None;
             }

crates/editor/src/items.rs πŸ”—

@@ -929,7 +929,11 @@ impl Item for Editor {
         })
     }
 
-    fn as_searchable(&self, handle: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
+    fn as_searchable(
+        &self,
+        handle: &Entity<Self>,
+        _: &App,
+    ) -> Option<Box<dyn SearchableItemHandle>> {
         Some(Box::new(handle.clone()))
     }
 

crates/editor/src/split.rs πŸ”—

@@ -0,0 +1,262 @@
+use feature_flags::{FeatureFlag, FeatureFlagAppExt as _};
+use gpui::{
+    Action, AppContext as _, Entity, EventEmitter, Focusable, NoAction, Subscription, WeakEntity,
+};
+use multi_buffer::{MultiBuffer, MultiBufferFilterMode};
+use project::Project;
+use ui::{
+    App, Context, InteractiveElement as _, IntoElement as _, ParentElement as _, Render,
+    Styled as _, Window, div,
+};
+use workspace::{
+    ActivePaneDecorator, Item, ItemHandle, Pane, PaneGroup, SplitDirection, Workspace,
+};
+
+use crate::{Editor, EditorEvent};
+
+struct SplitDiffFeatureFlag;
+
+impl FeatureFlag for SplitDiffFeatureFlag {
+    const NAME: &'static str = "split-diff";
+
+    fn enabled_for_staff() -> bool {
+        true
+    }
+}
+
+#[derive(Clone, Copy, PartialEq, Eq, Action, Default)]
+#[action(namespace = editor)]
+struct SplitDiff;
+
+#[derive(Clone, Copy, PartialEq, Eq, Action, Default)]
+#[action(namespace = editor)]
+struct UnsplitDiff;
+
+pub struct SplittableEditor {
+    primary_editor: Entity<Editor>,
+    secondary: Option<SecondaryEditor>,
+    panes: PaneGroup,
+    workspace: WeakEntity<Workspace>,
+    _subscriptions: Vec<Subscription>,
+}
+
+struct SecondaryEditor {
+    editor: Entity<Editor>,
+    pane: Entity<Pane>,
+    has_latest_selection: bool,
+    _subscriptions: Vec<Subscription>,
+}
+
+impl SplittableEditor {
+    pub fn primary_editor(&self) -> &Entity<Editor> {
+        &self.primary_editor
+    }
+
+    pub fn last_selected_editor(&self) -> &Entity<Editor> {
+        if let Some(secondary) = &self.secondary
+            && secondary.has_latest_selection
+        {
+            &secondary.editor
+        } else {
+            &self.primary_editor
+        }
+    }
+
+    pub fn new_unsplit(
+        buffer: Entity<MultiBuffer>,
+        project: Entity<Project>,
+        workspace: Entity<Workspace>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let primary_editor =
+            cx.new(|cx| Editor::for_multibuffer(buffer, Some(project.clone()), window, cx));
+        let pane = cx.new(|cx| {
+            let mut pane = Pane::new(
+                workspace.downgrade(),
+                project,
+                Default::default(),
+                None,
+                NoAction.boxed_clone(),
+                true,
+                window,
+                cx,
+            );
+            pane.set_should_display_tab_bar(|_, _| false);
+            pane.add_item(primary_editor.boxed_clone(), true, true, None, window, cx);
+            pane
+        });
+        let panes = PaneGroup::new(pane);
+        // TODO(split-diff) we might want to tag editor events with whether they came from primary/secondary
+        let subscriptions =
+            vec![
+                cx.subscribe(&primary_editor, |this, _, event: &EditorEvent, cx| {
+                    if let EditorEvent::SelectionsChanged { .. } = event
+                        && let Some(secondary) = &mut this.secondary
+                    {
+                        secondary.has_latest_selection = false;
+                    }
+                    cx.emit(event.clone())
+                }),
+            ];
+
+        window.defer(cx, {
+            let workspace = workspace.downgrade();
+            let primary_editor = primary_editor.downgrade();
+            move |window, cx| {
+                workspace
+                    .update(cx, |workspace, cx| {
+                        primary_editor.update(cx, |editor, cx| {
+                            editor.added_to_workspace(workspace, window, cx);
+                        })
+                    })
+                    .ok();
+            }
+        });
+        Self {
+            primary_editor,
+            secondary: None,
+            panes,
+            workspace: workspace.downgrade(),
+            _subscriptions: subscriptions,
+        }
+    }
+
+    fn split(&mut self, _: &SplitDiff, window: &mut Window, cx: &mut Context<Self>) {
+        if !cx.has_flag::<SplitDiffFeatureFlag>() {
+            return;
+        }
+        if self.secondary.is_some() {
+            return;
+        }
+        let Some(workspace) = self.workspace.upgrade() else {
+            return;
+        };
+        let project = workspace.read(cx).project().clone();
+        let follower = self.primary_editor.update(cx, |primary, cx| {
+            primary.buffer().update(cx, |buffer, cx| {
+                let follower = buffer.get_or_create_follower(cx);
+                buffer.set_filter_mode(Some(MultiBufferFilterMode::KeepInsertions));
+                follower
+            })
+        });
+        follower.update(cx, |follower, _| {
+            follower.set_filter_mode(Some(MultiBufferFilterMode::KeepDeletions));
+        });
+        let secondary_editor = workspace.update(cx, |workspace, cx| {
+            cx.new(|cx| {
+                let mut editor = Editor::for_multibuffer(follower, Some(project), window, cx);
+                // TODO(split-diff) this should be at the multibuffer level
+                editor.set_use_base_text_line_numbers(true, cx);
+                editor.added_to_workspace(workspace, window, cx);
+                editor
+            })
+        });
+        let secondary_pane = cx.new(|cx| {
+            let mut pane = Pane::new(
+                workspace.downgrade(),
+                workspace.read(cx).project().clone(),
+                Default::default(),
+                None,
+                NoAction.boxed_clone(),
+                true,
+                window,
+                cx,
+            );
+            pane.set_should_display_tab_bar(|_, _| false);
+            pane.add_item(
+                ItemHandle::boxed_clone(&secondary_editor),
+                false,
+                false,
+                None,
+                window,
+                cx,
+            );
+            pane
+        });
+
+        let subscriptions =
+            vec![
+                cx.subscribe(&secondary_editor, |this, _, event: &EditorEvent, cx| {
+                    if let EditorEvent::SelectionsChanged { .. } = event
+                        && let Some(secondary) = &mut this.secondary
+                    {
+                        secondary.has_latest_selection = true;
+                    }
+                    cx.emit(event.clone())
+                }),
+            ];
+        self.secondary = Some(SecondaryEditor {
+            editor: secondary_editor,
+            pane: secondary_pane.clone(),
+            has_latest_selection: false,
+            _subscriptions: subscriptions,
+        });
+        let primary_pane = self.panes.first_pane();
+        self.panes
+            .split(&primary_pane, &secondary_pane, SplitDirection::Left)
+            .unwrap();
+        cx.notify();
+    }
+
+    fn unsplit(&mut self, _: &UnsplitDiff, _: &mut Window, cx: &mut Context<Self>) {
+        let Some(secondary) = self.secondary.take() else {
+            return;
+        };
+        self.panes.remove(&secondary.pane).unwrap();
+        self.primary_editor.update(cx, |primary, cx| {
+            primary.buffer().update(cx, |buffer, _| {
+                buffer.set_filter_mode(None);
+            });
+        });
+        cx.notify();
+    }
+
+    pub fn added_to_workspace(
+        &mut self,
+        workspace: &mut Workspace,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.workspace = workspace.weak_handle();
+        self.primary_editor.update(cx, |primary_editor, cx| {
+            primary_editor.added_to_workspace(workspace, window, cx);
+        });
+        if let Some(secondary) = &self.secondary {
+            secondary.editor.update(cx, |secondary_editor, cx| {
+                secondary_editor.added_to_workspace(workspace, window, cx);
+            });
+        }
+    }
+}
+
+impl EventEmitter<EditorEvent> for SplittableEditor {}
+impl Focusable for SplittableEditor {
+    fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
+        self.primary_editor.read(cx).focus_handle(cx)
+    }
+}
+
+impl Render for SplittableEditor {
+    fn render(
+        &mut self,
+        window: &mut ui::Window,
+        cx: &mut ui::Context<Self>,
+    ) -> impl ui::IntoElement {
+        let Some(active) = self.panes.panes().into_iter().next() else {
+            return div().into_any_element();
+        };
+        div()
+            .id("splittable-editor")
+            .on_action(cx.listener(Self::split))
+            .on_action(cx.listener(Self::unsplit))
+            .size_full()
+            .child(self.panes.render(
+                None,
+                &ActivePaneDecorator::new(active, &self.workspace),
+                window,
+                cx,
+            ))
+            .into_any_element()
+    }
+}

crates/git_ui/src/commit_view.rs πŸ”—

@@ -513,7 +513,7 @@ impl Item for CommitView {
         }
     }
 
-    fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
+    fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
         Some(Box::new(self.editor.clone()))
     }
 

crates/git_ui/src/file_diff_view.rs πŸ”—

@@ -278,7 +278,7 @@ impl Item for FileDiffView {
         }
     }
 
-    fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
+    fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
         Some(Box::new(self.editor.clone()))
     }
 

crates/git_ui/src/project_diff.rs πŸ”—

@@ -8,7 +8,7 @@ use anyhow::{Context as _, Result, anyhow};
 use buffer_diff::{BufferDiff, DiffHunkSecondaryStatus};
 use collections::{HashMap, HashSet};
 use editor::{
-    Addon, Editor, EditorEvent, SelectionEffects,
+    Addon, Editor, EditorEvent, SelectionEffects, SplittableEditor,
     actions::{GoToHunk, GoToPreviousHunk},
     multibuffer_context_lines,
     scroll::Autoscroll,
@@ -56,7 +56,8 @@ actions!(
         Add,
         /// Shows the diff between the working directory and your default
         /// branch (typically main or master).
-        BranchDiff
+        BranchDiff,
+        LeaderAndFollower,
     ]
 );
 
@@ -64,7 +65,7 @@ pub struct ProjectDiff {
     project: Entity<Project>,
     multibuffer: Entity<MultiBuffer>,
     branch_diff: Entity<branch_diff::BranchDiff>,
-    editor: Entity<Editor>,
+    editor: Entity<SplittableEditor>,
     buffer_diff_subscriptions: HashMap<Arc<RelPath>, (Entity<BufferDiff>, Subscription)>,
     workspace: WeakEntity<Workspace>,
     focus_handle: FocusHandle,
@@ -172,7 +173,9 @@ impl ProjectDiff {
 
     pub fn autoscroll(&self, cx: &mut Context<Self>) {
         self.editor.update(cx, |editor, cx| {
-            editor.request_autoscroll(Autoscroll::fit(), cx);
+            editor.primary_editor().update(cx, |editor, cx| {
+                editor.request_autoscroll(Autoscroll::fit(), cx);
+            })
         })
     }
 
@@ -226,44 +229,44 @@ impl ProjectDiff {
         cx: &mut Context<Self>,
     ) -> Self {
         let focus_handle = cx.focus_handle();
-        let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite));
+        let multibuffer = cx.new(|cx| {
+            let mut multibuffer = MultiBuffer::new(Capability::ReadWrite);
+            multibuffer.set_all_diff_hunks_expanded(cx);
+            multibuffer
+        });
 
         let editor = cx.new(|cx| {
-            let mut diff_display_editor =
-                Editor::for_multibuffer(multibuffer.clone(), Some(project.clone()), window, cx);
-            diff_display_editor.disable_diagnostics(cx);
-            diff_display_editor.set_expand_all_diff_hunks(cx);
-
-            match branch_diff.read(cx).diff_base() {
-                DiffBase::Head => {
-                    diff_display_editor.register_addon(GitPanelAddon {
-                        workspace: workspace.downgrade(),
-                    });
-                }
-                DiffBase::Merge { .. } => {
-                    diff_display_editor.register_addon(BranchDiffAddon {
-                        branch_diff: branch_diff.clone(),
-                    });
-                    diff_display_editor.start_temporary_diff_override();
-                    diff_display_editor.set_render_diff_hunk_controls(
-                        Arc::new(|_, _, _, _, _, _, _, _| gpui::Empty.into_any_element()),
-                        cx,
-                    );
-                    //
-                }
-            }
+            let diff_display_editor = SplittableEditor::new_unsplit(
+                multibuffer.clone(),
+                project.clone(),
+                workspace.clone(),
+                window,
+                cx,
+            );
             diff_display_editor
-        });
-        window.defer(cx, {
-            let workspace = workspace.clone();
-            let editor = editor.clone();
-            move |window, cx| {
-                workspace.update(cx, |workspace, cx| {
-                    editor.update(cx, |editor, cx| {
-                        editor.added_to_workspace(workspace, window, cx);
-                    })
+                .primary_editor()
+                .update(cx, |editor, cx| {
+                    editor.disable_diagnostics(cx);
+
+                    match branch_diff.read(cx).diff_base() {
+                        DiffBase::Head => {
+                            editor.register_addon(GitPanelAddon {
+                                workspace: workspace.downgrade(),
+                            });
+                        }
+                        DiffBase::Merge { .. } => {
+                            editor.register_addon(BranchDiffAddon {
+                                branch_diff: branch_diff.clone(),
+                            });
+                            editor.start_temporary_diff_override();
+                            editor.set_render_diff_hunk_controls(
+                                Arc::new(|_, _, _, _, _, _, _, _| gpui::Empty.into_any_element()),
+                                cx,
+                            );
+                        }
+                    }
                 });
-            }
+            diff_display_editor
         });
         cx.subscribe_in(&editor, window, Self::handle_editor_event)
             .detach();
@@ -343,7 +346,7 @@ impl ProjectDiff {
     }
 
     pub fn active_path(&self, cx: &App) -> Option<ProjectPath> {
-        let editor = self.editor.read(cx);
+        let editor = self.editor.read(cx).last_selected_editor().read(cx);
         let position = editor.selections.newest_anchor().head();
         let multi_buffer = editor.buffer().read(cx);
         let (_, buffer, _) = multi_buffer.excerpt_containing(position, cx)?;
@@ -358,14 +361,16 @@ impl ProjectDiff {
     fn move_to_path(&mut self, path_key: PathKey, window: &mut Window, cx: &mut Context<Self>) {
         if let Some(position) = self.multibuffer.read(cx).location_for_path(&path_key, cx) {
             self.editor.update(cx, |editor, cx| {
-                editor.change_selections(
-                    SelectionEffects::scroll(Autoscroll::focused()),
-                    window,
-                    cx,
-                    |s| {
-                        s.select_ranges([position..position]);
-                    },
-                )
+                editor.primary_editor().update(cx, |editor, cx| {
+                    editor.change_selections(
+                        SelectionEffects::scroll(Autoscroll::focused()),
+                        window,
+                        cx,
+                        |s| {
+                            s.select_ranges([position..position]);
+                        },
+                    )
+                })
             });
         } else {
             self.pending_scroll = Some(path_key);
@@ -373,7 +378,7 @@ impl ProjectDiff {
     }
 
     fn button_states(&self, cx: &App) -> ButtonStates {
-        let editor = self.editor.read(cx);
+        let editor = self.editor.read(cx).primary_editor().read(cx);
         let snapshot = self.multibuffer.read(cx).snapshot(cx);
         let prev_next = snapshot.diff_hunks().nth(1).is_some();
         let mut selection = true;
@@ -384,7 +389,13 @@ impl ProjectDiff {
             .collect::<Vec<_>>();
         if !ranges.iter().any(|range| range.start != range.end) {
             selection = false;
-            if let Some((excerpt_id, _, range)) = self.editor.read(cx).active_excerpt(cx) {
+            if let Some((excerpt_id, _, range)) = self
+                .editor
+                .read(cx)
+                .primary_editor()
+                .read(cx)
+                .active_excerpt(cx)
+            {
                 ranges = vec![multi_buffer::Anchor::range_in_buffer(excerpt_id, range)];
             } else {
                 ranges = Vec::default();
@@ -432,7 +443,7 @@ impl ProjectDiff {
 
     fn handle_editor_event(
         &mut self,
-        editor: &Entity<Editor>,
+        editor: &Entity<SplittableEditor>,
         event: &EditorEvent,
         window: &mut Window,
         cx: &mut Context<Self>,
@@ -476,9 +487,12 @@ impl ProjectDiff {
         self.buffer_diff_subscriptions
             .insert(path_key.path.clone(), (diff.clone(), subscription));
 
+        // TODO(split-diff) we shouldn't have a conflict addon when split
         let conflict_addon = self
             .editor
             .read(cx)
+            .primary_editor()
+            .read(cx)
             .addon::<ConflictAddon>()
             .expect("project diff editor should have a conflict addon");
 
@@ -518,20 +532,27 @@ impl ProjectDiff {
         });
 
         self.editor.update(cx, |editor, cx| {
-            if was_empty {
-                editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| {
-                    // TODO select the very beginning (possibly inside a deletion)
-                    selections
-                        .select_ranges([multi_buffer::Anchor::min()..multi_buffer::Anchor::min()])
-                });
-            }
-            if is_excerpt_newly_added
-                && (file_status.is_deleted()
-                    || (file_status.is_untracked()
-                        && GitPanelSettings::get_global(cx).collapse_untracked_diff))
-            {
-                editor.fold_buffer(snapshot.text.remote_id(), cx)
-            }
+            editor.primary_editor().update(cx, |editor, cx| {
+                if was_empty {
+                    editor.change_selections(
+                        SelectionEffects::no_scroll(),
+                        window,
+                        cx,
+                        |selections| {
+                            selections.select_ranges([
+                                multi_buffer::Anchor::min()..multi_buffer::Anchor::min()
+                            ])
+                        },
+                    );
+                }
+                if is_excerpt_newly_added
+                    && (file_status.is_deleted()
+                        || (file_status.is_untracked()
+                            && GitPanelSettings::get_global(cx).collapse_untracked_diff))
+                {
+                    editor.fold_buffer(snapshot.text.remote_id(), cx)
+                }
+            })
         });
 
         if self.multibuffer.read(cx).is_empty()
@@ -650,8 +671,11 @@ impl Item for ProjectDiff {
     }
 
     fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        self.editor
-            .update(cx, |editor, cx| editor.deactivated(window, cx));
+        self.editor.update(cx, |editor, cx| {
+            editor.primary_editor().update(cx, |primary_editor, cx| {
+                primary_editor.deactivated(window, cx);
+            })
+        });
     }
 
     fn navigate(
@@ -660,8 +684,11 @@ impl Item for ProjectDiff {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> bool {
-        self.editor
-            .update(cx, |editor, cx| editor.navigate(data, window, cx))
+        self.editor.update(cx, |editor, cx| {
+            editor.primary_editor().update(cx, |primary_editor, cx| {
+                primary_editor.navigate(data, window, cx)
+            })
+        })
     }
 
     fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
@@ -689,8 +716,9 @@ impl Item for ProjectDiff {
         Some("Project Diff Opened")
     }
 
-    fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
-        Some(Box::new(self.editor.clone()))
+    fn as_searchable(&self, _: &Entity<Self>, cx: &App) -> Option<Box<dyn SearchableItemHandle>> {
+        // TODO(split-diff) SplitEditor should be searchable
+        Some(Box::new(self.editor.read(cx).primary_editor().clone()))
     }
 
     fn for_each_project_item(
@@ -698,7 +726,11 @@ impl Item for ProjectDiff {
         cx: &App,
         f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
     ) {
-        self.editor.for_each_project_item(cx, f)
+        self.editor
+            .read(cx)
+            .primary_editor()
+            .read(cx)
+            .for_each_project_item(cx, f)
     }
 
     fn set_nav_history(
@@ -707,8 +739,10 @@ impl Item for ProjectDiff {
         _: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        self.editor.update(cx, |editor, _| {
-            editor.set_nav_history(Some(nav_history));
+        self.editor.update(cx, |editor, cx| {
+            editor.primary_editor().update(cx, |primary_editor, _| {
+                primary_editor.set_nav_history(Some(nav_history));
+            })
         });
     }
 
@@ -752,7 +786,11 @@ impl Item for ProjectDiff {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Task<Result<()>> {
-        self.editor.save(options, project, window, cx)
+        self.editor.update(cx, |editor, cx| {
+            editor.primary_editor().update(cx, |primary_editor, cx| {
+                primary_editor.save(options, project, window, cx)
+            })
+        })
     }
 
     fn save_as(
@@ -771,19 +809,23 @@ impl Item for ProjectDiff {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Task<Result<()>> {
-        self.editor.reload(project, window, cx)
+        self.editor.update(cx, |editor, cx| {
+            editor.primary_editor().update(cx, |primary_editor, cx| {
+                primary_editor.reload(project, window, cx)
+            })
+        })
     }
 
     fn act_as_type<'a>(
         &'a self,
         type_id: TypeId,
         self_handle: &'a Entity<Self>,
-        _: &'a App,
+        cx: &'a App,
     ) -> Option<gpui::AnyEntity> {
         if type_id == TypeId::of::<Self>() {
             Some(self_handle.clone().into())
         } else if type_id == TypeId::of::<Editor>() {
-            Some(self.editor.clone().into())
+            Some(self.editor.read(cx).primary_editor().clone().into())
         } else {
             None
         }
@@ -794,7 +836,11 @@ impl Item for ProjectDiff {
     }
 
     fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
-        self.editor.breadcrumbs(theme, cx)
+        self.editor
+            .read(cx)
+            .last_selected_editor()
+            .read(cx)
+            .breadcrumbs(theme, cx)
     }
 
     fn added_to_workspace(
@@ -1629,7 +1675,7 @@ mod tests {
         );
         cx.run_until_parked();
 
-        let editor = diff.read_with(cx, |diff, _| diff.editor.clone());
+        let editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).primary_editor().clone());
         assert_state_with_diff(
             &editor,
             cx,
@@ -1685,7 +1731,7 @@ mod tests {
                 window,
                 cx,
             );
-            diff.editor.clone()
+            diff.editor.read(cx).primary_editor().clone()
         });
         assert_state_with_diff(
             &editor,
@@ -1706,7 +1752,7 @@ mod tests {
                 window,
                 cx,
             );
-            diff.editor.clone()
+            diff.editor.read(cx).primary_editor().clone()
         });
         assert_state_with_diff(
             &editor,
@@ -1759,7 +1805,8 @@ mod tests {
         );
         cx.run_until_parked();
 
-        let diff_editor = diff.read_with(cx, |diff, _| diff.editor.clone());
+        let diff_editor =
+            diff.read_with(cx, |diff, cx| diff.editor.read(cx).primary_editor().clone());
 
         assert_state_with_diff(
             &diff_editor,
@@ -1883,7 +1930,7 @@ mod tests {
             workspace.active_item_as::<ProjectDiff>(cx).unwrap()
         });
         cx.focus(&item);
-        let editor = item.read_with(cx, |item, _| item.editor.clone());
+        let editor = item.read_with(cx, |item, cx| item.editor.read(cx).primary_editor().clone());
 
         let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
 
@@ -1997,7 +2044,7 @@ mod tests {
             workspace.active_item_as::<ProjectDiff>(cx).unwrap()
         });
         cx.focus(&item);
-        let editor = item.read_with(cx, |item, _| item.editor.clone());
+        let editor = item.read_with(cx, |item, cx| item.editor.read(cx).primary_editor().clone());
 
         let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
 
@@ -2044,7 +2091,7 @@ mod tests {
         cx.run_until_parked();
 
         cx.update(|window, cx| {
-            let editor = diff.read(cx).editor.clone();
+            let editor = diff.read(cx).editor.read(cx).primary_editor().clone();
             let excerpt_ids = editor.read(cx).buffer().read(cx).excerpt_ids();
             assert_eq!(excerpt_ids.len(), 1);
             let excerpt_id = excerpt_ids[0];
@@ -2061,6 +2108,8 @@ mod tests {
                 .read(cx)
                 .editor
                 .read(cx)
+                .primary_editor()
+                .read(cx)
                 .addon::<ConflictAddon>()
                 .unwrap()
                 .conflict_set(buffer_id)
@@ -2144,7 +2193,7 @@ mod tests {
         );
         cx.run_until_parked();
 
-        let editor = diff.read_with(cx, |diff, _| diff.editor.clone());
+        let editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).primary_editor().clone());
 
         assert_state_with_diff(
             &editor,
@@ -2255,7 +2304,7 @@ mod tests {
         );
         cx.run_until_parked();
 
-        let editor = diff.read_with(cx, |diff, _| diff.editor.clone());
+        let editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).primary_editor().clone());
 
         assert_state_with_diff(
             &editor,
@@ -2349,7 +2398,7 @@ mod tests {
             workspace.active_item_as::<ProjectDiff>(cx).unwrap()
         });
         cx.focus(&item);
-        let editor = item.read_with(cx, |item, _| item.editor.clone());
+        let editor = item.read_with(cx, |item, cx| item.editor.read(cx).primary_editor().clone());
 
         fs.set_head_and_index_for_repo(
             Path::new(path!("/project/.git")),

crates/git_ui/src/text_diff_view.rs πŸ”—

@@ -339,7 +339,7 @@ impl Item for TextDiffView {
         }
     }
 
-    fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
+    fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
         Some(Box::new(self.diff_editor.clone()))
     }
 

crates/language_tools/src/lsp_log_view.rs πŸ”—

@@ -744,7 +744,11 @@ impl Item for LspLogView {
         None
     }
 
-    fn as_searchable(&self, handle: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
+    fn as_searchable(
+        &self,
+        handle: &Entity<Self>,
+        _: &App,
+    ) -> Option<Box<dyn SearchableItemHandle>> {
         Some(Box::new(handle.clone()))
     }
 

crates/multi_buffer/src/multi_buffer.rs πŸ”—

@@ -36,7 +36,9 @@ use std::{
     any::type_name,
     borrow::Cow,
     cell::{Cell, Ref, RefCell},
-    cmp, fmt,
+    cmp,
+    collections::VecDeque,
+    fmt::{self, Debug},
     future::Future,
     io,
     iter::{self, FromIterator},
@@ -61,6 +63,9 @@ pub use self::path_key::PathKey;
 #[derive(Debug, Default, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord)]
 pub struct ExcerptId(u32);
 
+#[derive(Debug, Default, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord)]
+pub struct BaseTextRow(pub u32);
+
 /// One or more [`Buffers`](Buffer) being edited in a single view.
 ///
 /// See <https://zed.dev/features#multi-buffers>
@@ -87,6 +92,14 @@ pub struct MultiBuffer {
     /// The writing capability of the multi-buffer.
     capability: Capability,
     buffer_changed_since_sync: Rc<Cell<bool>>,
+    follower: Option<Entity<MultiBuffer>>,
+    filter_mode: Option<MultiBufferFilterMode>,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum MultiBufferFilterMode {
+    KeepInsertions,
+    KeepDeletions,
 }
 
 #[derive(Clone, Debug, PartialEq, Eq)]
@@ -549,24 +562,31 @@ pub struct MultiBufferSnapshot {
 
 #[derive(Debug, Clone)]
 enum DiffTransform {
-    BufferContent {
+    Unmodified {
         summary: MBTextSummary,
-        inserted_hunk_info: Option<DiffTransformHunkInfo>,
+    },
+    InsertedHunk {
+        summary: MBTextSummary,
+        hunk_info: DiffTransformHunkInfo,
+    },
+    FilteredInsertedHunk {
+        summary: MBTextSummary,
+        hunk_info: DiffTransformHunkInfo,
     },
     DeletedHunk {
         summary: TextSummary,
         buffer_id: BufferId,
         hunk_info: DiffTransformHunkInfo,
-        base_text_byte_range: Range<usize>,
         has_trailing_newline: bool,
     },
 }
 
-#[derive(Clone, Copy, Debug)]
+#[derive(Clone, Debug)]
 struct DiffTransformHunkInfo {
     excerpt_id: ExcerptId,
     hunk_start_anchor: text::Anchor,
     hunk_secondary_status: DiffHunkSecondaryStatus,
+    base_text_byte_range: Range<usize>,
 }
 
 impl Eq for DiffTransformHunkInfo {}
@@ -593,6 +613,15 @@ pub struct ExcerptInfo {
     pub end_row: MultiBufferRow,
 }
 
+/// Used with [`MultiBuffer::push_buffer_content_transform`]
+#[derive(Clone, Debug)]
+struct CurrentInsertedHunk {
+    hunk_excerpt_start: ExcerptOffset,
+    insertion_end_offset: ExcerptOffset,
+    hunk_info: DiffTransformHunkInfo,
+    is_filtered: bool,
+}
+
 impl std::fmt::Debug for ExcerptInfo {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         f.debug_struct(type_name::<Self>())
@@ -632,6 +661,7 @@ pub struct ExpandInfo {
 pub struct RowInfo {
     pub buffer_id: Option<BufferId>,
     pub buffer_row: Option<u32>,
+    pub base_text_row: Option<BaseTextRow>,
     pub multibuffer_row: Option<MultiBufferRow>,
     pub diff_status: Option<buffer_diff::DiffHunkStatus>,
     pub expand_info: Option<ExpandInfo>,
@@ -927,7 +957,7 @@ impl<'a, MBD: MultiBufferDimension> Dimension<'a, DiffTransformSummary> for Diff
 struct MultiBufferCursor<'a, MBD, BD> {
     excerpts: Cursor<'a, 'static, Excerpt, ExcerptDimension<MBD>>,
     diff_transforms: Cursor<'a, 'static, DiffTransform, DiffTransforms<MBD>>,
-    diffs: &'a TreeMap<BufferId, BufferDiffSnapshot>,
+    snapshot: &'a MultiBufferSnapshot,
     cached_region: Option<MultiBufferRegion<'a, MBD, BD>>,
 }
 
@@ -938,10 +968,21 @@ struct MultiBufferRegion<'a, MBD, BD> {
     diff_hunk_status: Option<DiffHunkStatus>,
     excerpt: &'a Excerpt,
     buffer_range: Range<BD>,
+    diff_base_byte_range: Option<Range<usize>>,
     range: Range<MBD>,
     has_trailing_newline: bool,
 }
 
+impl<'a, MBD, BD> MultiBufferRegion<'a, MBD, BD>
+where
+    MBD: Ord,
+    BD: Ord,
+{
+    fn is_filtered(&self) -> bool {
+        self.range.is_empty() && self.buffer_range.is_empty() && self.diff_hunk_status == None
+    }
+}
+
 struct ExcerptChunks<'a> {
     excerpt_id: ExcerptId,
     content_chunks: BufferChunks<'a>,
@@ -1054,6 +1095,8 @@ impl MultiBuffer {
             paths_by_excerpt: Default::default(),
             buffer_changed_since_sync: Default::default(),
             history: History::default(),
+            follower: None,
+            filter_mode: None,
         }
     }
 
@@ -1087,8 +1130,8 @@ impl MultiBuffer {
         Self {
             snapshot: RefCell::new(self.snapshot.borrow().clone()),
             buffers: buffers,
-            excerpts_by_path: Default::default(),
-            paths_by_excerpt: Default::default(),
+            excerpts_by_path: self.excerpts_by_path.clone(),
+            paths_by_excerpt: self.paths_by_excerpt.clone(),
             diffs: diff_bases,
             subscriptions: Default::default(),
             singleton: self.singleton,
@@ -1096,6 +1139,46 @@ impl MultiBuffer {
             history: self.history.clone(),
             title: self.title.clone(),
             buffer_changed_since_sync,
+            follower: None,
+            filter_mode: None,
+        }
+    }
+
+    pub fn get_or_create_follower(&mut self, cx: &mut Context<Self>) -> Entity<MultiBuffer> {
+        use gpui::AppContext as _;
+
+        if let Some(follower) = &self.follower {
+            return follower.clone();
+        }
+
+        let follower = cx.new(|cx| self.clone(cx));
+        follower.update(cx, |follower, _cx| {
+            follower.capability = Capability::ReadOnly;
+        });
+        self.follower = Some(follower.clone());
+        follower
+    }
+
+    pub fn set_filter_mode(&mut self, new_mode: Option<MultiBufferFilterMode>) {
+        self.filter_mode = new_mode;
+        let excerpt_len = self
+            .snapshot
+            .get_mut()
+            .diff_transforms
+            .summary()
+            .excerpt_len();
+        let edits = Self::sync_diff_transforms(
+            self.snapshot.get_mut(),
+            vec![Edit {
+                old: ExcerptDimension(MultiBufferOffset(0))..excerpt_len,
+                new: ExcerptDimension(MultiBufferOffset(0))..excerpt_len,
+            }],
+            // TODO(split-diff) is this right?
+            DiffChangeKind::BufferEdited,
+            new_mode,
+        );
+        if !edits.is_empty() {
+            self.subscriptions.publish(edits);
         }
     }
 
@@ -1578,7 +1661,7 @@ impl MultiBuffer {
         cx: &mut Context<Self>,
     ) -> Vec<ExcerptId>
     where
-        O: text::ToOffset,
+        O: text::ToOffset + Clone,
     {
         self.insert_excerpts_after(ExcerptId::max(), buffer, ranges, cx)
     }
@@ -1616,7 +1699,7 @@ impl MultiBuffer {
         cx: &mut Context<Self>,
     ) -> Vec<ExcerptId>
     where
-        O: text::ToOffset,
+        O: text::ToOffset + Clone,
     {
         let mut ids = Vec::new();
         let mut next_excerpt_id =
@@ -1645,10 +1728,13 @@ impl MultiBuffer {
         ranges: impl IntoIterator<Item = (ExcerptId, ExcerptRange<O>)>,
         cx: &mut Context<Self>,
     ) where
-        O: text::ToOffset,
+        O: text::ToOffset + Clone,
     {
+        // TODO(split-diff) see if it's worth time avoiding collecting here later
+        let collected_ranges: Vec<_> = ranges.into_iter().collect();
+
         assert_eq!(self.history.transaction_depth(), 0);
-        let mut ranges = ranges.into_iter().peekable();
+        let mut ranges = collected_ranges.iter().cloned().peekable();
         if ranges.peek().is_none() {
             return Default::default();
         }
@@ -1748,11 +1834,23 @@ impl MultiBuffer {
                 new: edit_start..edit_end,
             }],
             DiffChangeKind::BufferEdited,
+            self.filter_mode,
         );
         if !edits.is_empty() {
             self.subscriptions.publish(edits);
         }
 
+        if let Some(follower) = &self.follower {
+            follower.update(cx, |follower, cx| {
+                follower.insert_excerpts_with_ids_after(
+                    prev_excerpt_id,
+                    buffer.clone(),
+                    collected_ranges,
+                    cx,
+                );
+            })
+        }
+
         cx.emit(Event::Edited {
             edited_buffer: None,
         });
@@ -1802,10 +1900,16 @@ impl MultiBuffer {
                 new: start..start,
             }],
             DiffChangeKind::BufferEdited,
+            self.filter_mode,
         );
         if !edits.is_empty() {
             self.subscriptions.publish(edits);
         }
+        if let Some(follower) = &self.follower {
+            follower.update(cx, |follower, cx| {
+                follower.clear(cx);
+            })
+        }
         cx.emit(Event::Edited {
             edited_buffer: None,
         });
@@ -2094,10 +2198,22 @@ impl MultiBuffer {
             snapshot.trailing_excerpt_update_count += 1;
         }
 
-        let edits = Self::sync_diff_transforms(&mut snapshot, edits, DiffChangeKind::BufferEdited);
+        let edits = Self::sync_diff_transforms(
+            &mut snapshot,
+            edits,
+            DiffChangeKind::BufferEdited,
+            self.filter_mode,
+        );
         if !edits.is_empty() {
             self.subscriptions.publish(edits);
         }
+
+        if let Some(follower) = &self.follower {
+            follower.update(cx, |follower, cx| {
+                follower.remove_excerpts(ids.clone(), cx);
+            })
+        }
+
         cx.emit(Event::Edited {
             edited_buffer: None,
         });
@@ -2253,6 +2369,7 @@ impl MultiBuffer {
             DiffChangeKind::DiffUpdated {
                 base_changed: base_text_changed,
             },
+            self.filter_mode,
         );
         if !edits.is_empty() {
             self.subscriptions.publish(edits);
@@ -2415,7 +2532,14 @@ impl MultiBuffer {
             text::Anchor::min_max_range_for_buffer(buffer_id),
             cx,
         );
-        self.diffs.insert(buffer_id, DiffState::new(diff, cx));
+        self.diffs
+            .insert(buffer_id, DiffState::new(diff.clone(), cx));
+
+        if let Some(follower) = &self.follower {
+            follower.update(cx, |follower, cx| {
+                follower.add_diff(diff, cx);
+            })
+        }
     }
 
     pub fn diff_for(&self, buffer_id: BufferId) -> Option<Entity<BufferDiff>> {
@@ -2528,6 +2652,7 @@ impl MultiBuffer {
             &mut snapshot,
             excerpt_edits,
             DiffChangeKind::ExpandOrCollapseHunks { expand },
+            self.filter_mode,
         );
         if !edits.is_empty() {
             self.subscriptions.publish(edits);
@@ -2615,7 +2740,16 @@ impl MultiBuffer {
         drop(cursor);
         snapshot.excerpts = new_excerpts;
 
-        let edits = Self::sync_diff_transforms(&mut snapshot, edits, DiffChangeKind::BufferEdited);
+        if let Some(follower) = &self.follower {
+            follower.update(cx, |follower, cx| follower.resize_excerpt(id, range, cx));
+        }
+
+        let edits = Self::sync_diff_transforms(
+            &mut snapshot,
+            edits,
+            DiffChangeKind::BufferEdited,
+            self.filter_mode,
+        );
         if !edits.is_empty() {
             self.subscriptions.publish(edits);
         }
@@ -2649,7 +2783,7 @@ impl MultiBuffer {
         let mut cursor = snapshot
             .excerpts
             .cursor::<Dimensions<Option<&Locator>, ExcerptOffset>>(());
-        let mut edits = Vec::<Edit<ExcerptOffset>>::new();
+        let mut excerpt_edits = Vec::<Edit<ExcerptOffset>>::new();
 
         for locator in &locators {
             let prefix = cursor.slice(&Some(locator), Bias::Left);
@@ -2701,15 +2835,15 @@ impl MultiBuffer {
                 new: new_start_offset..new_start_offset + new_text_len,
             };
 
-            if let Some(last_edit) = edits.last_mut() {
+            if let Some(last_edit) = excerpt_edits.last_mut() {
                 if last_edit.old.end == edit.old.start {
                     last_edit.old.end = edit.old.end;
                     last_edit.new.end = edit.new.end;
                 } else {
-                    edits.push(edit);
+                    excerpt_edits.push(edit);
                 }
             } else {
-                edits.push(edit);
+                excerpt_edits.push(edit);
             }
 
             new_excerpts.push(excerpt, ());
@@ -2720,12 +2854,22 @@ impl MultiBuffer {
         new_excerpts.append(cursor.suffix(), ());
 
         drop(cursor);
-        snapshot.excerpts = new_excerpts;
+        snapshot.excerpts = new_excerpts.clone();
 
-        let edits = Self::sync_diff_transforms(&mut snapshot, edits, DiffChangeKind::BufferEdited);
+        let edits = Self::sync_diff_transforms(
+            &mut snapshot,
+            excerpt_edits.clone(),
+            DiffChangeKind::BufferEdited,
+            self.filter_mode,
+        );
         if !edits.is_empty() {
             self.subscriptions.publish(edits);
         }
+        if let Some(follower) = &self.follower {
+            follower.update(cx, |follower, cx| {
+                follower.expand_excerpts(ids.clone(), line_count, direction, cx);
+            })
+        }
         cx.emit(Event::Edited {
             edited_buffer: None,
         });
@@ -2738,10 +2882,11 @@ impl MultiBuffer {
         if !changed {
             return;
         }
-        let edits = Self::sync_(
+        let edits = Self::sync_from_buffer_changes(
             &mut self.snapshot.borrow_mut(),
             &self.buffers,
             &self.diffs,
+            self.filter_mode,
             cx,
         );
         if !edits.is_empty() {
@@ -2754,17 +2899,24 @@ impl MultiBuffer {
         if !changed {
             return;
         }
-        let edits = Self::sync_(self.snapshot.get_mut(), &self.buffers, &self.diffs, cx);
+        let edits = Self::sync_from_buffer_changes(
+            self.snapshot.get_mut(),
+            &self.buffers,
+            &self.diffs,
+            self.filter_mode,
+            cx,
+        );
 
         if !edits.is_empty() {
             self.subscriptions.publish(edits);
         }
     }
 
-    fn sync_(
+    fn sync_from_buffer_changes(
         snapshot: &mut MultiBufferSnapshot,
         buffers: &HashMap<BufferId, BufferState>,
         diffs: &HashMap<BufferId, DiffState>,
+        filter_mode: Option<MultiBufferFilterMode>,
         cx: &App,
     ) -> Vec<Edit<MultiBufferOffset>> {
         let MultiBufferSnapshot {
@@ -2887,13 +3039,14 @@ impl MultiBuffer {
 
         drop(cursor);
         *excerpts = new_excerpts;
-        Self::sync_diff_transforms(snapshot, edits, DiffChangeKind::BufferEdited)
+        Self::sync_diff_transforms(snapshot, edits, DiffChangeKind::BufferEdited, filter_mode)
     }
 
     fn sync_diff_transforms(
         snapshot: &mut MultiBufferSnapshot,
         excerpt_edits: Vec<text::Edit<ExcerptOffset>>,
         change_kind: DiffChangeKind,
+        filter_mode: Option<MultiBufferFilterMode>,
     ) -> Vec<Edit<MultiBufferOffset>> {
         if excerpt_edits.is_empty() {
             return vec![];
@@ -2910,8 +3063,8 @@ impl MultiBuffer {
         let mut at_transform_boundary = true;
         let mut end_of_current_insert = None;
 
-        let mut excerpt_edits = excerpt_edits.into_iter().peekable();
-        while let Some(edit) = excerpt_edits.next() {
+        let mut excerpt_edits: VecDeque<_> = excerpt_edits.into_iter().collect();
+        while let Some(edit) = excerpt_edits.pop_front() {
             excerpts.seek_forward(&edit.new.start, Bias::Right);
             if excerpts.item().is_none() && *excerpts.start() == edit.new.start {
                 excerpts.prev();
@@ -2932,7 +3085,13 @@ impl MultiBuffer {
             }
 
             // Compute the start of the edit in output coordinates.
-            let edit_start_overshoot = edit.old.start - old_diff_transforms.start().0;
+            let edit_start_overshoot = if let Some(DiffTransform::FilteredInsertedHunk { .. }) =
+                old_diff_transforms.item()
+            {
+                0
+            } else {
+                edit.old.start - old_diff_transforms.start().0
+            };
             let edit_old_start = old_diff_transforms.start().1 + edit_start_overshoot;
             let edit_new_start =
                 MultiBufferOffset((edit_old_start.0 as isize + output_delta) as usize);
@@ -2946,12 +3105,56 @@ impl MultiBuffer {
                 &mut old_expanded_hunks,
                 snapshot,
                 change_kind,
+                filter_mode,
             );
 
+            // When the added range of a hunk is edited, the end anchor of the hunk may be moved later
+            // in response by hunks_intersecting_range to keep it at a row boundary. In KeepDeletions
+            // mode, we need to make sure that the whole added range is still filtered out in this situation.
+            // We do that by adding an additional edit that covers the rest of the hunk added range.
+            if let Some(current_inserted_hunk) = &end_of_current_insert
+                && current_inserted_hunk.is_filtered
+                // No additional edit needed if we've already covered the whole added range.
+                && current_inserted_hunk.insertion_end_offset > edit.new.end
+                // No additional edit needed if this edit just touched the start of the hunk
+                // (this also prevents pushing the deleted region for the hunk twice).
+                && edit.new.end > current_inserted_hunk.hunk_excerpt_start
+                // No additional edit needed if there is a subsequent edit that intersects
+                // the same hunk (the last such edit will take care of it).
+                && excerpt_edits.front().is_none_or(|next_edit| {
+                    next_edit.new.start >= current_inserted_hunk.insertion_end_offset
+                })
+            {
+                let overshoot = current_inserted_hunk.insertion_end_offset - edit.new.end;
+                let additional_edit = Edit {
+                    old: edit.old.end..edit.old.end + overshoot,
+                    new: edit.new.end..current_inserted_hunk.insertion_end_offset,
+                };
+                excerpt_edits.push_front(additional_edit);
+            }
+
             // Compute the end of the edit in output coordinates.
-            let edit_old_end_overshoot = edit.old.end - old_diff_transforms.start().0;
-            let edit_new_end_overshoot = edit.new.end - new_diff_transforms.summary().excerpt_len();
-            let edit_old_end = old_diff_transforms.start().1 + edit_old_end_overshoot;
+            let edit_old_end_overshoot = if let Some(DiffTransform::FilteredInsertedHunk {
+                ..
+            }) = old_diff_transforms.item()
+            {
+                ExcerptDimension(MultiBufferOffset(0))
+            } else {
+                ExcerptDimension(MultiBufferOffset(
+                    edit.old.end - old_diff_transforms.start().0,
+                ))
+            };
+            let edit_new_end_overshoot = if let Some(current_inserted_hunk) = &end_of_current_insert
+                && current_inserted_hunk.is_filtered
+            {
+                let insertion_end_offset = current_inserted_hunk.insertion_end_offset;
+                let excerpt_len = new_diff_transforms.summary().excerpt_len();
+                let base = insertion_end_offset.max(excerpt_len);
+                edit.new.end.saturating_sub(base)
+            } else {
+                edit.new.end - new_diff_transforms.summary().excerpt_len()
+            };
+            let edit_old_end = old_diff_transforms.start().1 + edit_old_end_overshoot.0;
             let edit_new_end = new_diff_transforms.summary().output.len + edit_new_end_overshoot;
             let output_edit = Edit {
                 old: edit_old_start..edit_old_end,
@@ -2968,16 +3171,16 @@ impl MultiBuffer {
             // then recreate the content up to the end of this transform, to prepare
             // for reusing additional slices of the old transforms.
             if excerpt_edits
-                .peek()
+                .front()
                 .is_none_or(|next_edit| next_edit.old.start >= old_diff_transforms.end().0)
             {
                 let keep_next_old_transform = (old_diff_transforms.start().0 >= edit.old.end)
                     && match old_diff_transforms.item() {
-                        Some(DiffTransform::BufferContent {
-                            inserted_hunk_info: Some(hunk),
-                            ..
-                        }) => excerpts.item().is_some_and(|excerpt| {
-                            hunk.hunk_start_anchor.is_valid(&excerpt.buffer)
+                        Some(
+                            DiffTransform::InsertedHunk { hunk_info, .. }
+                            | DiffTransform::FilteredInsertedHunk { hunk_info, .. },
+                        ) => excerpts.item().is_some_and(|excerpt| {
+                            hunk_info.hunk_start_anchor.is_valid(&excerpt.buffer)
                         }),
                         _ => true,
                     };
@@ -2993,7 +3196,7 @@ impl MultiBuffer {
                     snapshot,
                     &mut new_diff_transforms,
                     excerpt_offset,
-                    end_of_current_insert,
+                    end_of_current_insert.as_ref(),
                 );
                 at_transform_boundary = true;
             }
@@ -3005,9 +3208,8 @@ impl MultiBuffer {
         // Ensure there's always at least one buffer content transform.
         if new_diff_transforms.is_empty() {
             new_diff_transforms.push(
-                DiffTransform::BufferContent {
+                DiffTransform::Unmodified {
                     summary: Default::default(),
-                    inserted_hunk_info: None,
                 },
                 (),
             );
@@ -3031,10 +3233,11 @@ impl MultiBuffer {
             Dimensions<ExcerptOffset, MultiBufferOffset>,
         >,
         new_diff_transforms: &mut SumTree<DiffTransform>,
-        end_of_current_insert: &mut Option<(ExcerptOffset, DiffTransformHunkInfo)>,
+        end_of_current_insert: &mut Option<CurrentInsertedHunk>,
         old_expanded_hunks: &mut HashSet<DiffTransformHunkInfo>,
         snapshot: &MultiBufferSnapshot,
         change_kind: DiffChangeKind,
+        filter_mode: Option<MultiBufferFilterMode>,
     ) -> bool {
         log::trace!(
             "recomputing diff transform for edit {:?} => {:?}",
@@ -3100,6 +3303,7 @@ impl MultiBuffer {
                         excerpt_id: excerpt.id,
                         hunk_start_anchor: hunk.buffer_range.start,
                         hunk_secondary_status: hunk.secondary_status,
+                        base_text_byte_range: hunk.diff_base_byte_range.clone(),
                     };
 
                     let hunk_excerpt_start = excerpt_start
@@ -3111,7 +3315,7 @@ impl MultiBuffer {
                         snapshot,
                         new_diff_transforms,
                         hunk_excerpt_start,
-                        *end_of_current_insert,
+                        end_of_current_insert.as_ref(),
                     );
 
                     // For every existing hunk, determine if it was previously expanded
@@ -3144,6 +3348,7 @@ impl MultiBuffer {
                         if !hunk.diff_base_byte_range.is_empty()
                             && hunk_buffer_range.start >= edit_buffer_start
                             && hunk_buffer_range.start <= excerpt_buffer_end
+                            && filter_mode != Some(MultiBufferFilterMode::KeepInsertions)
                         {
                             let base_text = diff.base_text();
                             let mut text_cursor =
@@ -3159,10 +3364,9 @@ impl MultiBuffer {
 
                             new_diff_transforms.push(
                                 DiffTransform::DeletedHunk {
-                                    base_text_byte_range: hunk.diff_base_byte_range.clone(),
                                     summary: base_text_summary,
                                     buffer_id: excerpt.buffer_id,
-                                    hunk_info,
+                                    hunk_info: hunk_info.clone(),
                                     has_trailing_newline,
                                 },
                                 (),
@@ -3170,8 +3374,15 @@ impl MultiBuffer {
                         }
 
                         if !hunk_buffer_range.is_empty() {
-                            *end_of_current_insert =
-                                Some((hunk_excerpt_end.min(excerpt_end), hunk_info));
+                            let is_filtered =
+                                filter_mode == Some(MultiBufferFilterMode::KeepDeletions);
+                            let insertion_end_offset = hunk_excerpt_end.min(excerpt_end);
+                            *end_of_current_insert = Some(CurrentInsertedHunk {
+                                hunk_excerpt_start,
+                                insertion_end_offset,
+                                hunk_info,
+                                is_filtered,
+                            });
                         }
                     }
                 }
@@ -3191,15 +3402,8 @@ impl MultiBuffer {
         new_transforms: &mut SumTree<DiffTransform>,
         subtree: SumTree<DiffTransform>,
     ) {
-        if let Some(DiffTransform::BufferContent {
-            inserted_hunk_info,
-            summary,
-        }) = subtree.first()
-            && Self::extend_last_buffer_content_transform(
-                new_transforms,
-                *inserted_hunk_info,
-                *summary,
-            )
+        if let Some(transform) = subtree.first()
+            && Self::extend_last_buffer_content_transform(new_transforms, transform)
         {
             let mut cursor = subtree.cursor::<()>(());
             cursor.next();
@@ -3211,16 +3415,7 @@ impl MultiBuffer {
     }
 
     fn push_diff_transform(new_transforms: &mut SumTree<DiffTransform>, transform: DiffTransform) {
-        if let DiffTransform::BufferContent {
-            inserted_hunk_info: inserted_hunk_anchor,
-            summary,
-        } = transform
-            && Self::extend_last_buffer_content_transform(
-                new_transforms,
-                inserted_hunk_anchor,
-                summary,
-            )
-        {
+        if Self::extend_last_buffer_content_transform(new_transforms, &transform) {
             return;
         }
         new_transforms.push(transform, ());
@@ -3230,55 +3425,56 @@ impl MultiBuffer {
         old_snapshot: &MultiBufferSnapshot,
         new_transforms: &mut SumTree<DiffTransform>,
         end_offset: ExcerptOffset,
-        current_inserted_hunk: Option<(ExcerptOffset, DiffTransformHunkInfo)>,
+        current_inserted_hunk: Option<&CurrentInsertedHunk>,
     ) {
-        let inserted_region = current_inserted_hunk.map(|(insertion_end_offset, hunk_info)| {
-            (end_offset.min(insertion_end_offset), Some(hunk_info))
-        });
-        let unchanged_region = [(end_offset, None)];
-
-        for (end_offset, inserted_hunk_info) in inserted_region.into_iter().chain(unchanged_region)
-        {
+        if let Some(current_inserted_hunk) = current_inserted_hunk {
             let start_offset = new_transforms.summary().excerpt_len();
-            if end_offset <= start_offset {
-                continue;
+            let end_offset = current_inserted_hunk.insertion_end_offset.min(end_offset);
+            if end_offset > start_offset {
+                let summary_to_add = old_snapshot
+                    .text_summary_for_excerpt_offset_range::<MBTextSummary>(
+                        start_offset..end_offset,
+                    );
+
+                let transform = if current_inserted_hunk.is_filtered {
+                    DiffTransform::FilteredInsertedHunk {
+                        summary: summary_to_add,
+                        hunk_info: current_inserted_hunk.hunk_info.clone(),
+                    }
+                } else {
+                    DiffTransform::InsertedHunk {
+                        summary: summary_to_add,
+                        hunk_info: current_inserted_hunk.hunk_info.clone(),
+                    }
+                };
+                if !Self::extend_last_buffer_content_transform(new_transforms, &transform) {
+                    new_transforms.push(transform, ())
+                }
             }
+        }
+
+        let start_offset = new_transforms.summary().excerpt_len();
+        if end_offset > start_offset {
             let summary_to_add = old_snapshot
                 .text_summary_for_excerpt_offset_range::<MBTextSummary>(start_offset..end_offset);
 
-            if !Self::extend_last_buffer_content_transform(
-                new_transforms,
-                inserted_hunk_info,
-                summary_to_add,
-            ) {
-                new_transforms.push(
-                    DiffTransform::BufferContent {
-                        summary: summary_to_add,
-                        inserted_hunk_info,
-                    },
-                    (),
-                )
+            let transform = DiffTransform::Unmodified {
+                summary: summary_to_add,
+            };
+            if !Self::extend_last_buffer_content_transform(new_transforms, &transform) {
+                new_transforms.push(transform, ())
             }
         }
     }
 
     fn extend_last_buffer_content_transform(
         new_transforms: &mut SumTree<DiffTransform>,
-        new_inserted_hunk_info: Option<DiffTransformHunkInfo>,
-        summary_to_add: MBTextSummary,
+        transform: &DiffTransform,
     ) -> bool {
         let mut did_extend = false;
         new_transforms.update_last(
             |last_transform| {
-                if let DiffTransform::BufferContent {
-                    summary,
-                    inserted_hunk_info: inserted_hunk_anchor,
-                } = last_transform
-                    && *inserted_hunk_anchor == new_inserted_hunk_info
-                {
-                    *summary += summary_to_add;
-                    did_extend = true;
-                }
+                did_extend = last_transform.merge_with(&transform);
             },
             (),
         );
@@ -3286,6 +3482,72 @@ impl MultiBuffer {
     }
 }
 
+impl DiffTransform {
+    /// Ergonomic wrapper for [`DiffTransform::merged_with`] that applies the
+    /// merging in-place. Returns `true` if merging was possible.
+    #[must_use = "check whether merging actually succeeded"]
+    fn merge_with(&mut self, other: &Self) -> bool {
+        match self.to_owned().merged_with(other) {
+            Some(merged) => {
+                *self = merged;
+                true
+            }
+            None => false,
+        }
+    }
+
+    /// Attempt to merge `self` with `other`, and return the merged transform.
+    ///
+    /// This will succeed if all of the following are true:
+    /// - both transforms are the same variant
+    /// - neither transform is [`DiffTransform::DeletedHunk`]
+    /// - if both transform are either [`DiffTransform::InsertedHunk`] or
+    ///   [`DiffTransform::FilteredInsertedHunk`], then their
+    ///   `hunk_info.hunk_start_anchor`s match
+    #[must_use = "check whether merging actually succeeded"]
+    #[rustfmt::skip]
+    fn merged_with(self, other: &Self) -> Option<Self> {
+        match (self, other) {
+            (
+                DiffTransform::Unmodified { mut summary },
+                DiffTransform::Unmodified { summary: other_summary },
+            ) => {
+                summary += *other_summary;
+                Some(DiffTransform::Unmodified { summary })
+            }
+            (
+                DiffTransform::FilteredInsertedHunk { mut summary, hunk_info },
+                DiffTransform::FilteredInsertedHunk {
+                    hunk_info: other_hunk_info,
+                    summary: other_summary,
+                },
+            ) => {
+                if hunk_info.hunk_start_anchor == other_hunk_info.hunk_start_anchor {
+                    summary += *other_summary;
+                    Some(DiffTransform::FilteredInsertedHunk { summary, hunk_info })
+                } else {
+                    None
+                }
+            }
+            (
+                DiffTransform::InsertedHunk { mut summary, hunk_info },
+                DiffTransform::InsertedHunk {
+                    hunk_info: other_hunk_info,
+                    summary: other_summary,
+                },
+            ) => {
+                if hunk_info.hunk_start_anchor == other_hunk_info.hunk_start_anchor {
+                    summary += *other_summary;
+                    Some(DiffTransform::InsertedHunk { summary, hunk_info })
+                } else {
+                    None
+                }
+            }
+            _ => return None,
+        }
+    }
+}
+
 fn build_excerpt_ranges(
     ranges: impl IntoIterator<Item = Range<Point>>,
     context_line_count: u32,
@@ -4517,19 +4779,20 @@ impl MultiBufferSnapshot {
         let end_overshoot = std::cmp::min(range.end, diff_transform_end) - diff_transform_start;
 
         let mut result = match first_transform {
-            DiffTransform::BufferContent { .. } => {
+            DiffTransform::Unmodified { .. } | DiffTransform::InsertedHunk { .. } => {
                 let excerpt_start = cursor.start().1 + start_overshoot;
                 let excerpt_end = cursor.start().1 + end_overshoot;
                 self.text_summary_for_excerpt_offset_range(excerpt_start..excerpt_end)
             }
+            DiffTransform::FilteredInsertedHunk { .. } => MBD::default(),
             DiffTransform::DeletedHunk {
                 buffer_id,
-                base_text_byte_range,
                 has_trailing_newline,
+                hunk_info,
                 ..
             } => {
-                let buffer_start = base_text_byte_range.start + start_overshoot;
-                let mut buffer_end = base_text_byte_range.start + end_overshoot;
+                let buffer_start = hunk_info.base_text_byte_range.start + start_overshoot;
+                let mut buffer_end = hunk_info.base_text_byte_range.start + end_overshoot;
                 let Some(base_text) = self.diffs.get(buffer_id).map(|diff| diff.base_text()) else {
                     panic!("{:?} is in non-existent deleted hunk", range.start)
                 };
@@ -4571,25 +4834,26 @@ impl MultiBufferSnapshot {
 
         let overshoot = range.end - cursor.start().0;
         let suffix = match last_transform {
-            DiffTransform::BufferContent { .. } => {
+            DiffTransform::Unmodified { .. } | DiffTransform::InsertedHunk { .. } => {
                 let end = cursor.start().1 + overshoot;
                 self.text_summary_for_excerpt_offset_range::<MBD>(cursor.start().1..end)
             }
+            DiffTransform::FilteredInsertedHunk { .. } => MBD::default(),
             DiffTransform::DeletedHunk {
-                base_text_byte_range,
                 buffer_id,
                 has_trailing_newline,
+                hunk_info,
                 ..
             } => {
-                let buffer_end = base_text_byte_range.start + overshoot;
+                let buffer_end = hunk_info.base_text_byte_range.start + overshoot;
                 let Some(base_text) = self.diffs.get(buffer_id).map(|diff| diff.base_text()) else {
                     panic!("{:?} is in non-existent deleted hunk", range.end)
                 };
 
                 let mut suffix = base_text.text_summary_for_range::<MBD::TextDimension, _>(
-                    base_text_byte_range.start..buffer_end,
+                    hunk_info.base_text_byte_range.start..buffer_end,
                 );
-                if *has_trailing_newline && buffer_end == base_text_byte_range.end + 1 {
+                if *has_trailing_newline && buffer_end == hunk_info.base_text_byte_range.end + 1 {
                     suffix.add_assign(&<MBD::TextDimension>::from_text_summary(
                         &TextSummary::from("\n"),
                     ))
@@ -4695,7 +4959,7 @@ impl MultiBufferSnapshot {
             match diff_transforms.item() {
                 Some(DiffTransform::DeletedHunk {
                     buffer_id,
-                    base_text_byte_range,
+                    hunk_info,
                     ..
                 }) => {
                     if let Some(diff_base_anchor) = &anchor.diff_base_anchor
@@ -4704,12 +4968,12 @@ impl MultiBufferSnapshot {
                         && base_text.can_resolve(diff_base_anchor)
                     {
                         let base_text_offset = diff_base_anchor.to_offset(base_text);
-                        if base_text_offset >= base_text_byte_range.start
-                            && base_text_offset <= base_text_byte_range.end
+                        if base_text_offset >= hunk_info.base_text_byte_range.start
+                            && base_text_offset <= hunk_info.base_text_byte_range.end
                         {
                             let position_in_hunk = base_text
                                 .text_summary_for_range::<MBD::TextDimension, _>(
-                                    base_text_byte_range.start..base_text_offset,
+                                    hunk_info.base_text_byte_range.start..base_text_offset,
                                 );
                             position.0.add_text_dim(&position_in_hunk);
                         } else if at_transform_end {
@@ -4723,8 +4987,14 @@ impl MultiBufferSnapshot {
                         diff_transforms.next();
                         continue;
                     }
-                    let overshoot = excerpt_position - diff_transforms.start().0;
-                    position += overshoot;
+
+                    if !matches!(
+                        diff_transforms.item(),
+                        Some(DiffTransform::FilteredInsertedHunk { .. })
+                    ) {
+                        let overshoot = excerpt_position - diff_transforms.start().0;
+                        position += overshoot;
+                    }
                 }
             }
 
@@ -5014,11 +5284,12 @@ impl MultiBufferSnapshot {
         let mut diff_base_anchor = None;
         if let Some(DiffTransform::DeletedHunk {
             buffer_id,
-            base_text_byte_range,
             has_trailing_newline,
+            hunk_info,
             ..
         }) = diff_transforms.item()
         {
+            let base_text_byte_range = &hunk_info.base_text_byte_range;
             let diff = self.diffs.get(buffer_id).expect("missing diff");
             if offset_in_transform > base_text_byte_range.len() {
                 debug_assert!(*has_trailing_newline);
@@ -5167,7 +5438,7 @@ impl MultiBufferSnapshot {
         MultiBufferCursor {
             excerpts,
             diff_transforms,
-            diffs: &self.diffs,
+            snapshot: &self,
             cached_region: None,
         }
     }
@@ -6313,6 +6584,11 @@ impl MultiBufferSnapshot {
         let excerpts = self.excerpts.items(());
         let excerpt_ids = self.excerpt_ids.items(());
 
+        assert!(
+            self.excerpts.is_empty() || !self.diff_transforms.is_empty(),
+            "must be at least one diff transform if excerpts exist"
+        );
+
         for (ix, excerpt) in excerpts.iter().enumerate() {
             if ix == 0 {
                 if excerpt.locator <= Locator::min() {
@@ -6335,36 +6611,26 @@ impl MultiBufferSnapshot {
 
         if self.diff_transforms.summary().input != self.excerpts.summary().text {
             panic!(
-                "incorrect input summary. expected {:?}, got {:?}. transforms: {:+?}",
+                "incorrect input summary. expected {:#?}, got {:#?}. transforms: {:#?}",
                 self.excerpts.summary().text,
                 self.diff_transforms.summary().input,
                 self.diff_transforms.items(()),
             );
         }
 
-        let mut prev_transform: Option<&DiffTransform> = None;
-        for item in self.diff_transforms.iter() {
-            if let DiffTransform::BufferContent {
-                summary,
-                inserted_hunk_info,
-            } = item
+        for (left, right) in self.diff_transforms.iter().tuple_windows() {
+            use sum_tree::Item;
+
+            if left.is_buffer_content()
+                && left.summary(()).input.len == MultiBufferOffset(0)
+                && !self.is_empty()
             {
-                if let Some(DiffTransform::BufferContent {
-                    inserted_hunk_info: prev_inserted_hunk_info,
-                    ..
-                }) = prev_transform
-                    && *inserted_hunk_info == *prev_inserted_hunk_info
-                {
-                    panic!(
-                        "multiple adjacent buffer content transforms with is_inserted_hunk = {inserted_hunk_info:?}. transforms: {:+?}",
-                        self.diff_transforms.items(())
-                    );
-                }
-                if summary.len == MultiBufferOffset(0) && !self.is_empty() {
-                    panic!("empty buffer content transform");
-                }
+                panic!("empty buffer content transform in non-empty snapshot");
             }
-            prev_transform = Some(item);
+            assert!(
+                left.clone().merged_with(right).is_none(),
+                "two consecutive diff transforms could have been merged, but weren't"
+            );
         }
     }
 }

crates/multi_buffer/src/multi_buffer_tests.rs πŸ”—

@@ -29,6 +29,7 @@ fn test_empty_singleton(cx: &mut App) {
         [RowInfo {
             buffer_id: Some(buffer_id),
             buffer_row: Some(0),
+            base_text_row: None,
             multibuffer_row: Some(MultiBufferRow(0)),
             diff_status: None,
             expand_info: None,
@@ -2242,7 +2243,7 @@ struct ReferenceExcerpt {
 struct ReferenceRegion {
     buffer_id: Option<BufferId>,
     range: Range<usize>,
-    buffer_start: Option<Point>,
+    buffer_range: Option<Range<Point>>,
     status: Option<DiffHunkStatus>,
     excerpt_id: Option<ExcerptId>,
 }
@@ -2353,9 +2354,15 @@ impl ReferenceMultibuffer {
         }
     }
 
-    fn expected_content(&self, cx: &App) -> (String, Vec<RowInfo>, HashSet<MultiBufferRow>) {
+    fn expected_content(
+        &self,
+        filter_mode: Option<MultiBufferFilterMode>,
+        all_diff_hunks_expanded: bool,
+        cx: &App,
+    ) -> (String, Vec<RowInfo>, HashSet<MultiBufferRow>) {
         let mut text = String::new();
         let mut regions = Vec::<ReferenceRegion>::new();
+        let mut filtered_regions = Vec::<ReferenceRegion>::new();
         let mut excerpt_boundary_rows = HashSet::default();
         for excerpt in &self.excerpts {
             excerpt_boundary_rows.insert(MultiBufferRow(text.matches('\n').count() as u32));
@@ -2379,10 +2386,12 @@ impl ReferenceMultibuffer {
                     continue;
                 }
 
-                if !excerpt.expanded_diff_hunks.iter().any(|expanded_anchor| {
-                    expanded_anchor.to_offset(buffer).max(buffer_range.start)
-                        == hunk_range.start.max(buffer_range.start)
-                }) {
+                if !all_diff_hunks_expanded
+                    && !excerpt.expanded_diff_hunks.iter().any(|expanded_anchor| {
+                        expanded_anchor.to_offset(buffer).max(buffer_range.start)
+                            == hunk_range.start.max(buffer_range.start)
+                    })
+                {
                     log::trace!("skipping a hunk that's not marked as expanded");
                     continue;
                 }
@@ -2396,16 +2405,20 @@ impl ReferenceMultibuffer {
                     // Add the buffer text before the hunk
                     let len = text.len();
                     text.extend(buffer.text_for_range(offset..hunk_range.start));
-                    regions.push(ReferenceRegion {
-                        buffer_id: Some(buffer.remote_id()),
-                        range: len..text.len(),
-                        buffer_start: Some(buffer.offset_to_point(offset)),
-                        status: None,
-                        excerpt_id: Some(excerpt.id),
-                    });
+                    if text.len() > len {
+                        regions.push(ReferenceRegion {
+                            buffer_id: Some(buffer.remote_id()),
+                            range: len..text.len(),
+                            buffer_range: Some((offset..hunk_range.start).to_point(&buffer)),
+                            status: None,
+                            excerpt_id: Some(excerpt.id),
+                        });
+                    }
 
                     // Add the deleted text for the hunk.
-                    if !hunk.diff_base_byte_range.is_empty() {
+                    if !hunk.diff_base_byte_range.is_empty()
+                        && filter_mode != Some(MultiBufferFilterMode::KeepInsertions)
+                    {
                         let mut base_text = base_buffer
                             .text_for_range(hunk.diff_base_byte_range.clone())
                             .collect::<String>();
@@ -2417,9 +2430,7 @@ impl ReferenceMultibuffer {
                         regions.push(ReferenceRegion {
                             buffer_id: Some(base_buffer.remote_id()),
                             range: len..text.len(),
-                            buffer_start: Some(
-                                base_buffer.offset_to_point(hunk.diff_base_byte_range.start),
-                            ),
+                            buffer_range: Some(hunk.diff_base_byte_range.to_point(&base_buffer)),
                             status: Some(DiffHunkStatus::deleted(hunk.secondary_status)),
                             excerpt_id: Some(excerpt.id),
                         });
@@ -2430,16 +2441,27 @@ impl ReferenceMultibuffer {
 
                 // Add the inserted text for the hunk.
                 if hunk_range.end > offset {
-                    let len = text.len();
-                    text.extend(buffer.text_for_range(offset..hunk_range.end));
-                    regions.push(ReferenceRegion {
+                    let is_filtered = filter_mode == Some(MultiBufferFilterMode::KeepDeletions);
+                    let range = if is_filtered {
+                        text.len()..text.len()
+                    } else {
+                        let len = text.len();
+                        text.extend(buffer.text_for_range(offset..hunk_range.end));
+                        len..text.len()
+                    };
+                    let region = ReferenceRegion {
                         buffer_id: Some(buffer.remote_id()),
-                        range: len..text.len(),
-                        buffer_start: Some(buffer.offset_to_point(offset)),
+                        range,
+                        buffer_range: Some((offset..hunk_range.end).to_point(&buffer)),
                         status: Some(DiffHunkStatus::added(hunk.secondary_status)),
                         excerpt_id: Some(excerpt.id),
-                    });
+                    };
                     offset = hunk_range.end;
+                    if is_filtered {
+                        filtered_regions.push(region);
+                    } else {
+                        regions.push(region);
+                    }
                 }
             }
 
@@ -2450,7 +2472,7 @@ impl ReferenceMultibuffer {
             regions.push(ReferenceRegion {
                 buffer_id: Some(buffer.remote_id()),
                 range: len..text.len(),
-                buffer_start: Some(buffer.offset_to_point(offset)),
+                buffer_range: Some((offset..buffer_range.end).to_point(&buffer)),
                 status: None,
                 excerpt_id: Some(excerpt.id),
             });
@@ -2461,7 +2483,7 @@ impl ReferenceMultibuffer {
             regions.push(ReferenceRegion {
                 buffer_id: None,
                 range: 0..1,
-                buffer_start: Some(Point::new(0, 0)),
+                buffer_range: Some(Point::new(0, 0)..Point::new(0, 1)),
                 status: None,
                 excerpt_id: None,
             });
@@ -2480,10 +2502,47 @@ impl ReferenceMultibuffer {
                     .position(|region| region.range.contains(&ix))
                     .map_or(RowInfo::default(), |region_ix| {
                         let region = &regions[region_ix];
-                        let buffer_row = region.buffer_start.map(|start_point| {
-                            start_point.row
+                        let buffer_row = region.buffer_range.as_ref().map(|buffer_range| {
+                            buffer_range.start.row
                                 + text[region.range.start..ix].matches('\n').count() as u32
                         });
+                        let main_buffer = self
+                            .excerpts
+                            .iter()
+                            .find(|e| e.id == region.excerpt_id.unwrap())
+                            .map(|e| e.buffer.clone());
+                        let base_text_row = match region.status {
+                            None => Some(
+                                main_buffer
+                                    .as_ref()
+                                    .map(|main_buffer| {
+                                        let diff = self
+                                            .diffs
+                                            .get(&main_buffer.read(cx).remote_id())
+                                            .unwrap();
+                                        let buffer_row = buffer_row.unwrap();
+                                        BaseTextRow(
+                                            diff.read(cx).snapshot(cx).row_to_base_text_row(
+                                                buffer_row,
+                                                &main_buffer.read(cx).snapshot(),
+                                            ),
+                                        )
+                                    })
+                                    .unwrap_or_default(),
+                            ),
+                            Some(DiffHunkStatus {
+                                kind: DiffHunkStatusKind::Added,
+                                ..
+                            }) => None,
+                            Some(DiffHunkStatus {
+                                kind: DiffHunkStatusKind::Deleted,
+                                ..
+                            }) => Some(BaseTextRow(buffer_row.unwrap())),
+                            Some(DiffHunkStatus {
+                                kind: DiffHunkStatusKind::Modified,
+                                ..
+                            }) => unreachable!(),
+                        };
                         let is_excerpt_start = region_ix == 0
                             || &regions[region_ix - 1].excerpt_id != &region.excerpt_id
                             || regions[region_ix - 1].range.is_empty();
@@ -2507,18 +2566,15 @@ impl ReferenceMultibuffer {
                             is_end = true;
                             is_excerpt_end = true;
                         }
+                        let multibuffer_row =
+                            MultiBufferRow(text[..ix].matches('\n').count() as u32);
                         let mut expand_direction = None;
-                        if let Some(buffer) = &self
-                            .excerpts
-                            .iter()
-                            .find(|e| e.id == region.excerpt_id.unwrap())
-                            .map(|e| e.buffer.clone())
-                        {
-                            let needs_expand_up =
-                                is_excerpt_start && is_start && buffer_row.unwrap() > 0;
+                        if let Some(buffer) = &main_buffer {
+                            let buffer_row = buffer_row.unwrap();
+                            let needs_expand_up = is_excerpt_start && is_start && buffer_row > 0;
                             let needs_expand_down = is_excerpt_end
                                 && is_end
-                                && buffer.read(cx).max_point().row > buffer_row.unwrap();
+                                && buffer.read(cx).max_point().row > buffer_row;
                             expand_direction = if needs_expand_up && needs_expand_down {
                                 Some(ExpandExcerptDirection::UpAndDown)
                             } else if needs_expand_up {
@@ -2533,11 +2589,10 @@ impl ReferenceMultibuffer {
                             buffer_id: region.buffer_id,
                             diff_status: region.status,
                             buffer_row,
+                            base_text_row,
                             wrapped_buffer_row: None,
 
-                            multibuffer_row: Some(MultiBufferRow(
-                                text[..ix].matches('\n').count() as u32
-                            )),
+                            multibuffer_row: Some(multibuffer_row),
                             expand_info: expand_direction.zip(region.excerpt_id).map(
                                 |(direction, excerpt_id)| ExpandInfo {
                                     direction,
@@ -2664,18 +2719,48 @@ async fn test_random_set_ranges(cx: &mut TestAppContext, mut rng: StdRng) {
     }
 }
 
+// TODO(split-diff) bump up iterations
+// #[gpui::test(iterations = 100)]
+#[gpui::test]
+async fn test_random_filtered_multibuffer(cx: &mut TestAppContext, rng: StdRng) {
+    let multibuffer = cx.new(|cx| {
+        let mut multibuffer = MultiBuffer::new(Capability::ReadWrite);
+        multibuffer.set_all_diff_hunks_expanded(cx);
+        multibuffer.set_filter_mode(Some(MultiBufferFilterMode::KeepInsertions));
+        multibuffer
+    });
+    let follower = multibuffer.update(cx, |multibuffer, cx| multibuffer.get_or_create_follower(cx));
+    follower.update(cx, |follower, _| {
+        assert!(follower.all_diff_hunks_expanded());
+        follower.set_filter_mode(Some(MultiBufferFilterMode::KeepDeletions));
+    });
+    test_random_multibuffer_impl(multibuffer, cx, rng).await;
+}
+
 #[gpui::test(iterations = 100)]
-async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) {
+async fn test_random_multibuffer(cx: &mut TestAppContext, rng: StdRng) {
+    let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite));
+    test_random_multibuffer_impl(multibuffer, cx, rng).await;
+}
+
+async fn test_random_multibuffer_impl(
+    multibuffer: Entity<MultiBuffer>,
+    cx: &mut TestAppContext,
+    mut rng: StdRng,
+) {
     let operations = env::var("OPERATIONS")
         .map(|i| i.parse().expect("invalid `OPERATIONS` variable"))
         .unwrap_or(10);
 
+    multibuffer.read_with(cx, |multibuffer, _| assert!(multibuffer.is_empty()));
+    let all_diff_hunks_expanded =
+        multibuffer.read_with(cx, |multibuffer, _| multibuffer.all_diff_hunks_expanded());
     let mut buffers: Vec<Entity<Buffer>> = Vec::new();
     let mut base_texts: HashMap<BufferId, String> = HashMap::default();
-    let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite));
     let mut reference = ReferenceMultibuffer::default();
     let mut anchors = Vec::new();
     let mut old_versions = Vec::new();
+    let mut old_follower_versions = Vec::new();
     let mut needs_diff_calculation = false;
 
     for _ in 0..operations {
@@ -2774,7 +2859,7 @@ async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) {
                     assert!(excerpt.contains(anchor));
                 }
             }
-            45..=55 if !reference.excerpts.is_empty() => {
+            45..=55 if !reference.excerpts.is_empty() && !all_diff_hunks_expanded => {
                 multibuffer.update(cx, |multibuffer, cx| {
                     let snapshot = multibuffer.snapshot(cx);
                     let excerpt_ix = rng.random_range(0..reference.excerpts.len());
@@ -2858,17 +2943,6 @@ async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) {
                     (start_ix..end_ix, anchor_range)
                 });
 
-                multibuffer.update(cx, |multibuffer, cx| {
-                    let id = buffer_handle.read(cx).remote_id();
-                    if multibuffer.diff_for(id).is_none() {
-                        let base_text = base_texts.get(&id).unwrap();
-                        let diff = cx
-                            .new(|cx| BufferDiff::new_with_base_text(base_text, buffer_handle, cx));
-                        reference.add_diff(diff.clone(), cx);
-                        multibuffer.add_diff(diff, cx)
-                    }
-                });
-
                 let excerpt_id = multibuffer.update(cx, |multibuffer, cx| {
                     multibuffer
                         .insert_excerpts_after(
@@ -2886,208 +2960,276 @@ async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) {
                     excerpt_id,
                     (buffer_handle.clone(), anchor_range),
                 );
+
+                multibuffer.update(cx, |multibuffer, cx| {
+                    let id = buffer_handle.read(cx).remote_id();
+                    if multibuffer.diff_for(id).is_none() {
+                        let base_text = base_texts.get(&id).unwrap();
+                        let diff = cx
+                            .new(|cx| BufferDiff::new_with_base_text(base_text, buffer_handle, cx));
+                        reference.add_diff(diff.clone(), cx);
+                        multibuffer.add_diff(diff, cx)
+                    }
+                });
             }
         }
 
         if rng.random_bool(0.3) {
             multibuffer.update(cx, |multibuffer, cx| {
                 old_versions.push((multibuffer.snapshot(cx), multibuffer.subscribe()));
+
+                if let Some(follower) = &multibuffer.follower {
+                    follower.update(cx, |follower, cx| {
+                        old_follower_versions.push((follower.snapshot(cx), follower.subscribe()));
+                    })
+                }
             })
         }
 
-        let snapshot = multibuffer.read_with(cx, |multibuffer, cx| multibuffer.snapshot(cx));
-        let actual_text = snapshot.text();
-        let actual_boundary_rows = snapshot
-            .excerpt_boundaries_in_range(MultiBufferOffset(0)..)
-            .map(|b| b.row)
-            .collect::<HashSet<_>>();
-        let actual_row_infos = snapshot.row_infos(MultiBufferRow(0)).collect::<Vec<_>>();
+        multibuffer.read_with(cx, |multibuffer, cx| {
+            check_multibuffer(multibuffer, &reference, &anchors, cx, &mut rng);
+
+            if let Some(follower) = &multibuffer.follower {
+                check_multibuffer(follower.read(cx), &reference, &anchors, cx, &mut rng);
+            }
+        });
+    }
+
+    let snapshot = multibuffer.read_with(cx, |multibuffer, cx| multibuffer.snapshot(cx));
+    for (old_snapshot, subscription) in old_versions {
+        check_multibuffer_edits(&snapshot, &old_snapshot, subscription);
+    }
+    if let Some(follower) = multibuffer.read_with(cx, |multibuffer, _| multibuffer.follower.clone())
+    {
+        let snapshot = follower.read_with(cx, |follower, cx| follower.snapshot(cx));
+        for (old_snapshot, subscription) in old_follower_versions {
+            check_multibuffer_edits(&snapshot, &old_snapshot, subscription);
+        }
+    }
+}
+
+fn check_multibuffer(
+    multibuffer: &MultiBuffer,
+    reference: &ReferenceMultibuffer,
+    anchors: &[Anchor],
+    cx: &App,
+    rng: &mut StdRng,
+) {
+    let snapshot = multibuffer.snapshot(cx);
+    let filter_mode = multibuffer.filter_mode;
+    assert!(filter_mode.is_some() == snapshot.all_diff_hunks_expanded);
+    let actual_text = snapshot.text();
+    let actual_boundary_rows = snapshot
+        .excerpt_boundaries_in_range(MultiBufferOffset(0)..)
+        .map(|b| b.row)
+        .collect::<HashSet<_>>();
+    let actual_row_infos = snapshot.row_infos(MultiBufferRow(0)).collect::<Vec<_>>();
+
+    let (expected_text, expected_row_infos, expected_boundary_rows) =
+        reference.expected_content(filter_mode, snapshot.all_diff_hunks_expanded, cx);
 
-        let (expected_text, expected_row_infos, expected_boundary_rows) =
-            cx.update(|cx| reference.expected_content(cx));
+    let (unfiltered_text, unfiltered_row_infos, unfiltered_boundary_rows) =
+        reference.expected_content(None, snapshot.all_diff_hunks_expanded, cx);
 
-        let has_diff = actual_row_infos
+    let has_diff = actual_row_infos
+        .iter()
+        .any(|info| info.diff_status.is_some())
+        || unfiltered_row_infos
             .iter()
-            .any(|info| info.diff_status.is_some())
-            || expected_row_infos
-                .iter()
-                .any(|info| info.diff_status.is_some());
-        let actual_diff = format_diff(
-            &actual_text,
-            &actual_row_infos,
-            &actual_boundary_rows,
-            Some(has_diff),
-        );
-        let expected_diff = format_diff(
-            &expected_text,
-            &expected_row_infos,
-            &expected_boundary_rows,
-            Some(has_diff),
+            .any(|info| info.diff_status.is_some());
+    let actual_diff = format_diff(
+        &actual_text,
+        &actual_row_infos,
+        &actual_boundary_rows,
+        Some(has_diff),
+    );
+    let expected_diff = format_diff(
+        &expected_text,
+        &expected_row_infos,
+        &expected_boundary_rows,
+        Some(has_diff),
+    );
+
+    log::info!("Multibuffer content:\n{}", actual_diff);
+    if filter_mode.is_some() {
+        log::info!(
+            "Unfiltered multibuffer content:\n{}",
+            format_diff(
+                &unfiltered_text,
+                &unfiltered_row_infos,
+                &unfiltered_boundary_rows,
+                None,
+            ),
         );
+    }
 
-        log::info!("Multibuffer content:\n{}", actual_diff);
+    assert_eq!(
+        actual_row_infos.len(),
+        actual_text.split('\n').count(),
+        "line count: {}",
+        actual_text.split('\n').count()
+    );
+    pretty_assertions::assert_eq!(actual_diff, expected_diff);
+    pretty_assertions::assert_eq!(actual_text, expected_text);
+    pretty_assertions::assert_eq!(actual_row_infos, expected_row_infos);
 
+    for _ in 0..5 {
+        let start_row = rng.random_range(0..=expected_row_infos.len());
         assert_eq!(
-            actual_row_infos.len(),
-            actual_text.split('\n').count(),
-            "line count: {}",
-            actual_text.split('\n').count()
+            snapshot
+                .row_infos(MultiBufferRow(start_row as u32))
+                .collect::<Vec<_>>(),
+            &expected_row_infos[start_row..],
+            "buffer_rows({})",
+            start_row
         );
-        pretty_assertions::assert_eq!(actual_diff, expected_diff);
-        pretty_assertions::assert_eq!(actual_text, expected_text);
-        pretty_assertions::assert_eq!(actual_row_infos, expected_row_infos);
-
-        for _ in 0..5 {
-            let start_row = rng.random_range(0..=expected_row_infos.len());
-            assert_eq!(
-                snapshot
-                    .row_infos(MultiBufferRow(start_row as u32))
-                    .collect::<Vec<_>>(),
-                &expected_row_infos[start_row..],
-                "buffer_rows({})",
-                start_row
-            );
-        }
+    }
 
+    assert_eq!(
+        snapshot.widest_line_number(),
+        expected_row_infos
+            .into_iter()
+            .filter_map(|info| {
+                if info.diff_status.is_some_and(|status| status.is_deleted()) {
+                    None
+                } else {
+                    info.buffer_row
+                }
+            })
+            .max()
+            .unwrap()
+            + 1
+    );
+    let reference_ranges = reference
+        .excerpts
+        .iter()
+        .map(|excerpt| {
+            (
+                excerpt.id,
+                excerpt.range.to_offset(&excerpt.buffer.read(cx).snapshot()),
+            )
+        })
+        .collect::<HashMap<_, _>>();
+    for i in 0..snapshot.len().0 {
+        let excerpt = snapshot
+            .excerpt_containing(MultiBufferOffset(i)..MultiBufferOffset(i))
+            .unwrap();
         assert_eq!(
-            snapshot.widest_line_number(),
-            expected_row_infos
-                .into_iter()
-                .filter_map(|info| {
-                    if info.diff_status.is_some_and(|status| status.is_deleted()) {
-                        None
-                    } else {
-                        info.buffer_row
-                    }
-                })
-                .max()
-                .unwrap()
-                + 1
+            excerpt.buffer_range().start.0..excerpt.buffer_range().end.0,
+            reference_ranges[&excerpt.id()]
         );
-        let reference_ranges = cx.update(|cx| {
-            reference
-                .excerpts
-                .iter()
-                .map(|excerpt| {
-                    (
-                        excerpt.id,
-                        excerpt.range.to_offset(&excerpt.buffer.read(cx).snapshot()),
-                    )
-                })
-                .collect::<HashMap<_, _>>()
-        });
-        for i in 0..snapshot.len().0 {
-            let excerpt = snapshot
-                .excerpt_containing(MultiBufferOffset(i)..MultiBufferOffset(i))
-                .unwrap();
-            assert_eq!(
-                excerpt.buffer_range().start.0..excerpt.buffer_range().end.0,
-                reference_ranges[&excerpt.id()]
-            );
-        }
+    }
 
-        assert_consistent_line_numbers(&snapshot);
-        assert_position_translation(&snapshot);
+    assert_consistent_line_numbers(&snapshot);
+    assert_position_translation(&snapshot);
 
-        for (row, line) in expected_text.split('\n').enumerate() {
-            assert_eq!(
-                snapshot.line_len(MultiBufferRow(row as u32)),
-                line.len() as u32,
-                "line_len({}).",
-                row
-            );
-        }
+    for (row, line) in expected_text.split('\n').enumerate() {
+        assert_eq!(
+            snapshot.line_len(MultiBufferRow(row as u32)),
+            line.len() as u32,
+            "line_len({}).",
+            row
+        );
+    }
 
-        let text_rope = Rope::from(expected_text.as_str());
-        for _ in 0..10 {
-            let end_ix = text_rope.clip_offset(rng.random_range(0..=text_rope.len()), Bias::Right);
-            let start_ix = text_rope.clip_offset(rng.random_range(0..=end_ix), Bias::Left);
+    let text_rope = Rope::from(expected_text.as_str());
+    for _ in 0..10 {
+        let end_ix = text_rope.clip_offset(rng.random_range(0..=text_rope.len()), Bias::Right);
+        let start_ix = text_rope.clip_offset(rng.random_range(0..=end_ix), Bias::Left);
 
-            let text_for_range = snapshot
-                .text_for_range(MultiBufferOffset(start_ix)..MultiBufferOffset(end_ix))
-                .collect::<String>();
-            assert_eq!(
-                text_for_range,
-                &expected_text[start_ix..end_ix],
-                "incorrect text for range {:?}",
-                start_ix..end_ix
-            );
+        let text_for_range = snapshot
+            .text_for_range(MultiBufferOffset(start_ix)..MultiBufferOffset(end_ix))
+            .collect::<String>();
+        assert_eq!(
+            text_for_range,
+            &expected_text[start_ix..end_ix],
+            "incorrect text for range {:?}",
+            start_ix..end_ix
+        );
 
-            let expected_summary =
-                MBTextSummary::from(TextSummary::from(&expected_text[start_ix..end_ix]));
-            assert_eq!(
-                snapshot.text_summary_for_range::<MBTextSummary, _>(
-                    MultiBufferOffset(start_ix)..MultiBufferOffset(end_ix)
-                ),
-                expected_summary,
-                "incorrect summary for range {:?}",
-                start_ix..end_ix
-            );
-        }
+        let expected_summary =
+            MBTextSummary::from(TextSummary::from(&expected_text[start_ix..end_ix]));
+        assert_eq!(
+            snapshot.text_summary_for_range::<MBTextSummary, _>(
+                MultiBufferOffset(start_ix)..MultiBufferOffset(end_ix)
+            ),
+            expected_summary,
+            "incorrect summary for range {:?}",
+            start_ix..end_ix
+        );
+    }
 
-        // Anchor resolution
-        let summaries = snapshot.summaries_for_anchors::<MultiBufferOffset, _>(&anchors);
-        assert_eq!(anchors.len(), summaries.len());
-        for (anchor, resolved_offset) in anchors.iter().zip(summaries) {
-            assert!(resolved_offset <= snapshot.len());
-            assert_eq!(
-                snapshot.summary_for_anchor::<MultiBufferOffset>(anchor),
-                resolved_offset,
-                "anchor: {:?}",
-                anchor
-            );
-        }
+    // Anchor resolution
+    let summaries = snapshot.summaries_for_anchors::<MultiBufferOffset, _>(anchors);
+    assert_eq!(anchors.len(), summaries.len());
+    for (anchor, resolved_offset) in anchors.iter().zip(summaries) {
+        assert!(resolved_offset <= snapshot.len());
+        assert_eq!(
+            snapshot.summary_for_anchor::<MultiBufferOffset>(anchor),
+            resolved_offset,
+            "anchor: {:?}",
+            anchor
+        );
+    }
 
-        for _ in 0..10 {
-            let end_ix = text_rope.clip_offset(rng.random_range(0..=text_rope.len()), Bias::Right);
-            assert_eq!(
-                snapshot
-                    .reversed_chars_at(MultiBufferOffset(end_ix))
-                    .collect::<String>(),
-                expected_text[..end_ix].chars().rev().collect::<String>(),
-            );
-        }
+    for _ in 0..10 {
+        let end_ix = text_rope.clip_offset(rng.random_range(0..=text_rope.len()), Bias::Right);
+        assert_eq!(
+            snapshot
+                .reversed_chars_at(MultiBufferOffset(end_ix))
+                .collect::<String>(),
+            expected_text[..end_ix].chars().rev().collect::<String>(),
+        );
+    }
 
-        for _ in 0..10 {
-            let end_ix = rng.random_range(0..=text_rope.len());
-            let end_ix = text_rope.floor_char_boundary(end_ix);
-            let start_ix = rng.random_range(0..=end_ix);
-            let start_ix = text_rope.floor_char_boundary(start_ix);
-            assert_eq!(
-                snapshot
-                    .bytes_in_range(MultiBufferOffset(start_ix)..MultiBufferOffset(end_ix))
-                    .flatten()
-                    .copied()
-                    .collect::<Vec<_>>(),
-                expected_text.as_bytes()[start_ix..end_ix].to_vec(),
-                "bytes_in_range({:?})",
-                start_ix..end_ix,
-            );
-        }
+    for _ in 0..10 {
+        let end_ix = rng.random_range(0..=text_rope.len());
+        let end_ix = text_rope.floor_char_boundary(end_ix);
+        let start_ix = rng.random_range(0..=end_ix);
+        let start_ix = text_rope.floor_char_boundary(start_ix);
+        assert_eq!(
+            snapshot
+                .bytes_in_range(MultiBufferOffset(start_ix)..MultiBufferOffset(end_ix))
+                .flatten()
+                .copied()
+                .collect::<Vec<_>>(),
+            expected_text.as_bytes()[start_ix..end_ix].to_vec(),
+            "bytes_in_range({:?})",
+            start_ix..end_ix,
+        );
     }
+}
 
-    let snapshot = multibuffer.read_with(cx, |multibuffer, cx| multibuffer.snapshot(cx));
-    for (old_snapshot, subscription) in old_versions {
-        let edits = subscription.consume().into_inner();
+fn check_multibuffer_edits(
+    snapshot: &MultiBufferSnapshot,
+    old_snapshot: &MultiBufferSnapshot,
+    subscription: Subscription<MultiBufferOffset>,
+) {
+    let edits = subscription.consume().into_inner();
 
-        log::info!(
-            "applying subscription edits to old text: {:?}: {:?}",
-            old_snapshot.text(),
-            edits,
-        );
+    log::info!(
+        "applying subscription edits to old text: {:?}: {:#?}",
+        old_snapshot.text(),
+        edits,
+    );
 
-        let mut text = old_snapshot.text();
-        for edit in edits {
-            let new_text: String = snapshot
-                .text_for_range(edit.new.start..edit.new.end)
-                .collect();
-            text.replace_range(
-                edit.new.start.0..edit.new.start.0 + (edit.old.end.0 - edit.old.start.0),
-                &new_text,
-            );
-        }
-        assert_eq!(text.to_string(), snapshot.text());
+    let mut text = old_snapshot.text();
+    for edit in edits {
+        let new_text: String = snapshot
+            .text_for_range(edit.new.start..edit.new.end)
+            .collect();
+        text.replace_range(
+            (edit.new.start.0..edit.new.start.0 + (edit.old.end.0 - edit.old.start.0)).clone(),
+            &new_text,
+        );
+        pretty_assertions::assert_eq!(
+            &text[0..edit.new.end.0],
+            snapshot
+                .text_for_range(MultiBufferOffset(0)..edit.new.end)
+                .collect::<String>()
+        );
     }
+    pretty_assertions::assert_eq!(text, snapshot.text());
 }
 
 #[gpui::test]
@@ -3534,12 +3676,210 @@ fn format_diff(
             } else {
                 ""
             };
-            format!("{boundary_row}{marker}{line}")
+            let expand = info
+                .expand_info
+                .map(|expand_info| match expand_info.direction {
+                    ExpandExcerptDirection::Up => " [↑]",
+                    ExpandExcerptDirection::Down => " [↓]",
+                    ExpandExcerptDirection::UpAndDown => " [↕]",
+                })
+                .unwrap_or_default();
+
+            format!("{boundary_row}{marker}{line}{expand}")
+            // let mbr = info
+            //     .multibuffer_row
+            //     .map(|row| format!("{:0>3}", row.0))
+            //     .unwrap_or_else(|| "???".to_string());
+            // let byte_range = format!("{byte_range_start:0>3}..{byte_range_end:0>3}");
+            // format!("{boundary_row}Row: {mbr}, Bytes: {byte_range} | {marker}{line}{expand}")
         })
         .collect::<Vec<_>>()
         .join("\n")
 }
 
+// fn format_transforms(snapshot: &MultiBufferSnapshot) -> String {
+//     snapshot
+//         .diff_transforms
+//         .iter()
+//         .map(|transform| {
+//             let (kind, summary) = match transform {
+//                 DiffTransform::DeletedHunk { summary, .. } => ("   Deleted", (*summary).into()),
+//                 DiffTransform::FilteredInsertedHunk { summary, .. } => ("  Filtered", *summary),
+//                 DiffTransform::InsertedHunk { summary, .. } => ("  Inserted", *summary),
+//                 DiffTransform::Unmodified { summary, .. } => ("Unmodified", *summary),
+//             };
+//             format!("{kind}(len: {}, lines: {:?})", summary.len, summary.lines)
+//         })
+//         .join("\n")
+// }
+
+// fn format_excerpts(snapshot: &MultiBufferSnapshot) -> String {
+//     snapshot
+//         .excerpts
+//         .iter()
+//         .map(|excerpt| {
+//             format!(
+//                 "Excerpt(buffer_range = {:?}, lines = {:?}, has_trailing_newline = {:?})",
+//                 excerpt.range.context.to_point(&excerpt.buffer),
+//                 excerpt.text_summary.lines,
+//                 excerpt.has_trailing_newline
+//             )
+//         })
+//         .join("\n")
+// }
+
+#[gpui::test]
+async fn test_basic_filtering(cx: &mut TestAppContext) {
+    let text = indoc!(
+        "
+        ZERO
+        one
+        TWO
+        three
+        six
+        "
+    );
+    let base_text = indoc!(
+        "
+        one
+        two
+        three
+        four
+        five
+        six
+        "
+    );
+
+    let buffer = cx.new(|cx| Buffer::local(text, cx));
+    let diff = cx.new(|cx| BufferDiff::new_with_base_text(base_text, &buffer, cx));
+    cx.run_until_parked();
+
+    let multibuffer = cx.new(|cx| {
+        let mut multibuffer = MultiBuffer::singleton(buffer.clone(), cx);
+        multibuffer.add_diff(diff.clone(), cx);
+        multibuffer.set_all_diff_hunks_expanded(cx);
+        multibuffer.set_filter_mode(Some(MultiBufferFilterMode::KeepDeletions));
+        multibuffer
+    });
+
+    let (mut snapshot, mut subscription) = multibuffer.update(cx, |multibuffer, cx| {
+        (multibuffer.snapshot(cx), multibuffer.subscribe())
+    });
+
+    assert_eq!(snapshot.text(), base_text);
+    assert_new_snapshot(
+        &multibuffer,
+        &mut snapshot,
+        &mut subscription,
+        cx,
+        indoc!(
+            "
+              one
+            - two
+              three
+            - four
+            - five
+              six
+            "
+        ),
+    );
+
+    buffer.update(cx, |buffer, cx| {
+        buffer.edit_via_marked_text(
+            indoc!(
+                "
+                ZERO
+                one
+                Β«<inserted>Β»WΒ«O
+                TΒ»hree
+                six
+                "
+            ),
+            None,
+            cx,
+        );
+    });
+    assert_new_snapshot(
+        &multibuffer,
+        &mut snapshot,
+        &mut subscription,
+        cx,
+        indoc! {
+            "
+              one
+            - two
+            - four
+            - five
+              six
+            "
+        },
+    );
+}
+
+#[gpui::test]
+async fn test_base_text_line_numbers(cx: &mut TestAppContext) {
+    let base_text = indoc! {"
+        one
+        two
+        three
+        four
+        five
+        six
+    "};
+    let buffer_text = indoc! {"
+        two
+        THREE
+        five
+        six
+        SEVEN
+    "};
+    let multibuffer = cx.update(|cx| MultiBuffer::build_simple(buffer_text, cx));
+    multibuffer.update(cx, |multibuffer, cx| {
+        let buffer = multibuffer.all_buffers().into_iter().next().unwrap();
+        let diff = cx.new(|cx| BufferDiff::new_with_base_text(base_text, &buffer, cx));
+        multibuffer.set_all_diff_hunks_expanded(cx);
+        multibuffer.add_diff(diff, cx);
+    });
+    let (mut snapshot, mut subscription) = multibuffer.update(cx, |multibuffer, cx| {
+        (multibuffer.snapshot(cx), multibuffer.subscribe())
+    });
+
+    assert_new_snapshot(
+        &multibuffer,
+        &mut snapshot,
+        &mut subscription,
+        cx,
+        indoc! {"
+            - one
+              two
+            - three
+            - four
+            + THREE
+              five
+              six
+            + SEVEN
+        "},
+    );
+    let base_text_rows = snapshot
+        .row_infos(MultiBufferRow(0))
+        .map(|row_info| row_info.base_text_row)
+        .collect::<Vec<_>>();
+    pretty_assertions::assert_eq!(
+        base_text_rows,
+        vec![
+            Some(BaseTextRow(0)),
+            Some(BaseTextRow(1)),
+            Some(BaseTextRow(2)),
+            Some(BaseTextRow(3)),
+            None,
+            Some(BaseTextRow(4)),
+            Some(BaseTextRow(5)),
+            None,
+            Some(BaseTextRow(6)),
+        ]
+    )
+}
+
 #[track_caller]
 fn assert_excerpts_match(
     multibuffer: &Entity<MultiBuffer>,

crates/multi_buffer/src/path_key.rs πŸ”—

@@ -50,6 +50,11 @@ impl MultiBuffer {
         if let Some(to_remove) = self.excerpts_by_path.remove(&path) {
             self.remove_excerpts(to_remove, cx)
         }
+        if let Some(follower) = &self.follower {

+            follower.update(cx, |follower, cx| {

+                follower.remove_excerpts_for_path(path, cx);

+            });

+        }

     }
 
     pub fn location_for_path(&self, path: &PathKey, cx: &App) -> Option<Anchor> {

crates/repl/src/notebook/notebook_ui.rs πŸ”—

@@ -756,7 +756,7 @@ impl Item for NotebookEditor {
     }
 
     // TODO
-    fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
+    fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
         None
     }
 

crates/rope/src/point.rs πŸ”—

@@ -1,15 +1,22 @@
 use std::{
     cmp::Ordering,
+    fmt::{self, Debug},
     ops::{Add, AddAssign, Range, Sub},
 };
 
 /// A zero-indexed point in a text buffer consisting of a row and column.
-#[derive(Clone, Copy, Default, Eq, PartialEq, Debug, Hash)]
+#[derive(Clone, Copy, Default, Eq, PartialEq, Hash)]
 pub struct Point {
     pub row: u32,
     pub column: u32,
 }
 
+impl Debug for Point {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "Point({}:{})", self.row, self.column)
+    }
+}
+
 impl Point {
     pub const MAX: Self = Self {
         row: u32::MAX,

crates/search/src/project_search.rs πŸ”—

@@ -505,7 +505,7 @@ impl Item for ProjectSearchView {
             None
         }
     }
-    fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
+    fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
         Some(Box::new(self.results_editor.clone()))
     }
 

crates/terminal_view/src/terminal_view.rs πŸ”—

@@ -1269,7 +1269,11 @@ impl Item for TerminalView {
         false
     }
 
-    fn as_searchable(&self, handle: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
+    fn as_searchable(
+        &self,
+        handle: &Entity<Self>,
+        _: &App,
+    ) -> Option<Box<dyn SearchableItemHandle>> {
         Some(Box::new(handle.clone()))
     }
 

crates/workspace/src/item.rs πŸ”—

@@ -287,7 +287,7 @@ pub trait Item: Focusable + EventEmitter<Self::Event> + Render + Sized {
         }
     }
 
-    fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
+    fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
         None
     }
 
@@ -981,7 +981,7 @@ impl<T: Item> ItemHandle for Entity<T> {
     }
 
     fn to_searchable_item_handle(&self, cx: &App) -> Option<Box<dyn SearchableItemHandle>> {
-        self.read(cx).as_searchable(self)
+        self.read(cx).as_searchable(self, cx)
     }
 
     fn breadcrumb_location(&self, cx: &App) -> ToolbarItemLocation {

typos.toml πŸ”—

@@ -54,6 +54,8 @@ extend-exclude = [
     "crates/editor/src/code_completion_tests.rs",
     # Linux repository structure is not a valid text, hence we should not check it for typos
     "crates/project_panel/benches/linux_repo_snapshot.txt",
+    # Some multibuffer test cases have word fragments that register as typos
+    "crates/multi_buffer/src/multi_buffer_tests.rs",
 ]
 
 [default]