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.