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 99c8bb19e8..6e558cbf39 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 8fe4dbcaad..b3e77414fe 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 d321c176a5..f53019744e 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 6849cd05dc..d9d7a37e99 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 1be21f3a0f..6249ae418c 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 5f1736e450..55de3f968b 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