editor: Make blame and inline blame work for multibuffers (#37366)

Lukas Wirth and Kirill Bulatov created

Release Notes:

- Added blame view and inline blame support for multi buffer editors

---------

Co-authored-by: Kirill Bulatov <kirill@zed.dev>

Change summary

crates/collab/src/tests/editor_tests.rs   |  22 
crates/editor/src/editor.rs               | 139 ++------
crates/editor/src/editor_tests.rs         |  19 
crates/editor/src/element.rs              |  38 +
crates/editor/src/git/blame.rs            | 370 +++++++++++++++---------
crates/multi_buffer/src/multi_buffer.rs   |   8 
crates/outline_panel/src/outline_panel.rs | 131 +++++---
crates/project/src/lsp_store.rs           |   5 
crates/search/src/project_search.rs       |  21 +
crates/sum_tree/src/tree_map.rs           |   1 
10 files changed, 429 insertions(+), 325 deletions(-)

Detailed changes

crates/collab/src/tests/editor_tests.rs 🔗

@@ -3425,16 +3425,16 @@ async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestA
         assert_eq!(
             entries,
             vec![
-                Some(blame_entry("1b1b1b", 0..1)),
-                Some(blame_entry("0d0d0d", 1..2)),
-                Some(blame_entry("3a3a3a", 2..3)),
-                Some(blame_entry("4c4c4c", 3..4)),
+                Some((buffer_id_b, blame_entry("1b1b1b", 0..1))),
+                Some((buffer_id_b, blame_entry("0d0d0d", 1..2))),
+                Some((buffer_id_b, blame_entry("3a3a3a", 2..3))),
+                Some((buffer_id_b, blame_entry("4c4c4c", 3..4))),
             ]
         );
 
         blame.update(cx, |blame, _| {
-            for (idx, entry) in entries.iter().flatten().enumerate() {
-                let details = blame.details_for_entry(entry).unwrap();
+            for (idx, (buffer, entry)) in entries.iter().flatten().enumerate() {
+                let details = blame.details_for_entry(*buffer, entry).unwrap();
                 assert_eq!(details.message, format!("message for idx-{}", idx));
                 assert_eq!(
                     details.permalink.unwrap().to_string(),
@@ -3474,9 +3474,9 @@ async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestA
             entries,
             vec![
                 None,
-                Some(blame_entry("0d0d0d", 1..2)),
-                Some(blame_entry("3a3a3a", 2..3)),
-                Some(blame_entry("4c4c4c", 3..4)),
+                Some((buffer_id_b, blame_entry("0d0d0d", 1..2))),
+                Some((buffer_id_b, blame_entry("3a3a3a", 2..3))),
+                Some((buffer_id_b, blame_entry("4c4c4c", 3..4))),
             ]
         );
     });
@@ -3511,8 +3511,8 @@ async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestA
             vec![
                 None,
                 None,
-                Some(blame_entry("3a3a3a", 2..3)),
-                Some(blame_entry("4c4c4c", 3..4)),
+                Some((buffer_id_b, blame_entry("3a3a3a", 2..3))),
+                Some((buffer_id_b, blame_entry("4c4c4c", 3..4))),
             ]
         );
     });

crates/editor/src/editor.rs 🔗

@@ -190,7 +190,6 @@ use std::{
     sync::Arc,
     time::{Duration, Instant},
 };
-use sum_tree::TreeMap;
 use task::{ResolvedTask, RunnableTag, TaskTemplate, TaskVariables};
 use text::{BufferId, FromAnchor, OffsetUtf16, Rope};
 use theme::{
@@ -227,7 +226,7 @@ const MAX_SELECTION_HISTORY_LEN: usize = 1024;
 pub(crate) const CURSORS_VISIBLE_FOR: Duration = Duration::from_millis(2000);
 #[doc(hidden)]
 pub const CODE_ACTIONS_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(250);
-const SELECTION_HIGHLIGHT_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(100);
+pub const SELECTION_HIGHLIGHT_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(100);
 
 pub(crate) const CODE_ACTION_TIMEOUT: Duration = Duration::from_secs(5);
 pub(crate) const FORMAT_TIMEOUT: Duration = Duration::from_secs(5);
@@ -1060,8 +1059,8 @@ pub struct Editor {
     placeholder_text: Option<Arc<str>>,
     highlight_order: usize,
     highlighted_rows: HashMap<TypeId, Vec<RowHighlight>>,
-    background_highlights: TreeMap<HighlightKey, BackgroundHighlight>,
-    gutter_highlights: TreeMap<TypeId, GutterHighlight>,
+    background_highlights: HashMap<HighlightKey, BackgroundHighlight>,
+    gutter_highlights: HashMap<TypeId, GutterHighlight>,
     scrollbar_marker_state: ScrollbarMarkerState,
     active_indent_guides_state: ActiveIndentGuidesState,
     nav_history: Option<ItemNavHistory>,
@@ -2112,8 +2111,8 @@ impl Editor {
             placeholder_text: None,
             highlight_order: 0,
             highlighted_rows: HashMap::default(),
-            background_highlights: TreeMap::default(),
-            gutter_highlights: TreeMap::default(),
+            background_highlights: HashMap::default(),
+            gutter_highlights: HashMap::default(),
             scrollbar_marker_state: ScrollbarMarkerState::default(),
             active_indent_guides_state: ActiveIndentGuidesState::default(),
             nav_history: None,
@@ -6630,7 +6629,7 @@ impl Editor {
             buffer_row: Some(point.row),
             ..Default::default()
         };
-        let Some(blame_entry) = blame
+        let Some((buffer, blame_entry)) = blame
             .update(cx, |blame, cx| blame.blame_for_rows(&[row_info], cx).next())
             .flatten()
         else {
@@ -6640,12 +6639,19 @@ impl Editor {
         let anchor = self.selections.newest_anchor().head();
         let position = self.to_pixel_point(anchor, &snapshot, window);
         if let (Some(position), Some(last_bounds)) = (position, self.last_bounds) {
-            self.show_blame_popover(&blame_entry, position + last_bounds.origin, true, cx);
+            self.show_blame_popover(
+                buffer,
+                &blame_entry,
+                position + last_bounds.origin,
+                true,
+                cx,
+            );
         };
     }
 
     fn show_blame_popover(
         &mut self,
+        buffer: BufferId,
         blame_entry: &BlameEntry,
         position: gpui::Point<Pixels>,
         ignore_timeout: bool,
@@ -6669,7 +6675,7 @@ impl Editor {
                             return;
                         };
                         let blame = blame.read(cx);
-                        let details = blame.details_for_entry(&blame_entry);
+                        let details = blame.details_for_entry(buffer, &blame_entry);
                         let markdown = cx.new(|cx| {
                             Markdown::new(
                                 details
@@ -19071,7 +19077,7 @@ impl Editor {
         let snapshot = self.snapshot(window, cx);
         let cursor = self.selections.newest::<Point>(cx).head();
         let (buffer, point, _) = snapshot.buffer_snapshot.point_to_buffer_point(cursor)?;
-        let blame_entry = blame
+        let (_, blame_entry) = blame
             .update(cx, |blame, cx| {
                 blame
                     .blame_for_rows(
@@ -19086,7 +19092,7 @@ impl Editor {
             })
             .flatten()?;
         let renderer = cx.global::<GlobalBlameRenderer>().0.clone();
-        let repo = blame.read(cx).repository(cx)?;
+        let repo = blame.read(cx).repository(cx, buffer.remote_id())?;
         let workspace = self.workspace()?.downgrade();
         renderer.open_blame_commit(blame_entry, repo, workspace, window, cx);
         None
@@ -19122,18 +19128,17 @@ impl Editor {
         cx: &mut Context<Self>,
     ) {
         if let Some(project) = self.project() {
-            let Some(buffer) = self.buffer().read(cx).as_singleton() else {
-                return;
-            };
-
-            if buffer.read(cx).file().is_none() {
+            if let Some(buffer) = self.buffer().read(cx).as_singleton()
+                && buffer.read(cx).file().is_none()
+            {
                 return;
             }
 
             let focused = self.focus_handle(cx).contains_focused(window, cx);
 
             let project = project.clone();
-            let blame = cx.new(|cx| GitBlame::new(buffer, project, user_triggered, focused, cx));
+            let blame = cx
+                .new(|cx| GitBlame::new(self.buffer.clone(), project, user_triggered, focused, cx));
             self.blame_subscription =
                 Some(cx.observe_in(&blame, window, |_, _, _, cx| cx.notify()));
             self.blame = Some(blame);
@@ -19783,7 +19788,24 @@ impl Editor {
         let buffer = &snapshot.buffer_snapshot;
         let start = buffer.anchor_before(0);
         let end = buffer.anchor_after(buffer.len());
-        self.background_highlights_in_range(start..end, &snapshot, cx.theme())
+        self.sorted_background_highlights_in_range(start..end, &snapshot, cx.theme())
+    }
+
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn sorted_background_highlights_in_range(
+        &self,
+        search_range: Range<Anchor>,
+        display_snapshot: &DisplaySnapshot,
+        theme: &Theme,
+    ) -> Vec<(Range<DisplayPoint>, Hsla)> {
+        let mut res = self.background_highlights_in_range(search_range, display_snapshot, theme);
+        res.sort_by(|a, b| {
+            a.0.start
+                .cmp(&b.0.start)
+                .then_with(|| a.0.end.cmp(&b.0.end))
+                .then_with(|| a.1.cmp(&b.1))
+        });
+        res
     }
 
     #[cfg(feature = "test-support")]
@@ -19848,6 +19870,9 @@ impl Editor {
             .is_some_and(|(_, highlights)| !highlights.is_empty())
     }
 
+    /// Returns all background highlights for a given range.
+    ///
+    /// The order of highlights is not deterministic, do sort the ranges if needed for the logic.
     pub fn background_highlights_in_range(
         &self,
         search_range: Range<Anchor>,
@@ -19886,84 +19911,6 @@ impl Editor {
         results
     }
 
-    pub fn background_highlight_row_ranges<T: 'static>(
-        &self,
-        search_range: Range<Anchor>,
-        display_snapshot: &DisplaySnapshot,
-        count: usize,
-    ) -> Vec<RangeInclusive<DisplayPoint>> {
-        let mut results = Vec::new();
-        let Some((_, ranges)) = self
-            .background_highlights
-            .get(&HighlightKey::Type(TypeId::of::<T>()))
-        else {
-            return vec![];
-        };
-
-        let start_ix = match ranges.binary_search_by(|probe| {
-            let cmp = probe
-                .end
-                .cmp(&search_range.start, &display_snapshot.buffer_snapshot);
-            if cmp.is_gt() {
-                Ordering::Greater
-            } else {
-                Ordering::Less
-            }
-        }) {
-            Ok(i) | Err(i) => i,
-        };
-        let mut push_region = |start: Option<Point>, end: Option<Point>| {
-            if let (Some(start_display), Some(end_display)) = (start, end) {
-                results.push(
-                    start_display.to_display_point(display_snapshot)
-                        ..=end_display.to_display_point(display_snapshot),
-                );
-            }
-        };
-        let mut start_row: Option<Point> = None;
-        let mut end_row: Option<Point> = None;
-        if ranges.len() > count {
-            return Vec::new();
-        }
-        for range in &ranges[start_ix..] {
-            if range
-                .start
-                .cmp(&search_range.end, &display_snapshot.buffer_snapshot)
-                .is_ge()
-            {
-                break;
-            }
-            let end = range.end.to_point(&display_snapshot.buffer_snapshot);
-            if let Some(current_row) = &end_row
-                && end.row == current_row.row
-            {
-                continue;
-            }
-            let start = range.start.to_point(&display_snapshot.buffer_snapshot);
-            if start_row.is_none() {
-                assert_eq!(end_row, None);
-                start_row = Some(start);
-                end_row = Some(end);
-                continue;
-            }
-            if let Some(current_end) = end_row.as_mut() {
-                if start.row > current_end.row + 1 {
-                    push_region(start_row, end_row);
-                    start_row = Some(start);
-                    end_row = Some(end);
-                } else {
-                    // Merge two hunks.
-                    *current_end = end;
-                }
-            } else {
-                unreachable!();
-            }
-        }
-        // We might still have a hunk that was not rendered (if there was a search hit on the last line)
-        push_region(start_row, end_row);
-        results
-    }
-
     pub fn gutter_highlights_in_range(
         &self,
         search_range: Range<Anchor>,

crates/editor/src/editor_tests.rs 🔗

@@ -15453,37 +15453,34 @@ fn test_highlighted_ranges(cx: &mut TestAppContext) {
         );
 
         let snapshot = editor.snapshot(window, cx);
-        let mut highlighted_ranges = editor.background_highlights_in_range(
+        let highlighted_ranges = editor.sorted_background_highlights_in_range(
             anchor_range(Point::new(3, 4)..Point::new(7, 4)),
             &snapshot,
             cx.theme(),
         );
-        // Enforce a consistent ordering based on color without relying on the ordering of the
-        // highlight's `TypeId` which is non-executor.
-        highlighted_ranges.sort_unstable_by_key(|(_, color)| *color);
         assert_eq!(
             highlighted_ranges,
             &[
                 (
-                    DisplayPoint::new(DisplayRow(4), 2)..DisplayPoint::new(DisplayRow(4), 4),
-                    Hsla::red(),
+                    DisplayPoint::new(DisplayRow(3), 2)..DisplayPoint::new(DisplayRow(3), 5),
+                    Hsla::green(),
                 ),
                 (
-                    DisplayPoint::new(DisplayRow(6), 3)..DisplayPoint::new(DisplayRow(6), 5),
+                    DisplayPoint::new(DisplayRow(4), 2)..DisplayPoint::new(DisplayRow(4), 4),
                     Hsla::red(),
                 ),
                 (
-                    DisplayPoint::new(DisplayRow(3), 2)..DisplayPoint::new(DisplayRow(3), 5),
+                    DisplayPoint::new(DisplayRow(5), 3)..DisplayPoint::new(DisplayRow(5), 6),
                     Hsla::green(),
                 ),
                 (
-                    DisplayPoint::new(DisplayRow(5), 3)..DisplayPoint::new(DisplayRow(5), 6),
-                    Hsla::green(),
+                    DisplayPoint::new(DisplayRow(6), 3)..DisplayPoint::new(DisplayRow(6), 5),
+                    Hsla::red(),
                 ),
             ]
         );
         assert_eq!(
-            editor.background_highlights_in_range(
+            editor.sorted_background_highlights_in_range(
                 anchor_range(Point::new(5, 6)..Point::new(6, 4)),
                 &snapshot,
                 cx.theme(),

crates/editor/src/element.rs 🔗

@@ -117,6 +117,7 @@ struct SelectionLayout {
 struct InlineBlameLayout {
     element: AnyElement,
     bounds: Bounds<Pixels>,
+    buffer_id: BufferId,
     entry: BlameEntry,
 }
 
@@ -1157,7 +1158,7 @@ impl EditorElement {
             cx.notify();
         }
 
-        if let Some((bounds, blame_entry)) = &position_map.inline_blame_bounds {
+        if let Some((bounds, buffer_id, blame_entry)) = &position_map.inline_blame_bounds {
             let mouse_over_inline_blame = bounds.contains(&event.position);
             let mouse_over_popover = editor
                 .inline_blame_popover
@@ -1170,7 +1171,7 @@ impl EditorElement {
                 .is_some_and(|state| state.keyboard_grace);
 
             if mouse_over_inline_blame || mouse_over_popover {
-                editor.show_blame_popover(blame_entry, event.position, false, cx);
+                editor.show_blame_popover(*buffer_id, blame_entry, event.position, false, cx);
             } else if !keyboard_grace {
                 editor.hide_blame_popover(cx);
             }
@@ -2454,7 +2455,7 @@ impl EditorElement {
             padding * em_width
         };
 
-        let entry = blame
+        let (buffer_id, entry) = blame
             .update(cx, |blame, cx| {
                 blame.blame_for_rows(&[*row_info], cx).next()
             })
@@ -2489,13 +2490,22 @@ impl EditorElement {
         let size = element.layout_as_root(AvailableSpace::min_size(), window, cx);
         let bounds = Bounds::new(absolute_offset, size);
 
-        self.layout_blame_entry_popover(entry.clone(), blame, line_height, text_hitbox, window, cx);
+        self.layout_blame_entry_popover(
+            entry.clone(),
+            blame,
+            line_height,
+            text_hitbox,
+            row_info.buffer_id?,
+            window,
+            cx,
+        );
 
         element.prepaint_as_root(absolute_offset, AvailableSpace::min_size(), window, cx);
 
         Some(InlineBlameLayout {
             element,
             bounds,
+            buffer_id,
             entry,
         })
     }
@@ -2506,6 +2516,7 @@ impl EditorElement {
         blame: Entity<GitBlame>,
         line_height: Pixels,
         text_hitbox: &Hitbox,
+        buffer: BufferId,
         window: &mut Window,
         cx: &mut App,
     ) {
@@ -2530,6 +2541,7 @@ impl EditorElement {
                 popover_state.markdown,
                 workspace,
                 &blame,
+                buffer,
                 window,
                 cx,
             )
@@ -2604,14 +2616,16 @@ impl EditorElement {
             .into_iter()
             .enumerate()
             .flat_map(|(ix, blame_entry)| {
+                let (buffer_id, blame_entry) = blame_entry?;
                 let mut element = render_blame_entry(
                     ix,
                     &blame,
-                    blame_entry?,
+                    blame_entry,
                     &self.style,
                     &mut last_used_color,
                     self.editor.clone(),
                     workspace.clone(),
+                    buffer_id,
                     blame_renderer.clone(),
                     cx,
                 )?;
@@ -7401,12 +7415,13 @@ fn render_blame_entry_popover(
     markdown: Entity<Markdown>,
     workspace: WeakEntity<Workspace>,
     blame: &Entity<GitBlame>,
+    buffer: BufferId,
     window: &mut Window,
     cx: &mut App,
 ) -> Option<AnyElement> {
     let renderer = cx.global::<GlobalBlameRenderer>().0.clone();
     let blame = blame.read(cx);
-    let repository = blame.repository(cx)?;
+    let repository = blame.repository(cx, buffer)?;
     renderer.render_blame_entry_popover(
         blame_entry,
         scroll_handle,
@@ -7427,6 +7442,7 @@ fn render_blame_entry(
     last_used_color: &mut Option<(PlayerColor, Oid)>,
     editor: Entity<Editor>,
     workspace: Entity<Workspace>,
+    buffer: BufferId,
     renderer: Arc<dyn BlameRenderer>,
     cx: &mut App,
 ) -> Option<AnyElement> {
@@ -7447,8 +7463,8 @@ fn render_blame_entry(
     last_used_color.replace((sha_color, blame_entry.sha));
 
     let blame = blame.read(cx);
-    let details = blame.details_for_entry(&blame_entry);
-    let repository = blame.repository(cx)?;
+    let details = blame.details_for_entry(buffer, &blame_entry);
+    let repository = blame.repository(cx, buffer)?;
     renderer.render_blame_entry(
         &style.text,
         blame_entry,
@@ -8755,7 +8771,7 @@ impl Element for EditorElement {
                                 return None;
                             }
                             let blame = editor.blame.as_ref()?;
-                            let blame_entry = blame
+                            let (_, blame_entry) = blame
                                 .update(cx, |blame, cx| {
                                     let row_infos =
                                         snapshot.row_infos(snapshot.longest_row()).next()?;
@@ -9305,7 +9321,7 @@ impl Element for EditorElement {
                         text_hitbox: text_hitbox.clone(),
                         inline_blame_bounds: inline_blame_layout
                             .as_ref()
-                            .map(|layout| (layout.bounds, layout.entry.clone())),
+                            .map(|layout| (layout.bounds, layout.buffer_id, layout.entry.clone())),
                         display_hunks: display_hunks.clone(),
                         diff_hunk_control_bounds,
                     });
@@ -9969,7 +9985,7 @@ pub(crate) struct PositionMap {
     pub snapshot: EditorSnapshot,
     pub text_hitbox: Hitbox,
     pub gutter_hitbox: Hitbox,
-    pub inline_blame_bounds: Option<(Bounds<Pixels>, BlameEntry)>,
+    pub inline_blame_bounds: Option<(Bounds<Pixels>, BufferId, BlameEntry)>,
     pub display_hunks: Vec<(DisplayDiffHunk, Option<Hitbox>)>,
     pub diff_hunk_control_bounds: Vec<(DisplayRow, Bounds<Pixels>)>,
 }

crates/editor/src/git/blame.rs 🔗

@@ -10,16 +10,18 @@ use gpui::{
     AnyElement, App, AppContext as _, Context, Entity, Hsla, ScrollHandle, Subscription, Task,
     TextStyle, WeakEntity, Window,
 };
-use language::{Bias, Buffer, BufferSnapshot, Edit};
+use itertools::Itertools;
+use language::{Bias, BufferSnapshot, Edit};
 use markdown::Markdown;
-use multi_buffer::RowInfo;
+use multi_buffer::{MultiBuffer, RowInfo};
 use project::{
-    Project, ProjectItem,
+    Project, ProjectItem as _,
     git_store::{GitStoreEvent, Repository, RepositoryEvent},
 };
 use smallvec::SmallVec;
 use std::{sync::Arc, time::Duration};
 use sum_tree::SumTree;
+use text::BufferId;
 use workspace::Workspace;
 
 #[derive(Clone, Debug, Default)]
@@ -63,16 +65,19 @@ impl<'a> sum_tree::Dimension<'a, GitBlameEntrySummary> for u32 {
     }
 }
 
-pub struct GitBlame {
-    project: Entity<Project>,
-    buffer: Entity<Buffer>,
+struct GitBlameBuffer {
     entries: SumTree<GitBlameEntry>,
-    commit_details: HashMap<Oid, ParsedCommitMessage>,
     buffer_snapshot: BufferSnapshot,
     buffer_edits: text::Subscription,
+    commit_details: HashMap<Oid, ParsedCommitMessage>,
+}
+
+pub struct GitBlame {
+    project: Entity<Project>,
+    multi_buffer: WeakEntity<MultiBuffer>,
+    buffers: HashMap<BufferId, GitBlameBuffer>,
     task: Task<Result<()>>,
     focused: bool,
-    generated: bool,
     changed_while_blurred: bool,
     user_triggered: bool,
     regenerate_on_edit_task: Task<Result<()>>,
@@ -184,44 +189,44 @@ impl gpui::Global for GlobalBlameRenderer {}
 
 impl GitBlame {
     pub fn new(
-        buffer: Entity<Buffer>,
+        multi_buffer: Entity<MultiBuffer>,
         project: Entity<Project>,
         user_triggered: bool,
         focused: bool,
         cx: &mut Context<Self>,
     ) -> Self {
-        let entries = SumTree::from_item(
-            GitBlameEntry {
-                rows: buffer.read(cx).max_point().row + 1,
-                blame: None,
+        let multi_buffer_subscription = cx.subscribe(
+            &multi_buffer,
+            |git_blame, multi_buffer, event, cx| match event {
+                multi_buffer::Event::DirtyChanged => {
+                    if !multi_buffer.read(cx).is_dirty(cx) {
+                        git_blame.generate(cx);
+                    }
+                }
+                multi_buffer::Event::ExcerptsAdded { .. }
+                | multi_buffer::Event::ExcerptsEdited { .. } => git_blame.regenerate_on_edit(cx),
+                _ => {}
             },
-            &(),
         );
 
-        let buffer_subscriptions = cx.subscribe(&buffer, |this, buffer, event, cx| match event {
-            language::BufferEvent::DirtyChanged => {
-                if !buffer.read(cx).is_dirty() {
-                    this.generate(cx);
-                }
-            }
-            language::BufferEvent::Edited => {
-                this.regenerate_on_edit(cx);
-            }
-            _ => {}
-        });
-
         let project_subscription = cx.subscribe(&project, {
-            let buffer = buffer.clone();
+            let multi_buffer = multi_buffer.downgrade();
 
-            move |this, _, event, cx| {
+            move |git_blame, _, event, cx| {
                 if let project::Event::WorktreeUpdatedEntries(_, updated) = event {
-                    let project_entry_id = buffer.read(cx).entry_id(cx);
+                    let Some(multi_buffer) = multi_buffer.upgrade() else {
+                        return;
+                    };
+                    let project_entry_id = multi_buffer
+                        .read(cx)
+                        .as_singleton()
+                        .and_then(|it| it.read(cx).entry_id(cx));
                     if updated
                         .iter()
                         .any(|(_, entry_id, _)| project_entry_id == Some(*entry_id))
                     {
                         log::debug!("Updated buffers. Regenerating blame data...",);
-                        this.generate(cx);
+                        git_blame.generate(cx);
                     }
                 }
             }
@@ -239,24 +244,17 @@ impl GitBlame {
                 _ => {}
             });
 
-        let buffer_snapshot = buffer.read(cx).snapshot();
-        let buffer_edits = buffer.update(cx, |buffer, _| buffer.subscribe());
-
         let mut this = Self {
             project,
-            buffer,
-            buffer_snapshot,
-            entries,
-            buffer_edits,
+            multi_buffer: multi_buffer.downgrade(),
+            buffers: HashMap::default(),
             user_triggered,
             focused,
             changed_while_blurred: false,
-            commit_details: HashMap::default(),
             task: Task::ready(Ok(())),
-            generated: false,
             regenerate_on_edit_task: Task::ready(Ok(())),
             _regenerate_subscriptions: vec![
-                buffer_subscriptions,
+                multi_buffer_subscription,
                 project_subscription,
                 git_store_subscription,
             ],
@@ -265,56 +263,63 @@ impl GitBlame {
         this
     }
 
-    pub fn repository(&self, cx: &App) -> Option<Entity<Repository>> {
+    pub fn repository(&self, cx: &App, id: BufferId) -> Option<Entity<Repository>> {
         self.project
             .read(cx)
             .git_store()
             .read(cx)
-            .repository_and_path_for_buffer_id(self.buffer.read(cx).remote_id(), cx)
+            .repository_and_path_for_buffer_id(id, cx)
             .map(|(repo, _)| repo)
     }
 
     pub fn has_generated_entries(&self) -> bool {
-        self.generated
+        !self.buffers.is_empty()
     }
 
-    pub fn details_for_entry(&self, entry: &BlameEntry) -> Option<ParsedCommitMessage> {
-        self.commit_details.get(&entry.sha).cloned()
+    pub fn details_for_entry(
+        &self,
+        buffer: BufferId,
+        entry: &BlameEntry,
+    ) -> Option<ParsedCommitMessage> {
+        self.buffers
+            .get(&buffer)?
+            .commit_details
+            .get(&entry.sha)
+            .cloned()
     }
 
     pub fn blame_for_rows<'a>(
         &'a mut self,
         rows: &'a [RowInfo],
-        cx: &App,
-    ) -> impl 'a + Iterator<Item = Option<BlameEntry>> {
-        self.sync(cx);
-
-        let buffer_id = self.buffer_snapshot.remote_id();
-        let mut cursor = self.entries.cursor::<u32>(&());
+        cx: &'a mut App,
+    ) -> impl Iterator<Item = Option<(BufferId, BlameEntry)>> + use<'a> {
         rows.iter().map(move |info| {
-            let row = info
-                .buffer_row
-                .filter(|_| info.buffer_id == Some(buffer_id))?;
-            cursor.seek_forward(&row, Bias::Right);
-            cursor.item()?.blame.clone()
+            let buffer_id = info.buffer_id?;
+            self.sync(cx, buffer_id);
+
+            let buffer_row = info.buffer_row?;
+            let mut cursor = self.buffers.get(&buffer_id)?.entries.cursor::<u32>(&());
+            cursor.seek_forward(&buffer_row, Bias::Right);
+            Some((buffer_id, cursor.item()?.blame.clone()?))
         })
     }
 
-    pub fn max_author_length(&mut self, cx: &App) -> usize {
-        self.sync(cx);
-
+    pub fn max_author_length(&mut self, cx: &mut App) -> usize {
         let mut max_author_length = 0;
-
-        for entry in self.entries.iter() {
-            let author_len = entry
-                .blame
-                .as_ref()
-                .and_then(|entry| entry.author.as_ref())
-                .map(|author| author.len());
-            if let Some(author_len) = author_len
-                && author_len > max_author_length
-            {
-                max_author_length = author_len;
+        self.sync_all(cx);
+
+        for buffer in self.buffers.values() {
+            for entry in buffer.entries.iter() {
+                let author_len = entry
+                    .blame
+                    .as_ref()
+                    .and_then(|entry| entry.author.as_ref())
+                    .map(|author| author.len());
+                if let Some(author_len) = author_len
+                    && author_len > max_author_length
+                {
+                    max_author_length = author_len;
+                }
             }
         }
 
@@ -336,22 +341,48 @@ impl GitBlame {
         }
     }
 
-    fn sync(&mut self, cx: &App) {
-        let edits = self.buffer_edits.consume();
-        let new_snapshot = self.buffer.read(cx).snapshot();
+    fn sync_all(&mut self, cx: &mut App) {
+        let Some(multi_buffer) = self.multi_buffer.upgrade() else {
+            return;
+        };
+        multi_buffer
+            .read(cx)
+            .excerpt_buffer_ids()
+            .into_iter()
+            .for_each(|id| self.sync(cx, id));
+    }
+
+    fn sync(&mut self, cx: &mut App, buffer_id: BufferId) {
+        let Some(blame_buffer) = self.buffers.get_mut(&buffer_id) else {
+            return;
+        };
+        let Some(buffer) = self
+            .multi_buffer
+            .upgrade()
+            .and_then(|multi_buffer| multi_buffer.read(cx).buffer(buffer_id))
+        else {
+            return;
+        };
+        let edits = blame_buffer.buffer_edits.consume();
+        let new_snapshot = buffer.read(cx).snapshot();
 
         let mut row_edits = edits
             .into_iter()
             .map(|edit| {
-                let old_point_range = self.buffer_snapshot.offset_to_point(edit.old.start)
-                    ..self.buffer_snapshot.offset_to_point(edit.old.end);
+                let old_point_range = blame_buffer.buffer_snapshot.offset_to_point(edit.old.start)
+                    ..blame_buffer.buffer_snapshot.offset_to_point(edit.old.end);
                 let new_point_range = new_snapshot.offset_to_point(edit.new.start)
                     ..new_snapshot.offset_to_point(edit.new.end);
 
                 if old_point_range.start.column
-                    == self.buffer_snapshot.line_len(old_point_range.start.row)
+                    == blame_buffer
+                        .buffer_snapshot
+                        .line_len(old_point_range.start.row)
                     && (new_snapshot.chars_at(edit.new.start).next() == Some('\n')
-                        || self.buffer_snapshot.line_len(old_point_range.end.row) == 0)
+                        || blame_buffer
+                            .buffer_snapshot
+                            .line_len(old_point_range.end.row)
+                            == 0)
                 {
                     Edit {
                         old: old_point_range.start.row + 1..old_point_range.end.row + 1,
@@ -375,7 +406,7 @@ impl GitBlame {
             .peekable();
 
         let mut new_entries = SumTree::default();
-        let mut cursor = self.entries.cursor::<u32>(&());
+        let mut cursor = blame_buffer.entries.cursor::<u32>(&());
 
         while let Some(mut edit) = row_edits.next() {
             while let Some(next_edit) = row_edits.peek() {
@@ -433,17 +464,28 @@ impl GitBlame {
         new_entries.append(cursor.suffix(), &());
         drop(cursor);
 
-        self.buffer_snapshot = new_snapshot;
-        self.entries = new_entries;
+        blame_buffer.buffer_snapshot = new_snapshot;
+        blame_buffer.entries = new_entries;
     }
 
     #[cfg(test)]
     fn check_invariants(&mut self, cx: &mut Context<Self>) {
-        self.sync(cx);
-        assert_eq!(
-            self.entries.summary().rows,
-            self.buffer.read(cx).max_point().row + 1
-        );
+        self.sync_all(cx);
+        for (&id, buffer) in &self.buffers {
+            assert_eq!(
+                buffer.entries.summary().rows,
+                self.multi_buffer
+                    .upgrade()
+                    .unwrap()
+                    .read(cx)
+                    .buffer(id)
+                    .unwrap()
+                    .read(cx)
+                    .max_point()
+                    .row
+                    + 1
+            );
+        }
     }
 
     fn generate(&mut self, cx: &mut Context<Self>) {
@@ -451,62 +493,105 @@ impl GitBlame {
             self.changed_while_blurred = true;
             return;
         }
-        let buffer_edits = self.buffer.update(cx, |buffer, _| buffer.subscribe());
-        let snapshot = self.buffer.read(cx).snapshot();
         let blame = self.project.update(cx, |project, cx| {
-            project.blame_buffer(&self.buffer, None, cx)
+            let Some(multi_buffer) = self.multi_buffer.upgrade() else {
+                return Vec::new();
+            };
+            multi_buffer
+                .read(cx)
+                .all_buffer_ids()
+                .into_iter()
+                .filter_map(|id| {
+                    let buffer = multi_buffer.read(cx).buffer(id)?;
+                    let snapshot = buffer.read(cx).snapshot();
+                    let buffer_edits = buffer.update(cx, |buffer, _| buffer.subscribe());
+
+                    let blame_buffer = project.blame_buffer(&buffer, None, cx);
+                    Some((id, snapshot, buffer_edits, blame_buffer))
+                })
+                .collect::<Vec<_>>()
         });
         let provider_registry = GitHostingProviderRegistry::default_global(cx);
 
         self.task = cx.spawn(async move |this, cx| {
-            let result = cx
+            let (result, errors) = cx
                 .background_spawn({
-                    let snapshot = snapshot.clone();
                     async move {
-                        let Some(Blame {
-                            entries,
-                            messages,
-                            remote_url,
-                        }) = blame.await?
-                        else {
-                            return Ok(None);
-                        };
-
-                        let entries = build_blame_entry_sum_tree(entries, snapshot.max_point().row);
-                        let commit_details =
-                            parse_commit_messages(messages, remote_url, provider_registry).await;
-
-                        anyhow::Ok(Some((entries, commit_details)))
+                        let mut res = vec![];
+                        let mut errors = vec![];
+                        for (id, snapshot, buffer_edits, blame) in blame {
+                            match blame.await {
+                                Ok(Some(Blame {
+                                    entries,
+                                    messages,
+                                    remote_url,
+                                })) => {
+                                    let entries = build_blame_entry_sum_tree(
+                                        entries,
+                                        snapshot.max_point().row,
+                                    );
+                                    let commit_details = parse_commit_messages(
+                                        messages,
+                                        remote_url,
+                                        provider_registry.clone(),
+                                    )
+                                    .await;
+
+                                    res.push((
+                                        id,
+                                        snapshot,
+                                        buffer_edits,
+                                        Some(entries),
+                                        commit_details,
+                                    ));
+                                }
+                                Ok(None) => {
+                                    res.push((id, snapshot, buffer_edits, None, Default::default()))
+                                }
+                                Err(e) => errors.push(e),
+                            }
+                        }
+                        (res, errors)
                     }
                 })
                 .await;
 
-            this.update(cx, |this, cx| match result {
-                Ok(None) => {
-                    // Nothing to do, e.g. no repository found
+            this.update(cx, |this, cx| {
+                this.buffers.clear();
+                for (id, snapshot, buffer_edits, entries, commit_details) in result {
+                    let Some(entries) = entries else {
+                        continue;
+                    };
+                    this.buffers.insert(
+                        id,
+                        GitBlameBuffer {
+                            buffer_edits,
+                            buffer_snapshot: snapshot,
+                            entries,
+                            commit_details,
+                        },
+                    );
                 }
-                Ok(Some((entries, commit_details))) => {
-                    this.buffer_edits = buffer_edits;
-                    this.buffer_snapshot = snapshot;
-                    this.entries = entries;
-                    this.commit_details = commit_details;
-                    this.generated = true;
-                    cx.notify();
+                cx.notify();
+                if !errors.is_empty() {
+                    this.project.update(cx, |_, cx| {
+                        if this.user_triggered {
+                            log::error!("failed to get git blame data: {errors:?}");
+                            let notification = errors
+                                .into_iter()
+                                .format_with(",", |e, f| f(&format_args!("{:#}", e)))
+                                .to_string();
+                            cx.emit(project::Event::Toast {
+                                notification_id: "git-blame".into(),
+                                message: notification,
+                            });
+                        } else {
+                            // If we weren't triggered by a user, we just log errors in the background, instead of sending
+                            // notifications.
+                            log::debug!("failed to get git blame data: {errors:?}");
+                        }
+                    })
                 }
-                Err(error) => this.project.update(cx, |_, cx| {
-                    if this.user_triggered {
-                        log::error!("failed to get git blame data: {error:?}");
-                        let notification = format!("{:#}", error).trim().to_string();
-                        cx.emit(project::Event::Toast {
-                            notification_id: "git-blame".into(),
-                            message: notification,
-                        });
-                    } else {
-                        // If we weren't triggered by a user, we just log errors in the background, instead of sending
-                        // notifications.
-                        log::debug!("failed to get git blame data: {error:?}");
-                    }
-                }),
             })
         });
     }
@@ -520,7 +605,7 @@ impl GitBlame {
             this.update(cx, |this, cx| {
                 this.generate(cx);
             })
-        })
+        });
     }
 }
 
@@ -659,6 +744,9 @@ mod tests {
                 )
                 .collect::<Vec<_>>(),
             expected
+                .into_iter()
+                .map(|it| Some((buffer_id, it?)))
+                .collect::<Vec<_>>()
         );
     }
 
@@ -705,6 +793,7 @@ mod tests {
             })
             .await
             .unwrap();
+        let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
 
         let blame = cx.new(|cx| GitBlame::new(buffer.clone(), project.clone(), true, true, cx));
 
@@ -785,6 +874,7 @@ mod tests {
             .await
             .unwrap();
         let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id());
+        let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
 
         let git_blame = cx.new(|cx| GitBlame::new(buffer.clone(), project, false, true, cx));
 
@@ -806,14 +896,14 @@ mod tests {
                     )
                     .collect::<Vec<_>>(),
                 vec![
-                    Some(blame_entry("1b1b1b", 0..1)),
-                    Some(blame_entry("0d0d0d", 1..2)),
-                    Some(blame_entry("3a3a3a", 2..3)),
+                    Some((buffer_id, blame_entry("1b1b1b", 0..1))),
+                    Some((buffer_id, blame_entry("0d0d0d", 1..2))),
+                    Some((buffer_id, blame_entry("3a3a3a", 2..3))),
                     None,
                     None,
-                    Some(blame_entry("3a3a3a", 5..6)),
-                    Some(blame_entry("0d0d0d", 6..7)),
-                    Some(blame_entry("3a3a3a", 7..8)),
+                    Some((buffer_id, blame_entry("3a3a3a", 5..6))),
+                    Some((buffer_id, blame_entry("0d0d0d", 6..7))),
+                    Some((buffer_id, blame_entry("3a3a3a", 7..8))),
                 ]
             );
             // Subset of lines
@@ -831,8 +921,8 @@ mod tests {
                     )
                     .collect::<Vec<_>>(),
                 vec![
-                    Some(blame_entry("0d0d0d", 1..2)),
-                    Some(blame_entry("3a3a3a", 2..3)),
+                    Some((buffer_id, blame_entry("0d0d0d", 1..2))),
+                    Some((buffer_id, blame_entry("3a3a3a", 2..3))),
                     None
                 ]
             );
@@ -852,7 +942,7 @@ mod tests {
                         cx
                     )
                     .collect::<Vec<_>>(),
-                vec![Some(blame_entry("0d0d0d", 1..2)), None, None]
+                vec![Some((buffer_id, blame_entry("0d0d0d", 1..2))), None, None]
             );
         });
     }
@@ -895,6 +985,7 @@ mod tests {
             .await
             .unwrap();
         let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id());
+        let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
 
         let git_blame = cx.new(|cx| GitBlame::new(buffer.clone(), project, false, true, cx));
 
@@ -1061,8 +1152,9 @@ mod tests {
             })
             .await
             .unwrap();
+        let mbuffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx));
 
-        let git_blame = cx.new(|cx| GitBlame::new(buffer.clone(), project, false, true, cx));
+        let git_blame = cx.new(|cx| GitBlame::new(mbuffer.clone(), project, false, true, cx));
         cx.executor().run_until_parked();
         git_blame.update(cx, |blame, cx| blame.check_invariants(cx));
 

crates/multi_buffer/src/multi_buffer.rs 🔗

@@ -735,7 +735,7 @@ impl MultiBuffer {
 
     pub fn as_singleton(&self) -> Option<Entity<Buffer>> {
         if self.singleton {
-            return Some(
+            Some(
                 self.buffers
                     .borrow()
                     .values()
@@ -743,7 +743,7 @@ impl MultiBuffer {
                     .unwrap()
                     .buffer
                     .clone(),
-            );
+            )
         } else {
             None
         }
@@ -2552,6 +2552,10 @@ impl MultiBuffer {
             .collect()
     }
 
+    pub fn all_buffer_ids(&self) -> Vec<BufferId> {
+        self.buffers.borrow().keys().copied().collect()
+    }
+
     pub fn buffer(&self, buffer_id: BufferId) -> Option<Entity<Buffer>> {
         self.buffers
             .borrow()

crates/outline_panel/src/outline_panel.rs 🔗

@@ -5402,8 +5402,9 @@ mod tests {
         init_test(cx);
 
         let fs = FakeFs::new(cx.background_executor.clone());
-        populate_with_test_ra_project(&fs, "/rust-analyzer").await;
-        let project = Project::test(fs.clone(), ["/rust-analyzer".as_ref()], cx).await;
+        let root = path!("/rust-analyzer");
+        populate_with_test_ra_project(&fs, root).await;
+        let project = Project::test(fs.clone(), [Path::new(root)], cx).await;
         project.read_with(cx, |project, _| {
             project.languages().add(Arc::new(rust_lang()))
         });
@@ -5448,15 +5449,16 @@ mod tests {
                 });
         });
 
-        let all_matches = r#"/rust-analyzer/
+        let all_matches = format!(
+            r#"{root}/
   crates/
     ide/src/
       inlay_hints/
         fn_lifetime_fn.rs
-          search: match config.param_names_for_lifetime_elision_hints {
-          search: allocated_lifetimes.push(if config.param_names_for_lifetime_elision_hints {
-          search: Some(it) if config.param_names_for_lifetime_elision_hints => {
-          search: InlayHintsConfig { param_names_for_lifetime_elision_hints: true, ..TEST_CONFIG },
+          search: match config.param_names_for_lifetime_elision_hints {{
+          search: allocated_lifetimes.push(if config.param_names_for_lifetime_elision_hints {{
+          search: Some(it) if config.param_names_for_lifetime_elision_hints => {{
+          search: InlayHintsConfig {{ param_names_for_lifetime_elision_hints: true, ..TEST_CONFIG }},
       inlay_hints.rs
         search: pub param_names_for_lifetime_elision_hints: bool,
         search: param_names_for_lifetime_elision_hints: self
@@ -5467,7 +5469,9 @@ mod tests {
         analysis_stats.rs
           search: param_names_for_lifetime_elision_hints: true,
       config.rs
-        search: param_names_for_lifetime_elision_hints: self"#;
+        search: param_names_for_lifetime_elision_hints: self"#
+        );
+
         let select_first_in_all_matches = |line_to_select: &str| {
             assert!(all_matches.contains(line_to_select));
             all_matches.replacen(
@@ -5524,7 +5528,7 @@ mod tests {
                     cx,
                 ),
                 format!(
-                    r#"/rust-analyzer/
+                    r#"{root}/
   crates/
     ide/src/
       inlay_hints/
@@ -5594,7 +5598,7 @@ mod tests {
                     cx,
                 ),
                 format!(
-                    r#"/rust-analyzer/
+                    r#"{root}/
   crates/
     ide/src/{SELECTED_MARKER}
     rust-analyzer/src/
@@ -5631,8 +5635,9 @@ mod tests {
         init_test(cx);
 
         let fs = FakeFs::new(cx.background_executor.clone());
-        populate_with_test_ra_project(&fs, "/rust-analyzer").await;
-        let project = Project::test(fs.clone(), ["/rust-analyzer".as_ref()], cx).await;
+        let root = path!("/rust-analyzer");
+        populate_with_test_ra_project(&fs, root).await;
+        let project = Project::test(fs.clone(), [Path::new(root)], cx).await;
         project.read_with(cx, |project, _| {
             project.languages().add(Arc::new(rust_lang()))
         });
@@ -5676,15 +5681,16 @@ mod tests {
                     );
                 });
         });
-        let all_matches = r#"/rust-analyzer/
+        let all_matches = format!(
+            r#"{root}/
   crates/
     ide/src/
       inlay_hints/
         fn_lifetime_fn.rs
-          search: match config.param_names_for_lifetime_elision_hints {
-          search: allocated_lifetimes.push(if config.param_names_for_lifetime_elision_hints {
-          search: Some(it) if config.param_names_for_lifetime_elision_hints => {
-          search: InlayHintsConfig { param_names_for_lifetime_elision_hints: true, ..TEST_CONFIG },
+          search: match config.param_names_for_lifetime_elision_hints {{
+          search: allocated_lifetimes.push(if config.param_names_for_lifetime_elision_hints {{
+          search: Some(it) if config.param_names_for_lifetime_elision_hints => {{
+          search: InlayHintsConfig {{ param_names_for_lifetime_elision_hints: true, ..TEST_CONFIG }},
       inlay_hints.rs
         search: pub param_names_for_lifetime_elision_hints: bool,
         search: param_names_for_lifetime_elision_hints: self
@@ -5695,7 +5701,8 @@ mod tests {
         analysis_stats.rs
           search: param_names_for_lifetime_elision_hints: true,
       config.rs
-        search: param_names_for_lifetime_elision_hints: self"#;
+        search: param_names_for_lifetime_elision_hints: self"#
+        );
 
         cx.executor()
             .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
@@ -5768,8 +5775,9 @@ mod tests {
         init_test(cx);
 
         let fs = FakeFs::new(cx.background_executor.clone());
-        populate_with_test_ra_project(&fs, path!("/rust-analyzer")).await;
-        let project = Project::test(fs.clone(), [path!("/rust-analyzer").as_ref()], cx).await;
+        let root = path!("/rust-analyzer");
+        populate_with_test_ra_project(&fs, root).await;
+        let project = Project::test(fs.clone(), [Path::new(root)], cx).await;
         project.read_with(cx, |project, _| {
             project.languages().add(Arc::new(rust_lang()))
         });
@@ -5813,9 +5821,8 @@ mod tests {
                     );
                 });
         });
-        let root_path = format!("{}/", path!("/rust-analyzer"));
         let all_matches = format!(
-            r#"{root_path}
+            r#"{root}/
   crates/
     ide/src/
       inlay_hints/
@@ -5977,7 +5984,7 @@ mod tests {
 
         let fs = FakeFs::new(cx.background_executor.clone());
         fs.insert_tree(
-            "/root",
+            path!("/root"),
             json!({
                 "one": {
                     "a.txt": "aaa aaa"
@@ -5989,7 +5996,7 @@ mod tests {
             }),
         )
         .await;
-        let project = Project::test(fs.clone(), [Path::new("/root/one")], cx).await;
+        let project = Project::test(fs.clone(), [Path::new(path!("/root/one"))], cx).await;
         let workspace = add_outline_panel(&project, cx).await;
         let cx = &mut VisualTestContext::from_window(*workspace, cx);
         let outline_panel = outline_panel(&workspace, cx);
@@ -6000,7 +6007,7 @@ mod tests {
         let items = workspace
             .update(cx, |workspace, window, cx| {
                 workspace.open_paths(
-                    vec![PathBuf::from("/root/two")],
+                    vec![PathBuf::from(path!("/root/two"))],
                     OpenOptions {
                         visible: Some(OpenVisible::OnlyDirectories),
                         ..Default::default()
@@ -6064,13 +6071,17 @@ mod tests {
                     outline_panel.selected_entry(),
                     cx,
                 ),
-                r#"/root/one/
+                format!(
+                    r#"{}/
   a.txt
     search: aaa aaa  <==== selected
     search: aaa aaa
-/root/two/
+{}/
   b.txt
-    search: a aaa"#
+    search: a aaa"#,
+                    path!("/root/one"),
+                    path!("/root/two"),
+                ),
             );
         });
 
@@ -6090,11 +6101,15 @@ mod tests {
                     outline_panel.selected_entry(),
                     cx,
                 ),
-                r#"/root/one/
+                format!(
+                    r#"{}/
   a.txt  <==== selected
-/root/two/
+{}/
   b.txt
-    search: a aaa"#
+    search: a aaa"#,
+                    path!("/root/one"),
+                    path!("/root/two"),
+                ),
             );
         });
 
@@ -6114,9 +6129,13 @@ mod tests {
                     outline_panel.selected_entry(),
                     cx,
                 ),
-                r#"/root/one/
+                format!(
+                    r#"{}/
   a.txt
-/root/two/  <==== selected"#
+{}/  <==== selected"#,
+                    path!("/root/one"),
+                    path!("/root/two"),
+                ),
             );
         });
 
@@ -6135,11 +6154,15 @@ mod tests {
                     outline_panel.selected_entry(),
                     cx,
                 ),
-                r#"/root/one/
+                format!(
+                    r#"{}/
   a.txt
-/root/two/  <==== selected
+{}/  <==== selected
   b.txt
-    search: a aaa"#
+    search: a aaa"#,
+                    path!("/root/one"),
+                    path!("/root/two"),
+                )
             );
         });
     }
@@ -6165,7 +6188,7 @@ struct OutlineEntryExcerpt {
             }),
         )
         .await;
-        let project = Project::test(fs.clone(), [root.as_ref()], cx).await;
+        let project = Project::test(fs.clone(), [Path::new(root)], cx).await;
         project.read_with(cx, |project, _| {
             project.languages().add(Arc::new(
                 rust_lang()
@@ -6508,7 +6531,7 @@ outline: struct OutlineEntryExcerpt
     async fn test_frontend_repo_structure(cx: &mut TestAppContext) {
         init_test(cx);
 
-        let root = "/frontend-project";
+        let root = path!("/frontend-project");
         let fs = FakeFs::new(cx.background_executor.clone());
         fs.insert_tree(
             root,
@@ -6545,7 +6568,7 @@ outline: struct OutlineEntryExcerpt
             }),
         )
         .await;
-        let project = Project::test(fs.clone(), [root.as_ref()], cx).await;
+        let project = Project::test(fs.clone(), [Path::new(root)], cx).await;
         let workspace = add_outline_panel(&project, cx).await;
         let cx = &mut VisualTestContext::from_window(*workspace, cx);
         let outline_panel = outline_panel(&workspace, cx);
@@ -6599,10 +6622,11 @@ outline: struct OutlineEntryExcerpt
                     outline_panel.selected_entry(),
                     cx,
                 ),
-                r#"/frontend-project/
+                format!(
+                    r#"{root}/
   public/lottie/
     syntax-tree.json
-      search: { "something": "static" }  <==== selected
+      search: {{ "something": "static" }}  <==== selected
   src/
     app/(site)/
       (about)/jobs/[slug]/
@@ -6614,6 +6638,7 @@ outline: struct OutlineEntryExcerpt
     components/
       ErrorBoundary.tsx
         search: static"#
+                )
             );
         });
 
@@ -6636,15 +6661,17 @@ outline: struct OutlineEntryExcerpt
                     outline_panel.selected_entry(),
                     cx,
                 ),
-                r#"/frontend-project/
+                format!(
+                    r#"{root}/
   public/lottie/
     syntax-tree.json
-      search: { "something": "static" }
+      search: {{ "something": "static" }}
   src/
     app/(site)/  <==== selected
     components/
       ErrorBoundary.tsx
         search: static"#
+                )
             );
         });
 
@@ -6664,15 +6691,17 @@ outline: struct OutlineEntryExcerpt
                     outline_panel.selected_entry(),
                     cx,
                 ),
-                r#"/frontend-project/
+                format!(
+                    r#"{root}/
   public/lottie/
     syntax-tree.json
-      search: { "something": "static" }
+      search: {{ "something": "static" }}
   src/
     app/(site)/
     components/
       ErrorBoundary.tsx
         search: static  <==== selected"#
+                )
             );
         });
 
@@ -6696,14 +6725,16 @@ outline: struct OutlineEntryExcerpt
                     outline_panel.selected_entry(),
                     cx,
                 ),
-                r#"/frontend-project/
+                format!(
+                    r#"{root}/
   public/lottie/
     syntax-tree.json
-      search: { "something": "static" }
+      search: {{ "something": "static" }}
   src/
     app/(site)/
     components/
       ErrorBoundary.tsx  <==== selected"#
+                )
             );
         });
 
@@ -6727,15 +6758,17 @@ outline: struct OutlineEntryExcerpt
                     outline_panel.selected_entry(),
                     cx,
                 ),
-                r#"/frontend-project/
+                format!(
+                    r#"{root}/
   public/lottie/
     syntax-tree.json
-      search: { "something": "static" }
+      search: {{ "something": "static" }}
   src/
     app/(site)/
     components/
       ErrorBoundary.tsx  <==== selected
         search: static"#
+                )
             );
         });
     }

crates/project/src/lsp_store.rs 🔗

@@ -2566,10 +2566,7 @@ impl LocalLspStore {
         };
 
         let Ok(file_url) = lsp::Uri::from_file_path(old_path.as_path()) else {
-            debug_panic!(
-                "`{}` is not parseable as an URI",
-                old_path.to_string_lossy()
-            );
+            debug_panic!("{old_path:?} is not parseable as an URI");
             return;
         };
         self.unregister_buffer_from_language_servers(buffer, &file_url, cx);

crates/search/src/project_search.rs 🔗

@@ -1384,6 +1384,9 @@ impl ProjectSearchView {
         let match_ranges = self.entity.read(cx).match_ranges.clone();
         if match_ranges.is_empty() {
             self.active_match_index = None;
+            self.results_editor.update(cx, |editor, cx| {
+                editor.clear_background_highlights::<Self>(cx);
+            });
         } else {
             self.active_match_index = Some(0);
             self.update_match_index(cx);
@@ -2338,7 +2341,7 @@ pub fn perform_project_search(
 
 #[cfg(test)]
 pub mod tests {
-    use std::{ops::Deref as _, sync::Arc};
+    use std::{ops::Deref as _, sync::Arc, time::Duration};
 
     use super::*;
     use editor::{DisplayPoint, display_map::DisplayRow};
@@ -2381,6 +2384,7 @@ pub mod tests {
                 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;"
             );
             let match_background_color = cx.theme().colors().search_match_background;
+            let selection_background_color = cx.theme().colors().editor_document_highlight_bracket_background;
             assert_eq!(
                 search_view
                     .results_editor
@@ -2390,14 +2394,23 @@ pub mod tests {
                         DisplayPoint::new(DisplayRow(2), 32)..DisplayPoint::new(DisplayRow(2), 35),
                         match_background_color
                     ),
+                    (
+                        DisplayPoint::new(DisplayRow(2), 37)..DisplayPoint::new(DisplayRow(2), 40),
+                        selection_background_color
+                    ),
                     (
                         DisplayPoint::new(DisplayRow(2), 37)..DisplayPoint::new(DisplayRow(2), 40),
                         match_background_color
                     ),
+                    (
+                        DisplayPoint::new(DisplayRow(5), 6)..DisplayPoint::new(DisplayRow(5), 9),
+                        selection_background_color
+                    ),
                     (
                         DisplayPoint::new(DisplayRow(5), 6)..DisplayPoint::new(DisplayRow(5), 9),
                         match_background_color
-                    )
+                    ),
+
                 ]
             );
             assert_eq!(search_view.active_match_index, Some(0));
@@ -4156,6 +4169,10 @@ pub mod tests {
                 search_view.search(cx);
             })
             .unwrap();
+        // Ensure editor highlights appear after the search is done
+        cx.executor().advance_clock(
+            editor::SELECTION_HIGHLIGHT_DEBOUNCE_TIMEOUT + Duration::from_millis(100),
+        );
         cx.background_executor.run_until_parked();
     }
 }

crates/sum_tree/src/tree_map.rs 🔗

@@ -2,6 +2,7 @@ use std::{cmp::Ordering, fmt::Debug};
 
 use crate::{Bias, Dimension, Edit, Item, KeyedItem, SeekTarget, SumTree, Summary};
 
+/// A cheaply-clonable ordered map based on a [SumTree](crate::SumTree).
 #[derive(Clone, PartialEq, Eq)]
 pub struct TreeMap<K, V>(SumTree<MapEntry<K, V>>)
 where