| 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 | ||
| 10 | use std::collections::BTreeMap; | |
| 11 | ||
| 12 | use maud::{html, Markup}; | |
| 13 | ||
| 14 | use 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. | |
| 19 | pub 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. | |
| 26 | pub 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. | |
| 36 | pub 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 | ||
| 47 | impl<'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. | |
| 55 | pub 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. | |
| 79 | pub 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)] | |
| 98 | struct 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 | ||
| 105 | impl<'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. | |
| 146 | fn 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 | ||
| 162 | fn 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. | |
| 184 | pub 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. | |
| 267 | pub 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. | |
| 283 | pub 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. | |
| 294 | pub 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. | |
| 302 | pub 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). | |
| 309 | pub(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 | ||
| 321 | pub(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. | |
| 333 | pub(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)] | |
| 342 | pub(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