Skip to main content

max / makenotwork

Describe the source browser The consumer half of 19d7602d. Five git templates go: tree, file, blame, commit, and the three partials only the commit page included. What replaces them is src/quasi/git_browse.rs, git_blame.rs and git_commit.rs. The addresses carry a file path, and quasi-router's matcher takes {name} for one segment with no wildcard, so these are not quasi routes: the axum handlers stay and render the screens themselves, which is custom_page's arrangement. quasi::document_shell is the join, so a screen served this way gets the same head, chrome and CSRF meta a mounted document does. Source stops being markup anywhere. git::blame_file and git::diff_commit each escaped their lines into HTML on the way out; the screens carry Node::Code and quasi-webview escapes at the point markup is produced, so there is one path from a lexer to a document and src/quasi/widgets/code.rs is gone with the template it was holding the gap for. A diff line also loses its terminator: it draws as a blank line under every row when the content is white-space: pre. The diff is Cells::changed's only consumer, which is what it was added for. A row says which side it is on and the sign is drawn from that rather than written into the content, so a terminal can spell it its own way. The origin column is gone with it. The commit page's write forms are described, so they post through htmx, and a 303 there is followed inside the XHR. notes_write and annotations answer through helpers::redirect_to now, which is HX-Redirect to an htmx caller and a 303 to a browser. 26 templates carrying an inline <code> go through crate::quasi::literal, so the element and the escaping are the renderer's at each of them. What is left hand-written is item.html's license placeholder, which is a box a script fills rather than a value.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
Author: Max Johnson <me@maxj.phd> · 2026-09-02 17:47 UTC
Signed with PGP, not checked
Commit: 8cc7e63963f67083baf6577db01e90aaed6af9c5
Parent: 8f3e3ee
50 files changed, +2564 insertions, -1043 deletions
M server/Cargo.lock +29 -29
@@ -2875,7 +2875,7 @@
2875 2875 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
2876 2876 dependencies = [
2877 2877 "libc",
2878 - "windows-sys 0.61.2",
2878 + "windows-sys 0.52.0",
2879 2879 ]
2880 2880
2881 2881 [[package]]
@@ -3660,7 +3660,7 @@
3660 3660 checksum = "1a791e6620676a875f362f3156ed213e73ca099a09bf992c18812abe65cc37b1"
3661 3661 dependencies = [
3662 3662 "bstr",
3663 - "hashbrown 0.17.1",
3663 + "hashbrown 0.15.5",
3664 3664 ]
3665 3665
3666 3666 [[package]]
@@ -4811,7 +4811,7 @@
4811 4811 source = "registry+https://github.com/rust-lang/crates.io-index"
4812 4812 checksum = "82cb6a9f675da968c63b6208c641b9dca58fc0133ae53375736b1767b0cab8bd"
4813 4813 dependencies = [
4814 - "windows-sys 0.61.2",
4814 + "windows-sys 0.59.0",
4815 4815 ]
4816 4816
4817 4817 [[package]]
@@ -5722,7 +5722,7 @@
5722 5722 source = "registry+https://github.com/rust-lang/crates.io-index"
5723 5723 checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
5724 5724 dependencies = [
5725 - "windows-sys 0.61.2",
5725 + "windows-sys 0.59.0",
5726 5726 ]
5727 5727
5728 5728 [[package]]
@@ -6636,7 +6636,7 @@
6636 6636
6637 6637 [[package]]
6638 6638 name = "quasi-axum"
6639 - version = "0.99.0"
6639 + version = "0.99.1"
6640 6640 dependencies = [
6641 6641 "axum",
6642 6642 "http 1.4.2",
@@ -6647,7 +6647,7 @@
6647 6647
6648 6648 [[package]]
6649 6649 name = "quasi-basics"
6650 - version = "0.99.0"
6650 + version = "0.99.1"
6651 6651 dependencies = [
6652 6652 "makeover-layout",
6653 6653 "quasi-router",
@@ -6655,7 +6655,7 @@
6655 6655
6656 6656 [[package]]
6657 6657 name = "quasi-http"
6658 - version = "0.99.0"
6658 + version = "0.99.1"
6659 6659 dependencies = [
6660 6660 "form_urlencoded",
6661 6661 "http 1.4.2",
@@ -6664,7 +6664,7 @@
6664 6664
6665 6665 [[package]]
6666 6666 name = "quasi-router"
6667 - version = "0.99.0"
6667 + version = "0.99.1"
6668 6668 dependencies = [
6669 6669 "makeover-layout",
6670 6670 ]
@@ -6687,7 +6687,7 @@
6687 6687
6688 6688 [[package]]
6689 6689 name = "quasi-webview"
6690 - version = "0.99.0"
6690 + version = "0.99.1"
6691 6691 dependencies = [
6692 6692 "docengine",
6693 6693 "makeover-layout",
@@ -7339,7 +7339,7 @@
7339 7339 "errno",
7340 7340 "libc",
7341 7341 "linux-raw-sys 0.12.1",
7342 - "windows-sys 0.61.2",
7342 + "windows-sys 0.52.0",
7343 7343 ]
7344 7344
7345 7345 [[package]]
@@ -7419,7 +7419,7 @@
7419 7419 "security-framework 3.7.0",
7420 7420 "security-framework-sys",
7421 7421 "webpki-root-certs",
7422 - "windows-sys 0.61.2",
7422 + "windows-sys 0.52.0",
7423 7423 ]
7424 7424
7425 7425 [[package]]
@@ -8545,7 +8545,7 @@
8545 8545 "getrandom 0.4.3",
8546 8546 "once_cell",
8547 8547 "rustix 1.1.4",
8548 - "windows-sys 0.61.2",
8548 + "windows-sys 0.52.0",
8549 8549 ]
8550 8550
8551 8551 [[package]]
@@ -9971,7 +9971,7 @@
9971 9971 source = "registry+https://github.com/rust-lang/crates.io-index"
9972 9972 checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
9973 9973 dependencies = [
9974 - "windows-sys 0.61.2",
9974 + "windows-sys 0.48.0",
9975 9975 ]
9976 9976
9977 9977 [[package]]
@@ -10759,6 +10759,22 @@
10759 10759 "pkg-config",
10760 10760 ]
10761 10761
10762 + [[patch.unused]]
10763 + name = "quasi-immediate"
10764 + version = "0.99.1"
10765 +
10766 + [[patch.unused]]
10767 + name = "quasi-notifs"
10768 + version = "0.99.1"
10769 +
10770 + [[patch.unused]]
10771 + name = "quasi-store"
10772 + version = "0.1.0"
10773 +
10774 + [[patch.unused]]
10775 + name = "quasi-tauri"
10776 + version = "0.99.1"
10777 +
10762 10778 [[patch.unused]]
10763 10779 name = "kberg"
10764 10780 version = "0.1.0"
@@ -10778,19 +10794,3 @@
10778 10794 [[patch.unused]]
10779 10795 name = "synckit-config"
10780 10796 version = "0.2.0"
10781 -
10782 - [[patch.unused]]
10783 - name = "quasi-immediate"
10784 - version = "0.99.0"
10785 -
10786 - [[patch.unused]]
10787 - name = "quasi-notifs"
10788 - version = "0.99.0"
10789 -
10790 - [[patch.unused]]
10791 - name = "quasi-store"
10792 - version = "0.1.0"
10793 -
10794 - [[patch.unused]]
10795 - name = "quasi-tauri"
10796 - version = "0.99.0"
@@ -1,7 +1,25 @@
1 - (function(){
2 - function hl(){var h=location.hash;if(h&&h.match(/^#L\d+$/)){
3 - document.querySelectorAll('.git-file-content tr.highlighted').forEach(function(r){r.classList.remove('highlighted')});
4 - var el=document.getElementById(h.slice(1));if(el)el.classList.add('highlighted');
5 - }}
6 - window.addEventListener('hashchange',hl);hl();
1 + (function () {
2 + // A line's identity is `L42`, which the description says with
3 + // `Cells::identified` and quasi-webview emits as `data-value`. Not an id:
4 + // a row identity is unique within its table and an id is unique in a
5 + // document, so the browser does not scroll to the hash by itself and this
6 + // does it.
7 + function highlight() {
8 + var hash = location.hash;
9 + if (!hash || !hash.match(/^#L\d+$/)) {
10 + return;
11 + }
12 + document.querySelectorAll('.table-row.is-line').forEach(function (row) {
13 + row.classList.remove('is-line');
14 + });
15 + var row = document.querySelector(
16 + '[data-value="' + hash.slice(1) + '"]'
17 + );
18 + if (row) {
19 + row.classList.add('is-line');
20 + row.scrollIntoView({ block: 'center' });
21 + }
22 + }
23 + window.addEventListener('hashchange', highlight);
24 + highlight();
7 25 })();
@@ -6379,10 +6379,22 @@
6379 6379 ========================================================================== */
6380 6380
6381 6381 .git-repo-header { margin-bottom: var(--gap-pane); }
6382 - .git-repo-name { font-size: var(--text-head); margin: 0 0 var(--gap-bound); }
6383 - .git-repo-name a { color: var(--content); text-decoration: none; }
6384 - .git-repo-name a:hover { opacity: 0.6; }
6382 + /* The identity line, templated (`.git-repo-name`) and described
6383 + (`#git-repo-name`). The separator is punctuation the reader never presses,
6384 + so a described one is drawn here rather than said in the description. */
6385 + .git-repo-name,
6386 + #git-repo-name { font-size: var(--text-head); margin: 0 0 var(--gap-bound); }
6387 + .git-repo-name a,
6388 + #git-repo-name a { color: var(--content); text-decoration: none; }
6389 + .git-repo-name a:hover,
6390 + #git-repo-name a:hover { opacity: 0.6; }
6385 6391 .git-repo-name .sep { opacity: 0.3; margin: 0 var(--gap-bound); }
6392 + #git-repo-name .run { gap: 0; }
6393 + #git-repo-name .link + .link::before {
6394 + content: "/";
6395 + opacity: 0.3;
6396 + margin: 0 var(--gap-bound);
6397 + }
6386 6398 .git-repo-desc { opacity: 0.6; margin: 0 0 var(--gap-section); font-size: var(--text-note); }
6387 6399 .git-clone-url {
6388 6400 font-family: var(--font-mono);
@@ -6438,14 +6450,27 @@
6438 6450 #git-nav .row-current .row-activate { opacity: 1; }
6439 6451
6440 6452 /* Breadcrumb navigation */
6441 - .git-breadcrumb {
6453 + .git-breadcrumb,
6454 + #git-breadcrumb {
6442 6455 font-family: var(--font-mono);
6443 6456 font-size: var(--text-note);
6444 6457 margin-bottom: var(--gap-section);
6445 6458 }
6446 - .git-breadcrumb a { color: var(--content); text-decoration: none; }
6447 - .git-breadcrumb a:hover { text-decoration: underline; }
6459 + .git-breadcrumb a,
6460 + #git-breadcrumb a { color: var(--content); text-decoration: none; }
6461 + .git-breadcrumb a:hover,
6462 + #git-breadcrumb a:hover { text-decoration: underline; }
6448 6463 .git-breadcrumb .sep { opacity: 0.3; margin: 0 var(--gap-bound); }
6464 + /* The trail's separators, for `#git-repo-name`'s reason. `.text` is the last
6465 + crumb, which is the thing itself and goes nowhere. */
6466 + #git-breadcrumb .run { gap: 0; }
6467 + #git-breadcrumb .run > * + *::before,
6468 + #git-breadcrumb .run > * + * > .text::before {
6469 + content: "/";
6470 + opacity: 0.3;
6471 + margin: 0 var(--gap-bound);
6472 + }
6473 + #git-breadcrumb .text { display: inline; margin: 0; }
6449 6474
6450 6475 /* File tree table */
6451 6476 .git-tree { width: 100%; border-collapse: collapse; font-size: var(--text-note); }
@@ -6471,6 +6496,19 @@
6471 6496 font-family: var(--font-mono);
6472 6497 font-size: var(--text-fine);
6473 6498 }
6499 + /* The same listing, described (`crate::quasi::git_browse`). `.git-tree` stays
6500 + because `repo.html` still writes it; the `col-` classes are the renderer's,
6501 + taken from the column names. The icon column is gone on purpose: it was one
6502 + character wide and said what the trailing slash on a directory's name
6503 + already said. */
6504 + #git-browse .table { font-size: var(--text-note); }
6505 + #git-browse .col-Name .cell-link { font-family: var(--font-mono); }
6506 + #git-browse .col-Size {
6507 + text-align: right;
6508 + opacity: 0.5;
6509 + font-family: var(--font-mono);
6510 + font-size: var(--text-fine);
6511 + }
6474 6512
6475 6513 /* README rendering */
6476 6514 .git-readme { margin-top: var(--gap-page); padding-top: var(--gap-pane); border-top: 1px solid var(--border); }
@@ -6499,11 +6537,9 @@
6499 6537 .git-readme-body th,
6500 6538 .git-readme-body td { border: 1px solid var(--border); padding: var(--gap-peer) var(--gap-group); }
6501 6539
6502 - /* File viewer */
6503 - .git-file-header {
6504 - display: flex;
6505 - justify-content: space-between;
6506 - align-items: center;
6540 + /* The strip over a file: how big it is, and the other ways to read it.
6541 + Described (`crate::quasi::git_browse`, `crate::quasi::git_blame`). */
6542 + #git-file-header {
6507 6543 padding: var(--gap-peer) var(--gap-group);
6508 6544 background: var(--surface-overlay);
6509 6545 border: 1px solid var(--border);
@@ -6511,47 +6547,47 @@
6511 6547 border-radius: var(--radius-control) var(--radius-control) 0 0;
6512 6548 font-size: var(--text-fine);
6513 6549 }
6514 - .git-file-meta {
6515 - font-family: var(--font-mono);
6516 - opacity: 0.6;
6517 - }
6518 - .git-file-actions a {
6519 - font-family: var(--font-mono);
6520 - text-decoration: none;
6521 - color: var(--content);
6522 - opacity: 0.6;
6523 - font-size: var(--text-fine);
6524 - }
6525 - .git-file-actions a:hover { opacity: 1; }
6526 - .git-file-content {
6527 - border: 1px solid var(--border);
6528 - border-radius: 0 0 var(--radius-control) var(--radius-control);
6529 - overflow-x: auto;
6530 - }
6531 - .git-file-content table { border-collapse: collapse; width: 100%; }
6532 - .git-file-content .line-number {
6550 + /* The described strip is a run, which lays out across already; what the app
6551 + still says is that the count and the links go to opposite ends. */
6552 + #git-file-header .run { justify-content: space-between; }
6553 + #git-file-header .text { margin: 0; font-family: var(--font-mono); opacity: 0.6; }
6554 + /* The design system's link colour and underline stand; what the strip adds is
6555 + that these read as file actions rather than as prose links. */
6556 + #git-file-header .link { font-family: var(--font-mono); opacity: 0.6; }
6557 + #git-file-header .link:hover { opacity: 1; }
6558 + /* The file view, described (`crate::quasi::git_browse`): a table of one row per
6559 + line, under the strip that names the file. The `col-` classes are the
6560 + renderer's. */
6561 + #git-browse .col-Line {
6533 6562 width: 1px;
6534 6563 white-space: nowrap;
6535 - padding: 0 var(--gap-section);
6536 6564 text-align: right;
6537 6565 user-select: none;
6538 6566 opacity: 0.3;
6539 6567 font-family: var(--font-mono);
6540 6568 font-size: var(--text-fine);
6541 6569 border-right: 1px solid var(--border);
6542 - vertical-align: top;
6543 6570 }
6544 - .git-file-content .line-code {
6545 - padding: 0 var(--gap-section);
6571 + #git-browse .col-Line .cell-link { color: inherit; display: block; }
6572 + #git-browse .col-Line .cell-link:hover { opacity: 0.7; }
6573 + #git-browse .col-Code {
6546 6574 white-space: pre;
6547 6575 font-family: var(--font-mono);
6548 6576 font-size: var(--text-fine);
6549 6577 line-height: 1.5;
6550 6578 }
6551 - .git-file-content .line-code span {
6552 - font-family: var(--font-mono);
6579 + /* The line the address names. `data-value` rather than `:target`, since a row
6580 + identity is not an element id: `page-git-file.js` sets this. */
6581 + #git-browse .table-row.is-line { background: var(--highlight-faint); }
6582 + /* The strip and the table are one box: the strip rounds its top corners and
6583 + the table under it rounds the bottom two. Only the file view has a strip,
6584 + which is why this is a sibling rule and not one on the table. */
6585 + #git-file-header ~ .table {
6586 + border: 1px solid var(--border);
6587 + border-radius: 0 0 var(--radius-control) var(--radius-control);
6588 + overflow-x: auto;
6553 6589 }
6554 - .git-binary-notice {
6590 + #git-binary-notice {
6555 6591 padding: var(--gap-page);
6556 6592 text-align: center;
6557 6593 opacity: 0.5;
@@ -6560,6 +6596,8 @@
6560 6596 border: 1px solid var(--border);
6561 6597 border-radius: 0 0 var(--radius-control) var(--radius-control);
6562 6598 }
6599 + #git-binary-notice .run { justify-content: center; }
6600 + #git-binary-notice .text { margin: 0; }
6563 6601
6564 6602 /* Syntax highlighting.
6565 6603 The class names are quasi-webview's, from `makeover_layout::Syntax::name`,
@@ -6640,8 +6678,11 @@
6640 6678 .git-release-version .version-meta { opacity: 0.5; }
6641 6679
6642 6680 /* Git commit detail + diff */
6643 - .git-commit-detail { margin-bottom: var(--gap-pane); }
6644 - .git-commit-full-message {
6681 + /* The commit page, described (`crate::quasi::git_commit`). */
6682 + #git-commit-detail { margin-bottom: var(--gap-pane); }
6683 + /* The message, as it was written: a commit message's line breaks are the
6684 + author's and wrapping them away loses the shape of the thing. */
6685 + #git-commit-detail > .text {
6645 6686 font-family: var(--font-mono);
6646 6687 font-size: var(--text-note);
6647 6688 white-space: pre-wrap;
@@ -6651,20 +6692,21 @@
6651 6692 border: 1px solid var(--border);
6652 6693 margin-bottom: var(--gap-section);
6653 6694 }
6654 - .git-commit-detail-meta { font-size: var(--text-note); opacity: 0.7; line-height: 1.8; }
6655 - .git-commit-detail-meta .git-commit-oid { font-family: var(--font-mono); font-size: var(--text-fine); word-break: break-all; }
6656 - .git-commit-parents a { font-family: var(--font-mono); color: var(--content); text-decoration: none; }
6657 - .git-commit-parents a:hover { text-decoration: underline; }
6658 - /* Commit trailers, lifted out of the message body and shown as what they are */
6659 - .git-commit-trailers {
6695 + /* Trailers, lifted out of the message body and shown as what they are. Rows
6696 + here rather than a `dl`: the description says a list of pairs and the grid is
6697 + this sheet's reading of it. */
6698 + #git-commit-detail > .list { font-size: var(--text-note); margin-bottom: var(--gap-section); }
6699 + #git-commit-detail > .list .row {
6660 6700 display: grid;
6661 6701 grid-template-columns: max-content 1fr;
6662 6702 gap: var(--gap-bound) var(--gap-section);
6663 - font-size: var(--text-note);
6664 - margin-bottom: var(--gap-section);
6665 6703 }
6666 - .git-commit-trailers dt { opacity: 0.6; }
6667 - .git-commit-trailers dd { margin: 0; font-family: var(--font-mono); font-size: var(--text-fine); }
6704 + #git-commit-detail > .list .row-primary { opacity: 0.6; }
6705 + #git-commit-detail > .list .row-meta { font-family: var(--font-mono); font-size: var(--text-fine); }
6706 + #git-commit-detail-meta { font-size: var(--text-note); opacity: 0.7; line-height: 1.8; }
6707 + #git-commit-detail-meta .text { margin: 0; }
6708 + #git-commit-oid .code { font-size: var(--text-fine); word-break: break-all; }
6709 + #git-commit-parents .link { font-family: var(--font-mono); }
6668 6710 /* Tags, with the annotation an annotated tag carries */
6669 6711 .git-tag-list { list-style: none; }
6670 6712 .git-tag { padding: var(--gap-section) 0; border-bottom: 1px solid var(--border); }
@@ -6794,33 +6836,31 @@
6794 6836 .git-note-body > :first-child { margin-top: 0; }
6795 6837 .git-note-body > :last-child { margin-bottom: 0; }
6796 6838 /* Writing a note. Only rendered for a reader who can push. */
6797 - .git-notes-edit {
6839 + /* The write halves and the reader's own annotations, described. `#annotation`
6840 + is the reader's own note on this commit, which lives in their annotation
6841 + repository rather than in the one being browsed; `#annotations` is the
6842 + read-only panel above it. */
6843 + #git-notes-edit,
6844 + #annotation,
6845 + #annotations {
6798 6846 border-top: 1px solid var(--border);
6799 6847 padding-top: var(--gap-section);
6800 6848 margin-bottom: var(--gap-pane);
6801 6849 }
6802 - .git-notes-edit textarea { width: 100%; font-family: var(--font-mono); font-size: var(--text-note); }
6803 - .git-note-form { margin-bottom: var(--gap-section); }
6804 - .git-note-delete { margin-bottom: var(--gap-pane); }
6805 - .git-note-merged {
6850 + #git-notes-edit textarea,
6851 + #annotation textarea { font-family: var(--font-mono); font-size: var(--text-note); }
6852 + #git-notes-edit .form { margin-bottom: var(--gap-section); }
6853 + #git-notes-edit .button { margin-bottom: var(--gap-pane); }
6854 + #annotations .heading { font-size: var(--text-note); margin: 0 0 var(--gap-peer); }
6855 + #annotations > .text { font-size: var(--text-note); color: var(--content-muted); }
6856 + /* A merge notice: what is on screen is not exactly what was typed. */
6857 + #git-notes-edit > .text[data-tone="warning"],
6858 + #annotation > .text[data-tone="warning"] {
6806 6859 border-left: 2px solid var(--content);
6807 6860 padding: var(--gap-peer) var(--gap-section);
6808 6861 margin-bottom: var(--gap-section);
6809 6862 font-size: var(--text-note);
6810 6863 }
6811 - /* A reader's own annotation, which lives in their repository rather than this one. */
6812 - .git-annotation {
6813 - border-top: 1px solid var(--border);
6814 - padding-top: var(--gap-section);
6815 - margin-bottom: var(--gap-pane);
6816 - }
6817 - .git-annotation textarea { width: 100%; font-family: var(--font-mono); font-size: var(--text-note); }
6818 - .git-annotations {
6819 - border-top: 1px solid var(--border);
6820 - padding-top: var(--gap-section);
6821 - margin-bottom: var(--gap-pane);
6822 - }
6823 - .git-annotations-heading { font-size: var(--text-note); margin: 0 0 var(--gap-peer); }
6824 6864 .git-annotations-privacy,
6825 6865 .git-annotations-empty,
6826 6866 .git-annotations-count { font-size: var(--text-note); color: var(--content-muted); }
@@ -6842,136 +6882,84 @@
6842 6882 .git-annotation-namespace,
6843 6883 .git-annotation-when { font-family: var(--font-mono); color: var(--content-muted); }
6844 6884 .git-annotation-orphan { font-size: var(--text-note); color: var(--content-muted); }
6845 - .git-diff-stats {
6885 + /* The diff, described (`crate::quasi::git_commit`). `19d7602d`: a row says
6886 + which side it is on through `data-change`, and the sign is drawn here rather
6887 + than written into the content, so a terminal can spell it its own way. */
6888 + #git-diff-stats {
6846 6889 font-size: var(--text-note);
6847 6890 padding: var(--gap-section) 0;
6848 6891 margin-bottom: var(--gap-section);
6849 6892 border-bottom: 1px solid var(--border);
6850 6893 }
6851 - .git-diff-stats .additions { color: var(--diff-add); }
6852 - .git-diff-stats .deletions { color: var(--diff-del); }
6853 - .git-diff-file { margin-bottom: var(--gap-pane); border: 1px solid var(--border); }
6854 - .git-diff-file-header {
6855 - display: flex;
6856 - align-items: center;
6857 - gap: var(--gap-peer);
6894 + #git-diff-stats .text { margin: 0; }
6895 + #git-diff-stats .text[data-tone="success"] { color: var(--diff-add); }
6896 + #git-diff-stats .text[data-tone="danger"] { color: var(--diff-del); }
6897 + #git-commit [id^="diff-"] { margin-bottom: var(--gap-pane); border: 1px solid var(--border); }
6898 + #git-commit [id^="diff-header-"] {
6858 6899 padding: var(--gap-peer) var(--gap-section);
6859 6900 background: var(--surface-overlay);
6860 6901 border-bottom: 1px solid var(--border);
6902 + border-radius: 0;
6861 6903 font-size: var(--text-note);
6862 - flex-wrap: wrap;
6863 6904 }
6864 - .git-diff-file-header a { color: var(--content); text-decoration: none; }
6865 - .git-diff-file-header a:hover { text-decoration: underline; }
6866 - .git-diff-file-stats { margin-left: auto; font-size: var(--text-fine); }
6867 - .git-diff-file-stats .additions { color: var(--diff-add); }
6868 - .git-diff-file-stats .deletions { color: var(--diff-del); margin-left: var(--gap-bound); }
6869 - .diff-status-added, .diff-status-deleted, .diff-status-modified, .diff-status-renamed {
6870 - display: inline-block;
6871 - width: 1.2rem;
6872 - text-align: center;
6873 - font-size: var(--text-fine);
6874 - font-weight: 700;
6875 - font-family: var(--font-mono);
6876 - border-radius: var(--radius-fine);
6877 - line-height: 1.4;
6878 - }
6879 - .diff-status-added { background: color-mix(in oklch, var(--success) 14%, var(--surface-page)); color: var(--success); }
6880 - .diff-status-deleted { background: color-mix(in oklch, var(--danger) 14%, var(--surface-page)); color: var(--danger); }
6881 - .diff-status-modified { background: color-mix(in oklch, var(--warning) 14%, var(--surface-page)); color: var(--warning); }
6882 - .diff-status-renamed { background: color-mix(in oklch, var(--info) 14%, var(--surface-page)); color: var(--info); }
6883 - .git-diff-table {
6884 - width: 100%;
6885 - border-collapse: collapse;
6905 + /* The counts sit at the far end, as `.git-diff-file-stats` did with its auto
6906 + margin. */
6907 + #git-commit [id^="diff-header-"] .link + .cell-drops-next { margin-left: auto; }
6908 + #git-commit [id^="diff-header-"] .text { margin: 0; font-size: var(--text-fine); }
6909 + #git-commit [id^="diff-header-"] .text[data-tone="success"] { color: var(--diff-add); }
6910 + #git-commit [id^="diff-header-"] .text[data-tone="danger"] { color: var(--diff-del); }
6911 + #git-commit [id^="diff-"] .table {
6886 6912 font-family: var(--font-mono);
6887 6913 font-size: var(--text-fine);
6888 - table-layout: fixed;
6914 + border: 0;
6889 6915 }
6890 - .git-diff-hunk-header td {
6891 - padding: var(--gap-bound) var(--gap-peer);
6892 - background: var(--highlight-faint);
6893 - color: var(--content);
6894 - opacity: 0.6;
6895 - font-size: var(--text-fine);
6896 - }
6897 - .git-diff-lineno {
6898 - width: 3.5rem;
6899 - padding: 0 var(--gap-peer);
6916 + #git-commit [id^="diff-"] .col-Old,
6917 + #git-commit [id^="diff-"] .col-New {
6900 6918 text-align: right;
6901 - color: var(--content);
6902 6919 opacity: 0.35;
6903 6920 user-select: none;
6904 - vertical-align: top;
6905 6921 }
6906 - .git-diff-origin {
6907 - width: 1rem;
6908 - text-align: center;
6922 + #git-commit [id^="diff-"] .col-Line { white-space: pre-wrap; word-break: break-all; }
6923 + /* The hunk header: the one row of the table that is on neither side. */
6924 + #git-commit [id^="diff-"] .table-row:not([data-change]) .col-Line {
6925 + background: var(--highlight-faint);
6926 + opacity: 0.6;
6927 + }
6928 + #git-commit [id^="diff-"] .table-row[data-change="added"] { background: var(--diff-add-bg); }
6929 + #git-commit [id^="diff-"] .table-row[data-change="removed"] { background: var(--diff-del-bg); }
6930 + /* The sign, drawn rather than said. `::before` on the line cell, so it lands in
6931 + the gutter the origin column used to be. */
6932 + #git-commit [id^="diff-"] .table-row[data-change] .col-Line::before {
6933 + display: inline-block;
6934 + width: 1ch;
6935 + margin-right: var(--gap-bound);
6909 6936 user-select: none;
6910 - vertical-align: top;
6911 6937 }
6912 - .git-diff-content {
6913 - padding: 0 var(--gap-peer);
6914 - white-space: pre-wrap;
6915 - word-break: break-all;
6938 + #git-commit [id^="diff-"] .table-row[data-change="added"] .col-Line::before {
6939 + content: "+";
6940 + color: var(--diff-add);
6916 6941 }
6917 - .git-diff-line-add { background: var(--diff-add-bg); }
6918 - .git-diff-line-add .git-diff-origin { color: var(--diff-add); }
6919 - .git-diff-line-del { background: var(--diff-del-bg); }
6920 - .git-diff-line-del .git-diff-origin { color: var(--diff-del); }
6921 - .git-diff-line-ctx { background: transparent; }
6922 - .git-diff-truncated { padding: var(--gap-peer) var(--gap-section); font-size: var(--text-fine); opacity: 0.5; font-style: italic; }
6923 -
6924 - /* Git file view line linking */
6925 - .git-file-content .line-number a {
6926 - color: inherit;
6927 - text-decoration: none;
6928 - display: block;
6942 + #git-commit [id^="diff-"] .table-row[data-change="removed"] .col-Line::before {
6943 + content: "-";
6944 + color: var(--diff-del);
6929 6945 }
6930 - .git-file-content .line-number a:hover { opacity: 0.7; }
6931 - .git-file-content tr:target td,
6932 - .git-file-content tr.highlighted td { background: var(--highlight-faint); }
6946 + #git-commit [id^="diff-"] .table-row[data-change="context"] .col-Line::before { content: " "; }
6933 6947
6934 6948 /* Git blame view */
6935 - .git-blame-content { overflow-x: auto; border: 1px solid var(--border); }
6936 - .git-blame-content table { border-collapse: collapse; width: 100%; font-size: var(--text-fine); }
6937 - .git-blame-info {
6938 - padding: 0 var(--gap-peer);
6939 - font-family: var(--font-mono);
6940 - white-space: nowrap;
6941 - width: 4.5rem;
6942 - }
6943 - .git-blame-info a { color: var(--content); text-decoration: none; }
6944 - .git-blame-info a:hover { text-decoration: underline; }
6945 - .git-blame-author {
6946 - padding: 0 var(--gap-bound);
6949 + /* Git blame view, described (`crate::quasi::git_blame`). The `col-` classes are
6950 + the renderer's, taken from the column names the description gives. */
6951 + #git-blame .table {
6952 + border: 1px solid var(--border);
6953 + border-radius: 0 0 var(--radius-control) var(--radius-control);
6947 6954 font-size: var(--text-fine);
6948 - opacity: 0.5;
6949 - white-space: nowrap;
6950 - max-width: 8rem;
6951 - overflow: hidden;
6952 - text-overflow: ellipsis;
6953 6955 }
6954 - .git-blame-date {
6955 - padding: 0 var(--gap-bound);
6956 - font-size: var(--text-fine);
6957 - opacity: 0.35;
6958 - white-space: nowrap;
6959 - width: 5.5rem;
6960 - }
6961 - .git-blame-content .line-number {
6962 - padding: 0 var(--gap-peer);
6963 - text-align: right;
6964 - opacity: 0.35;
6965 - user-select: none;
6966 - width: 3rem;
6967 - font-family: var(--font-mono);
6968 - }
6969 - .git-blame-content .line-code {
6970 - padding: 0 var(--gap-peer);
6971 - white-space: pre;
6972 - font-family: var(--font-mono);
6973 - }
6974 - .git-blame-boundary td { border-top: 1px solid var(--border); }
6956 + #git-blame .cell { font-family: var(--font-mono); }
6957 + #git-blame .cell-link { color: var(--content); text-decoration: none; }
6958 + #git-blame .cell-link:hover { text-decoration: underline; }
6959 + #git-blame .col-Author { opacity: 0.5; }
6960 + #git-blame .col-Date { opacity: 0.35; }
6961 + #git-blame .col-Line { text-align: right; opacity: 0.35; user-select: none; }
6962 + #git-blame .col-Code { white-space: pre; }
6975 6963
6976 6964 /* The two repository listings, /git and /git/{owner}, moved to the description
6977 6965 layer. Both were a <ul> of two-line entries with a visibility chip; both are
@@ -7002,18 +6990,16 @@
7002 6990 @media (max-width: 839px) {
7003 6991 /* Git browser mobile */
7004 6992 #git-nav .run { gap: var(--gap-peer); }
7005 - .git-repo-name { font-size: var(--text-subhead); }
6993 + .git-repo-name,
6994 + #git-repo-name { font-size: var(--text-subhead); }
7006 6995 .git-clone-url { font-size: var(--text-fine); word-break: break-all; }
7007 6996 .git-tree .icon { display: none; }
7008 6997 .git-tree td, .git-tree th { padding: var(--gap-bound) var(--gap-peer); }
7009 - .git-file-header { flex-direction: column; align-items: flex-start; gap: var(--gap-bound); }
7010 - .git-file-content .line-number { padding: 0 var(--gap-peer); }
7011 - .git-file-content .line-code { padding: 0 var(--gap-peer); font-size: var(--text-fine); }
6998 + #git-file-header .run { justify-content: flex-start; gap: var(--gap-bound); }
6999 + #git-browse .col-Code { font-size: var(--text-fine); }
7012 7000 .git-commit-meta { flex-direction: column; gap: var(--gap-bound); }
7013 7001 .git-release-version { flex-wrap: wrap; gap: var(--gap-peer); }
7014 - .git-diff-file-header { font-size: var(--text-fine); }
7015 - .git-diff-table { font-size: var(--text-fine); }
7016 - .git-diff-lineno { width: 2.5rem; font-size: var(--text-fine); }
7002 + #git-commit [id^="diff-header-"] { font-size: var(--text-fine); }
7017 7003 .git-blame-author { display: none; }
7018 7004 .git-blame-date { display: none; }
7019 7005
@@ -280,13 +280,22 @@
280 280 (rebuilt, trailers)
281 281 }
282 282
283 - /// Escape the HTML-significant characters in a line of file content.
284 - fn escape(content: &[u8]) -> String {
285 - content
286 - .to_str_lossy()
287 - .replace('&', "&amp;")
288 - .replace('<', "&lt;")
289 - .replace('>', "&gt;")
283 + /// A diff line as text.
284 + ///
285 + /// Lossy because a diff is read rather than applied: a byte sequence that is
286 + /// not UTF-8 is shown with replacement characters instead of costing the reader
287 + /// the whole hunk.
288 + ///
289 + /// It does not escape. `quasi_router::Node::Code` carries the line and the
290 + /// renderer escapes at the point markup is produced, so a pass here would
291 + /// double every ampersand in every diff on the site.
292 + fn line_text(content: &[u8]) -> String {
293 + // Without the terminator: a line is what is on it, and the newline is what
294 + // separates it from the next. Carried, it draws as a blank line under every
295 + // row of every diff, since the content is `white-space: pre`.
296 + let text = content.to_str_lossy();
297 + let line = text.strip_suffix('\n').unwrap_or(&text);
298 + line.strip_suffix('\r').unwrap_or(line).to_owned()
290 299 }
291 300
292 301 /// Binary detection, matching the blob reader: a null byte in the first 8KB.
@@ -358,7 +367,7 @@
358 367
359 368 out.push(DiffLine {
360 369 origin,
361 - content: escape(content),
370 + content: line_text(content),
362 371 old_lineno: old,
363 372 new_lineno: new,
364 373 });
@@ -608,18 +617,13 @@
608 617 .format("%Y-%m-%d")
609 618 .to_string();
610 619
611 - let escaped = line_content
612 - .replace('&', "&amp;")
613 - .replace('<', "&lt;")
614 - .replace('>', "&gt;");
615 -
616 620 result.push(BlameLine {
617 621 lineno,
618 622 commit_oid: oid_str,
619 623 commit_short_oid: short_oid,
620 624 author_name,
621 625 time_formatted,
622 - content: escaped,
626 + content: (*line_content).to_owned(),
623 627 // libgit2 reported a boundary hunk when the walk stopped at its
624 628 // oldest commit. gitoxide has no equivalent flag, and nothing in
625 629 // the blame view renders it differently.
@@ -186,12 +186,17 @@
186 186 }
187 187 }
188 188
189 - pub fn css_class(&self) -> &'static str {
189 + /// The word behind the letter.
190 + ///
191 + /// [`label`](Self::label) is one character because the badge is small, and
192 + /// a reader who has not learned the four letters gets nothing from it. This
193 + /// is what the badge carries as its detail.
194 + pub fn name(&self) -> &'static str {
190 195 match self {
191 - Self::Added => "diff-status-added",
192 - Self::Deleted => "diff-status-deleted",
193 - Self::Modified => "diff-status-modified",
194 - Self::Renamed => "diff-status-renamed",
196 + Self::Added => "Added",
197 + Self::Deleted => "Deleted",
198 + Self::Modified => "Modified",
199 + Self::Renamed => "Renamed",
195 200 }
196 201 }
197 202 }
@@ -204,7 +209,15 @@
204 209
205 210 /// A single line in a diff hunk.
206 211 pub struct DiffLine {
212 + /// Which side the line is on: `+`, `-`, or a space for context.
213 + ///
214 + /// Kept because the diff is collected from a unified diff and this is what
215 + /// it says. The commit page turns it into a `makeover_layout::Change` and
216 + /// never draws it: a sign in the content is a sign a renderer cannot say
217 + /// its own way.
207 218 pub origin: char,
219 + /// The line as it is in the blob. Not markup, `BlameLine::content`'s
220 + /// reason.
208 221 pub content: String,
209 222 pub old_lineno: Option<u32>,
210 223 pub new_lineno: Option<u32>,
@@ -219,6 +232,9 @@
219 232 pub commit_short_oid: String,
220 233 pub author_name: String,
221 234 pub time_formatted: String,
235 + /// The line as it is in the blob. Not markup: the blame view carries it as
236 + /// an inline `quasi_router::Node::Code` and the renderer escapes it, so a
237 + /// second escaping pass here would double every ampersand.
222 238 pub content: String,
223 239 pub is_boundary: bool,
224 240 }
@@ -1032,9 +1048,11 @@
1032 1048 }
1033 1049
1034 1050 #[test]
1035 - fn commit_diff_escapes_html_in_content() {
1036 - // Diff lines are rendered into the commit page unescaped by the
1037 - // template, so the escaping has to happen here.
1051 + fn commit_diff_carries_the_line_and_not_markup() {
1052 + // The commit page carries a diff line as an inline
1053 + // `quasi_router::Node::Code` and the renderer escapes at the point
1054 + // markup is produced (`crate::quasi::git_commit`), so this escapes
1055 + // nothing: a pass here would double every ampersand on the site.
1038 1056 use gix::objs::tree::EntryKind;
1039 1057
1040 1058 let (_tmp, _bare_path, repo) = init_test_repo("escape");
@@ -1049,9 +1067,7 @@
1049 1067 let files = commit_diff(&repo, head, 10, 1000).unwrap();
1050 1068
1051 1069 let content = &files[0].hunks[0].lines[0].content;
1052 - assert!(!content.contains("<script>"));
1053 - assert!(content.contains("&lt;script&gt;"));
1054 - assert!(content.contains("&amp;&amp;"));
1070 + assert_eq!(content, "<script>alert('x') && y</script>");
1055 1071 }
1056 1072
1057 1073 #[test]
@@ -54,6 +54,9 @@
54 54 pub mod feeds;
55 55 pub mod follow;
56 56 pub mod forum_memberships;
57 + pub mod git_blame;
58 + pub mod git_browse;
59 + pub mod git_commit;
57 60 pub mod git_explore;
58 61 pub mod git_repos;
59 62 pub mod item_files;
@@ -64,6 +67,7 @@
64 67 pub mod library_tabs;
65 68 pub mod license_key_act;
66 69 pub mod link_remove_act;
70 + pub mod literal;
67 71 pub mod media_picker;
68 72 pub mod payout_summary;
69 73 pub mod policy;
@@ -241,14 +245,7 @@
241 245 /// holds no session.
242 246 #[must_use]
243 247 pub fn document_shell(&self) -> quasi_webview::Shell {
244 - crate::shell::described()
245 - .sending("X-CSRF-Token", &self.csrf)
246 - .with_body_last(crate::shell::body_last())
247 - .with_chrome(crate::quasi::shortcuts::chrome())
248 - .with_head(format!(
249 - "<meta name=\"csrf-token\" content=\"{}\">",
250 - crate::helpers::escape_html(&self.csrf)
251 - ))
248 + document_shell(&self.csrf)
252 249 }
253 250
254 251 /// Everything the handler drew, for the renderer to mount.
@@ -257,6 +254,26 @@
257 254 }
258 255 }
259 256
257 + /// The shell a described document is drawn in, by the token it carries.
258 + ///
259 + /// [`Viewer::document_shell`] is this with the token already in hand. It is a
260 + /// free function as well because a screen whose address carries a wildcard
261 + /// segment cannot be a quasi route at all -- `quasi_router`'s matcher takes
262 + /// `{name}` and nothing else -- so the git file and blame views are built here
263 + /// and served from their own axum handlers, which hold a token and no
264 + /// [`Viewer`].
265 + #[must_use]
266 + pub fn document_shell(csrf: &str) -> quasi_webview::Shell {
267 + crate::shell::described()
268 + .sending("X-CSRF-Token", csrf)
269 + .with_body_last(crate::shell::body_last())
270 + .with_chrome(crate::quasi::shortcuts::chrome())
271 + .with_head(format!(
272 + "<meta name=\"csrf-token\" content=\"{}\">",
273 + crate::helpers::escape_html(csrf)
274 + ))
275 + }
276 +
260 277 /// Who a mount is willing to answer.
261 278 ///
262 279 /// The signed-out question `b5cbb646` left open, answered here rather than by a
@@ -267,15 +267,11 @@
267 267 PasskeyListTemplate,
268 268 // Git source browser
269 269 GitRepoTemplate,
270 - GitTreeTemplate,
271 - GitFileTemplate,
272 270 GitCommitsTemplate,
273 - GitCommitDetailTemplate,
274 271 GitNotesTemplate,
275 272 GitAnnotationsTemplate,
276 273 GitTagsTemplate,
277 274 GitReplaceTemplate,
278 - GitBlameTemplate,
279 275 GitFileLogTemplate,
280 276 // Git issues
281 277 GitIssueListTemplate,