From 464c0be2b7e935c9a91353867563f2f54431d3c1 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Mon, 1 Dec 2025 22:36:30 -0500 Subject: [PATCH] git: Add word diff highlighting (#43269) This PR adds word/character diff for expanded diff hunks that have both a deleted and added section, as well as a setting `word_diff_enabled` to enable/disable word diffs per language. - `word_diff_enabled`: Defaults to true. Whether or not expanded diff hunks will show word diff highlights when they're able to. ### Preview image ### Architecture I had three architecture goals I wanted to have when adding word diff support: - Caching: We should only calculate word diffs once and save the result. This is because calculating word diffs can be expensive, and Zed should always be responsive. - Don't block the main thread: Word diffs should be computed in the background to prevent hanging Zed. - Lazy calculation: We should calculate word diffs for buffers that are not visible to a user. To accomplish the three goals, word diffs are computed as a part of `BufferDiff` diff hunk processing because it happens on a background thread, is cached until the file is edited, and is only refreshed for open buffers. My original implementation calculated word diffs every frame in the Editor element. This had the benefit of lazy evaluation because it only calculated visible frames, but it didn't have caching for the calculations, and the code wasn't organized. Because the hunk calculations would happen in two separate places instead of just `BufferDiff`. Finally, it always happened on the main thread because it was during the `EditorElement` layout phase. I used Zed's [`diff_internal`](https://github.com/zed-industries/zed/blob/02b2aa6c50c03d3005bec2effbc9f87161fbb1e8/crates/language/src/text_diff.rs#L230-L267) as a starting place for word diff calculations because it uses `Imara_diff` behind the scenes and already has language-specific support. #### Future Improvements In the future, we could add `AST` based word diff highlights, e.g. https://github.com/zed-industries/zed/pull/43691. Release Notes: - git: Show word diff highlight in expanded diff hunks with less than 5 lines. - git: Add `word_diff_enabled` as a language setting that defaults to true. --------- Co-authored-by: David Kleingeld Co-authored-by: Cole Miller Co-authored-by: cameron Co-authored-by: Lukas Wirth --- Cargo.lock | 1 + Cargo.toml | 1 + assets/settings/default.json | 7 + assets/themes/one/one.json | 4 + crates/buffer_diff/Cargo.toml | 4 +- crates/buffer_diff/src/buffer_diff.rs | 130 +++++++++++++++++- crates/editor/src/display_map.rs | 2 + crates/editor/src/editor.rs | 10 ++ crates/editor/src/element.rs | 91 +++++++++--- crates/language/src/language.rs | 1 + crates/language/src/language_settings.rs | 8 ++ crates/language/src/text_diff.rs | 86 ++++++++++++ crates/multi_buffer/src/multi_buffer.rs | 26 ++++ crates/multi_buffer/src/multi_buffer_tests.rs | 122 ++++++++++++++-- .../settings/src/settings_content/language.rs | 7 + crates/settings/src/settings_content/theme.rs | 8 ++ crates/settings/src/vscode_import.rs | 1 + crates/settings_ui/src/page_data.rs | 19 +++ crates/theme/src/default_colors.rs | 28 +++- crates/theme/src/fallback_themes.rs | 26 +++- crates/theme/src/schema.rs | 8 ++ crates/theme/src/styles/colors.rs | 5 +- 22 files changed, 547 insertions(+), 48 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 99c8bb19e8c45dd60f36b4234b275ff80ee43f16..6e558cbf395866ce6b75ff5764ba98a5ec81607a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2423,6 +2423,7 @@ dependencies = [ "rand 0.9.2", "rope", "serde_json", + "settings", "sum_tree", "text", "unindent", diff --git a/Cargo.toml b/Cargo.toml index 8fe4dbcaadc8413ee915ee6c2b12065ef98e8430..b3e77414fe511445a73d3341b53ab8f8f589d884 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -639,6 +639,7 @@ serde_urlencoded = "0.7" sha2 = "0.10" shellexpand = "2.1.0" shlex = "1.3.0" +similar = "2.6" simplelog = "0.12.2" slotmap = "1.0.6" smallvec = { version = "1.6", features = ["union"] } diff --git a/assets/settings/default.json b/assets/settings/default.json index d321c176a59d492b6d1b3b7a22dca0d31e5c6298..f53019744e72daa253e3ddfa96f48a0541186b61 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1209,6 +1209,13 @@ "tab_size": 4, // What debuggers are preferred by default for all languages. "debuggers": [], + // Whether to enable word diff highlighting in the editor. + // + // When enabled, changed words within modified lines are highlighted + // to show exactly what changed. + // + // Default: true + "word_diff_enabled": true, // Control what info is collected by Zed. "telemetry": { // Send debug info like crash reports. diff --git a/assets/themes/one/one.json b/assets/themes/one/one.json index 6849cd05dc70752216789ae04e81fad232f7b14b..d9d7a37e996053d6f7c6cb28ec7f0d3f92e3b394 100644 --- a/assets/themes/one/one.json +++ b/assets/themes/one/one.json @@ -98,6 +98,8 @@ "link_text.hover": "#74ade8ff", "version_control.added": "#27a657ff", "version_control.modified": "#d3b020ff", + "version_control.word_added": "#2EA04859", + "version_control.word_deleted": "#78081BCC", "version_control.deleted": "#e06c76ff", "version_control.conflict_marker.ours": "#a1c1811a", "version_control.conflict_marker.theirs": "#74ade81a", @@ -499,6 +501,8 @@ "link_text.hover": "#5c78e2ff", "version_control.added": "#27a657ff", "version_control.modified": "#d3b020ff", + "version_control.word_added": "#2EA04859", + "version_control.word_deleted": "#F85149CC", "version_control.deleted": "#e06c76ff", "conflict": "#a48819ff", "conflict.background": "#faf2e6ff", diff --git a/crates/buffer_diff/Cargo.toml b/crates/buffer_diff/Cargo.toml index 1be21f3a0f1ef7aafa222a611d858f8adb097454..6249ae418c593f5ae8bca3408d8f5f25df7c871b 100644 --- a/crates/buffer_diff/Cargo.toml +++ b/crates/buffer_diff/Cargo.toml @@ -12,7 +12,7 @@ workspace = true path = "src/buffer_diff.rs" [features] -test-support = [] +test-support = ["settings"] [dependencies] anyhow.workspace = true @@ -24,6 +24,7 @@ language.workspace = true log.workspace = true pretty_assertions.workspace = true rope.workspace = true +settings = { workspace = true, optional = true } sum_tree.workspace = true text.workspace = true util.workspace = true @@ -33,6 +34,7 @@ ctor.workspace = true gpui = { workspace = true, features = ["test-support"] } rand.workspace = true serde_json.workspace = true +settings.workspace = true text = { workspace = true, features = ["test-support"] } unindent.workspace = true zlog.workspace = true diff --git a/crates/buffer_diff/src/buffer_diff.rs b/crates/buffer_diff/src/buffer_diff.rs index 5f1736e450556f2943618b49eee926eb3bbb4338..55de3f968bc1cc9ff5d640b0d3ca30221e413632 100644 --- a/crates/buffer_diff/src/buffer_diff.rs +++ b/crates/buffer_diff/src/buffer_diff.rs @@ -1,7 +1,10 @@ use futures::channel::oneshot; use git2::{DiffLineType as GitDiffLineType, DiffOptions as GitOptions, Patch as GitPatch}; use gpui::{App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Task, TaskLabel}; -use language::{BufferRow, Language, LanguageRegistry}; +use language::{ + BufferRow, DiffOptions, File, Language, LanguageName, LanguageRegistry, + language_settings::language_settings, word_diff_ranges, +}; use rope::Rope; use std::{ cmp::Ordering, @@ -15,10 +18,12 @@ use text::{Anchor, Bias, BufferId, OffsetRangeExt, Point, ToOffset as _, ToPoint use util::ResultExt; pub static CALCULATE_DIFF_TASK: LazyLock = LazyLock::new(TaskLabel::new); +pub const MAX_WORD_DIFF_LINE_COUNT: usize = 5; pub struct BufferDiff { pub buffer_id: BufferId, inner: BufferDiffInner, + // diff of the index vs head secondary_diff: Option>, } @@ -31,6 +36,7 @@ pub struct BufferDiffSnapshot { #[derive(Clone)] struct BufferDiffInner { hunks: SumTree, + // Used for making staging mo pending_hunks: SumTree, base_text: language::BufferSnapshot, base_text_exists: bool, @@ -50,11 +56,18 @@ pub enum DiffHunkStatusKind { } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +/// Diff of Working Copy vs Index +/// aka 'is this hunk staged or not' pub enum DiffHunkSecondaryStatus { + /// Unstaged HasSecondaryHunk, + /// Partially staged OverlapsWithSecondaryHunk, + /// Staged NoSecondaryHunk, + /// We are unstaging SecondaryHunkAdditionPending, + /// We are stagind SecondaryHunkRemovalPending, } @@ -68,6 +81,10 @@ pub struct DiffHunk { /// The range in the buffer's diff base text to which this hunk corresponds. pub diff_base_byte_range: Range, pub secondary_status: DiffHunkSecondaryStatus, + // Anchors representing the word diff locations in the active buffer + pub buffer_word_diffs: Vec>, + // Offsets relative to the start of the deleted diff that represent word diff locations + pub base_word_diffs: Vec>, } /// We store [`InternalDiffHunk`]s internally so we don't need to store the additional row range. @@ -75,6 +92,8 @@ pub struct DiffHunk { struct InternalDiffHunk { buffer_range: Range, diff_base_byte_range: Range, + base_word_diffs: Vec>, + buffer_word_diffs: Vec>, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -208,6 +227,13 @@ impl BufferDiffSnapshot { let base_text_pair; let base_text_exists; let base_text_snapshot; + let diff_options = build_diff_options( + None, + language.as_ref().map(|l| l.name()), + language.as_ref().map(|l| l.default_scope()), + cx, + ); + if let Some(text) = &base_text { let base_text_rope = Rope::from(text.as_str()); base_text_pair = Some((text.clone(), base_text_rope.clone())); @@ -225,7 +251,7 @@ impl BufferDiffSnapshot { .background_executor() .spawn_labeled(*CALCULATE_DIFF_TASK, { let buffer = buffer.clone(); - async move { compute_hunks(base_text_pair, buffer) } + async move { compute_hunks(base_text_pair, buffer, diff_options) } }); async move { @@ -248,6 +274,12 @@ impl BufferDiffSnapshot { base_text_snapshot: language::BufferSnapshot, cx: &App, ) -> impl Future + use<> { + let diff_options = build_diff_options( + base_text_snapshot.file(), + base_text_snapshot.language().map(|l| l.name()), + base_text_snapshot.language().map(|l| l.default_scope()), + cx, + ); let base_text_exists = base_text.is_some(); let base_text_pair = base_text.map(|text| { debug_assert_eq!(&*text, &base_text_snapshot.text()); @@ -259,7 +291,7 @@ impl BufferDiffSnapshot { inner: BufferDiffInner { base_text: base_text_snapshot, pending_hunks: SumTree::new(&buffer), - hunks: compute_hunks(base_text_pair, buffer), + hunks: compute_hunks(base_text_pair, buffer, diff_options), base_text_exists, }, secondary_diff: None, @@ -602,11 +634,15 @@ impl BufferDiffInner { [ ( &hunk.buffer_range.start, - (hunk.buffer_range.start, hunk.diff_base_byte_range.start), + ( + hunk.buffer_range.start, + hunk.diff_base_byte_range.start, + hunk, + ), ), ( &hunk.buffer_range.end, - (hunk.buffer_range.end, hunk.diff_base_byte_range.end), + (hunk.buffer_range.end, hunk.diff_base_byte_range.end, hunk), ), ] }); @@ -625,8 +661,11 @@ impl BufferDiffInner { let mut summaries = buffer.summaries_for_anchors_with_payload::(anchor_iter); iter::from_fn(move || { loop { - let (start_point, (start_anchor, start_base)) = summaries.next()?; - let (mut end_point, (mut end_anchor, end_base)) = summaries.next()?; + let (start_point, (start_anchor, start_base, hunk)) = summaries.next()?; + let (mut end_point, (mut end_anchor, end_base, _)) = summaries.next()?; + + let base_word_diffs = hunk.base_word_diffs.clone(); + let buffer_word_diffs = hunk.buffer_word_diffs.clone(); if !start_anchor.is_valid(buffer) { continue; @@ -696,6 +735,8 @@ impl BufferDiffInner { range: start_point..end_point, diff_base_byte_range: start_base..end_base, buffer_range: start_anchor..end_anchor, + base_word_diffs, + buffer_word_diffs, secondary_status, }); } @@ -727,6 +768,8 @@ impl BufferDiffInner { buffer_range: hunk.buffer_range.clone(), // The secondary status is not used by callers of this method. secondary_status: DiffHunkSecondaryStatus::NoSecondaryHunk, + base_word_diffs: hunk.base_word_diffs.clone(), + buffer_word_diffs: hunk.buffer_word_diffs.clone(), }) }) } @@ -795,9 +838,36 @@ impl BufferDiffInner { } } +fn build_diff_options( + file: Option<&Arc>, + language: Option, + language_scope: Option, + cx: &App, +) -> Option { + #[cfg(any(test, feature = "test-support"))] + { + if !cx.has_global::() { + return Some(DiffOptions { + language_scope, + max_word_diff_line_count: MAX_WORD_DIFF_LINE_COUNT, + ..Default::default() + }); + } + } + + language_settings(language, file, cx) + .word_diff_enabled + .then_some(DiffOptions { + language_scope, + max_word_diff_line_count: MAX_WORD_DIFF_LINE_COUNT, + ..Default::default() + }) +} + fn compute_hunks( diff_base: Option<(Arc, Rope)>, buffer: text::BufferSnapshot, + diff_options: Option, ) -> SumTree { let mut tree = SumTree::new(&buffer); @@ -823,6 +893,8 @@ fn compute_hunks( InternalDiffHunk { buffer_range: buffer.anchor_before(0)..buffer.anchor_before(0), diff_base_byte_range: 0..diff_base.len() - 1, + base_word_diffs: Vec::default(), + buffer_word_diffs: Vec::default(), }, &buffer, ); @@ -838,6 +910,7 @@ fn compute_hunks( &diff_base_rope, &buffer, &mut divergence, + diff_options.as_ref(), ); tree.push(hunk, &buffer); } @@ -847,6 +920,8 @@ fn compute_hunks( InternalDiffHunk { buffer_range: Anchor::min_max_range_for_buffer(buffer.remote_id()), diff_base_byte_range: 0..0, + base_word_diffs: Vec::default(), + buffer_word_diffs: Vec::default(), }, &buffer, ); @@ -861,6 +936,7 @@ fn process_patch_hunk( diff_base: &Rope, buffer: &text::BufferSnapshot, buffer_row_divergence: &mut i64, + diff_options: Option<&DiffOptions>, ) -> InternalDiffHunk { let line_item_count = patch.num_lines_in_hunk(hunk_index).unwrap(); assert!(line_item_count > 0); @@ -925,9 +1001,49 @@ fn process_patch_hunk( let start = Point::new(buffer_row_range.start, 0); let end = Point::new(buffer_row_range.end, 0); let buffer_range = buffer.anchor_before(start)..buffer.anchor_before(end); + + let base_line_count = line_item_count.saturating_sub(buffer_row_range.len()); + + let (base_word_diffs, buffer_word_diffs) = if let Some(diff_options) = diff_options + && !buffer_row_range.is_empty() + && base_line_count == buffer_row_range.len() + && diff_options.max_word_diff_line_count >= base_line_count + { + let base_text: String = diff_base + .chunks_in_range(diff_base_byte_range.clone()) + .collect(); + + let buffer_text: String = buffer.text_for_range(buffer_range.clone()).collect(); + + let (base_word_diffs, buffer_word_diffs_relative) = word_diff_ranges( + &base_text, + &buffer_text, + DiffOptions { + language_scope: diff_options.language_scope.clone(), + ..*diff_options + }, + ); + + let buffer_start_offset = buffer_range.start.to_offset(buffer); + let buffer_word_diffs = buffer_word_diffs_relative + .into_iter() + .map(|range| { + let start = buffer.anchor_after(buffer_start_offset + range.start); + let end = buffer.anchor_after(buffer_start_offset + range.end); + start..end + }) + .collect(); + + (base_word_diffs, buffer_word_diffs) + } else { + (Vec::default(), Vec::default()) + }; + InternalDiffHunk { buffer_range, diff_base_byte_range, + base_word_diffs, + buffer_word_diffs, } } diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 7189dd9f2061b4d542c7d50ee4b90c5681a9d86e..081e8ec2e5c3fed341d9949689f6d8bbbb7ccf1c 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -181,6 +181,8 @@ impl DisplayMap { .update(cx, |map, cx| map.sync(tab_snapshot, edits, cx)); let block_snapshot = self.block_map.read(wrap_snapshot, edits).snapshot; + // todo word diff here? + DisplaySnapshot { block_snapshot, diagnostics_max_severity: self.diagnostics_max_severity, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index b1c6da8c0e6e9c197ff0ac8a24168f480de3790b..3ceb9d8d699a5aa7f743e3c30042b47c486f17b4 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -284,6 +284,9 @@ pub enum ConflictsTheirs {} pub enum ConflictsOursMarker {} pub enum ConflictsTheirsMarker {} +pub struct HunkAddedColor; +pub struct HunkRemovedColor; + #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub enum Navigated { Yes, @@ -307,6 +310,7 @@ enum DisplayDiffHunk { display_row_range: Range, multi_buffer_range: Range, status: DiffHunkStatus, + word_diffs: Vec>, }, } @@ -19457,6 +19461,10 @@ impl Editor { &hunks .map(|hunk| buffer_diff::DiffHunk { buffer_range: hunk.buffer_range, + // We don't need to pass in word diffs here because they're only used for rendering and + // this function changes internal state + base_word_diffs: Vec::default(), + buffer_word_diffs: Vec::default(), diff_base_byte_range: hunk.diff_base_byte_range.start.0 ..hunk.diff_base_byte_range.end.0, secondary_status: hunk.secondary_status, @@ -24126,10 +24134,12 @@ impl EditorSnapshot { end_row.0 += 1; } let is_created_file = hunk.is_created_file(); + DisplayDiffHunk::Unfolded { status: hunk.status(), diff_base_byte_range: hunk.diff_base_byte_range.start.0 ..hunk.diff_base_byte_range.end.0, + word_diffs: hunk.word_diffs, display_row_range: hunk_display_start.row()..end_row, multi_buffer_range: Anchor::range_in_buffer( hunk.excerpt_id, diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index a629d45408825c061c417821c12ab260e2ed2f77..89f9a6793d81e3de9ba27c97091fe446061c31ff 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -5572,6 +5572,50 @@ impl EditorElement { } } + fn layout_word_diff_highlights( + display_hunks: &[(DisplayDiffHunk, Option)], + row_infos: &[RowInfo], + start_row: DisplayRow, + snapshot: &EditorSnapshot, + highlighted_ranges: &mut Vec<(Range, Hsla)>, + cx: &mut App, + ) { + let colors = cx.theme().colors(); + + let word_highlights = display_hunks + .into_iter() + .filter_map(|(hunk, _)| match hunk { + DisplayDiffHunk::Unfolded { + word_diffs, status, .. + } => Some((word_diffs, status)), + _ => None, + }) + .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)) + }) + }); + + highlighted_ranges.extend(word_highlights); + } + fn layout_diff_hunk_controls( &self, row_range: Range, @@ -9122,7 +9166,7 @@ impl Element for EditorElement { ); let end_row = DisplayRow(end_row); - let row_infos = snapshot + let row_infos = snapshot // note we only get the visual range .row_infos(start_row) .take((start_row..end_row).len()) .collect::>(); @@ -9153,16 +9197,27 @@ impl Element for EditorElement { let is_light = cx.theme().appearance().is_light(); + let mut highlighted_ranges = self + .editor_with_selections(cx) + .map(|editor| { + editor.read(cx).background_highlights_in_range( + start_anchor..end_anchor, + &snapshot.display_snapshot, + cx.theme(), + ) + }) + .unwrap_or_default(); + for (ix, row_info) in row_infos.iter().enumerate() { let Some(diff_status) = row_info.diff_status else { continue; }; let background_color = match diff_status.kind { - DiffHunkStatusKind::Added => cx.theme().colors().version_control_added, - DiffHunkStatusKind::Deleted => { - cx.theme().colors().version_control_deleted - } + DiffHunkStatusKind::Added => + cx.theme().colors().version_control_added, + DiffHunkStatusKind::Deleted => + cx.theme().colors().version_control_deleted, DiffHunkStatusKind::Modified => { debug_panic!("modified diff status for row info"); continue; @@ -9200,21 +9255,14 @@ impl Element for EditorElement { filled_highlight }; + let base_display_point = + DisplayPoint::new(start_row + DisplayRow(ix as u32), 0); + highlighted_rows - .entry(start_row + DisplayRow(ix as u32)) + .entry(base_display_point.row()) .or_insert(background); } - let highlighted_ranges = self - .editor_with_selections(cx) - .map(|editor| { - editor.read(cx).background_highlights_in_range( - start_anchor..end_anchor, - &snapshot.display_snapshot, - cx.theme(), - ) - }) - .unwrap_or_default(); let highlighted_gutter_ranges = self.editor.read(cx).gutter_highlights_in_range( start_anchor..end_anchor, @@ -9387,7 +9435,7 @@ impl Element for EditorElement { let crease_trailers = window.with_element_namespace("crease_trailers", |window| { self.layout_crease_trailers( - row_infos.iter().copied(), + row_infos.iter().cloned(), &snapshot, window, cx, @@ -9403,6 +9451,15 @@ impl Element for EditorElement { cx, ); + Self::layout_word_diff_highlights( + &display_hunks, + &row_infos, + start_row, + &snapshot, + &mut highlighted_ranges, + cx, + ); + let merged_highlighted_ranges = if let Some((_, colors)) = document_colors.as_ref() { &highlighted_ranges diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index 3cd619c0d2fc8d2df87783b4eedae99b339eb3b9..0451be3ee164aa70b549f3502a45f5e52fbafce3 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -66,6 +66,7 @@ use task::RunnableTag; pub use task_context::{ContextLocation, ContextProvider, RunnableRange}; pub use text_diff::{ DiffOptions, apply_diff_patch, line_diff, text_diff, text_diff_with_options, unified_diff, + word_diff_ranges, }; use theme::SyntaxTheme; pub use toolchain::{ diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs index c5b2dc45e163e55c2427badd8d3f4a24dab64916..3bf4e35c6b5cfd7f2a1f221bde4cec181998ab6a 100644 --- a/crates/language/src/language_settings.rs +++ b/crates/language/src/language_settings.rs @@ -153,6 +153,13 @@ pub struct LanguageSettings { pub completions: CompletionSettings, /// Preferred debuggers for this language. pub debuggers: Vec, + /// Whether to enable word diff highlighting in the editor. + /// + /// When enabled, changed words within modified lines are highlighted + /// to show exactly what changed. + /// + /// Default: `true` + pub word_diff_enabled: bool, /// Whether to use tree-sitter bracket queries to detect and colorize the brackets in the editor. pub colorize_brackets: bool, } @@ -595,6 +602,7 @@ impl settings::Settings for AllLanguageSettings { lsp_insert_mode: completions.lsp_insert_mode.unwrap(), }, debuggers: settings.debuggers.unwrap(), + word_diff_enabled: settings.word_diff_enabled.unwrap(), } } diff --git a/crates/language/src/text_diff.rs b/crates/language/src/text_diff.rs index 5a74362d7d3cb2404cc67ed32595a06efd291ca4..1fb94b9f5e87015f317e3e88a963c06c7ea41b70 100644 --- a/crates/language/src/text_diff.rs +++ b/crates/language/src/text_diff.rs @@ -44,6 +44,92 @@ pub fn text_diff(old_text: &str, new_text: &str) -> Vec<(Range, Arc) text_diff_with_options(old_text, new_text, DiffOptions::default()) } +/// Computes word-level diff ranges between two strings. +/// +/// Returns a tuple of (old_ranges, new_ranges) where each vector contains +/// the byte ranges of changed words in the respective text. +/// Whitespace-only changes are excluded from the results. +pub fn word_diff_ranges( + old_text: &str, + new_text: &str, + options: DiffOptions, +) -> (Vec>, Vec>) { + let mut input: InternedInput<&str> = InternedInput::default(); + input.update_before(tokenize(old_text, options.language_scope.clone())); + input.update_after(tokenize(new_text, options.language_scope)); + + let mut old_ranges: Vec> = Vec::new(); + let mut new_ranges: Vec> = Vec::new(); + + diff_internal(&input, |old_byte_range, new_byte_range, _, _| { + for range in split_on_whitespace(old_text, &old_byte_range) { + if let Some(last) = old_ranges.last_mut() + && last.end >= range.start + { + last.end = range.end; + } else { + old_ranges.push(range); + } + } + + for range in split_on_whitespace(new_text, &new_byte_range) { + if let Some(last) = new_ranges.last_mut() + && last.end >= range.start + { + last.end = range.end; + } else { + new_ranges.push(range); + } + } + }); + + (old_ranges, new_ranges) +} + +fn split_on_whitespace(text: &str, range: &Range) -> Vec> { + if range.is_empty() { + return Vec::new(); + } + + let slice = &text[range.clone()]; + let mut ranges = Vec::new(); + let mut offset = 0; + + for line in slice.lines() { + let line_start = offset; + let line_end = line_start + line.len(); + offset = line_end + 1; + let trimmed = line.trim(); + + if !trimmed.is_empty() { + let leading = line.len() - line.trim_start().len(); + let trailing = line.len() - line.trim_end().len(); + let trimmed_start = range.start + line_start + leading; + let trimmed_end = range.start + line_end - trailing; + + let original_line_start = text[..range.start + line_start] + .rfind('\n') + .map(|i| i + 1) + .unwrap_or(0); + let original_line_end = text[range.start + line_start..] + .find('\n') + .map(|i| range.start + line_start + i) + .unwrap_or(text.len()); + let original_line = &text[original_line_start..original_line_end]; + let original_trimmed_start = + original_line_start + (original_line.len() - original_line.trim_start().len()); + let original_trimmed_end = + original_line_end - (original_line.len() - original_line.trim_end().len()); + + if trimmed_start > original_trimmed_start || trimmed_end < original_trimmed_end { + ranges.push(trimmed_start..trimmed_end); + } + } + } + + ranges +} + pub struct DiffOptions { pub language_scope: Option, pub max_word_diff_len: usize, diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index 680e7b1c48a9d858180da95203ed0fc8a9299af2..5fac7dd4587132cd532073e571991018e643faa6 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -152,6 +152,8 @@ pub struct MultiBufferDiffHunk { pub diff_base_byte_range: Range, /// Whether or not this hunk also appears in the 'secondary diff'. pub secondary_status: DiffHunkSecondaryStatus, + /// The word diffs for this hunk. + pub word_diffs: Vec>, } impl MultiBufferDiffHunk { @@ -561,6 +563,7 @@ pub struct MultiBufferSnapshot { } #[derive(Debug, Clone)] +/// A piece of text in the multi-buffer enum DiffTransform { Unmodified { summary: MBTextSummary, @@ -961,6 +964,8 @@ struct MultiBufferCursor<'a, MBD, BD> { cached_region: Option>, } +/// Matches transformations to an item +/// This is essentially a more detailed version of DiffTransform #[derive(Clone)] struct MultiBufferRegion<'a, MBD, BD> { buffer: &'a BufferSnapshot, @@ -3870,11 +3875,31 @@ impl MultiBufferSnapshot { } else { range.end.row + 1 }; + + let word_diffs = (!hunk.base_word_diffs.is_empty() + || !hunk.buffer_word_diffs.is_empty()) + .then(|| { + let hunk_start_offset = + Anchor::in_buffer(excerpt.id, hunk.buffer_range.start).to_offset(self); + + hunk.base_word_diffs + .iter() + .map(|diff| hunk_start_offset + diff.start..hunk_start_offset + diff.end) + .chain( + hunk.buffer_word_diffs + .into_iter() + .map(|diff| Anchor::range_in_buffer(excerpt.id, diff).to_offset(self)), + ) + .collect() + }) + .unwrap_or_default(); + Some(MultiBufferDiffHunk { row_range: MultiBufferRow(range.start.row)..MultiBufferRow(end_row), buffer_id: excerpt.buffer_id, excerpt_id: excerpt.id, buffer_range: hunk.buffer_range.clone(), + word_diffs, diff_base_byte_range: BufferOffset(hunk.diff_base_byte_range.start) ..BufferOffset(hunk.diff_base_byte_range.end), secondary_status: hunk.secondary_status, @@ -6834,6 +6859,7 @@ where TextDimension::add_assign(&mut buffer_end, &buffer_range_len); let start = self.diff_transforms.start().output_dimension.0; let end = self.diff_transforms.end().output_dimension.0; + Some(MultiBufferRegion { buffer, excerpt, diff --git a/crates/multi_buffer/src/multi_buffer_tests.rs b/crates/multi_buffer/src/multi_buffer_tests.rs index 286ed9537d1ec0293b92ae47c3bc548a47f35232..fc2edcac15be72c60309c5c386393ad83c387860 100644 --- a/crates/multi_buffer/src/multi_buffer_tests.rs +++ b/crates/multi_buffer/src/multi_buffer_tests.rs @@ -351,7 +351,7 @@ fn test_excerpt_boundaries_and_clipping(cx: &mut App) { } #[gpui::test] -fn test_diff_boundary_anchors(cx: &mut TestAppContext) { +async fn test_diff_boundary_anchors(cx: &mut TestAppContext) { let base_text = "one\ntwo\nthree\n"; let text = "one\nthree\n"; let buffer = cx.new(|cx| Buffer::local(text, cx)); @@ -393,7 +393,7 @@ fn test_diff_boundary_anchors(cx: &mut TestAppContext) { } #[gpui::test] -fn test_diff_hunks_in_range(cx: &mut TestAppContext) { +async fn test_diff_hunks_in_range(cx: &mut TestAppContext) { let base_text = "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\n"; let text = "one\nfour\nseven\n"; let buffer = cx.new(|cx| Buffer::local(text, cx)); @@ -473,7 +473,7 @@ fn test_diff_hunks_in_range(cx: &mut TestAppContext) { } #[gpui::test] -fn test_editing_text_in_diff_hunks(cx: &mut TestAppContext) { +async fn test_editing_text_in_diff_hunks(cx: &mut TestAppContext) { let base_text = "one\ntwo\nfour\nfive\nsix\nseven\n"; let text = "one\ntwo\nTHREE\nfour\nfive\nseven\n"; let buffer = cx.new(|cx| Buffer::local(text, cx)); @@ -905,7 +905,7 @@ fn test_empty_multibuffer(cx: &mut App) { } #[gpui::test] -fn test_empty_diff_excerpt(cx: &mut TestAppContext) { +async fn test_empty_diff_excerpt(cx: &mut TestAppContext) { let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite)); let buffer = cx.new(|cx| Buffer::local("", cx)); let base_text = "a\nb\nc"; @@ -1235,7 +1235,7 @@ fn test_resolving_anchors_after_replacing_their_excerpts(cx: &mut App) { } #[gpui::test] -fn test_basic_diff_hunks(cx: &mut TestAppContext) { +async fn test_basic_diff_hunks(cx: &mut TestAppContext) { let text = indoc!( " ZERO @@ -1480,7 +1480,7 @@ fn test_basic_diff_hunks(cx: &mut TestAppContext) { } #[gpui::test] -fn test_repeatedly_expand_a_diff_hunk(cx: &mut TestAppContext) { +async fn test_repeatedly_expand_a_diff_hunk(cx: &mut TestAppContext) { let text = indoc!( " one @@ -1994,7 +1994,7 @@ fn test_set_excerpts_for_buffer_rename(cx: &mut TestAppContext) { } #[gpui::test] -fn test_diff_hunks_with_multiple_excerpts(cx: &mut TestAppContext) { +async fn test_diff_hunks_with_multiple_excerpts(cx: &mut TestAppContext) { let base_text_1 = indoc!( " one @@ -3236,6 +3236,7 @@ fn check_multibuffer_edits( fn test_history(cx: &mut App) { let test_settings = SettingsStore::test(cx); cx.set_global(test_settings); + let group_interval: Duration = Duration::from_millis(1); let buffer_1 = cx.new(|cx| { let mut buf = Buffer::local("1234", cx); @@ -3476,7 +3477,7 @@ async fn test_enclosing_indent(cx: &mut TestAppContext) { } #[gpui::test] -fn test_summaries_for_anchors(cx: &mut TestAppContext) { +async fn test_summaries_for_anchors(cx: &mut TestAppContext) { let base_text_1 = indoc!( " bar @@ -3553,7 +3554,7 @@ fn test_summaries_for_anchors(cx: &mut TestAppContext) { } #[gpui::test] -fn test_trailing_deletion_without_newline(cx: &mut TestAppContext) { +async fn test_trailing_deletion_without_newline(cx: &mut TestAppContext) { let base_text_1 = "one\ntwo".to_owned(); let text_1 = "one\n".to_owned(); @@ -4278,8 +4279,10 @@ fn test_random_chunk_bitmaps(cx: &mut App, mut rng: StdRng) { } } -#[gpui::test(iterations = 100)] +#[gpui::test(iterations = 10)] fn test_random_chunk_bitmaps_with_diffs(cx: &mut App, mut rng: StdRng) { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); use buffer_diff::BufferDiff; use util::RandomCharIter; @@ -4435,6 +4438,105 @@ fn test_random_chunk_bitmaps_with_diffs(cx: &mut App, mut rng: StdRng) { } } +fn collect_word_diffs( + base_text: &str, + modified_text: &str, + cx: &mut TestAppContext, +) -> Vec { + let buffer = cx.new(|cx| Buffer::local(modified_text, cx)); + let diff = cx.new(|cx| BufferDiff::new_with_base_text(base_text, &buffer, cx)); + cx.run_until_parked(); + + let multibuffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::singleton(buffer.clone(), cx); + multibuffer.add_diff(diff.clone(), cx); + multibuffer + }); + + multibuffer.update(cx, |multibuffer, cx| { + multibuffer.expand_diff_hunks(vec![Anchor::min()..Anchor::max()], cx); + }); + + let snapshot = multibuffer.read_with(cx, |multibuffer, cx| multibuffer.snapshot(cx)); + let text = snapshot.text(); + + snapshot + .diff_hunks() + .flat_map(|hunk| hunk.word_diffs) + .map(|range| text[range.start.0..range.end.0].to_string()) + .collect() +} + +#[gpui::test] +async fn test_word_diff_simple_replacement(cx: &mut TestAppContext) { + let settings_store = cx.update(|cx| SettingsStore::test(cx)); + cx.set_global(settings_store); + + let base_text = "hello world foo bar\n"; + let modified_text = "hello WORLD foo BAR\n"; + + let word_diffs = collect_word_diffs(base_text, modified_text, cx); + + assert_eq!(word_diffs, vec!["world", "bar", "WORLD", "BAR"]); +} + +#[gpui::test] +async fn test_word_diff_consecutive_modified_lines(cx: &mut TestAppContext) { + let settings_store = cx.update(|cx| SettingsStore::test(cx)); + cx.set_global(settings_store); + + let base_text = "aaa bbb\nccc ddd\n"; + let modified_text = "aaa BBB\nccc DDD\n"; + + let word_diffs = collect_word_diffs(base_text, modified_text, cx); + + assert_eq!( + word_diffs, + vec!["bbb", "ddd", "BBB", "DDD"], + "consecutive modified lines should produce word diffs when line counts match" + ); +} + +#[gpui::test] +async fn test_word_diff_modified_lines_with_deletion_between(cx: &mut TestAppContext) { + let settings_store = cx.update(|cx| SettingsStore::test(cx)); + cx.set_global(settings_store); + + let base_text = "aaa bbb\ndeleted line\nccc ddd\n"; + let modified_text = "aaa BBB\nccc DDD\n"; + + let word_diffs = collect_word_diffs(base_text, modified_text, cx); + + assert_eq!( + word_diffs, + Vec::::new(), + "modified lines with a deleted line between should not produce word diffs" + ); +} + +#[gpui::test] +async fn test_word_diff_disabled(cx: &mut TestAppContext) { + let settings_store = cx.update(|cx| { + let mut settings_store = SettingsStore::test(cx); + settings_store.update_user_settings(cx, |settings| { + settings.project.all_languages.defaults.word_diff_enabled = Some(false); + }); + settings_store + }); + cx.set_global(settings_store); + + let base_text = "hello world\n"; + let modified_text = "hello WORLD\n"; + + let word_diffs = collect_word_diffs(base_text, modified_text, cx); + + assert_eq!( + word_diffs, + Vec::::new(), + "word diffs should be empty when disabled" + ); +} + /// Tests `excerpt_containing` and `excerpts_for_range` (functions mapping multi-buffer text-coordinates to excerpts) #[gpui::test] fn test_excerpts_containment_functions(cx: &mut App) { diff --git a/crates/settings/src/settings_content/language.rs b/crates/settings/src/settings_content/language.rs index 166444c44b28133cfe20933c5b12acc42edb2399..6b8a372269d44935e20426a0b669fed96a33dadf 100644 --- a/crates/settings/src/settings_content/language.rs +++ b/crates/settings/src/settings_content/language.rs @@ -418,6 +418,13 @@ pub struct LanguageSettingsContent { /// /// Default: [] pub debuggers: Option>, + /// Whether to enable word diff highlighting in the editor. + /// + /// When enabled, changed words within modified lines are highlighted + /// to show exactly what changed. + /// + /// Default: true + pub word_diff_enabled: Option, /// Whether to use tree-sitter bracket queries to detect and colorize the brackets in the editor. /// /// Default: false diff --git a/crates/settings/src/settings_content/theme.rs b/crates/settings/src/settings_content/theme.rs index f089b076fbdf404589057b1001b232e9fcb2ee79..49942634af3da9f7009ba02ca6cbf79c30ddaa13 100644 --- a/crates/settings/src/settings_content/theme.rs +++ b/crates/settings/src/settings_content/theme.rs @@ -861,6 +861,14 @@ pub struct ThemeColorsContent { #[serde(rename = "version_control.ignored")] pub version_control_ignored: Option, + /// Color for added words in word diffs. + #[serde(rename = "version_control.word_added")] + pub version_control_word_added: Option, + + /// Color for deleted words in word diffs. + #[serde(rename = "version_control.word_deleted")] + pub version_control_word_deleted: Option, + /// Background color for row highlights of "ours" regions in merge conflicts. #[serde(rename = "version_control.conflict_marker.ours")] pub version_control_conflict_marker_ours: Option, diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 7ba07395964266e303965733bdccda42ba7df60e..0a4e249d60c6888d9a950dcc5be4600d0047ce00 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -490,6 +490,7 @@ impl VsCodeSettings { .flat_map(|n| n.as_u64().map(|n| n as usize)) .collect() }), + word_diff_enabled: None, } } diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index d2c01c626c114d4cb4874265f0725ac834d1a381..fd1bbbcc6e0be6abcfbbdeeb85c0c33203db5ee1 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -6981,6 +6981,25 @@ fn language_settings_data() -> Vec { files: USER | PROJECT, }), SettingsPageItem::SectionHeader("Miscellaneous"), + SettingsPageItem::SettingItem(SettingItem { + title: "Word Diff Enabled", + description: "Whether to enable word diff highlighting in the editor. When enabled, changed words within modified lines are highlighted to show exactly what changed.", + field: Box::new(SettingField { + json_path: Some("languages.$(language).word_diff_enabled"), + pick: |settings_content| { + language_settings_field(settings_content, |language| { + language.word_diff_enabled.as_ref() + }) + }, + write: |settings_content, value| { + language_settings_field_mut(settings_content, value, |language, value| { + language.word_diff_enabled = value; + }) + }, + }), + metadata: None, + files: USER | PROJECT, + }), SettingsPageItem::SettingItem(SettingItem { title: "Debuggers", description: "Preferred debuggers for this language.", diff --git a/crates/theme/src/default_colors.rs b/crates/theme/src/default_colors.rs index a9cd163b8c634f6c3fd8061164b72f8b54127c81..50da8c72b63443f2c70df59ccb9f5f5caf777ca8 100644 --- a/crates/theme/src/default_colors.rs +++ b/crates/theme/src/default_colors.rs @@ -9,11 +9,17 @@ pub(crate) fn neutral() -> ColorScaleSet { } const ADDED_COLOR: Hsla = Hsla { - h: 142. / 360., - s: 0.68, - l: 0.45, + h: 134. / 360., + s: 0.55, + l: 0.40, a: 1.0, }; +const WORD_ADDED_COLOR: Hsla = Hsla { + h: 134. / 360., + s: 0.55, + l: 0.40, + a: 0.35, +}; const MODIFIED_COLOR: Hsla = Hsla { h: 48. / 360., s: 0.76, @@ -21,11 +27,17 @@ const MODIFIED_COLOR: Hsla = Hsla { a: 1.0, }; const REMOVED_COLOR: Hsla = Hsla { - h: 355. / 360., - s: 0.65, - l: 0.65, + h: 350. / 360., + s: 0.88, + l: 0.25, a: 1.0, }; +const WORD_DELETED_COLOR: Hsla = Hsla { + h: 350. / 360., + s: 0.88, + l: 0.25, + a: 0.80, +}; /// The default colors for the theme. /// @@ -152,6 +164,8 @@ impl ThemeColors { version_control_renamed: MODIFIED_COLOR, version_control_conflict: orange().light().step_12(), version_control_ignored: gray().light().step_12(), + version_control_word_added: WORD_ADDED_COLOR, + version_control_word_deleted: WORD_DELETED_COLOR, version_control_conflict_marker_ours: green().light().step_10().alpha(0.5), version_control_conflict_marker_theirs: blue().light().step_10().alpha(0.5), vim_normal_background: system.transparent, @@ -287,6 +301,8 @@ impl ThemeColors { version_control_renamed: MODIFIED_COLOR, version_control_conflict: orange().dark().step_12(), version_control_ignored: gray().dark().step_12(), + version_control_word_added: WORD_ADDED_COLOR, + version_control_word_deleted: WORD_DELETED_COLOR, version_control_conflict_marker_ours: green().dark().step_10().alpha(0.5), version_control_conflict_marker_theirs: blue().dark().step_10().alpha(0.5), vim_normal_background: system.transparent, diff --git a/crates/theme/src/fallback_themes.rs b/crates/theme/src/fallback_themes.rs index ae120165f23095266cf92fd33a1cd1ccb88fe309..2351ed6bcbd2297ebb5a173d17c095d92bb27c20 100644 --- a/crates/theme/src/fallback_themes.rs +++ b/crates/theme/src/fallback_themes.rs @@ -71,11 +71,17 @@ pub(crate) fn zed_default_dark() -> Theme { let yellow = hsla(39. / 360., 67. / 100., 69. / 100., 1.0); const ADDED_COLOR: Hsla = Hsla { - h: 142. / 360., - s: 0.68, - l: 0.45, + h: 134. / 360., + s: 0.55, + l: 0.40, a: 1.0, }; + const WORD_ADDED_COLOR: Hsla = Hsla { + h: 134. / 360., + s: 0.55, + l: 0.40, + a: 0.35, + }; const MODIFIED_COLOR: Hsla = Hsla { h: 48. / 360., s: 0.76, @@ -83,11 +89,17 @@ pub(crate) fn zed_default_dark() -> Theme { a: 1.0, }; const REMOVED_COLOR: Hsla = Hsla { - h: 355. / 360., - s: 0.65, - l: 0.65, + h: 350. / 360., + s: 0.88, + l: 0.25, a: 1.0, }; + const WORD_DELETED_COLOR: Hsla = Hsla { + h: 350. / 360., + s: 0.88, + l: 0.25, + a: 0.80, + }; let player = PlayerColors::dark(); Theme { @@ -231,6 +243,8 @@ pub(crate) fn zed_default_dark() -> Theme { version_control_renamed: MODIFIED_COLOR, version_control_conflict: crate::orange().light().step_12(), version_control_ignored: crate::gray().light().step_12(), + version_control_word_added: WORD_ADDED_COLOR, + version_control_word_deleted: WORD_DELETED_COLOR, version_control_conflict_marker_ours: crate::green().light().step_12().alpha(0.5), version_control_conflict_marker_theirs: crate::blue().light().step_12().alpha(0.5), diff --git a/crates/theme/src/schema.rs b/crates/theme/src/schema.rs index c4ed624bf642e0820fd9187224f96e2acfa92018..9c9cfbffef681890a802d21b8bcff85d358a64b8 100644 --- a/crates/theme/src/schema.rs +++ b/crates/theme/src/schema.rs @@ -744,6 +744,14 @@ pub fn theme_colors_refinement( .and_then(|color| try_parse_color(color).ok()) // Fall back to `conflict`, for backwards compatibility. .or(status_colors.ignored), + version_control_word_added: this + .version_control_word_added + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + version_control_word_deleted: this + .version_control_word_deleted + .as_ref() + .and_then(|color| try_parse_color(color).ok()), #[allow(deprecated)] version_control_conflict_marker_ours: this .version_control_conflict_marker_ours diff --git a/crates/theme/src/styles/colors.rs b/crates/theme/src/styles/colors.rs index 179d02b91684410bb641893e87759bd30cc73b36..c6766ca955700e2b7c3cd0e86ab16535fca8d852 100644 --- a/crates/theme/src/styles/colors.rs +++ b/crates/theme/src/styles/colors.rs @@ -300,7 +300,10 @@ pub struct ThemeColors { pub version_control_conflict: Hsla, /// Represents an ignored entry in version control systems. pub version_control_ignored: Hsla, - + /// Represents an added word in a word diff. + pub version_control_word_added: Hsla, + /// Represents a deleted word in a word diff. + pub version_control_word_deleted: Hsla, /// Represents the "ours" region of a merge conflict. pub version_control_conflict_marker_ours: Hsla, /// Represents the "theirs" region of a merge conflict.