Jump to…
snowfix: 500 error because of database is patchedyuzpxzopsouq1mo
Matt W1//! One diff renderer, shared by every page that shows a patch.
Matt W2//!
Matt W3//! A change's files tab and a commit's page are the same reading task — which
Matt W4//! files, how big, and what changed in each — so they are the same markup and
Matt W5//! the same CSS. What differs is what hangs *off* the diff: the change view
Matt W6//! anchors comment threads to lines, the commit view links each file to its
Matt W7//! blob. Both arrive through [`DiffView`] rather than by growing a second
Matt W8//! renderer that drifts from this one.
Matt W9
Matt W10use std::collections::BTreeMap;
Matt W11
Matt W12use maud::{html, Markup};
Matt W13
Matt W14use df_store::{ChangeKind, Diff, DiffLine, DiffLineKind, DiffSpan, FileDiff};
Matt W15
Matt W16/// A file this long is folded by default. Past a few hundred lines a file is
Matt W17/// no longer something a reviewer reads on the way past — it is a destination —
Matt W18/// and leaving it open buries every file after it.
Matt W19pub const LARGE_FILE_LINES: usize = 400;
Matt W20
Matt W21/// Per-line markup a caller wants woven into the table.
Matt W22///
Matt W23/// Both hooks are called for every rendered line, so an implementation that
Matt W24/// only cares about one side must check the line itself; a hook that returns
Matt W25/// empty markup costs nothing.
Matt W26pub struct LineHooks<'a> {
Matt W27 /// Extra markup inside the new-side line-number cell — the comment
Matt W28 /// affordance, in practice.
Matt W29 pub gutter: &'a dyn Fn(&FileDiff, &DiffLine) -> Markup,
Matt W30 /// Full-width `<tr>`s placed directly beneath the line. Anything returned
Matt W31 /// here must be table rows, since that is where it lands.
Matt W32 pub under: &'a dyn Fn(&FileDiff, &DiffLine) -> Markup,
Matt W33}
Matt W34
Matt W35/// A diff and the decisions a page makes about how to show it.
Matt W36pub struct DiffView<'a> {
Matt W37 pub diff: &'a Diff,
Matt W38 /// Fold every file, for skimming the shape of a large change first.
Matt W39 pub collapsed: bool,
Matt W40 /// `…/blob/{rev}` — when set, every file header links to the file as it
Matt W41 /// stands at this revision. A change's files tab leaves it `None`: the
Matt W42 /// revision under review is not necessarily one the browser can serve.
Matt W43 pub blob_base: Option<&'a str>,
Matt W44 pub hooks: Option<LineHooks<'a>>,
Matt W45}
Matt W46
Matt W47impl<'a> DiffView<'a> {
Matt W48 /// A diff with no annotations — the commit page's default.
Matt W49 pub fn new(diff: &'a Diff) -> Self {
Matt W50 DiffView { diff, collapsed: false, blob_base: None, hooks: None }
Matt W51 }
Matt W52}
Matt W53
Matt W54/// How much changed, as one line: the count, the two totals, and the ratio bar.
Matt W55pub fn stat_summary(d: &Diff) -> Markup {
Matt W56 html! {
Matt W57 div .diffbar-stat {
Matt W58 strong { (d.files.len()) }
Matt W59 " " (if d.files.len() == 1 { "file" } else { "files" })
Matt W60 span .cl-add { "+" (d.total_additions) }
Matt W61 span .cl-del { "−" (d.total_deletions) }
Matt W62 (stat_bar(d.total_additions, d.total_deletions))
Matt W63 }
Matt W64 }
Matt W65}
Matt W66
Matt W67// ─── the changed-file tree ───────────────────────────────────────────────────
Matt W68
Matt W69/// The changed files as the directory tree they actually live in.
Matt W70///
Matt W71/// A flat list of thirty paths in a Rust workspace is thirty rows that all
Matt W72/// begin `crates/df-…/src/`, and the eye has to read to the end of each one to
Matt W73/// tell them apart. The tree factors that prefix out once, so what is left on
Matt W74/// each row is the part that differs — and the shape of the commit ("this
Matt W75/// touched the store and the web crate") becomes visible without reading at
Matt W76/// all.
Matt W77///
Matt W78/// Directories are `<details>`, so folding one needs no script.
Matt W79pub fn tree(d: &Diff) -> Markup {
Matt W80 if d.files.is_empty() {
Matt W81 return html! {};
Matt W82 }
Matt W83
Matt W84 let root = TreeNode::build(&d.files);
Matt W85
Matt W86 html! {
Matt W87 aside .difftree aria-label="Changed files" {
Matt W88 div .dt-head {
Matt W89 (d.files.len()) " " (if d.files.len() == 1 { "file" } else { "files" }) " changed"
Matt W90 }
Matt W91 nav .dt-body { (tree_level(&root)) }
Matt W92 }
Matt W93 }
Matt W94}
Matt W95
Matt W96/// One level of the changed-file tree.
Matt W97#[derive(Default)]
Matt W98struct TreeNode<'a> {
Matt W99 /// Ordered by name, which is how every file browser lists a directory and
Matt W100 /// therefore where a reader already expects to find things.
Matt W101 dirs: BTreeMap<&'a str, TreeNode<'a>>,
Matt W102 files: Vec<&'a FileDiff>,
Matt W103}
Matt W104
Matt W105impl<'a> TreeNode<'a> {
Matt W106 fn build(files: &'a [FileDiff]) -> TreeNode<'a> {
Matt W107 let mut root = TreeNode::default();
Matt W108
Matt W109 for f in files {
Matt W110 let mut node = &mut root;
Matt W111 let mut segments = f.path.split('/').filter(|s| !s.is_empty()).peekable();
Matt W112
Matt W113 while let Some(seg) = segments.next() {
Matt W114 if segments.peek().is_none() {
Matt W115 node.files.push(f);
Matt W116 } else {
Matt W117 node = node.dirs.entry(seg).or_default();
Matt W118 }
Matt W119 }
Matt W120 }
Matt W121
Matt W122 root
Matt W123 }
Matt W124
Matt W125 /// Fold a chain of single-child directories into one row.
Matt W126 ///
Matt W127 /// `crates/df-web/src/views/` is one place, not four, and indenting it four
Matt W128 /// times spends the whole width of the sidebar saying nothing. Returns the
Matt W129 /// joined label and the first node that actually branches.
Matt W130 fn collapse(&'a self, name: &'a str) -> (String, &'a TreeNode<'a>) {
Matt W131 let mut label = name.to_string();
Matt W132 let mut node = self;
Matt W133
Matt W134 while node.files.is_empty() && node.dirs.len() == 1 {
Matt W135 let (child_name, child) = node.dirs.iter().next().expect("len == 1");
Matt W136 label.push('/');
Matt W137 label.push_str(child_name);
Matt W138 node = child;
Matt W139 }
Matt W140
Matt W141 (label, node)
Matt W142 }
Matt W143}
Matt W144
Matt W145/// Directories first, then files — both alphabetical.
Matt W146fn tree_level(node: &TreeNode<'_>) -> Markup {
Matt W147 html! {
Matt W148 @for (name, child) in &node.dirs {
Matt W149 @let (label, child) = child.collapse(name);
Matt W150 details .dt-dir open {
Matt W151 summary .dt-row {
Matt W152 span .dt-caret aria-hidden="true" {}
Matt W153 span .dt-dirname { (label) }
Matt W154 }
Matt W155 div .dt-kids { (tree_level(child)) }
Matt W156 }
Matt W157 }
Matt W158 @for f in &node.files { (tree_file(f)) }
Matt W159 }
Matt W160}
Matt W161
Matt W162fn tree_file(f: &FileDiff) -> Markup {
Matt W163 let (_, name) = split_path(&f.path);
Matt W164
Matt W165 html! {
Matt W166 // The full path in `title`, because the visible name is truncated and
Matt W167 // two files can share a basename.
Matt W168 a .dt-file href=(format!("#f-{}", path_anchor(&f.path))) title=(f.path) {
Matt W169 (kind_glyph(f.kind))
Matt W170 span .dt-name { (name) }
Matt W171 span .spacer {}
Matt W172 @if f.binary {
Matt W173 span .fx-bin { "bin" }
Matt W174 } @else {
Matt W175 span .cl-add { "+" (f.additions) }
Matt W176 span .cl-del { "−" (f.deletions) }
Matt W177 }
Matt W178 }
Matt W179 }
Matt W180}
Matt W181
Matt W182/// The diff itself: one foldable block per file, each a table of numbered
Matt W183/// lines.
Matt W184pub fn files(v: &DiffView<'_>) -> Markup {
Matt W185 let d = v.diff;
Matt W186
Matt W187 html! {
Matt W188 @if d.truncated {
Matt W189 div .banner.banner-error role="alert" {
Matt W190 "This diff exceeds the render limits and is shown in part. \
Matt W191 Fetch the revision with " code { "jj" } " to read it in full."
Matt W192 }
Matt W193 }
Matt W194
Matt W195 @if d.files.is_empty() {
Matt W196 div .panel { p .dim style="margin:0" { "No changes." } }
Matt W197 }
Matt W198
Matt W199 @for file in &d.files {
Matt W200 @let anchor = path_anchor(&file.path);
Matt W201 @let len: usize = file.hunks.iter().map(|h| h.lines.len()).sum();
Matt W202 @let big = len > LARGE_FILE_LINES;
Matt W203
Matt W204 details .filediff.is-big[big] id=(format!("f-{}", anchor))
Matt W205 open[!v.collapsed && !big && !file.binary] {
Matt W206 summary .filediff-head {
Matt W207 span .fd-caret aria-hidden="true" {}
Matt W208 (kind_glyph(file.kind))
Matt W209 span .fd-path.mono {
Matt W210 @let (dir, name) = split_path(&file.path);
Matt W211 @if !dir.is_empty() { span .fd-dir { (dir) } }
Matt W212 span .fd-name { (name) }
Matt W213 }
Matt W214 @if let Some(old) = &file.old_path {
Matt W215 span .fd-old .mono { "← " (old) }
Matt W216 }
Matt W217 span .spacer {}
Matt W218 // A deleted file has no blob at this revision to link to.
Matt W219 @if let (Some(b), false) = (v.blob_base, file.kind == ChangeKind::Deleted) {
Matt W220 a .fd-view href=(format!("{b}/{}", file.path)) { "View file" }
Matt W221 }
Matt W222 @if big { span .fd-note { (len) " lines" } }
Matt W223 @if file.binary {
Matt W224 span .fx-bin { "binary" }
Matt W225 } @else {
Matt W226 span .cl-add { "+" (file.additions) }
Matt W227 span .cl-del { "" (file.deletions) }
Matt W228 (stat_bar(file.additions, file.deletions))
Matt W229 }
Matt W230 }
Matt W231
Matt W232 @if file.binary {
Matt W233 p .dim style="padding:10px 12px;margin:0" { "Binary file not shown." }
Matt W234 } @else {
Matt W235 table .mono .difftable {
Matt W236 tbody {
Matt W237 @for hunk in &file.hunks {
Matt W238 tr .hunkhead {
Matt W239 td colspan="4" {
Matt W240 span .hh-at { "@@ −" (hunk.old_start) "," (hunk.old_lines)
Matt W241 " +" (hunk.new_start) "," (hunk.new_lines) " @@" }
Matt W242 }
Matt W243 }
Matt W244 @for l in &hunk.lines {
Matt W245 tr .dl.(line_class(l.kind)) {
Matt W246 td .lineno { @if let Some(n) = l.old_lineno { (n) } }
Matt W247 td .lineno {
Matt W248 @if let Some(n) = l.new_lineno { (n) }
Matt W249 @if let Some(h) = &v.hooks { ((h.gutter)(file, l)) }
Matt W250 }
Matt W251 td .dl-mark { (marker(l.kind)) }
Matt W252 td .codeline { (spans(&l.spans, l.kind)) }
Matt W253 }
Matt W254 @if let Some(h) = &v.hooks { ((h.under)(file, l)) }
Matt W255 }
Matt W256 }
Matt W257 }
Matt W258 }
Matt W259 }
Matt W260 }
Matt W261 }
Matt W262 }
Matt W263}
Matt W264
Matt W265/// The add/delete ratio as a bar. Two numbers say how much; the bar says which
Matt W266/// way, which is the thing that reads at a glance down a list of forty files.
Matt W267pub fn stat_bar(add: usize, del: usize) -> Markup {
Matt W268 let total = add + del;
Matt W269 let pct = (add * 100).checked_div(total).unwrap_or(0);
Matt W270
Matt W271 html! {
Matt W272 span .statbar aria-hidden="true" {
Matt W273 @if total > 0 {
Matt W274 span .statbar-add style=(format!("width:{pct}%")) {}
Matt W275 span .statbar-del style=(format!("width:{}%", 100 - pct)) {}
Matt W276 }
Matt W277 }
Matt W278 }
Matt W279}
Matt W280
Matt W281/// A/M/D/R — the one-letter status every reviewer already reads without
Matt W282/// thinking, from `git status` onwards.
Matt W283pub fn kind_glyph(k: ChangeKind) -> Markup {
Matt W284 let (letter, class, title) = match k {
Matt W285 ChangeKind::Added => ("A", "is-add", "added"),
Matt W286 ChangeKind::Modified => ("M", "is-mod", "modified"),
Matt W287 ChangeKind::Deleted => ("D", "is-del", "deleted"),
Matt W288 ChangeKind::Renamed => ("R", "is-ren", "renamed"),
Matt W289 };
Matt W290 html! { span .fkind.(class) title=(title) { (letter) } }
Matt W291}
Matt W292
Matt W293/// `("crates/df-web/src/", "views.rs")` — the directory dims, the file does not.
Matt W294pub fn split_path(path: &str) -> (&str, &str) {
Matt W295 match path.rfind('/') {
Matt W296 Some(i) => (&path[..=i], &path[i + 1..]),
Matt W297 None => ("", path),
Matt W298 }
Matt W299}
Matt W300
Matt W301/// A path turned into a fragment-safe anchor.
Matt W302pub fn path_anchor(path: &str) -> String {
Matt W303 path.chars()
Matt W304 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
Matt W305 .collect()
Matt W306}
Matt W307
Matt W308/// Render a line's word-level spans (spec §8).
Matt W309pub(crate) fn spans(spans: &[DiffSpan], kind: DiffLineKind) -> Markup {
Matt W310 html! {
Matt W311 @for s in spans {
Matt W312 @if s.emphasis && !matches!(kind, DiffLineKind::Context) {
Matt W313 span .word-changed { (s.text) }
Matt W314 } @else {
Matt W315 (s.text)
Matt W316 }
Matt W317 }
Matt W318 }
Matt W319}
Matt W320
Matt W321pub(crate) fn line_class(kind: DiffLineKind) -> &'static str {
Matt W322 match kind {
Matt W323 DiffLineKind::Added => "line-add",
Matt W324 DiffLineKind::Deleted => "line-del",
Matt W325 DiffLineKind::Context => "line-ctx",
Matt W326 }
Matt W327}
Matt W328
Matt W329/// The sign in its own column, so the code starts at the same x on every line.
Matt W330/// Inlining it into the text — as this used to — shifts context lines one
Matt W331/// character against the lines around them, and that misalignment is most of
Matt W332/// why an unstyled diff is hard to read.
Matt W333pub(crate) fn marker(kind: DiffLineKind) -> &'static str {
Matt W334 match kind {
Matt W335 DiffLineKind::Added => "+",
Matt W336 DiffLineKind::Deleted => "−",
Matt W337 DiffLineKind::Context => "",
Matt W338 }
Matt W339}
Matt W340
Matt W341#[cfg(test)]
Matt W342pub(crate) mod tests {
Matt W343 use super::*;
Matt W344
Matt W345 #[test]
Matt W346 fn path_anchors_cannot_break_out_of_a_fragment() {
Matt W347 assert_eq!(path_anchor("src/a b.rs"), "src-a-b-rs");
Matt W348 assert_eq!(path_anchor("a\"><script>"), "a---script-");
Matt W349 }
Matt W350
Matt W351 #[test]
Matt W352 fn a_path_splits_into_a_dimmable_directory_and_a_basename() {
Matt W353 assert_eq!(split_path("crates/df-web/views.rs"), ("crates/df-web/", "views.rs"));
Matt W354 assert_eq!(split_path("Cargo.toml"), ("", "Cargo.toml"));
Matt W355 assert_eq!(split_path("a/"), ("a/", ""));
Matt W356 }
Matt W357
Matt W358 /// Shared by the tests here and in `review`, so both views are exercised
Matt W359 /// against the same shape.
Matt W360 pub(crate) fn fixture(lines: usize) -> Diff {
Matt W361 let hunk = df_store::Hunk {
Matt W362 old_start: 1,
Matt W363 old_lines: lines as u32,
Matt W364 new_start: 1,
Matt W365 new_lines: lines as u32,
Matt W366 lines: (0..lines)
Matt W367 .map(|i| DiffLine {
Matt W368 kind: if i % 3 == 0 { DiffLineKind::Added } else { DiffLineKind::Context },
Matt W369 old_lineno: Some(i as u32 + 1),
Matt W370 new_lineno: Some(i as u32 + 1),
Matt W371 content: format!("line {i}"),
Matt W372 spans: vec![DiffSpan { text: format!("line {i}"), emphasis: false }],
Matt W373 })
Matt W374 .collect(),
Matt W375 };
Matt W376 Diff {
Matt W377 files: vec![FileDiff {
Matt W378 path: "crates/df-web/src/views/review.rs".into(),
Matt W379 old_path: None,
Matt W380 kind: ChangeKind::Modified,
Matt W381 binary: false,
Matt W382 additions: lines / 3,
Matt W383 deletions: 0,
Matt W384 hunks: vec![hunk],
Matt W385 }],
Matt W386 truncated: false,
Matt W387 total_additions: lines / 3,
Matt W388 total_deletions: 0,
Matt W389 }
Matt W390 }
Matt W391
Matt W392 /// A diff of the given paths, with no content — enough for anything that
Matt W393 /// only cares about the set of files.
Matt W394 fn paths(paths: &[&str]) -> Diff {
Matt W395 Diff {
Matt W396 files: paths
Matt W397 .iter()
Matt W398 .map(|p| FileDiff {
Matt W399 path: (*p).into(),
Matt W400 old_path: None,
Matt W401 kind: ChangeKind::Modified,
Matt W402 binary: false,
Matt W403 additions: 1,
Matt W404 deletions: 0,
Matt W405 hunks: Vec::new(),
Matt W406 })
Matt W407 .collect(),
Matt W408 truncated: false,
Matt W409 total_additions: paths.len(),
Matt W410 total_deletions: 0,
Matt W411 }
Matt W412 }
Matt W413
Matt W414 /// The tree exists to factor the shared prefix out of a column of paths,
Matt W415 /// and every leaf still has to reach its diff.
Matt W416 #[test]
Matt W417 fn the_tree_nests_directories_and_links_each_file_to_its_anchor() {
Matt W418 let d = paths(&["crates/df-web/src/main.rs", "crates/df-store/src/lib.rs"]);
Matt W419 let html = tree(&d).into_string();
Matt W420
Matt W421 // The shared prefix is one row, not two.
Matt W422 assert_eq!(html.matches(">crates<").count(), 1, "{html}");
Matt W423 // Both crates branch under it, in name order — and each one's own
Matt W424 // single-child chain has already folded into its row.
Matt W425 let store = html.find(">df-store/src<").expect("df-store/src");
Matt W426 let web = html.find(">df-web/src<").expect("df-web/src");
Matt W427 assert!(store < web, "directories are not alphabetical");
Matt W428 // Only the basename shows; the anchor carries the whole path.
Matt W429 assert!(html.contains(&format!(
Matt W430 "href=\"#f-{}\"",
Matt W431 path_anchor("crates/df-web/src/main.rs")
Matt W432 )));
Matt W433 assert!(html.contains(">main.rs<"));
Matt W434 // The full path stays reachable, since the visible name is truncated.
Matt W435 assert!(html.contains("title=\"crates/df-web/src/main.rs\""));
Matt W436 }
Matt W437
Matt W438 /// `crates/df-web/src/views/` is one place, not four. Indenting it four
Matt W439 /// times spends the whole sidebar saying nothing.
Matt W440 #[test]
Matt W441 fn a_chain_of_single_child_directories_collapses_to_one_row() {
Matt W442 let d = paths(&["crates/df-web/src/views/repo.rs"]);
Matt W443 let html = tree(&d).into_string();
Matt W444
Matt W445 assert!(html.contains(">crates/df-web/src/views<"), "{html}");
Matt W446 assert_eq!(html.matches("<details").count(), 1, "still nested: {html}");
Matt W447
Matt W448 // A directory that branches must *not* be folded into its parent.
Matt W449 let d = paths(&["a/b/one.rs", "a/c/two.rs"]);
Matt W450 let html = tree(&d).into_string();
Matt W451 assert!(html.contains(">a<"), "{html}");
Matt W452 assert!(!html.contains(">a/b<"));
Matt W453 }
Matt W454
Matt W455 /// Directories first, then files — the order every file browser uses, and
Matt W456 /// therefore where a reader already looks.
Matt W457 #[test]
Matt W458 fn root_files_sit_at_the_root_below_the_directories() {
Matt W459 let d = paths(&["Cargo.toml", "crates/df-web/src/main.rs"]);
Matt W460 let html = tree(&d).into_string();
Matt W461
Matt W462 let dir = html.find(">crates/df-web/src<").expect("crates/df-web/src");
Matt W463 let file = html.find(">Cargo.toml<").expect("Cargo.toml");
Matt W464 assert!(dir < file, "a root file came before the directories");
Matt W465 }
Matt W466
Matt W467 #[test]
Matt W468 fn an_empty_diff_gets_no_tree() {
Matt W469 assert!(tree(&Diff::default()).into_string().is_empty());
Matt W470 }
Matt W471
Matt W472 /// A file long enough to bury everything after it starts folded, and the
Matt W473 /// `collapsed` flag folds the rest.
Matt W474 #[test]
Matt W475 fn large_files_and_the_collapse_flag_fold_the_diff() {
Matt W476 let big = fixture(LARGE_FILE_LINES + 1);
Matt W477 let html = files(&DiffView::new(&big)).into_string();
Matt W478 assert!(html.contains("class=\"filediff is-big\""), "{html:.400}");
Matt W479 assert!(!html.contains("class=\"filediff is-big\" id=\"f-crates-df-web-src-views-review-rs\" open"));
Matt W480
Matt W481 let small = fixture(10);
Matt W482 assert!(files(&DiffView::new(&small)).into_string().contains(" open>"));
Matt W483 let folded = files(&DiffView { collapsed: true, ..DiffView::new(&small) }).into_string();
Matt W484 assert!(!folded.contains(" open>"), "collapsed folds every file");
Matt W485 }
Matt W486
Matt W487 /// The commit view's file header reaches the blob at that exact revision —
Matt W488 /// the "and now show me the whole file" step, one click from any hunk.
Matt W489 #[test]
Matt W490 fn a_blob_base_links_each_file_to_its_content_at_the_revision() {
Matt W491 let d = fixture(4);
Matt W492 let html =
Matt W493 files(&DiffView { blob_base: Some("/o/r/blob/abc"), ..DiffView::new(&d) }).into_string();
Matt W494 assert!(
Matt W495 html.contains("href=\"/o/r/blob/abc/crates/df-web/src/views/review.rs\""),
Matt W496 "{html:.600}"
Matt W497 );
Matt W498 // Without one, no link — a change's revision may not be browsable.
Matt W499 assert!(!files(&DiffView::new(&d)).into_string().contains("fd-view"));
Matt W500 }
Matt W501
Matt W502 /// A deleted file has no content at this revision, so linking to it would
Matt W503 /// be a 404 with extra steps.
Matt W504 #[test]
Matt W505 fn a_deleted_file_gets_no_view_link() {
Matt W506 let mut d = fixture(4);
Matt W507 d.files[0].kind = ChangeKind::Deleted;
Matt W508 let html =
Matt W509 files(&DiffView { blob_base: Some("/o/r/blob/abc"), ..DiffView::new(&d) }).into_string();
Matt W510 assert!(!html.contains("fd-view"), "{html:.400}");
Matt W511 }
Matt W512
Matt W513 #[test]
Matt W514 fn hunk_headers_carry_both_side_ranges() {
Matt W515 let d = fixture(4);
Matt W516 let html = files(&DiffView::new(&d)).into_string();
Matt W517 assert!(html.contains("@@ −1,4 +1,4 @@"), "{html:.600}");
Matt W518 }
Matt W519}

519 lines · Rust