git: Add word diff highlighting (#43269)

Anthony Eid , David Kleingeld , Cole Miller , cameron , and Lukas Wirth created

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
<img width="1502" height="430" alt="image"
src="https://github.com/user-attachments/assets/1a8d5b71-449e-44cd-bc87-d6b65bfca545"
/>

### 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 <davidsk@zed.dev>
Co-authored-by: Cole Miller <cole@zed.dev>
Co-authored-by: cameron <cameron.studdstreet@gmail.com>
Co-authored-by: Lukas Wirth <lukas@zed.dev>

Change summary

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 +++++++++++++++-
crates/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(-)

Detailed changes

Cargo.lock 🔗

@@ -2423,6 +2423,7 @@ dependencies = [
  "rand 0.9.2",
  "rope",
  "serde_json",
+ "settings",
  "sum_tree",
  "text",
  "unindent",

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"] }

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.

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

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

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<TaskLabel> = 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<Entity<BufferDiff>>,
 }
 
@@ -31,6 +36,7 @@ pub struct BufferDiffSnapshot {
 #[derive(Clone)]
 struct BufferDiffInner {
     hunks: SumTree<InternalDiffHunk>,
+    // Used for making staging mo
     pending_hunks: SumTree<PendingHunk>,
     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<usize>,
     pub secondary_status: DiffHunkSecondaryStatus,
+    // Anchors representing the word diff locations in the active buffer
+    pub buffer_word_diffs: Vec<Range<Anchor>>,
+    // Offsets relative to the start of the deleted diff that represent word diff locations
+    pub base_word_diffs: Vec<Range<usize>>,
 }
 
 /// 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<Anchor>,
     diff_base_byte_range: Range<usize>,
+    base_word_diffs: Vec<Range<usize>>,
+    buffer_word_diffs: Vec<Range<Anchor>>,
 }
 
 #[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<Output = Self> + 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::<Point, _, _>(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<dyn File>>,
+    language: Option<LanguageName>,
+    language_scope: Option<language::LanguageScope>,
+    cx: &App,
+) -> Option<DiffOptions> {
+    #[cfg(any(test, feature = "test-support"))]
+    {
+        if !cx.has_global::<settings::SettingsStore>() {
+            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<String>, Rope)>,
     buffer: text::BufferSnapshot,
+    diff_options: Option<DiffOptions>,
 ) -> SumTree<InternalDiffHunk> {
     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,
     }
 }
 

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,

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<DisplayRow>,
         multi_buffer_range: Range<Anchor>,
         status: DiffHunkStatus,
+        word_diffs: Vec<Range<MultiBufferOffset>>,
     },
 }
 
@@ -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,

crates/editor/src/element.rs 🔗

@@ -5572,6 +5572,50 @@ impl EditorElement {
         }
     }
 
+    fn layout_word_diff_highlights(
+        display_hunks: &[(DisplayDiffHunk, Option<Hitbox>)],
+        row_infos: &[RowInfo],
+        start_row: DisplayRow,
+        snapshot: &EditorSnapshot,
+        highlighted_ranges: &mut Vec<(Range<DisplayPoint>, 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<DisplayRow>,
@@ -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::<Vec<RowInfo>>();
@@ -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

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::{

crates/language/src/language_settings.rs 🔗

@@ -153,6 +153,13 @@ pub struct LanguageSettings {
     pub completions: CompletionSettings,
     /// Preferred debuggers for this language.
     pub debuggers: Vec<String>,
+    /// 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(),
             }
         }
 

crates/language/src/text_diff.rs 🔗

@@ -44,6 +44,92 @@ pub fn text_diff(old_text: &str, new_text: &str) -> Vec<(Range<usize>, Arc<str>)
     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<Range<usize>>, Vec<Range<usize>>) {
+    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<Range<usize>> = Vec::new();
+    let mut new_ranges: Vec<Range<usize>> = 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<usize>) -> Vec<Range<usize>> {
+    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<LanguageScope>,
     pub max_word_diff_len: usize,

crates/multi_buffer/src/multi_buffer.rs 🔗

@@ -152,6 +152,8 @@ pub struct MultiBufferDiffHunk {
     pub diff_base_byte_range: Range<BufferOffset>,
     /// 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<Range<MultiBufferOffset>>,
 }
 
 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<MultiBufferRegion<'a, MBD, BD>>,
 }
 
+/// 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,

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<String> {
+    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::<String>::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::<String>::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) {

crates/settings/src/settings_content/language.rs 🔗

@@ -418,6 +418,13 @@ pub struct LanguageSettingsContent {
     ///
     /// Default: []
     pub debuggers: Option<Vec<String>>,
+    /// 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<bool>,
     /// Whether to use tree-sitter bracket queries to detect and colorize the brackets in the editor.
     ///
     /// Default: false

crates/settings/src/settings_content/theme.rs 🔗

@@ -861,6 +861,14 @@ pub struct ThemeColorsContent {
     #[serde(rename = "version_control.ignored")]
     pub version_control_ignored: Option<String>,
 
+    /// Color for added words in word diffs.
+    #[serde(rename = "version_control.word_added")]
+    pub version_control_word_added: Option<String>,
+
+    /// Color for deleted words in word diffs.
+    #[serde(rename = "version_control.word_deleted")]
+    pub version_control_word_deleted: Option<String>,
+
     /// 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<String>,

crates/settings_ui/src/page_data.rs 🔗

@@ -6981,6 +6981,25 @@ fn language_settings_data() -> Vec<SettingsPageItem> {
             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.",

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,

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

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

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.