Merge pull request #5813 from Hmbown/feat/diff-intraline-20260901

feat(tui): diff cards emphasise the changed words within a line
This commit is contained in:
Hunter Bown
2026-09-01 21:01:11 -07:00
committed by GitHub
3 changed files with 580 additions and 32 deletions

2
Cargo.lock generated
View File

@@ -550,6 +550,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530"
dependencies = [
"memchr",
"regex-automata",
"serde_core",
]
@@ -5144,6 +5145,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f66ca1f7aca2474dc10c942eb22feffc897735f54cd1db90138c2fddb490987"
dependencies = [
"bstr",
"unicode-segmentation",
]
[[package]]

View File

@@ -70,7 +70,7 @@ rusqlite.workspace = true
rmcp = { version = "2.2.0", default-features = false, features = ["auth", "client"] }
rustls.workspace = true
qrcode = { version = "0.14", default-features = false }
similar = "3"
similar = { version = "3", features = ["unicode"] }
ansi-to-tui = { version = "8.0.1", default-features = false }
syntect = { version = "5.2", default-features = false, features = ["default-fancy"] }
serde.workspace = true

View File

@@ -2,12 +2,24 @@
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use similar::{ChangeTag, TextDiff};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use crate::palette;
const LINE_NUMBER_WIDTH: usize = 4;
/// Below this word-level similarity a replaced line pair is rewritten, not
/// edited, and emphasising the changed words would light up the whole row.
const INTRALINE_MIN_RATIO: f32 = 0.5;
/// Pairing stops here so a huge hunk cannot buffer word segments for every
/// replaced line; longer runs render line by line.
const INTRALINE_MAX_RUN: usize = 64;
/// A run of text inside a changed line and whether it is part of the change.
type Segment = (String, bool);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiffFileSummary {
pub path: String,
@@ -49,15 +61,19 @@ pub fn render_diff_bounded(diff: &str, width: u16, max_body_rows: usize) -> Boun
/// this form so the bounded preview budget is spent on the actual red/green
/// evidence instead of a second, generic summary.
/// Render at most `max_rows` body rows while counting every omitted wrapped
/// row. Allocation is bounded by the retained preview plus one source line's
/// wrapped representation, rather than by the size of the complete diff.
/// row. Allocation is bounded by the retained preview, one source line's
/// wrapped representation, and the current `-`/`+` run: its line slices
/// (borrowed, pointer-sized) plus word segments for at most
/// `INTRALINE_MAX_RUN` replaced pairs — never by the size of the complete
/// diff.
#[must_use]
pub fn render_diff_body_bounded(diff: &str, width: u16, max_rows: usize) -> BoundedDiffRender {
let mut collector = BoundedLineCollector::new(max_rows);
let mut old_line: Option<usize> = None;
let mut new_line: Option<usize> = None;
for raw in diff.lines() {
let mut lines = diff.lines().peekable();
'line: while let Some(raw) = lines.next() {
if raw.starts_with("diff --git") || raw.starts_with("index ") {
collector.extend(render_header_line(raw, width));
continue;
@@ -77,7 +93,12 @@ pub fn render_diff_body_bounded(diff: &str, width: u16, max_rows: usize) -> Boun
continue;
}
if raw.starts_with('+') && !raw.starts_with("+++") {
if is_no_newline_marker(raw) {
collector.extend(render_header_line(raw, width));
continue;
}
if is_added(raw) {
let content = raw.trim_start_matches('+');
collector.extend(render_diff_line(
content,
@@ -85,9 +106,8 @@ pub fn render_diff_body_bounded(diff: &str, width: u16, max_rows: usize) -> Boun
old_line,
new_line,
'+',
Style::default()
.fg(palette::DIFF_ADDED)
.bg(palette::DIFF_ADDED_BG),
added_style(),
None,
));
if let Some(line) = new_line.as_mut() {
*line = line.saturating_add(1);
@@ -95,20 +115,137 @@ pub fn render_diff_body_bounded(diff: &str, width: u16, max_rows: usize) -> Boun
continue;
}
if raw.starts_with('-') && !raw.starts_with("---") {
let content = raw.trim_start_matches('-');
collector.extend(render_diff_line(
content,
width,
old_line,
new_line,
'-',
Style::default()
.fg(palette::STATUS_ERROR)
.bg(palette::DIFF_DELETED_BG),
));
if let Some(line) = old_line.as_mut() {
*line = line.saturating_add(1);
if is_deleted(raw) {
// A deleted run followed by an added run of the same length is a
// set of replaced lines: emphasise the words that changed within
// each pair. Any other shape renders line by line as before.
let mut removed = vec![raw.trim_start_matches('-')];
let mut added: Vec<&str> = Vec::new();
let mut removed_markers: Vec<(usize, &str)> = Vec::new();
let mut added_markers: Vec<(usize, &str)> = Vec::new();
loop {
if let Some(marker) = lines.next_if(|next| is_no_newline_marker(next)) {
if added.is_empty() {
removed_markers.push((removed.len() - 1, marker));
} else {
added_markers.push((added.len() - 1, marker));
}
} else if added.is_empty()
&& let Some(next) = lines.next_if(|next| is_deleted(next))
{
if removed.len() < INTRALINE_MAX_RUN {
removed.push(next.trim_start_matches('-'));
} else {
flush_plain_run(
&mut collector,
&removed,
&removed_markers,
'-',
width,
&mut old_line,
&mut new_line,
);
render_plain_diff_line(
&mut collector,
next.trim_start_matches('-'),
width,
&mut old_line,
&mut new_line,
'-',
);
continue 'line;
}
} else if let Some(next) = lines.next_if(|next| is_added(next)) {
if added.len() < INTRALINE_MAX_RUN {
added.push(next.trim_start_matches('+'));
} else {
flush_plain_run(
&mut collector,
&removed,
&removed_markers,
'-',
width,
&mut old_line,
&mut new_line,
);
flush_plain_run(
&mut collector,
&added,
&added_markers,
'+',
width,
&mut old_line,
&mut new_line,
);
render_plain_diff_line(
&mut collector,
next.trim_start_matches('+'),
width,
&mut old_line,
&mut new_line,
'+',
);
continue 'line;
}
} else {
break;
}
}
let pairs: Vec<Option<(Vec<Segment>, Vec<Segment>)>> =
if removed.len() == added.len() && removed.len() <= INTRALINE_MAX_RUN {
removed
.iter()
.zip(&added)
.map(|(old, new)| intraline_segments(old, new))
.collect()
} else {
Vec::new()
};
for (idx, content) in removed.iter().enumerate() {
let emphasis = pairs
.get(idx)
.and_then(|pair| pair.as_ref().map(|(old, _)| old.as_slice()));
collector.extend(render_diff_line(
content,
width,
old_line,
new_line,
'-',
deleted_style(),
emphasis,
));
if let Some(line) = old_line.as_mut() {
*line = line.saturating_add(1);
}
for &(marker_idx, marker) in &removed_markers {
if marker_idx == idx {
collector.extend(render_header_line(marker, width));
}
}
}
for (idx, content) in added.iter().enumerate() {
let emphasis = pairs
.get(idx)
.and_then(|pair| pair.as_ref().map(|(_, new)| new.as_slice()));
collector.extend(render_diff_line(
content,
width,
old_line,
new_line,
'+',
added_style(),
emphasis,
));
if let Some(line) = new_line.as_mut() {
*line = line.saturating_add(1);
}
for &(marker_idx, marker) in &added_markers {
if marker_idx == idx {
collector.extend(render_header_line(marker, width));
}
}
}
continue;
}
@@ -122,6 +259,7 @@ pub fn render_diff_body_bounded(diff: &str, width: u16, max_rows: usize) -> Boun
new_line,
' ',
Style::default().fg(palette::TEXT_PRIMARY),
None,
));
if let Some(line) = old_line.as_mut() {
*line = line.saturating_add(1);
@@ -338,6 +476,196 @@ fn render_hunk_header(line: &str, width: u16) -> Vec<Line<'static>> {
wrap_with_style(line, style, width)
}
fn is_added(raw: &str) -> bool {
raw.starts_with('+') && !raw.starts_with("+++")
}
fn is_deleted(raw: &str) -> bool {
raw.starts_with('-') && !raw.starts_with("---")
}
fn is_no_newline_marker(raw: &str) -> bool {
raw.starts_with("\\ No newline")
}
fn added_style() -> Style {
Style::default()
.fg(palette::DIFF_ADDED)
.bg(palette::DIFF_ADDED_BG)
}
fn deleted_style() -> Style {
Style::default()
.fg(palette::STATUS_ERROR)
.bg(palette::DIFF_DELETED_BG)
}
/// Split a replaced line pair into word runs (unicode word boundaries, so
/// punctuation stays out of the emphasis), flagging the runs that differ.
///
/// Returns `None` when the pair shares too few *words* to read as an edit —
/// whitespace and punctuation tokens do not count, so two lines that agree
/// only on syntax are painted whole, the way they always were.
fn intraline_segments(old: &str, new: &str) -> Option<(Vec<Segment>, Vec<Segment>)> {
let diff = TextDiff::from_unicode_words(old, new);
let mut old_segments: Vec<Segment> = Vec::new();
let mut new_segments: Vec<Segment> = Vec::new();
let mut changed = false;
let mut shared_words = 0usize;
let mut total_words = 0usize;
for change in diff.iter_all_changes() {
let text = change.value();
let is_word = text.chars().any(char::is_alphanumeric);
match change.tag() {
ChangeTag::Equal => {
if is_word {
shared_words += 2;
total_words += 2;
}
push_segment(&mut old_segments, text, false);
push_segment(&mut new_segments, text, false);
}
ChangeTag::Delete => {
changed = true;
total_words += usize::from(is_word);
push_segment(&mut old_segments, text, true);
}
ChangeTag::Insert => {
changed = true;
total_words += usize::from(is_word);
push_segment(&mut new_segments, text, true);
}
}
}
if !changed || total_words == 0 {
return None;
}
let ratio = shared_words as f32 / total_words as f32;
(ratio >= INTRALINE_MIN_RATIO).then_some((old_segments, new_segments))
}
fn push_segment(segments: &mut Vec<Segment>, text: &str, emphasised: bool) {
match segments.last_mut() {
Some((run, flag)) if *flag == emphasised => run.push_str(text),
_ => segments.push((text.to_string(), emphasised)),
}
}
/// Paint the wrapped chunks of a changed line, mapping every non-whitespace
/// character back to its source flag by position. `wrap_text` only drops,
/// collapses, or re-inserts whitespace (the indent lead comes back on every
/// continuation chunk), so the non-whitespace sequence is the invariant.
/// Whitespace joins an emphasised run only when both its neighbours are in
/// it. Returns `None` if the invariant ever fails, and the caller paints the
/// whole line plainly rather than emphasising the wrong word.
fn emphasised_chunks(
chunks: &[String],
style: Style,
segments: &[Segment],
) -> Option<Vec<Vec<Span<'static>>>> {
let source: Vec<(char, bool)> = segments
.iter()
.flat_map(|(run, flag)| {
run.chars()
.filter(|ch| !ch.is_whitespace())
.map(move |ch| (ch, *flag))
})
.collect();
let emphasis = style.add_modifier(Modifier::BOLD | Modifier::REVERSED);
let mut cursor = 0usize;
let mut out = Vec::with_capacity(chunks.len());
for chunk in chunks {
let mut spans = Vec::new();
let mut run = String::new();
let mut run_flag = false;
let mut prev_flag = false;
for ch in chunk.chars() {
let flag = if ch.is_whitespace() {
prev_flag && source.get(cursor).is_some_and(|(_, next)| *next)
} else {
let (expected, flag) = *source.get(cursor)?;
debug_assert_eq!(
expected, ch,
"wrapped chunk diverged from its source line at {cursor}"
);
if expected != ch {
return None;
}
cursor += 1;
prev_flag = flag;
flag
};
if flag != run_flag && !run.is_empty() {
let painted = if run_flag { emphasis } else { style };
spans.push(Span::styled(std::mem::take(&mut run), painted));
}
run_flag = flag;
run.push(ch);
}
if !run.is_empty() {
let painted = if run_flag { emphasis } else { style };
spans.push(Span::styled(run, painted));
}
out.push(spans);
}
debug_assert_eq!(
cursor,
source.len(),
"wrapped chunks did not consume the whole source line"
);
(cursor == source.len()).then_some(out)
}
fn render_plain_diff_line(
collector: &mut BoundedLineCollector,
content: &str,
width: u16,
old_line: &mut Option<usize>,
new_line: &mut Option<usize>,
marker: char,
) {
let style = match marker {
'-' => deleted_style(),
'+' => added_style(),
_ => Style::default().fg(palette::TEXT_PRIMARY),
};
collector.extend(render_diff_line(
content, width, *old_line, *new_line, marker, style, None,
));
match marker {
'-' => {
if let Some(line) = old_line.as_mut() {
*line = line.saturating_add(1);
}
}
'+' => {
if let Some(line) = new_line.as_mut() {
*line = line.saturating_add(1);
}
}
_ => {}
}
}
fn flush_plain_run(
collector: &mut BoundedLineCollector,
lines: &[&str],
markers: &[(usize, &str)],
sign: char,
width: u16,
old_line: &mut Option<usize>,
new_line: &mut Option<usize>,
) {
for (idx, content) in lines.iter().enumerate() {
render_plain_diff_line(collector, content, width, old_line, new_line, sign);
for &(marker_idx, marker) in markers {
if marker_idx == idx {
collector.extend(render_header_line(marker, width));
}
}
}
}
fn render_diff_line(
content: &str,
width: u16,
@@ -345,25 +673,27 @@ fn render_diff_line(
new_line: Option<usize>,
marker: char,
style: Style,
emphasis: Option<&[Segment]>,
) -> Vec<Line<'static>> {
let prefix = format_line_numbers(old_line, new_line, marker);
let prefix_width = prefix.width();
let available = width.saturating_sub(prefix_width as u16).max(1) as usize;
let wrapped = wrap_text(content, available);
let mut painted = emphasis.and_then(|segments| emphasised_chunks(&wrapped, style, segments));
let mut out = Vec::new();
for (idx, chunk) in wrapped.into_iter().enumerate() {
if idx == 0 {
out.push(Line::from(vec![
Span::styled(prefix.clone(), Style::default().fg(palette::TEXT_MUTED)),
Span::styled(chunk, style),
]));
let gutter = if idx == 0 {
Span::styled(prefix.clone(), Style::default().fg(palette::TEXT_MUTED))
} else {
out.push(Line::from(vec![
Span::raw(" ".repeat(prefix_width)),
Span::styled(chunk, style),
]));
Span::raw(" ".repeat(prefix_width))
};
let mut spans = vec![gutter];
match painted.as_mut() {
Some(rows) => spans.append(&mut rows[idx]),
None => spans.push(Span::styled(chunk, style)),
}
out.push(Line::from(spans));
}
if out.is_empty() {
@@ -491,7 +821,223 @@ mod tests {
}
fn diff_content_text(line: &Line<'static>) -> Option<String> {
line.spans.get(1).map(|span| span.content.to_string())
line.spans
.get(1..)
.filter(|rest| !rest.is_empty())
.map(|rest| rest.iter().map(|span| span.content.as_ref()).collect())
}
fn emphasised_text(line: &Line<'static>) -> String {
line.spans
.iter()
.filter(|span| span.style.add_modifier.contains(Modifier::REVERSED))
.map(|span| span.content.as_ref())
.collect()
}
fn rendered_body(diff: &str, width: u16) -> Vec<Line<'static>> {
render_diff_body_bounded(diff, width, usize::MAX).lines
}
/// Text between the gutter and the first emphasised span, so a test can
/// pin where on the row the emphasis starts.
fn text_before_emphasis(line: &Line<'static>) -> String {
line.spans
.iter()
.skip(1)
.take_while(|span| !span.style.add_modifier.contains(Modifier::REVERSED))
.map(|span| span.content.as_ref())
.collect()
}
fn emphasis_per_row(diff: &str, width: u16) -> Vec<String> {
rendered_body(diff, width)
.iter()
.skip(1) // hunk header
.map(emphasised_text)
.collect()
}
#[test]
fn replaced_line_pair_emphasises_only_the_changed_words() {
let diff = "\
@@ -1,1 +1,1 @@
- let total = price * quantity;
+ let total = price * count;
";
let rendered = rendered_body(diff, 80);
assert_eq!(emphasis_per_row(diff, 80), vec!["quantity", "count"]);
let content = rendered
.iter()
.filter_map(diff_content_text)
.collect::<Vec<_>>();
assert_eq!(
content,
vec![
" let total = price * quantity;".to_string(),
" let total = price * count;".to_string()
],
"emphasis must not alter the line text"
);
}
#[test]
fn every_pair_in_a_replaced_run_is_emphasised_in_order() {
let diff = "\
@@ -1,2 +1,2 @@
-let alpha = 1;
-let beta = 2;
+let alpha = 10;
+let beta = 20;
";
assert_eq!(emphasis_per_row(diff, 80), vec!["1", "2", "10", "20"]);
}
#[test]
fn a_rewrite_inside_a_replaced_run_stays_plain_while_its_neighbour_is_emphasised() {
let diff = "\
@@ -1,2 +1,2 @@
-let x = 1;
-fn old_name() {}
+let x = 2;
+return None;
";
assert_eq!(emphasis_per_row(diff, 80), vec!["1", "", "2", ""]);
}
#[test]
fn lines_sharing_only_syntax_are_not_emphasised() {
// Whitespace, `=` and `;` agree; every word differs but `let`.
let diff = "\
@@ -1,1 +1,1 @@
-let alpha = beta;
+let gamma = delta;
";
assert_eq!(emphasis_per_row(diff, 80), vec!["", ""]);
}
#[test]
fn unequal_runs_render_without_emphasis() {
let unequal = "\
@@ -1,2 +1,1 @@
-let a = 1;
-let b = 2;
+let a = 1; let b = 2;
";
assert_eq!(emphasis_per_row(unequal, 80), vec!["", "", ""]);
}
#[test]
fn emphasis_follows_unicode_word_boundaries() {
let diff = "\
@@ -1,1 +1,1 @@
-café au lait, naïve
+café au thé, naïve
";
assert_eq!(emphasis_per_row(diff, 80), vec!["lait", "thé"]);
}
#[test]
fn no_newline_marker_does_not_break_pairing() {
let diff = "\
@@ -1,1 +1,1 @@
-old line
\\ No newline at end of file
+new line
\\ No newline at end of file
";
let rendered = rendered_body(diff, 80);
let rows: Vec<String> = rendered.iter().map(emphasised_text).collect();
assert_eq!(rows, vec!["", "old", "", "new", ""]);
let text: Vec<String> = rendered.iter().map(line_text).collect();
assert_eq!(
text.iter()
.filter(|row| row.contains("No newline at end of file"))
.count(),
2,
"markers are still shown: {text:?}"
);
}
#[test]
fn pure_insertion_no_newline_marker_is_a_header_row() {
let diff = "\
@@ -1,0 +1,1 @@
+inserted
\\ No newline at end of file
context
";
let rendered = rendered_body(diff, 80);
let marker = rendered
.iter()
.find(|line| line_text(line).contains("No newline at end of file"))
.expect("marker row");
assert!(marker.spans[0].style.add_modifier.contains(Modifier::BOLD));
let context = rendered
.iter()
.find(|line| line_text(line).contains("context"))
.expect("context row");
assert!(
line_text(context).starts_with(" 1 2 "),
"context numbering was changed by the marker: {context:?}"
);
}
#[test]
fn emphasis_lands_on_the_right_chunk_of_a_wrapped_indented_line() {
// Gutter is 12 columns; width 40 leaves 28 for text. With the 8-space
// indent re-inserted on every continuation chunk, the line wraps as
// " let total =" / " compute(alpha, beta," /
// " gamma, quantity);" — the change sits on the third chunk.
let diff = "\
@@ -1,1 +1,1 @@
- let total = compute(alpha, beta, gamma, quantity);
+ let total = compute(alpha, beta, gamma, count);
";
let rendered = rendered_body(diff, 40);
let rows: Vec<String> = rendered.iter().map(emphasised_text).collect();
assert_eq!(rows, vec!["", "", "", "quantity", "", "", "count"]);
assert_eq!(
diff_content_text(&rendered[3]).as_deref(),
Some(" gamma, quantity);")
);
assert_eq!(text_before_emphasis(&rendered[3]), " gamma, ");
assert_eq!(text_before_emphasis(&rendered[6]), " gamma, ");
// Row text is untouched by the emphasis.
assert_eq!(
diff_content_text(&rendered[6]).as_deref(),
Some(" gamma, count);")
);
}
#[test]
fn emphasis_survives_wrapping_without_changing_text() {
let diff = "\
@@ -1,1 +1,1 @@
-alpha beta gamma delta epsilon zeta eta theta iota kappa
+alpha beta gamma delta epsilon zeta eta THETA iota kappa
";
let rendered = rendered_body(diff, 30);
let rows: Vec<String> = rendered.iter().map(emphasised_text).collect();
// 18 text columns: "alpha beta gamma" / "delta epsilon zeta" /
// "eta theta iota" / "kappa" — the change sits on the third chunk.
assert_eq!(rows, vec!["", "", "", "theta", "", "", "", "THETA", ""]);
assert_eq!(text_before_emphasis(&rendered[3]), "eta ");
assert_eq!(text_before_emphasis(&rendered[7]), "eta ");
// Wrapping drops the space at each break; everything else survives.
let body: String = rendered
.iter()
.skip(1)
.filter_map(diff_content_text)
.collect::<String>()
.split_whitespace()
.collect();
assert_eq!(
body,
"alphabetagammadeltaepsilonzetaetathetaiotakappa\
alphabetagammadeltaepsilonzetaetaTHETAiotakappa"
);
}
#[test]