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 | |||
| 90 | 90 | .commit-list li:first-child { border-top:0; } | |
| 91 | 91 | .sha { font:12px ui-monospace,monospace; color:var(--muted); } | |
| 92 | 92 | .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; } | |
| 94 | 100 | table.diff { border:1px solid var(--border); border-radius:0 0 6px 6px; } | |
| 95 | 101 | table.diff td.sign { width:1%; text-align:center; color:var(--muted); user-select:none; } | |
| 96 | 102 | table.diff tr.ins { background:#e6ffec; } table.diff tr.ins td.sign { color:#1a7f37; } | |
| 97 | 103 | 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; } | |
| 98 | 105 | .badge { font-size:11px; border-radius:3px; padding:1px 6px; } | |
| 99 | 106 | .badge.add { background:#dafbe1; color:#1a7f37; } .badge.del { background:#ffebe9; color:#cf222e; } .badge.mod { background:#fff8c5; color:#7d4e00; } | |
| 100 | 107 | .st { font-size:11px; border-radius:2em; padding:1px 9px; font-weight:600; text-transform:capitalize; } | |
| ⋯ 1091 unchanged lines | |||
| 1192 | 1199 | } | |
| 1193 | 1200 | ||
| 1194 | 1201 | /// 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 | + | ||
| 1195 | 1206 | fn render_file_diff(change: &FileChange) -> Markup { | |
| 1196 | 1207 | let (badge_cls, badge) = match change.kind { | |
| 1197 | 1208 | ChangeKind::Added => ("add", "added"), | |
| 1198 | 1209 | ChangeKind::Deleted => ("del", "deleted"), | |
| 1199 | 1210 | ChangeKind::Modified => ("mod", "modified"), | |
| 1200 | 1211 | }; | |
| 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 | + | ||
| 1201 | 1222 | let binary = change.old.as_deref().is_some_and(is_binary) | |
| 1202 | 1223 | || 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" } })) | |
| 1210 | 1228 | 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)) | |
| 1215 | 1229 | } | |
| 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 => {} | |
| 1216 | 1250 | } | |
| 1217 | 1251 | } | |
| 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 | + | } | |
| 1218 | 1266 | } | |
| 1219 | 1267 | ||
| 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 | + | ||
| 1222 | 1293 | html! { | |
| 1223 | 1294 | 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 | + | } | |
| 1233 | 1311 | } | |
| 1234 | 1312 | } | |
| 1313 | + | (gap_row(trailing)) | |
| 1235 | 1314 | } | |
| 1236 | 1315 | } | |
| 1237 | 1316 | } | |
| ⋯ 159 unchanged lines | |||