anvilsign in

collin/anvil · 2c596008

feat: hunked, collapsible commit diffs with line numbers

Collin Richards · 2026-06-10 00:32 UTC · 2c59600826966a8f57f8cb00cc9d51165afc4a4b · parent 15dac03a · browse files

modifiedcrates/anvil-web/src/ui.rs+102 −23
⋯ 89 unchanged lines
9090 .commit-list li:first-child { border-top:0; }
9191 .sha { font:12px ui-monospace,monospace; color:var(--muted); }
9292 .file-diff { margin:16px 0; }
93-.file-diff .head { background:var(--code-bg); border:1px solid var(--border); border-bottom:0; border-radius:6px 6px 0 0; padding:6px 12px; font:12px ui-monospace,monospace; }
93+.file-diff summary.head { background:var(--code-bg); border:1px solid var(--border); border-radius:6px; padding:6px 12px; font:12px ui-monospace,monospace; cursor:pointer; display:flex; align-items:center; gap:8px; list-style:none; }
94+.file-diff summary.head::-webkit-details-marker { display:none; }
95+.file-diff summary.head::before { content:"\25B8"; color:var(--muted); }
96+.file-diff[open] summary.head::before { content:"\25BE"; }
97+.file-diff[open] summary.head { border-bottom:0; border-radius:6px 6px 0 0; }
98+.file-diff .stat { margin-left:auto; white-space:nowrap; }
99+.stat .plus { color:#1a7f37; } .stat .minus { color:#cf222e; }
94100 table.diff { border:1px solid var(--border); border-radius:0 0 6px 6px; }
95101 table.diff td.sign { width:1%; text-align:center; color:var(--muted); user-select:none; }
96102 table.diff tr.ins { background:#e6ffec; } table.diff tr.ins td.sign { color:#1a7f37; }
97103 table.diff tr.del { background:#ffebe9; } table.diff tr.del td.sign { color:#cf222e; }
104+table.diff tr.gap td { background:var(--code-bg); color:var(--muted); text-align:center; padding:3px 10px; user-select:none; font-size:11px; }
98105 .badge { font-size:11px; border-radius:3px; padding:1px 6px; }
99106 .badge.add { background:#dafbe1; color:#1a7f37; } .badge.del { background:#ffebe9; color:#cf222e; } .badge.mod { background:#fff8c5; color:#7d4e00; }
100107 .st { font-size:11px; border-radius:2em; padding:1px 9px; font-weight:600; text-transform:capitalize; }
⋯ 1091 unchanged lines
11921199 }
11931200
11941201 /// Render one file's diff (added/deleted/modified) as a unified line diff.
1202+/// A file diff bigger than this many rows starts collapsed (its header still
1203+/// shows the +/− counts; clicking expands it — native `details`, no JS).
1204+const DIFF_COLLAPSE_ROWS: usize = 400;
1205+
11951206 fn render_file_diff(change: &FileChange) -> Markup {
11961207 let (badge_cls, badge) = match change.kind {
11971208 ChangeKind::Added => ("add", "added"),
11981209 ChangeKind::Deleted => ("del", "deleted"),
11991210 ChangeKind::Modified => ("mod", "modified"),
12001211 };
1212+ let head = |stat: Markup| {
1213+ html! {
1214+ summary.head {
1215+ span class=(format!("badge {badge_cls}")) { (badge) }
1216+ span { (change.path) }
1217+ span.stat { (stat) }
1218+ }
1219+ }
1220+ };
1221+
12011222 let binary = change.old.as_deref().is_some_and(is_binary)
12021223 || change.new.as_deref().is_some_and(is_binary);
1203- html! {
1204- div.file-diff {
1205- div.head {
1206- span class=(format!("badge {badge_cls}")) { (badge) }
1207- " " (change.path)
1208- }
1209- @if binary {
1224+ if binary {
1225+ return html! {
1226+ details.file-diff open {
1227+ (head(html! { span.muted { "binary" } }))
12101228 div.box { div.row { span.muted { "Binary file" } } }
1211- } @else {
1212- @let old = change.old.as_deref().map(|b| String::from_utf8_lossy(b).into_owned()).unwrap_or_default();
1213- @let new = change.new.as_deref().map(|b| String::from_utf8_lossy(b).into_owned()).unwrap_or_default();
1214- (unified_diff(&old, &new))
12151229 }
1230+ };
1231+ }
1232+
1233+ let old = change
1234+ .old
1235+ .as_deref()
1236+ .map(|b| String::from_utf8_lossy(b).into_owned())
1237+ .unwrap_or_default();
1238+ let new = change
1239+ .new
1240+ .as_deref()
1241+ .map(|b| String::from_utf8_lossy(b).into_owned())
1242+ .unwrap_or_default();
1243+ let diff = TextDiff::from_lines(&old, &new);
1244+ let (mut adds, mut dels) = (0usize, 0usize);
1245+ for c in diff.iter_all_changes() {
1246+ match c.tag() {
1247+ ChangeTag::Insert => adds += 1,
1248+ ChangeTag::Delete => dels += 1,
1249+ ChangeTag::Equal => {}
12161250 }
12171251 }
1252+ // Hunks: changed lines plus 3 lines of context, not the whole file.
1253+ let groups = diff.grouped_ops(3);
1254+ let rendered_rows: usize = groups
1255+ .iter()
1256+ .flatten()
1257+ .map(|op| diff.iter_changes(op).count())
1258+ .sum();
1259+
1260+ html! {
1261+ details.file-diff open[rendered_rows <= DIFF_COLLAPSE_ROWS] {
1262+ (head(html! { span.plus { "+" (adds) } " " span.minus { "−" (dels) } }))
1263+ (diff_table(&diff, &groups, old.lines().count()))
1264+ }
1265+ }
12181266 }
12191267
1220-fn unified_diff(old: &str, new: &str) -> Markup {
1221- let diff = TextDiff::from_lines(old, new);
1268+/// Render grouped diff hunks as a table: old/new line numbers, a +/- sign
1269+/// column, and the line. Elided stretches show a "⋯ N unchanged lines" row
1270+/// (including before the first hunk and after the last).
1271+fn diff_table<'a>(
1272+ diff: &TextDiff<'a, 'a, '_, str>,
1273+ groups: &[Vec<similar::DiffOp>],
1274+ old_total: usize,
1275+) -> Markup {
1276+ let gap_row = |n: usize| {
1277+ html! {
1278+ @if n > 0 {
1279+ tr.gap { td colspan="4" { "⋯ " (n) " unchanged line" @if n != 1 { "s" } } }
1280+ }
1281+ }
1282+ };
1283+ // Unchanged-line gap before each group, and after the last one.
1284+ let mut prev_end = 0usize; // end of the previous group, in old-file lines
1285+ let mut with_gaps = Vec::with_capacity(groups.len());
1286+ for group in groups {
1287+ let start = group.first().map_or(prev_end, |op| op.old_range().start);
1288+ with_gaps.push((start.saturating_sub(prev_end), group));
1289+ prev_end = group.last().map_or(prev_end, |op| op.old_range().end);
1290+ }
1291+ let trailing = old_total.saturating_sub(prev_end);
1292+
12221293 html! {
12231294 table.code.diff {
1224- @for change in diff.iter_all_changes() {
1225- @let (sign, cls) = match change.tag() {
1226- ChangeTag::Delete => ("-", "del"),
1227- ChangeTag::Insert => ("+", "ins"),
1228- ChangeTag::Equal => (" ", ""),
1229- };
1230- tr class=(cls) {
1231- td.sign { (sign) }
1232- td { (change.value().trim_end_matches('\n')) }
1295+ @for (gap, group) in &with_gaps {
1296+ (gap_row(*gap))
1297+ @for op in group.iter() {
1298+ @for change in diff.iter_changes(op) {
1299+ @let (sign, cls) = match change.tag() {
1300+ ChangeTag::Delete => ("-", "del"),
1301+ ChangeTag::Insert => ("+", "ins"),
1302+ ChangeTag::Equal => (" ", ""),
1303+ };
1304+ tr class=(cls) {
1305+ td.ln { @if let Some(i) = change.old_index() { (i + 1) } }
1306+ td.ln { @if let Some(i) = change.new_index() { (i + 1) } }
1307+ td.sign { (sign) }
1308+ td { (change.value().trim_end_matches('\n')) }
1309+ }
1310+ }
12331311 }
12341312 }
1313+ (gap_row(trailing))
12351314 }
12361315 }
12371316 }
⋯ 159 unchanged lines