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 <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>
This commit is contained in:
co-authored by
David Kleingeld
Cole Miller
cameron
Lukas Wirth
parent
2df5993eb0
commit
464c0be2b7
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user