diff --git a/Cargo.lock b/Cargo.lock index 25bdfbb37..b46c27ffb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 984c4e0c3..6da5248a1 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -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 diff --git a/crates/tui/src/tui/diff_render.rs b/crates/tui/src/tui/diff_render.rs index 49c25343e..76e1a1e4a 100644 --- a/crates/tui/src/tui/diff_render.rs +++ b/crates/tui/src/tui/diff_render.rs @@ -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 = None; let mut new_line: Option = 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, Vec)>> = + 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> { 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, Vec)> { + let diff = TextDiff::from_unicode_words(old, new); + let mut old_segments: Vec = Vec::new(); + let mut new_segments: Vec = 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, 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>>> { + 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, + new_line: &mut Option, + 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, + new_line: &mut Option, +) { + 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, marker: char, style: Style, + emphasis: Option<&[Segment]>, ) -> Vec> { 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 { - 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> { + 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 { + 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::>(); + 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 = rendered.iter().map(emphasised_text).collect(); + assert_eq!(rows, vec!["", "old", "", "new", ""]); + let text: Vec = 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 = 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 = 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::() + .split_whitespace() + .collect(); + assert_eq!( + body, + "alphabetagammadeltaepsilonzetaetathetaiotakappa\ + alphabetagammadeltaepsilonzetaetaTHETAiotakappa" + ); } #[test]