editor: Do not include inlays in word diff highlights (#49007)

Release Notes:

- Fixed inlay hints being rendered as new inserted words in word based
diff highlighting

---------

Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com>
This commit is contained in:
Lukas Wirth 2026-02-12 10:26:06 +01:00 committed by GitHub
parent 21ad340f01
commit 213de2ec9b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 186 additions and 18 deletions

View file

@ -105,6 +105,7 @@ use multi_buffer::{
use project::project_settings::DiagnosticSeverity;
use project::{InlayId, lsp_store::LspFoldingRange, lsp_store::TokenType};
use serde::Deserialize;
use smallvec::SmallVec;
use sum_tree::{Bias, TreeMap};
use text::{BufferId, LineIndent, Patch, ToOffset as _};
use ui::{SharedString, px};
@ -1694,6 +1695,38 @@ impl DisplaySnapshot {
DisplayPoint(block_point)
}
/// Converts a buffer offset range into one or more `DisplayPoint` ranges
/// that cover only actual buffer text, excluding any inlay hint text that
/// falls within the range.
pub fn isomorphic_display_point_ranges_for_buffer_range(
&self,
range: Range<MultiBufferOffset>,
) -> SmallVec<[Range<DisplayPoint>; 1]> {
let inlay_snapshot = self.inlay_snapshot();
inlay_snapshot
.buffer_offset_to_inlay_ranges(range)
.map(|inlay_range| {
let inlay_point_to_display_point = |inlay_point: InlayPoint, bias: Bias| {
let fold_point = self.fold_snapshot().to_fold_point(inlay_point, bias);
let tab_point = self.tab_snapshot().fold_point_to_tab_point(fold_point);
let wrap_point = self.wrap_snapshot().tab_point_to_wrap_point(tab_point);
let block_point = self.block_snapshot.to_block_point(wrap_point);
DisplayPoint(block_point)
};
let start = inlay_point_to_display_point(
inlay_snapshot.to_point(inlay_range.start),
Bias::Left,
);
let end = inlay_point_to_display_point(
inlay_snapshot.to_point(inlay_range.end),
Bias::Left,
);
start..end
})
.collect()
}
pub fn display_point_to_point(&self, point: DisplayPoint, bias: Bias) -> Point {
self.inlay_snapshot()
.to_buffer_point(self.display_point_to_inlay_point(point, bias))
@ -3956,4 +3989,88 @@ pub mod tests {
store.update_user_settings(cx, f);
});
}
#[gpui::test]
fn test_isomorphic_display_point_ranges_for_buffer_range(cx: &mut gpui::TestAppContext) {
cx.update(|cx| init_test(cx, |_| {}));
let buffer = cx.new(|cx| Buffer::local("let x = 5;\n", cx));
let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
let buffer_snapshot = buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx));
let font_size = px(14.0);
let map = cx.new(|cx| {
DisplayMap::new(
buffer.clone(),
font("Helvetica"),
font_size,
None,
1,
1,
FoldPlaceholder::test(),
DiagnosticSeverity::Warning,
cx,
)
});
// Without inlays, a buffer range maps to a single display range.
let snapshot = map.update(cx, |map, cx| map.snapshot(cx));
let ranges = snapshot.isomorphic_display_point_ranges_for_buffer_range(
MultiBufferOffset(4)..MultiBufferOffset(9),
);
assert_eq!(ranges.len(), 1);
// "x = 5" is columns 4..9 with no inlays shifting anything.
assert_eq!(ranges[0].start, DisplayPoint::new(DisplayRow(0), 4));
assert_eq!(ranges[0].end, DisplayPoint::new(DisplayRow(0), 9));
// Insert a 4-char inlay hint ": i32" at buffer offset 5 (after "x").
map.update(cx, |map, cx| {
map.splice_inlays(
&[],
vec![Inlay::mock_hint(
0,
buffer_snapshot.anchor_after(MultiBufferOffset(5)),
": i32",
)],
cx,
);
});
let snapshot = map.update(cx, |map, cx| map.snapshot(cx));
assert_eq!(snapshot.text(), "let x: i32 = 5;\n");
// A buffer range [4..9] ("x = 5") now spans across the inlay.
// It should be split into two display ranges that skip the inlay text.
let ranges = snapshot.isomorphic_display_point_ranges_for_buffer_range(
MultiBufferOffset(4)..MultiBufferOffset(9),
);
assert_eq!(
ranges.len(),
2,
"expected the range to be split around the inlay, got: {:?}",
ranges,
);
// First sub-range: buffer [4, 5) → "x" at display columns 4..5
assert_eq!(ranges[0].start, DisplayPoint::new(DisplayRow(0), 4));
assert_eq!(ranges[0].end, DisplayPoint::new(DisplayRow(0), 5));
// Second sub-range: buffer [5, 9) → " = 5" at display columns 10..14
// (shifted right by the 5-char ": i32" inlay)
assert_eq!(ranges[1].start, DisplayPoint::new(DisplayRow(0), 10));
assert_eq!(ranges[1].end, DisplayPoint::new(DisplayRow(0), 14));
// A range entirely before the inlay is not split.
let ranges = snapshot.isomorphic_display_point_ranges_for_buffer_range(
MultiBufferOffset(0)..MultiBufferOffset(5),
);
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start, DisplayPoint::new(DisplayRow(0), 0));
assert_eq!(ranges[0].end, DisplayPoint::new(DisplayRow(0), 5));
// A range entirely after the inlay is not split.
let ranges = snapshot.isomorphic_display_point_ranges_for_buffer_range(
MultiBufferOffset(5)..MultiBufferOffset(9),
);
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start, DisplayPoint::new(DisplayRow(0), 10));
assert_eq!(ranges[0].end, DisplayPoint::new(DisplayRow(0), 14));
}
}

View file

@ -938,6 +938,51 @@ impl InlaySnapshot {
self.inlay_point_cursor().map(point)
}
/// Converts a buffer offset range into one or more `InlayOffset` ranges that
/// cover only the actual buffer text, skipping any inlay hint text that falls
/// within the range. When there are no inlays the returned vec contains a
/// single element identical to the input mapped into inlay-offset space.
pub fn buffer_offset_to_inlay_ranges(
&self,
range: Range<MultiBufferOffset>,
) -> impl Iterator<Item = Range<InlayOffset>> {
let mut cursor = self
.transforms
.cursor::<Dimensions<MultiBufferOffset, InlayOffset>>(());
cursor.seek(&range.start, Bias::Right);
std::iter::from_fn(move || {
loop {
match cursor.item()? {
Transform::Isomorphic(_) => {
let seg_buffer_start = cursor.start().0;
let seg_buffer_end = cursor.end().0;
let seg_inlay_start = cursor.start().1;
let overlap_start = cmp::max(range.start, seg_buffer_start);
let overlap_end = cmp::min(range.end, seg_buffer_end);
let past_end = seg_buffer_end >= range.end;
cursor.next();
if overlap_start < overlap_end {
let inlay_start =
InlayOffset(seg_inlay_start.0 + (overlap_start - seg_buffer_start));
let inlay_end =
InlayOffset(seg_inlay_start.0 + (overlap_end - seg_buffer_start));
return Some(inlay_start..inlay_end);
}
if past_end {
return None;
}
}
Transform::Inlay(_) => cursor.next(),
}
}
})
}
#[ztracing::instrument(skip_all)]
pub fn inlay_point_cursor(&self) -> InlayPointCursor<'_> {
let cursor = self.transforms.cursor::<Dimensions<Point, InlayPoint>>(());

View file

@ -5504,25 +5504,31 @@ impl EditorElement {
})
.filter(|(_, status)| status.is_modified())
.flat_map(|(word_diffs, _)| word_diffs)
.filter_map(|word_diff| {
let start_point = word_diff.start.to_display_point(&snapshot.display_snapshot);
let end_point = word_diff.end.to_display_point(&snapshot.display_snapshot);
let start_row_offset = start_point.row().0.saturating_sub(start_row.0) as usize;
.flat_map(|word_diff| {
let display_ranges = snapshot
.display_snapshot
.isomorphic_display_point_ranges_for_buffer_range(
word_diff.start..word_diff.end,
);
row_infos
.get(start_row_offset)
.and_then(|row_info| row_info.diff_status)
.and_then(|diff_status| {
let background_color = match diff_status.kind {
DiffHunkStatusKind::Added => colors.version_control_word_added,
DiffHunkStatusKind::Deleted => colors.version_control_word_deleted,
DiffHunkStatusKind::Modified => {
debug_panic!("modified diff status for row info");
return None;
}
};
Some((start_point..end_point, background_color))
})
display_ranges.into_iter().filter_map(|range| {
let start_row_offset = range.start.row().0.saturating_sub(start_row.0) as usize;
let diff_status = row_infos
.get(start_row_offset)
.and_then(|row_info| row_info.diff_status)?;
let background_color = match diff_status.kind {
DiffHunkStatusKind::Added => colors.version_control_word_added,
DiffHunkStatusKind::Deleted => colors.version_control_word_deleted,
DiffHunkStatusKind::Modified => {
debug_panic!("modified diff status for row info");
return None;
}
};
Some((range, background_color))
})
});
highlighted_ranges.extend(word_highlights);