Jump to…
snowfix: 500 error because of database is patchedyuzpxzopsouq1mo
1//! One diff renderer, shared by every page that shows a patch.
2//!
3//! A change's files tab and a commit's page are the same reading task — which
4//! files, how big, and what changed in each — so they are the same markup and
5//! the same CSS. What differs is what hangs *off* the diff: the change view
6//! anchors comment threads to lines, the commit view links each file to its
7//! blob. Both arrive through [`DiffView`] rather than by growing a second
8//! renderer that drifts from this one.
9
10use std::collections::BTreeMap;
11
12use maud::{html, Markup};
13
14use df_store::{ChangeKind, Diff, DiffLine, DiffLineKind, DiffSpan, FileDiff};
15
16/// A file this long is folded by default. Past a few hundred lines a file is
17/// no longer something a reviewer reads on the way past — it is a destination —
18/// and leaving it open buries every file after it.
19pub const LARGE_FILE_LINES: usize = 400;
20
21/// Per-line markup a caller wants woven into the table.
22///
23/// Both hooks are called for every rendered line, so an implementation that
24/// only cares about one side must check the line itself; a hook that returns
25/// empty markup costs nothing.
26pub struct LineHooks<'a> {
27 /// Extra markup inside the new-side line-number cell — the comment
28 /// affordance, in practice.
29 pub gutter: &'a dyn Fn(&FileDiff, &DiffLine) -> Markup,
30 /// Full-width `<tr>`s placed directly beneath the line. Anything returned
31 /// here must be table rows, since that is where it lands.
32 pub under: &'a dyn Fn(&FileDiff, &DiffLine) -> Markup,
33}
34
35/// A diff and the decisions a page makes about how to show it.
36pub struct DiffView<'a> {
37 pub diff: &'a Diff,
38 /// Fold every file, for skimming the shape of a large change first.
39 pub collapsed: bool,
40 /// `…/blob/{rev}` — when set, every file header links to the file as it
41 /// stands at this revision. A change's files tab leaves it `None`: the
42 /// revision under review is not necessarily one the browser can serve.
43 pub blob_base: Option<&'a str>,
44 pub hooks: Option<LineHooks<'a>>,
45}
46
47impl<'a> DiffView<'a> {
48 /// A diff with no annotations — the commit page's default.
49 pub fn new(diff: &'a Diff) -> Self {
50 DiffView { diff, collapsed: false, blob_base: None, hooks: None }
51 }
52}
53
54/// How much changed, as one line: the count, the two totals, and the ratio bar.
55pub fn stat_summary(d: &Diff) -> Markup {
56 html! {
57 div .diffbar-stat {
58 strong { (d.files.len()) }
59 " " (if d.files.len() == 1 { "file" } else { "files" })
60 span .cl-add { "+" (d.total_additions) }
61 span .cl-del { "−" (d.total_deletions) }
62 (stat_bar(d.total_additions, d.total_deletions))
63 }
64 }
65}
66
67// ─── the changed-file tree ───────────────────────────────────────────────────
68
69/// The changed files as the directory tree they actually live in.
70///
71/// A flat list of thirty paths in a Rust workspace is thirty rows that all
72/// begin `crates/df-…/src/`, and the eye has to read to the end of each one to
73/// tell them apart. The tree factors that prefix out once, so what is left on
74/// each row is the part that differs — and the shape of the commit ("this
75/// touched the store and the web crate") becomes visible without reading at
76/// all.
77///
78/// Directories are `<details>`, so folding one needs no script.
79pub fn tree(d: &Diff) -> Markup {
80 if d.files.is_empty() {
81 return html! {};
82 }
83
84 let root = TreeNode::build(&d.files);
85
86 html! {
87 aside .difftree aria-label="Changed files" {
88 div .dt-head {
89 (d.files.len()) " " (if d.files.len() == 1 { "file" } else { "files" }) " changed"
90 }
91 nav .dt-body { (tree_level(&root)) }
92 }
93 }
94}
95
96/// One level of the changed-file tree.
97#[derive(Default)]
98struct TreeNode<'a> {
99 /// Ordered by name, which is how every file browser lists a directory and
100 /// therefore where a reader already expects to find things.
101 dirs: BTreeMap<&'a str, TreeNode<'a>>,
102 files: Vec<&'a FileDiff>,
103}
104
105impl<'a> TreeNode<'a> {
106 fn build(files: &'a [FileDiff]) -> TreeNode<'a> {
107 let mut root = TreeNode::default();
108
109 for f in files {
110 let mut node = &mut root;
111 let mut segments = f.path.split('/').filter(|s| !s.is_empty()).peekable();
112
113 while let Some(seg) = segments.next() {
114 if segments.peek().is_none() {
115 node.files.push(f);
116 } else {
117 node = node.dirs.entry(seg).or_default();
118 }
119 }
120 }
121
122 root
123 }
124
125 /// Fold a chain of single-child directories into one row.
126 ///
127 /// `crates/df-web/src/views/` is one place, not four, and indenting it four
128 /// times spends the whole width of the sidebar saying nothing. Returns the
129 /// joined label and the first node that actually branches.
130 fn collapse(&'a self, name: &'a str) -> (String, &'a TreeNode<'a>) {
131 let mut label = name.to_string();
132 let mut node = self;
133
134 while node.files.is_empty() && node.dirs.len() == 1 {
135 let (child_name, child) = node.dirs.iter().next().expect("len == 1");
136 label.push('/');
137 label.push_str(child_name);
138 node = child;
139 }
140
141 (label, node)
142 }
143}
144
145/// Directories first, then files — both alphabetical.
146fn tree_level(node: &TreeNode<'_>) -> Markup {
147 html! {
148 @for (name, child) in &node.dirs {
149 @let (label, child) = child.collapse(name);
150 details .dt-dir open {
151 summary .dt-row {
152 span .dt-caret aria-hidden="true" {}
153 span .dt-dirname { (label) }
154 }
155 div .dt-kids { (tree_level(child)) }
156 }
157 }
158 @for f in &node.files { (tree_file(f)) }
159 }
160}
161
162fn tree_file(f: &FileDiff) -> Markup {
163 let (_, name) = split_path(&f.path);
164
165 html! {
166 // The full path in `title`, because the visible name is truncated and
167 // two files can share a basename.
168 a .dt-file href=(format!("#f-{}", path_anchor(&f.path))) title=(f.path) {
169 (kind_glyph(f.kind))
170 span .dt-name { (name) }
171 span .spacer {}
172 @if f.binary {
173 span .fx-bin { "bin" }
174 } @else {
175 span .cl-add { "+" (f.additions) }
176 span .cl-del { "−" (f.deletions) }
177 }
178 }
179 }
180}
181
182/// The diff itself: one foldable block per file, each a table of numbered
183/// lines.
184pub fn files(v: &DiffView<'_>) -> Markup {
185 let d = v.diff;
186
187 html! {
188 @if d.truncated {
189 div .banner.banner-error role="alert" {
190 "This diff exceeds the render limits and is shown in part. \
191 Fetch the revision with " code { "jj" } " to read it in full."
192 }
193 }
194
195 @if d.files.is_empty() {
196 div .panel { p .dim style="margin:0" { "No changes." } }
197 }
198
199 @for file in &d.files {
200 @let anchor = path_anchor(&file.path);
201 @let len: usize = file.hunks.iter().map(|h| h.lines.len()).sum();
202 @let big = len > LARGE_FILE_LINES;
203
204 details .filediff.is-big[big] id=(format!("f-{}", anchor))
205 open[!v.collapsed && !big && !file.binary] {
206 summary .filediff-head {
207 span .fd-caret aria-hidden="true" {}
208 (kind_glyph(file.kind))
209 span .fd-path.mono {
210 @let (dir, name) = split_path(&file.path);
211 @if !dir.is_empty() { span .fd-dir { (dir) } }
212 span .fd-name { (name) }
213 }
214 @if let Some(old) = &file.old_path {
215 span .fd-old .mono { "← " (old) }
216 }
217 span .spacer {}
218 // A deleted file has no blob at this revision to link to.
219 @if let (Some(b), false) = (v.blob_base, file.kind == ChangeKind::Deleted) {
220 a .fd-view href=(format!("{b}/{}", file.path)) { "View file" }
221 }
222 @if big { span .fd-note { (len) " lines" } }
223 @if file.binary {
224 span .fx-bin { "binary" }
225 } @else {
226 span .cl-add { "+" (file.additions) }
227 span .cl-del { "" (file.deletions) }
228 (stat_bar(file.additions, file.deletions))
229 }
230 }
231
232 @if file.binary {
233 p .dim style="padding:10px 12px;margin:0" { "Binary file not shown." }
234 } @else {
235 table .mono .difftable {
236 tbody {
237 @for hunk in &file.hunks {
238 tr .hunkhead {
239 td colspan="4" {
240 span .hh-at { "@@ −" (hunk.old_start) "," (hunk.old_lines)
241 " +" (hunk.new_start) "," (hunk.new_lines) " @@" }
242 }
243 }
244 @for l in &hunk.lines {
245 tr .dl.(line_class(l.kind)) {
246 td .lineno { @if let Some(n) = l.old_lineno { (n) } }
247 td .lineno {
248 @if let Some(n) = l.new_lineno { (n) }
249 @if let Some(h) = &v.hooks { ((h.gutter)(file, l)) }
250 }
251 td .dl-mark { (marker(l.kind)) }
252 td .codeline { (spans(&l.spans, l.kind)) }
253 }
254 @if let Some(h) = &v.hooks { ((h.under)(file, l)) }
255 }
256 }
257 }
258 }
259 }
260 }
261 }
262 }
263}
264
265/// The add/delete ratio as a bar. Two numbers say how much; the bar says which
266/// way, which is the thing that reads at a glance down a list of forty files.
267pub fn stat_bar(add: usize, del: usize) -> Markup {
268 let total = add + del;
269 let pct = (add * 100).checked_div(total).unwrap_or(0);
270
271 html! {
272 span .statbar aria-hidden="true" {
273 @if total > 0 {
274 span .statbar-add style=(format!("width:{pct}%")) {}
275 span .statbar-del style=(format!("width:{}%", 100 - pct)) {}
276 }
277 }
278 }
279}
280
281/// A/M/D/R — the one-letter status every reviewer already reads without
282/// thinking, from `git status` onwards.
283pub fn kind_glyph(k: ChangeKind) -> Markup {
284 let (letter, class, title) = match k {
285 ChangeKind::Added => ("A", "is-add", "added"),
286 ChangeKind::Modified => ("M", "is-mod", "modified"),
287 ChangeKind::Deleted => ("D", "is-del", "deleted"),
288 ChangeKind::Renamed => ("R", "is-ren", "renamed"),
289 };
290 html! { span .fkind.(class) title=(title) { (letter) } }
291}
292
293/// `("crates/df-web/src/", "views.rs")` — the directory dims, the file does not.
294pub fn split_path(path: &str) -> (&str, &str) {
295 match path.rfind('/') {
296 Some(i) => (&path[..=i], &path[i + 1..]),
297 None => ("", path),
298 }
299}
300
301/// A path turned into a fragment-safe anchor.
302pub fn path_anchor(path: &str) -> String {
303 path.chars()
304 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
305 .collect()
306}
307
308/// Render a line's word-level spans (spec §8).
309pub(crate) fn spans(spans: &[DiffSpan], kind: DiffLineKind) -> Markup {
310 html! {
311 @for s in spans {
312 @if s.emphasis && !matches!(kind, DiffLineKind::Context) {
313 span .word-changed { (s.text) }
314 } @else {
315 (s.text)
316 }
317 }
318 }
319}
320
321pub(crate) fn line_class(kind: DiffLineKind) -> &'static str {
322 match kind {
323 DiffLineKind::Added => "line-add",
324 DiffLineKind::Deleted => "line-del",
325 DiffLineKind::Context => "line-ctx",
326 }
327}
328
329/// The sign in its own column, so the code starts at the same x on every line.
330/// Inlining it into the text — as this used to — shifts context lines one
331/// character against the lines around them, and that misalignment is most of
332/// why an unstyled diff is hard to read.
333pub(crate) fn marker(kind: DiffLineKind) -> &'static str {
334 match kind {
335 DiffLineKind::Added => "+",
336 DiffLineKind::Deleted => "−",
337 DiffLineKind::Context => "",
338 }
339}
340
341#[cfg(test)]
342pub(crate) mod tests {
343 use super::*;
344
345 #[test]
346 fn path_anchors_cannot_break_out_of_a_fragment() {
347 assert_eq!(path_anchor("src/a b.rs"), "src-a-b-rs");
348 assert_eq!(path_anchor("a\"><script>"), "a---script-");
349 }
350
351 #[test]
352 fn a_path_splits_into_a_dimmable_directory_and_a_basename() {
353 assert_eq!(split_path("crates/df-web/views.rs"), ("crates/df-web/", "views.rs"));
354 assert_eq!(split_path("Cargo.toml"), ("", "Cargo.toml"));
355 assert_eq!(split_path("a/"), ("a/", ""));
356 }
357
358 /// Shared by the tests here and in `review`, so both views are exercised
359 /// against the same shape.
360 pub(crate) fn fixture(lines: usize) -> Diff {
361 let hunk = df_store::Hunk {
362 old_start: 1,
363 old_lines: lines as u32,
364 new_start: 1,
365 new_lines: lines as u32,
366 lines: (0..lines)
367 .map(|i| DiffLine {
368 kind: if i % 3 == 0 { DiffLineKind::Added } else { DiffLineKind::Context },
369 old_lineno: Some(i as u32 + 1),
370 new_lineno: Some(i as u32 + 1),
371 content: format!("line {i}"),
372 spans: vec![DiffSpan { text: format!("line {i}"), emphasis: false }],
373 })
374 .collect(),
375 };
376 Diff {
377 files: vec![FileDiff {
378 path: "crates/df-web/src/views/review.rs".into(),
379 old_path: None,
380 kind: ChangeKind::Modified,
381 binary: false,
382 additions: lines / 3,
383 deletions: 0,
384 hunks: vec![hunk],
385 }],
386 truncated: false,
387 total_additions: lines / 3,
388 total_deletions: 0,
389 }
390 }
391
392 /// A diff of the given paths, with no content — enough for anything that
393 /// only cares about the set of files.
394 fn paths(paths: &[&str]) -> Diff {
395 Diff {
396 files: paths
397 .iter()
398 .map(|p| FileDiff {
399 path: (*p).into(),
400 old_path: None,
401 kind: ChangeKind::Modified,
402 binary: false,
403 additions: 1,
404 deletions: 0,
405 hunks: Vec::new(),
406 })
407 .collect(),
408 truncated: false,
409 total_additions: paths.len(),
410 total_deletions: 0,
411 }
412 }
413
414 /// The tree exists to factor the shared prefix out of a column of paths,
415 /// and every leaf still has to reach its diff.
416 #[test]
417 fn the_tree_nests_directories_and_links_each_file_to_its_anchor() {
418 let d = paths(&["crates/df-web/src/main.rs", "crates/df-store/src/lib.rs"]);
419 let html = tree(&d).into_string();
420
421 // The shared prefix is one row, not two.
422 assert_eq!(html.matches(">crates<").count(), 1, "{html}");
423 // Both crates branch under it, in name order — and each one's own
424 // single-child chain has already folded into its row.
425 let store = html.find(">df-store/src<").expect("df-store/src");
426 let web = html.find(">df-web/src<").expect("df-web/src");
427 assert!(store < web, "directories are not alphabetical");
428 // Only the basename shows; the anchor carries the whole path.
429 assert!(html.contains(&format!(
430 "href=\"#f-{}\"",
431 path_anchor("crates/df-web/src/main.rs")
432 )));
433 assert!(html.contains(">main.rs<"));
434 // The full path stays reachable, since the visible name is truncated.
435 assert!(html.contains("title=\"crates/df-web/src/main.rs\""));
436 }
437
438 /// `crates/df-web/src/views/` is one place, not four. Indenting it four
439 /// times spends the whole sidebar saying nothing.
440 #[test]
441 fn a_chain_of_single_child_directories_collapses_to_one_row() {
442 let d = paths(&["crates/df-web/src/views/repo.rs"]);
443 let html = tree(&d).into_string();
444
445 assert!(html.contains(">crates/df-web/src/views<"), "{html}");
446 assert_eq!(html.matches("<details").count(), 1, "still nested: {html}");
447
448 // A directory that branches must *not* be folded into its parent.
449 let d = paths(&["a/b/one.rs", "a/c/two.rs"]);
450 let html = tree(&d).into_string();
451 assert!(html.contains(">a<"), "{html}");
452 assert!(!html.contains(">a/b<"));
453 }
454
455 /// Directories first, then files — the order every file browser uses, and
456 /// therefore where a reader already looks.
457 #[test]
458 fn root_files_sit_at_the_root_below_the_directories() {
459 let d = paths(&["Cargo.toml", "crates/df-web/src/main.rs"]);
460 let html = tree(&d).into_string();
461
462 let dir = html.find(">crates/df-web/src<").expect("crates/df-web/src");
463 let file = html.find(">Cargo.toml<").expect("Cargo.toml");
464 assert!(dir < file, "a root file came before the directories");
465 }
466
467 #[test]
468 fn an_empty_diff_gets_no_tree() {
469 assert!(tree(&Diff::default()).into_string().is_empty());
470 }
471
472 /// A file long enough to bury everything after it starts folded, and the
473 /// `collapsed` flag folds the rest.
474 #[test]
475 fn large_files_and_the_collapse_flag_fold_the_diff() {
476 let big = fixture(LARGE_FILE_LINES + 1);
477 let html = files(&DiffView::new(&big)).into_string();
478 assert!(html.contains("class=\"filediff is-big\""), "{html:.400}");
479 assert!(!html.contains("class=\"filediff is-big\" id=\"f-crates-df-web-src-views-review-rs\" open"));
480
481 let small = fixture(10);
482 assert!(files(&DiffView::new(&small)).into_string().contains(" open>"));
483 let folded = files(&DiffView { collapsed: true, ..DiffView::new(&small) }).into_string();
484 assert!(!folded.contains(" open>"), "collapsed folds every file");
485 }
486
487 /// The commit view's file header reaches the blob at that exact revision —
488 /// the "and now show me the whole file" step, one click from any hunk.
489 #[test]
490 fn a_blob_base_links_each_file_to_its_content_at_the_revision() {
491 let d = fixture(4);
492 let html =
493 files(&DiffView { blob_base: Some("/o/r/blob/abc"), ..DiffView::new(&d) }).into_string();
494 assert!(
495 html.contains("href=\"/o/r/blob/abc/crates/df-web/src/views/review.rs\""),
496 "{html:.600}"
497 );
498 // Without one, no link — a change's revision may not be browsable.
499 assert!(!files(&DiffView::new(&d)).into_string().contains("fd-view"));
500 }
501
502 /// A deleted file has no content at this revision, so linking to it would
503 /// be a 404 with extra steps.
504 #[test]
505 fn a_deleted_file_gets_no_view_link() {
506 let mut d = fixture(4);
507 d.files[0].kind = ChangeKind::Deleted;
508 let html =
509 files(&DiffView { blob_base: Some("/o/r/blob/abc"), ..DiffView::new(&d) }).into_string();
510 assert!(!html.contains("fd-view"), "{html:.400}");
511 }
512
513 #[test]
514 fn hunk_headers_carry_both_side_ranges() {
515 let d = fixture(4);
516 let html = files(&DiffView::new(&d)).into_string();
517 assert!(html.contains("@@ −1,4 +1,4 @@"), "{html:.600}");
518 }
519}

519 lines · Rust