| 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