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
### 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