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

Release Notes:

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

---------

Co-authored-by: Kirill Bulatov <kirill@zed.dev>
This commit is contained in:
Lukas Wirth 2025-09-03 16:22:35 +02:00 committed by GitHub
parent 92283285ae
commit c1ca7303a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 429 additions and 325 deletions

View file

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

View file

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

View file

@ -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(6), 3)..DisplayPoint::new(DisplayRow(6), 5),
Hsla::red(),
),
(
DisplayPoint::new(DisplayRow(3), 2)..DisplayPoint::new(DisplayRow(3), 5),
Hsla::green(),
),
(
DisplayPoint::new(DisplayRow(4), 2)..DisplayPoint::new(DisplayRow(4), 4),
Hsla::red(),
),
(
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(),

View file

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

View file

@ -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;
self.sync_all(cx);
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;
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 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;
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)))
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));

View file

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

View file

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

View file

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

View file

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

View file

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