web: add line permalinks to the file view
Arborium replaces each code block's innerHTML, and highlighting lines
independently breaks tokens that span newlines, so per-line rows can only
be built client-side, after the blob is highlighted.

Assisted-by: GLM-5.3 via pi
change kmzzkzkrzyqpumqxlpvpovokmnvkkwrr
commit e771ca92b547769ebb43f3753d51921f6e5bed40
author Alpha Chen <alpha@kejadlen.dev>
date
parent 5be79b03
diff --git a/README.md b/README.md
index cf82123..be5f7cb 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@ Named after the old bookbinding term: a gathering of folded leaves, sewn togethe
 A Rust binary that runs in a Docker container, fronted by the host's sshd and a TLS-terminating reverse proxy. It gives you:
 
 - **Git hosting over SSH**, via the host's sshd dispatching into the container. Explicit repo creation (`ssh git@host quire repo new <name>`).
-- **A read-only web view** for browsing README, tree, history, blame, diffs, and refs.
+- **A read-only web view** for browsing README, tree, history, blame, diffs, and refs. Files support line permalinks: `#L42` for a single line, `#L10-L20` for a range.
 - **Fennel-based CI** (Fennel is a Lisp that compiles to Lua), with pipelines defined in `.quire/ci.fnl`. Unsandboxed since every pipeline is code I've written; a sandboxed opt-in is planned for the day quire ever runs code I haven't.
 - **Automatic mirroring** to GitHub, Gitea, or any HTTPS remote on every push. Map each remote URL to a push token in `.quire/config.fnl` under `:mirrors` (the value names an entry in the global `:secrets`); quire pushes every updated ref to all of them independently of CI.
 - **Email notifications** (planned) for CI failures and recoveries. SMTP via `msmtp`; plain text; per-repo config for what to send and to whom.
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 8cccd2f..14cb1fc 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -160,7 +160,7 @@ Things to build after v1 is stable.
 Tracing "where did this code come from, where did it go" is the thing every forge does poorly and every developer wants. The baseline gets us `git log --follow` and basic blame. This is about going materially beyond that:
 
 - **Blame ladder.** Start on blame for a file at HEAD. Click any line → jump to the commit that last touched it and show blame at that commit's parent. Keep climbing. Turns blame from a point-in-time snapshot into navigable history. The UI affordance is "click to ascend" — like `tig`'s blame navigation but in a browser.
-- **Range follow.** Select contiguous lines in the file view, get `git log -L` for that range, rendered as commits with diffs scoped to those lines. Much more useful than full-file history when you're asking about a specific function.
+- **Range follow.** Select contiguous lines in the file view, get `git log -L` for that range, rendered as commits with diffs scoped to those lines. Much more useful than full-file history when you're asking about a specific function. The selection primitive exists: line permalinks (`#L10-L20`) are live, and every selection dispatches a `quire:lines-selected` event on `document` with `{start, end}`.
 - **Rename trail.** For a file, surface its full `(sha, path)` history as a list, so you can see the file's whole identity arc at once instead of reconstructing it from scattered commits.
 - **Cross-file code trails** (aspirational). When a block of code moves between files — extraction into a module, split of a big file — follow it. Hard and heuristic-dependent; no forge does it well. Worth trying `git log --find-copies-harder` as a starting point, maybe with Myers-diff-based block matching on top. If this proves tractable it's the feature that makes quire's web UI distinctive.
 
diff --git a/docs/STYLE_GUIDE.md b/docs/STYLE_GUIDE.md
index e1df5c8..6c15487 100644
--- a/docs/STYLE_GUIDE.md
+++ b/docs/STYLE_GUIDE.md
@@ -247,9 +247,16 @@ is `font-mono`, eyebrow + content blocks, separated by 24px.
 
 ### Single-column (file/code)
 
-File views go full-width. Use a 2-column grid inside (line-number gutter
-+ code body) with `gridTemplateColumns: 'auto 1fr'`. The gutter has
-`background: code` and a `1px solid rule2` right border.
+File views go full-width. The server renders a 2-column grid (line-number
+gutter + code body) with `gridTemplateColumns: 'auto 1fr'`; the gutter has
+`background: code` and a `1px solid rule2` right border, and each gutter
+number carries `id="L<n>"` so `#L42` works without JavaScript.
+
+After highlighting settles, `static/file-view.js` replaces that pair with
+per-line rows (`[number link | content]`, `--quire-gutter-w` keeping the
+number column a shared width). Rows are the anchor targets, keep the gutter
+aligned across wrapped lines, and carry the `.selected` class for the line
+range in the URL hash.
 
 ### Prose width
 
diff --git a/quire-server/src/quire/web/handlers/mod.rs b/quire-server/src/quire/web/handlers/mod.rs
index 7b83a90..d3ee74c 100644
--- a/quire-server/src/quire/web/handlers/mod.rs
+++ b/quire-server/src/quire/web/handlers/mod.rs
@@ -46,6 +46,15 @@ pub async fn stylesheet() -> Response {
         .into_response()
 }
 
+/// Serve the compiled-in file-view script (line permalinks).
+pub async fn file_view_script() -> Response {
+    (
+        [(header::CONTENT_TYPE, "text/javascript; charset=utf-8")],
+        include_str!("../../../../static/file-view.js"),
+    )
+        .into_response()
+}
+
 pub async fn config(State(quire): State<Quire>) -> Response {
     let tmpl = ConfigTemplate {
         crumbs: None,
@@ -83,6 +92,26 @@ mod tests {
             Self { _dir: dir, quire }
         }
 
+        /// Init the bare repo and push a single-file commit from a scratch
+        /// work tree, so tree/file views have real content to read.
+        fn populate_repo(&self, name: &str, content: &str) {
+            let bare = self.quire.repos_dir().join("example.git");
+            let run_bare = |args: &[&str]| git(bare.as_path(), args);
+            run_bare(&["init", "--bare"]);
+
+            let work = tempfile::tempdir().expect("workdir");
+            let workdir = work.path().to_path_buf();
+            let git_dir = |args: &[&str]| git(&workdir, args);
+            git_dir(&["init", "-b", "main"]);
+            git_dir(&["config", "user.email", "test@example.com"]);
+            git_dir(&["config", "user.name", "Test"]);
+            fs_err::write(workdir.join(name), content).expect("write file");
+            git_dir(&["add", "."]);
+            git_dir(&["commit", "-m", "seed"]);
+            git_dir(&["push", bare.to_str().expect("utf-8 path"), "main"]);
+            run_bare(&["symbolic-ref", "HEAD", "refs/heads/main"]);
+        }
+
         fn insert_run(
             &self,
             id: &str,
@@ -130,6 +159,20 @@ mod tests {
     const UUID1: &str = "aaaaaaaa-0000-0000-0000-000000000001";
     const SHA1: &str = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
 
+    fn git(cwd: &std::path::Path, args: &[&str]) {
+        let out = std::process::Command::new("git")
+            .args(["-c", "commit.gpgsign=false"])
+            .args(args)
+            .current_dir(cwd)
+            .output()
+            .expect("run git");
+        assert!(
+            out.status.success(),
+            "git {args:?} failed: {}",
+            String::from_utf8_lossy(&out.stderr)
+        );
+    }
+
     #[tokio::test]
     async fn repo_home_returns_ok_for_known_repo() {
         let env = TestEnv::new();
@@ -303,4 +346,32 @@ mod tests {
             .unwrap();
         assert_eq!(resp.status(), StatusCode::NOT_FOUND);
     }
+
+    #[tokio::test]
+    async fn file_view_renders_line_anchors_and_script() {
+        let env = TestEnv::new();
+        env.populate_repo("hello.txt", "alpha\nbeta\n");
+        let resp = env
+            .app()
+            .oneshot(
+                Request::builder()
+                    .uri("/example/tree/hello.txt")
+                    .body(Body::empty())
+                    .unwrap(),
+            )
+            .await
+            .unwrap();
+        assert_eq!(resp.status(), StatusCode::OK);
+        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
+            .await
+            .expect("read body");
+        let html = String::from_utf8_lossy(&body);
+        assert!(html.contains("id=\"L1\""), "missing anchor for line 1");
+        assert!(html.contains("id=\"L2\""), "missing anchor for line 2");
+        assert!(!html.contains("id=\"L3\""), "unexpected anchor past EOF");
+        assert!(
+            html.contains("/file-view.js"),
+            "file-view script not included"
+        );
+    }
 }
diff --git a/quire-server/src/quire/web/mod.rs b/quire-server/src/quire/web/mod.rs
index 7bebfaa..4de6c05 100644
--- a/quire-server/src/quire/web/mod.rs
+++ b/quire-server/src/quire/web/mod.rs
@@ -20,8 +20,8 @@ use axum_extra::routing::RouterExt;
 use crate::{
     Quire,
     quire::web::handlers::{
-        commit_view, config, log_view, repo_home, repo_list, run_detail, run_list, stylesheet,
-        tree_view, tree_view_path,
+        commit_view, config, file_view_script, log_view, repo_home, repo_list, run_detail,
+        run_list, stylesheet, tree_view, tree_view_path,
     },
 };
 
@@ -93,6 +93,7 @@ pub fn ci_router(quire: Quire) -> Router {
 pub fn public_router(quire: Quire) -> Router {
     Router::new()
         .route("/style.css", get(stylesheet))
+        .route("/file-view.js", get(file_view_script))
         .typed_get(repo_home)
         .typed_get(tree_view)
         .typed_get(tree_view_path)
diff --git a/quire-server/static/file-view.js b/quire-server/static/file-view.js
new file mode 100644
index 0000000..3a8a734
--- /dev/null
+++ b/quire-server/static/file-view.js
@@ -0,0 +1,166 @@
+// Line permalinks for the file view (#L42, #L10-L20).
+//
+// The server renders the code surface as a line-number gutter plus one
+// blob. After arborium highlights the blob, this script splits it into
+// per-line rows: each line gets a stable anchor, wrapped lines keep the
+// gutter aligned, and selection is a class instead of geometry.
+// Without JS (or before this runs), the gutter numbers carry the ids, so
+// #L42 still jumps natively.
+(function () {
+  "use strict";
+
+  var surface = document.querySelector(".code-surface");
+  if (!surface) return;
+
+  var gutter = surface.querySelector(".code-gutter");
+  var code = surface.querySelector("pre.code-body code");
+
+  // Parse "#L42" or "#L42-L99" into a normalized inclusive range.
+  function parseHash(hash) {
+    var m = /^#L(\d+)(?:-L(\d+))?$/.exec(hash);
+    if (!m) return null;
+    var start = parseInt(m[1], 10);
+    var end = m[2] === undefined ? start : parseInt(m[2], 10);
+    if (start > end) {
+      var t = start;
+      start = end;
+      end = t;
+    }
+    return { start: start, end: end };
+  }
+
+  // Move child nodes from src into dst until a newline is consumed.
+  // Token elements that span the newline are closed in dst and reopened as
+  // shallow clones on the next line, preserving highlighting across lines.
+  function takeLine(src, dst) {
+    while (src.firstChild) {
+      var node = src.firstChild;
+      if (node.nodeType === Node.TEXT_NODE) {
+        var idx = node.data.indexOf("\n");
+        if (idx === -1) {
+          dst.appendChild(node);
+          continue;
+        }
+        if (idx > 0) {
+          node.splitText(idx);
+          dst.appendChild(node);
+          node = src.firstChild; // remainder, starting with "\n"
+        }
+        node.deleteData(0, 1);
+        if (!node.data) node.remove();
+        return true;
+      }
+      if (node.nodeType !== Node.ELEMENT_NODE) {
+        dst.appendChild(node);
+        continue;
+      }
+      var clone = node.cloneNode(false);
+      var hit = takeLine(node, clone);
+      dst.appendChild(clone);
+      if (!hit) node.remove();
+      else return true;
+    }
+    return false;
+  }
+
+  function makeRow(n) {
+    var row = document.createElement("div");
+    row.className = "code-row";
+    var num = document.createElement("a");
+    num.className = "code-line-num";
+    num.id = "L" + n;
+    num.href = "#L" + n;
+    num.dataset.line = n;
+    num.textContent = n;
+    var content = document.createElement("span");
+    content.className = "code-line-content";
+    row.appendChild(num);
+    row.appendChild(content);
+    return { row: row, content: content };
+  }
+
+  var rows = [];
+  var anchorLine = null;
+
+  function split() {
+    if (!code || !code.textContent) return;
+    var lines = document.createElement("div");
+    lines.className = "code-lines";
+    var current = makeRow(1);
+    lines.appendChild(current.row);
+    var n = 1;
+    while (takeLine(code, current.content)) {
+      if (!code.firstChild) break; // blob ended with "\n": no trailing row
+      n += 1;
+      current = makeRow(n);
+      lines.appendChild(current.row);
+    }
+    if (gutter) {
+      lines.style.setProperty("--quire-gutter-w", gutter.offsetWidth + "px");
+    }
+    rows = Array.prototype.slice.call(lines.children);
+    surface.textContent = "";
+    surface.appendChild(lines);
+    surface.classList.add("code-surface--rows");
+    applySelection(true);
+  }
+
+  function applySelection(initial) {
+    var sel = parseHash(window.location.hash);
+    var start = 0;
+    var end = -1;
+    if (sel && sel.start <= rows.length) {
+      start = sel.start;
+      end = Math.min(sel.end, rows.length);
+    }
+    var first = null;
+    for (var i = 0; i < rows.length; i++) {
+      var selected = i + 1 >= start && i + 1 <= end;
+      rows[i].classList.toggle("selected", selected);
+      if (selected && !first) first = rows[i];
+    }
+    if (first) {
+      first.scrollIntoView({ block: initial ? "center" : "nearest" });
+      document.dispatchEvent(
+        new CustomEvent("quire:lines-selected", {
+          detail: { start: start, end: end },
+        })
+      );
+    }
+  }
+
+  surface.addEventListener("click", function (ev) {
+    var num = ev.target.closest(".code-line-num");
+    if (!num) return;
+    var line = parseInt(num.dataset.line, 10);
+    if (ev.shiftKey && anchorLine !== null) {
+      ev.preventDefault();
+      var a = Math.min(anchorLine, line);
+      var b = Math.max(anchorLine, line);
+      window.location.hash = a === b ? "#L" + a : "#L" + a + "-L" + b;
+    } else {
+      anchorLine = line; // plain click: the href sets the hash natively
+    }
+  });
+
+  window.addEventListener("hashchange", function () {
+    applySelection(false);
+  });
+
+  function start() {
+    if (window.quireHighlightSettled) {
+      split();
+      return;
+    }
+    document.addEventListener("quire:highlight-settled", function onSettled() {
+      document.removeEventListener("quire:highlight-settled", onSettled);
+      split();
+    });
+  }
+
+  if (document.readyState === "loading") {
+    document.addEventListener("DOMContentLoaded", start);
+  } else {
+    start();
+  }
+})();
diff --git a/quire-server/static/style.css b/quire-server/static/style.css
index 5f65ba6..8ce9285 100644
--- a/quire-server/static/style.css
+++ b/quire-server/static/style.css
@@ -846,6 +846,46 @@ pre code[data-lang] {
   font-size: 13px !important;
 }
 
+/* Per-line rows — file-view.js swaps the gutter/blob pair above for rows
+   after highlighting, so each line anchors (#L42) and wrapped lines keep
+   the gutter aligned. */
+.code-surface.code-surface--rows { display: block; }
+
+.code-row {
+  display: grid;
+  grid-template-columns: var(--quire-gutter-w, 4ch) minmax(0, 1fr);
+}
+
+.code-row.selected { background: var(--code); }
+
+.code-line-num {
+  box-sizing: border-box;
+  display: block;
+  padding: 0 10px 0 12px;
+  text-align: right;
+  color: var(--mutedFaint);
+  text-decoration: none;
+  border-right: 1px solid var(--rule);
+  user-select: none;
+  font-variant-numeric: tabular-nums;
+  white-space: pre;
+}
+
+a.code-line-num:hover,
+.code-row.selected .code-line-num { color: var(--ink); }
+
+.code-line-content {
+  padding: 0 16px 0 12px;
+  white-space: pre-wrap;
+  word-break: break-word;
+}
+
+.code-lines .code-row:first-child .code-line-num,
+.code-lines .code-row:first-child .code-line-content { padding-top: 16px; }
+
+.code-lines .code-row:last-child .code-line-num,
+.code-lines .code-row:last-child .code-line-content { padding-bottom: 16px; }
+
 /* ── Tree browser ──────────────────────────────────────────────── */
 
 .tree-path-crumb {
diff --git a/quire-server/templates/_base.html b/quire-server/templates/_base.html
index 1a7479a..29a2f71 100644
--- a/quire-server/templates/_base.html
+++ b/quire-server/templates/_base.html
@@ -35,11 +35,19 @@
           localStorage.setItem('quire-dark', this.darkMode ? '1' : '0');
           this._highlight();
         },
-        _highlight() {
+        async _highlight() {
           const arborium = window.arborium;
-          if (!arborium) return;
-          const theme = this.darkMode ? 'github-dark' : 'github-light';
-          arborium.highlightAll({ theme });
+          try {
+            if (arborium) {
+              const theme = this.darkMode ? 'github-dark' : 'github-light';
+              await arborium.highlightAll({ theme });
+            }
+          } finally {
+            // file-view.js restructures the file view after highlighting
+            // settles — success or failure — so it must always hear this.
+            window.quireHighlightSettled = true;
+            document.dispatchEvent(new Event('quire:highlight-settled'));
+          }
         }
       }
     }
diff --git a/quire-server/templates/file.html b/quire-server/templates/file.html
index adaf4c3..3a9c89c 100644
--- a/quire-server/templates/file.html
+++ b/quire-server/templates/file.html
@@ -27,7 +27,7 @@
     <div class="code-surface">
       <div class="code-gutter">
         {% for line_num in line_nums %}
-        <div class="code-line-num">{{ line_num }}</div>
+        <div class="code-line-num" id="L{{ line_num }}">{{ line_num }}</div>
         {% endfor %}
       </div>
       <pre class="code-body"><code data-lang="{{ language }}">{% for line in lines %}{{ line|safe }}{% endfor %}</code></pre>
@@ -89,4 +89,6 @@
   </aside>
 </div>
 
+<script defer src="/file-view.js"></script>
+
 {% endblock %}