From 213de2ec9bde5536acc89b59ccb18dffd95e1d38 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Thu, 12 Feb 2026 10:26:06 +0100 Subject: [PATCH] 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> --- crates/editor/src/display_map.rs | 117 +++++++++++++++++++++ crates/editor/src/display_map/inlay_map.rs | 45 ++++++++ crates/editor/src/element.rs | 44 ++++---- 3 files changed, 187 insertions(+), 19 deletions(-) diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 7342113192830fe6d2324e410bfd6482186f190d..ef44f34d21ada27896e8dab99f11fd6eb1255175 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -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, + ) -> SmallVec<[Range; 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)); + } } diff --git a/crates/editor/src/display_map/inlay_map.rs b/crates/editor/src/display_map/inlay_map.rs index 731133d98e26632dc40c12de7e52469951f9a935..3e8ea78f8272aa4c053a36a34653105d6d2194c2 100644 --- a/crates/editor/src/display_map/inlay_map.rs +++ b/crates/editor/src/display_map/inlay_map.rs @@ -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, + ) -> impl Iterator> { + let mut cursor = self + .transforms + .cursor::>(()); + 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::>(()); diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 340610faa984d993728416d4b026bd67e2809003..1cafffdfac755e7051c204bec6e1d3a98eabd3f9 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -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; - - 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)) - }) + .flat_map(|word_diff| { + let display_ranges = snapshot + .display_snapshot + .isomorphic_display_point_ranges_for_buffer_range( + word_diff.start..word_diff.end, + ); + + 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);