feat: add word diff highlighting to tui diffs

This commit is contained in:
Affaan Mustafa
2026-04-09 08:55:53 -07:00
parent f5437078e1
commit 2048f0d6f5

View File

@@ -38,8 +38,8 @@ const MAX_FILE_ACTIVITY_PATCH_LINES: usize = 3;
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
struct WorktreeDiffColumns { struct WorktreeDiffColumns {
removals: String, removals: Text<'static>,
additions: String, additions: Text<'static>,
hunk_offsets: Vec<usize>, hunk_offsets: Vec<usize>,
} }
@@ -591,20 +591,24 @@ impl Dashboard {
(self.output_title(), content) (self.output_title(), content)
} }
OutputMode::WorktreeDiff => { OutputMode::WorktreeDiff => {
let content = self let content = if let Some(patch) = self.selected_diff_patch.as_ref() {
.selected_diff_patch build_unified_diff_text(patch, self.theme_palette())
.clone() } else {
.or_else(|| { Text::from(
self.selected_diff_summary.as_ref().map(|summary| { self.selected_diff_summary
format!( .as_ref()
"{summary}\n\nNo patch content to preview yet. The worktree may be clean or only have summary-level changes." .map(|summary| {
) format!(
}) "{summary}\n\nNo patch content to preview yet. The worktree may be clean or only have summary-level changes."
}) )
.unwrap_or_else(|| { })
"No worktree diff available for the selected session.".to_string() .unwrap_or_else(|| {
}); "No worktree diff available for the selected session."
(self.output_title(), Text::from(content)) .to_string()
}),
)
};
(self.output_title(), content)
} }
OutputMode::ConflictProtocol => { OutputMode::ConflictProtocol => {
let content = self.selected_conflict_protocol.clone().unwrap_or_else(|| { let content = self.selected_conflict_protocol.clone().unwrap_or_else(|| {
@@ -646,7 +650,7 @@ impl Dashboard {
let Some(patch) = self.selected_diff_patch.as_ref() else { let Some(patch) = self.selected_diff_patch.as_ref() else {
return; return;
}; };
let columns = build_worktree_diff_columns(patch); let columns = build_worktree_diff_columns(patch, self.theme_palette());
let column_chunks = Layout::default() let column_chunks = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
@@ -3243,7 +3247,7 @@ impl Dashboard {
self.selected_diff_hunk_offsets_split = self self.selected_diff_hunk_offsets_split = self
.selected_diff_patch .selected_diff_patch
.as_deref() .as_deref()
.map(|patch| build_worktree_diff_columns(patch).hunk_offsets) .map(|patch| build_worktree_diff_columns(patch, self.theme_palette()).hunk_offsets)
.unwrap_or_default(); .unwrap_or_default();
if self.selected_diff_hunk >= self.current_diff_hunk_offsets().len() { if self.selected_diff_hunk >= self.current_diff_hunk_offsets().len() {
self.selected_diff_hunk = 0; self.selected_diff_hunk = 0;
@@ -5544,73 +5548,423 @@ fn highlight_output_line(
} }
} }
fn build_worktree_diff_columns(patch: &str) -> WorktreeDiffColumns { fn build_worktree_diff_columns(patch: &str, palette: ThemePalette) -> WorktreeDiffColumns {
let mut removals = Vec::new(); let mut removals = Vec::new();
let mut additions = Vec::new(); let mut additions = Vec::new();
let mut hunk_offsets = Vec::new(); let mut hunk_offsets = Vec::new();
let mut pending_removals = Vec::new();
let mut pending_additions = Vec::new();
for line in patch.lines() { for line in patch.lines() {
if is_diff_removal_line(line) {
pending_removals.push(line[1..].to_string());
continue;
}
if is_diff_addition_line(line) {
pending_additions.push(line[1..].to_string());
continue;
}
flush_split_diff_change_block(
&mut removals,
&mut additions,
&mut pending_removals,
&mut pending_additions,
palette,
);
if line.is_empty() { if line.is_empty() {
continue; continue;
} }
if line.starts_with("--- ") && !line.starts_with("--- a/") { if line.starts_with("@@") {
removals.push(line.to_string()); hunk_offsets.push(removals.len().max(additions.len()));
additions.push(line.to_string());
continue;
} }
if let Some(path) = line.strip_prefix("--- a/") { let styled_line = if line.starts_with(' ') {
removals.push(format!("File {path}")); styled_diff_context_line(line, palette)
continue; } else {
} styled_diff_meta_line(split_diff_display_line(line), palette)
};
if let Some(path) = line.strip_prefix("+++ b/") { removals.push(styled_line.clone());
additions.push(format!("File {path}")); additions.push(styled_line);
continue;
}
if line.starts_with("diff --git ") || line.starts_with("@@") {
if line.starts_with("@@") {
hunk_offsets.push(removals.len().max(additions.len()));
}
removals.push(line.to_string());
additions.push(line.to_string());
continue;
}
if line.starts_with('-') {
removals.push(line.to_string());
continue;
}
if line.starts_with('+') {
additions.push(line.to_string());
continue;
}
} }
flush_split_diff_change_block(
&mut removals,
&mut additions,
&mut pending_removals,
&mut pending_additions,
palette,
);
WorktreeDiffColumns { WorktreeDiffColumns {
removals: if removals.is_empty() { removals: if removals.is_empty() {
"No removals in this bounded preview.".to_string() Text::from("No removals in this bounded preview.")
} else { } else {
removals.join("\n") Text::from(removals)
}, },
additions: if additions.is_empty() { additions: if additions.is_empty() {
"No additions in this bounded preview.".to_string() Text::from("No additions in this bounded preview.")
} else { } else {
additions.join("\n") Text::from(additions)
}, },
hunk_offsets, hunk_offsets,
} }
} }
fn build_unified_diff_text(patch: &str, palette: ThemePalette) -> Text<'static> {
let mut lines = Vec::new();
let mut pending_removals = Vec::new();
let mut pending_additions = Vec::new();
for line in patch.lines() {
if is_diff_removal_line(line) {
pending_removals.push(line[1..].to_string());
continue;
}
if is_diff_addition_line(line) {
pending_additions.push(line[1..].to_string());
continue;
}
flush_unified_diff_change_block(
&mut lines,
&mut pending_removals,
&mut pending_additions,
palette,
);
if line.is_empty() {
continue;
}
lines.push(if line.starts_with(' ') {
styled_diff_context_line(line, palette)
} else {
styled_diff_meta_line(line, palette)
});
}
flush_unified_diff_change_block(
&mut lines,
&mut pending_removals,
&mut pending_additions,
palette,
);
Text::from(lines)
}
fn build_unified_diff_hunk_offsets(patch: &str) -> Vec<usize> { fn build_unified_diff_hunk_offsets(patch: &str) -> Vec<usize> {
patch let mut offsets = Vec::new();
.lines() let mut rendered_index = 0usize;
.enumerate() let mut pending_removals = 0usize;
.filter_map(|(index, line)| line.starts_with("@@").then_some(index)) let mut pending_additions = 0usize;
.collect()
for line in patch.lines() {
if is_diff_removal_line(line) {
pending_removals += 1;
continue;
}
if is_diff_addition_line(line) {
pending_additions += 1;
continue;
}
if pending_removals > 0 || pending_additions > 0 {
rendered_index += pending_removals + pending_additions;
pending_removals = 0;
pending_additions = 0;
}
if line.is_empty() {
continue;
}
if line.starts_with("@@") {
offsets.push(rendered_index);
}
rendered_index += 1;
}
offsets
}
fn flush_split_diff_change_block(
removals: &mut Vec<Line<'static>>,
additions: &mut Vec<Line<'static>>,
pending_removals: &mut Vec<String>,
pending_additions: &mut Vec<String>,
palette: ThemePalette,
) {
let pair_count = pending_removals.len().max(pending_additions.len());
for index in 0..pair_count {
match (pending_removals.get(index), pending_additions.get(index)) {
(Some(removal), Some(addition)) => {
let (removal_mask, addition_mask) =
diff_word_change_masks(removal.as_str(), addition.as_str());
removals.push(styled_diff_change_line(
'-',
removal,
&removal_mask,
diff_removal_style(palette),
diff_removal_word_style(),
));
additions.push(styled_diff_change_line(
'+',
addition,
&addition_mask,
diff_addition_style(palette),
diff_addition_word_style(),
));
}
(Some(removal), None) => {
removals.push(styled_diff_change_line(
'-',
removal,
&vec![false; tokenize_diff_words(removal).len()],
diff_removal_style(palette),
diff_removal_word_style(),
));
additions.push(Line::from(""));
}
(None, Some(addition)) => {
removals.push(Line::from(""));
additions.push(styled_diff_change_line(
'+',
addition,
&vec![false; tokenize_diff_words(addition).len()],
diff_addition_style(palette),
diff_addition_word_style(),
));
}
(None, None) => {}
}
}
pending_removals.clear();
pending_additions.clear();
}
fn flush_unified_diff_change_block(
lines: &mut Vec<Line<'static>>,
pending_removals: &mut Vec<String>,
pending_additions: &mut Vec<String>,
palette: ThemePalette,
) {
let pair_count = pending_removals.len().max(pending_additions.len());
for index in 0..pair_count {
match (pending_removals.get(index), pending_additions.get(index)) {
(Some(removal), Some(addition)) => {
let (removal_mask, addition_mask) =
diff_word_change_masks(removal.as_str(), addition.as_str());
lines.push(styled_diff_change_line(
'-',
removal,
&removal_mask,
diff_removal_style(palette),
diff_removal_word_style(),
));
lines.push(styled_diff_change_line(
'+',
addition,
&addition_mask,
diff_addition_style(palette),
diff_addition_word_style(),
));
}
(Some(removal), None) => lines.push(styled_diff_change_line(
'-',
removal,
&vec![false; tokenize_diff_words(removal).len()],
diff_removal_style(palette),
diff_removal_word_style(),
)),
(None, Some(addition)) => lines.push(styled_diff_change_line(
'+',
addition,
&vec![false; tokenize_diff_words(addition).len()],
diff_addition_style(palette),
diff_addition_word_style(),
)),
(None, None) => {}
}
}
pending_removals.clear();
pending_additions.clear();
}
fn split_diff_display_line(line: &str) -> String {
if line.starts_with("--- ") && !line.starts_with("--- a/") {
return line.to_string();
}
if let Some(path) = line.strip_prefix("--- a/") {
return format!("File {path}");
}
if let Some(path) = line.strip_prefix("+++ b/") {
return format!("File {path}");
}
line.to_string()
}
fn is_diff_removal_line(line: &str) -> bool {
line.starts_with('-') && !line.starts_with("--- ")
}
fn is_diff_addition_line(line: &str) -> bool {
line.starts_with('+') && !line.starts_with("+++ ")
}
fn styled_diff_meta_line(text: impl Into<String>, palette: ThemePalette) -> Line<'static> {
Line::from(vec![Span::styled(text.into(), diff_meta_style(palette))])
}
fn styled_diff_context_line(text: &str, palette: ThemePalette) -> Line<'static> {
Line::from(vec![Span::styled(
text.to_string(),
diff_context_style(palette),
)])
}
fn styled_diff_change_line(
prefix: char,
body: &str,
change_mask: &[bool],
base_style: Style,
changed_style: Style,
) -> Line<'static> {
let tokens = tokenize_diff_words(body);
let mut spans = vec![Span::styled(
prefix.to_string(),
base_style.add_modifier(Modifier::BOLD),
)];
for (index, token) in tokens.into_iter().enumerate() {
let style = if change_mask.get(index).copied().unwrap_or(false) {
changed_style
} else {
base_style
};
spans.push(Span::styled(token, style));
}
Line::from(spans)
}
fn tokenize_diff_words(text: &str) -> Vec<String> {
if text.is_empty() {
return Vec::new();
}
let mut tokens = Vec::new();
let mut current = String::new();
let mut current_is_whitespace: Option<bool> = None;
for ch in text.chars() {
let is_whitespace = ch.is_whitespace();
match current_is_whitespace {
Some(state) if state == is_whitespace => current.push(ch),
Some(_) => {
tokens.push(std::mem::take(&mut current));
current.push(ch);
current_is_whitespace = Some(is_whitespace);
}
None => {
current.push(ch);
current_is_whitespace = Some(is_whitespace);
}
}
}
if !current.is_empty() {
tokens.push(current);
}
tokens
}
fn diff_word_change_masks(left: &str, right: &str) -> (Vec<bool>, Vec<bool>) {
let left_tokens = tokenize_diff_words(left);
let right_tokens = tokenize_diff_words(right);
let left_len = left_tokens.len();
let right_len = right_tokens.len();
let mut lcs = vec![vec![0usize; right_len + 1]; left_len + 1];
for left_index in (0..left_len).rev() {
for right_index in (0..right_len).rev() {
lcs[left_index][right_index] = if left_tokens[left_index] == right_tokens[right_index] {
lcs[left_index + 1][right_index + 1] + 1
} else {
lcs[left_index + 1][right_index].max(lcs[left_index][right_index + 1])
};
}
}
let mut left_changed = vec![true; left_len];
let mut right_changed = vec![true; right_len];
let (mut left_index, mut right_index) = (0usize, 0usize);
while left_index < left_len && right_index < right_len {
if left_tokens[left_index] == right_tokens[right_index] {
left_changed[left_index] = false;
right_changed[right_index] = false;
left_index += 1;
right_index += 1;
} else if lcs[left_index + 1][right_index] >= lcs[left_index][right_index + 1] {
left_index += 1;
} else {
right_index += 1;
}
}
(left_changed, right_changed)
}
fn diff_meta_style(palette: ThemePalette) -> Style {
Style::default()
.fg(palette.accent)
.add_modifier(Modifier::BOLD)
}
fn diff_context_style(palette: ThemePalette) -> Style {
Style::default().fg(palette.muted)
}
fn diff_removal_style(palette: ThemePalette) -> Style {
let color = match palette.accent {
Color::Blue => Color::Red,
_ => Color::LightRed,
};
Style::default().fg(color)
}
fn diff_addition_style(palette: ThemePalette) -> Style {
let color = match palette.accent {
Color::Blue => Color::Green,
_ => Color::LightGreen,
};
Style::default().fg(color)
}
fn diff_removal_word_style() -> Style {
Style::default()
.bg(Color::Red)
.fg(Color::Black)
.add_modifier(Modifier::BOLD)
}
fn diff_addition_word_style() -> Style {
Style::default()
.bg(Color::Green)
.fg(Color::Black)
.add_modifier(Modifier::BOLD)
} }
fn session_state_label(state: &SessionState) -> &'static str { fn session_state_label(state: &SessionState) -> &'static str {
@@ -6262,7 +6616,7 @@ diff --git a/src/lib.rs b/src/lib.rs\n\
dashboard.selected_diff_summary = Some("1 file changed".to_string()); dashboard.selected_diff_summary = Some("1 file changed".to_string());
dashboard.selected_diff_patch = Some(patch.clone()); dashboard.selected_diff_patch = Some(patch.clone());
dashboard.selected_diff_hunk_offsets_split = dashboard.selected_diff_hunk_offsets_split =
build_worktree_diff_columns(&patch).hunk_offsets; build_worktree_diff_columns(&patch, dashboard.theme_palette()).hunk_offsets;
dashboard.selected_diff_hunk_offsets_unified = build_unified_diff_hunk_offsets(&patch); dashboard.selected_diff_hunk_offsets_unified = build_unified_diff_hunk_offsets(&patch);
dashboard.toggle_output_mode(); dashboard.toggle_output_mode();
@@ -6306,7 +6660,8 @@ diff --git a/src/lib.rs b/src/lib.rs\n\
+second new" +second new"
.to_string(); .to_string();
dashboard.selected_diff_patch = Some(patch.clone()); dashboard.selected_diff_patch = Some(patch.clone());
let split_offsets = build_worktree_diff_columns(&patch).hunk_offsets; let split_offsets =
build_worktree_diff_columns(&patch, dashboard.theme_palette()).hunk_offsets;
dashboard.selected_diff_hunk_offsets_split = split_offsets.clone(); dashboard.selected_diff_hunk_offsets_split = split_offsets.clone();
dashboard.selected_diff_hunk_offsets_unified = build_unified_diff_hunk_offsets(&patch); dashboard.selected_diff_hunk_offsets_unified = build_unified_diff_hunk_offsets(&patch);
dashboard.output_mode = OutputMode::WorktreeDiff; dashboard.output_mode = OutputMode::WorktreeDiff;
@@ -6688,13 +7043,74 @@ diff --git a/src/next.rs b/src/next.rs
-bye -bye
+hello"; +hello";
let columns = build_worktree_diff_columns(patch); let palette = test_dashboard(Vec::new(), 0).theme_palette();
assert!(columns.removals.contains("Branch diff vs main")); let columns = build_worktree_diff_columns(patch, palette);
assert!(columns.removals.contains("-old line")); let removals = text_plain_text(&columns.removals);
assert!(columns.removals.contains("-bye")); let additions = text_plain_text(&columns.additions);
assert!(columns.additions.contains("Working tree diff")); assert!(removals.contains("Branch diff vs main"));
assert!(columns.additions.contains("+new line")); assert!(removals.contains("-old line"));
assert!(columns.additions.contains("+hello")); assert!(removals.contains("-bye"));
assert!(additions.contains("Working tree diff"));
assert!(additions.contains("+new line"));
assert!(additions.contains("+hello"));
}
#[test]
fn split_diff_highlights_changed_words() {
let palette = test_dashboard(Vec::new(), 0).theme_palette();
let patch = "\
diff --git a/src/lib.rs b/src/lib.rs
@@ -1 +1 @@
-old line
+new line";
let columns = build_worktree_diff_columns(patch, palette);
let removal = columns
.removals
.lines
.iter()
.find(|line| line_plain_text(line) == "-old line")
.expect("removal line");
let addition = columns
.additions
.lines
.iter()
.find(|line| line_plain_text(line) == "+new line")
.expect("addition line");
assert_eq!(removal.spans[1].content.as_ref(), "old");
assert_eq!(removal.spans[1].style, diff_removal_word_style());
assert_eq!(removal.spans[2].content.as_ref(), " ");
assert_eq!(removal.spans[2].style, diff_removal_style(palette));
assert_eq!(addition.spans[1].content.as_ref(), "new");
assert_eq!(addition.spans[1].style, diff_addition_word_style());
}
#[test]
fn unified_diff_highlights_changed_words() {
let palette = test_dashboard(Vec::new(), 0).theme_palette();
let patch = "\
diff --git a/src/lib.rs b/src/lib.rs
@@ -1 +1 @@
-old line
+new line";
let text = build_unified_diff_text(patch, palette);
let removal = text
.lines
.iter()
.find(|line| line_plain_text(line) == "-old line")
.expect("removal line");
let addition = text
.lines
.iter()
.find(|line| line_plain_text(line) == "+new line")
.expect("addition line");
assert_eq!(removal.spans[1].content.as_ref(), "old");
assert_eq!(removal.spans[1].style, diff_removal_word_style());
assert_eq!(addition.spans[1].content.as_ref(), "new");
assert_eq!(addition.spans[1].style, diff_addition_word_style());
} }
#[test] #[test]
@@ -9859,6 +10275,21 @@ diff --git a/src/next.rs b/src/next.rs
) )
} }
fn line_plain_text(line: &Line<'_>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
}
fn text_plain_text(text: &Text<'_>) -> String {
text.lines
.iter()
.map(line_plain_text)
.collect::<Vec<_>>()
.join("\n")
}
fn test_dashboard(sessions: Vec<Session>, selected_session: usize) -> Dashboard { fn test_dashboard(sessions: Vec<Session>, selected_session: usize) -> Dashboard {
let selected_session = selected_session.min(sessions.len().saturating_sub(1)); let selected_session = selected_session.min(sessions.len().saturating_sub(1));
let cfg = Config::default(); let cfg = Config::default();