Files
tty7/src/ui/diff_rows.rs
T
l0ng-ai 45879d1c22 feat(diff): add a unified view and name the source in the header
The overlay could only ever show one thing one way: `git diff HEAD`, side
by side. Both halves of that are now choices.

A segmented control in the header switches between side-by-side and
unified, stored in `Config::diff_view` so the choice survives the next
open. Unified is measured against the split cell rather than designed
next to it: the same 19px row, the same type, and the same 0.12 wash
behind an addition and a removal. It differs only where the shape forces
it — 34px per line-number gutter instead of 42, and a column of its own
for `+`/`−`, without which the context lines' code would start two
characters left of everything else.

The header now says which patch it is showing. A branch name for the
worktree and for HEAD as before; the same with a STAGED chip for the
index, which is otherwise indistinguishable; the commit glyph and a
short object id for a commit or a range. The subject and author of a
commit are not there yet — `DiffSource::Commit` carries only the rev,
and buying them costs another round trip that the commit detail view
will be making anyway.

Two pieces of coupling go with it. `PANEL_DIFF_SOURCE` no longer decides
whether the panel's snapshot may seed an overlay, or whether an overlay
has gone stale: the first is settled by the snapshot's own source, the
second by the overlay's. A commit and a range never go stale at all, and
the two sources the cached `--numstat HEAD` counts cannot describe now
compare `ScmData` epochs instead — read when the probe starts, so a
write landing under it is not mistaken for one the result reflects.

And the file cards drop their private A/M/D/R table for the shared
`status_glyph`/`status_color`, so a file wears the same letter here as
in the panel and the tree.
2026-08-09 01:19:50 +08:00

243 lines
8.1 KiB
Rust

//! Turning a parsed hunk into rows a diff view can lay out.
//!
//! Side-by-side and unified are two renderings of the same `Vec<DiffLine>`, so
//! the pairing logic lives here — outside either renderer — and is unit tested
//! without a window.
use crate::terminal::git_diff::{DiffLine, LineKind};
/// A tab is worth this many columns. Not configurable: a diff is read next to
/// the file's other lines, not on its own, and the grid has to line up.
const TAB_WIDTH: usize = 4;
/// Diff text is laid out as a single run, so a literal tab would advance to the
/// renderer's idea of a tab stop rather than the file's. Both views expand
/// them the same way, or the two halves of a split row would drift apart.
pub(crate) fn expand_tabs(text: &str) -> String {
text.replace('\t', &" ".repeat(TAB_WIDTH))
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum Side {
Old,
New,
}
pub(crate) struct SplitCell {
pub(crate) no: Option<u32>,
pub(crate) text: String,
pub(crate) changed: bool,
}
pub(crate) struct SplitRow {
pub(crate) left: Option<SplitCell>,
pub(crate) right: Option<SplitCell>,
}
/// Pairs each run of removals with the run of additions that follows it, so a
/// rewritten line sits opposite the line it replaced. Whichever run is shorter
/// leaves empty cells at the bottom of the pair.
pub(crate) fn split_hunk(lines: &[DiffLine]) -> Vec<SplitRow> {
fn flush(rows: &mut Vec<SplitRow>, rem: &mut Vec<&DiffLine>, add: &mut Vec<&DiffLine>) {
for i in 0..rem.len().max(add.len()) {
rows.push(SplitRow {
left: rem.get(i).map(|l| SplitCell {
no: l.old_no,
text: expand_tabs(&l.text),
changed: true,
}),
right: add.get(i).map(|l| SplitCell {
no: l.new_no,
text: expand_tabs(&l.text),
changed: true,
}),
});
}
rem.clear();
add.clear();
}
let mut rows = Vec::new();
let mut rem: Vec<&DiffLine> = Vec::new();
let mut add: Vec<&DiffLine> = Vec::new();
for line in lines {
match line.kind {
LineKind::Removed => rem.push(line),
LineKind::Added => add.push(line),
LineKind::Context => {
flush(&mut rows, &mut rem, &mut add);
rows.push(SplitRow {
left: Some(SplitCell {
no: line.old_no,
text: expand_tabs(&line.text),
changed: false,
}),
right: Some(SplitCell {
no: line.new_no,
text: expand_tabs(&line.text),
changed: false,
}),
});
}
}
}
flush(&mut rows, &mut rem, &mut add);
rows
}
pub(crate) struct UnifiedRow {
pub(crate) old: Option<u32>,
pub(crate) new: Option<u32>,
pub(crate) kind: LineKind,
pub(crate) text: String,
}
/// One row per line, in git's own order — every removal in a run first, then
/// every addition. That is the opposite of [`split_hunk`], and it is the whole
/// difference between the two views: unified shows the patch as it was written,
/// side-by-side re-pairs it into before and after.
pub(crate) fn unified_rows(lines: &[DiffLine]) -> Vec<UnifiedRow> {
lines
.iter()
.map(|line| UnifiedRow {
old: line.old_no,
new: line.new_no,
kind: line.kind,
text: expand_tabs(&line.text),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn line(kind: LineKind, old: Option<u32>, new: Option<u32>, text: &str) -> DiffLine {
DiffLine {
kind,
old_no: old,
new_no: new,
text: text.to_string(),
}
}
/// A context line, two removals, one addition, a context line — the shape
/// where the two views visibly disagree.
fn hunk() -> Vec<DiffLine> {
vec![
line(LineKind::Context, Some(1), Some(1), "a"),
line(LineKind::Removed, Some(2), None, "b"),
line(LineKind::Removed, Some(3), None, "c"),
line(LineKind::Added, None, Some(2), "B"),
line(LineKind::Context, Some(4), Some(3), "d"),
]
}
#[test]
fn pairs_removed_and_added_side_by_side() {
let rows = split_hunk(&hunk());
assert_eq!(rows.len(), 4);
let l = rows[0].left.as_ref().unwrap();
let r = rows[0].right.as_ref().unwrap();
assert_eq!((l.no, l.text.as_str(), l.changed), (Some(1), "a", false));
assert_eq!((r.no, r.text.as_str(), r.changed), (Some(1), "a", false));
let l = rows[1].left.as_ref().unwrap();
let r = rows[1].right.as_ref().unwrap();
assert_eq!((l.no, l.text.as_str(), l.changed), (Some(2), "b", true));
assert_eq!((r.no, r.text.as_str(), r.changed), (Some(2), "B", true));
assert_eq!(rows[2].left.as_ref().unwrap().text, "c");
assert!(rows[2].right.is_none());
assert_eq!(rows[3].left.as_ref().unwrap().no, Some(4));
assert_eq!(rows[3].right.as_ref().unwrap().no, Some(3));
}
#[test]
fn expands_tabs_in_cell_text() {
let lines = vec![line(LineKind::Added, None, Some(1), "\tindented")];
let rows = split_hunk(&lines);
assert_eq!(rows[0].right.as_ref().unwrap().text, " indented");
assert!(rows[0].left.is_none());
}
#[test]
fn expand_tabs_is_a_fixed_width_substitution() {
assert_eq!(expand_tabs("plain"), "plain");
assert_eq!(expand_tabs("\tone"), " one");
assert_eq!(expand_tabs("\t\ttwo"), " two");
assert_eq!(
expand_tabs("a\tb"),
"a b",
"a fixed width, not the next tab stop — the diff has no column grid"
);
assert_eq!(expand_tabs(""), "");
}
#[test]
fn unified_keeps_gits_own_order() {
let rows = unified_rows(&hunk());
let shape: Vec<(Option<u32>, Option<u32>, LineKind, &str)> = rows
.iter()
.map(|r| (r.old, r.new, r.kind, r.text.as_str()))
.collect();
assert_eq!(
shape,
[
(Some(1), Some(1), LineKind::Context, "a"),
(Some(2), None, LineKind::Removed, "b"),
(Some(3), None, LineKind::Removed, "c"),
(None, Some(2), LineKind::Added, "B"),
(Some(4), Some(3), LineKind::Context, "d"),
],
"both removals come before the addition, unlike the split view"
);
}
#[test]
fn unified_numbers_each_column_from_the_side_it_belongs_to() {
let rows = unified_rows(&hunk());
assert!(
rows.iter()
.all(|r| (r.old.is_some() && r.new.is_some()) == (r.kind == LineKind::Context)),
"a context line is the only kind that exists on both sides"
);
assert!(
rows.iter()
.filter(|r| r.kind == LineKind::Added)
.all(|r| r.old.is_none())
);
assert!(
rows.iter()
.filter(|r| r.kind == LineKind::Removed)
.all(|r| r.new.is_none())
);
}
#[test]
fn unified_expands_tabs_the_same_way_split_does() {
let lines = vec![line(LineKind::Added, None, Some(1), "\tindented")];
assert_eq!(unified_rows(&lines)[0].text, " indented");
}
#[test]
fn both_views_render_every_line_exactly_once() {
let lines = hunk();
let unified = unified_rows(&lines);
assert_eq!(unified.len(), lines.len());
let cells: usize = split_hunk(&lines)
.iter()
.map(|r| r.left.is_some() as usize + r.right.is_some() as usize)
.sum();
let context = lines.iter().filter(|l| l.kind == LineKind::Context).count();
assert_eq!(
cells,
lines.len() + context,
"a context line fills two cells, a change fills one"
);
}
}