roots: add ents.js for click-to-select and select-to-comment
commit 00e3319
roots: add ents.js for click-to-select and select-to-comment
The blob header’s csrf-protected composer template and the gutter’s
per-row hooks were inert markup until now. Add a vanilla,
dependency-free script that click/shift-click a line-number gutter
into a selection (mirroring it into location.hash so the existing
server-rendered hash-anchor links keep working), injects a per-row
plus-button affordance, and opens the composer inline, filling its
hidden lines field and posting through the ordinary form flow — no
fetch/AJAX anywhere. The page stays fully usable with JS disabled.
feat: add assets/ents.js for line selection and the inline composer
feat: serve GET /ents.js next to the existing GET /style.css route
feat: load /ents.js with defer from the page shell head
feat: add .blob-add gutter affordance styling
Assisted-by: Claude:claude-sonnet-5
No reviews of this commit yet — record a verdict below.
Start a review
crates/cli/ents-web/src/assets.rs
@@ -4,7 +4,12 @@
//! (`pre-redo:crates/git-ents-server/src/web/style.css`), ported rather
//! than vendored -- including its type stack, which this crate's own
//! system-font fallback carries rather than the pre-redo Google Fonts load
-//! (see `ents.css`'s own header comment).
+//! (see `ents.css`'s own header comment). `ents.js` is new to this crate
+//! (pre-redo had no client-side script at all): a vanilla,
+//! dependency-free progressive enhancement over `crate::pages::files`'s
+//! raw-source blob view -- click-to-select a line or range and an inline
+//! comment composer -- served alongside `ents.css` the same way, via
+//! `crate::router`'s own `GET /ents.js` route.
//!
//! The icon functions below are vendored Octicons (`.gitvendors`, MIT; see
//! `assets/icons/LICENSE`), re-homed here from
@@ -21,6 +26,10 @@
pub(crate) const OVERRIDES: &str = include_str!("assets/ents.css");
+/// The client-side line-selection/comment-composer script
+/// [`crate::router`]'s `GET /ents.js` serves -- see this module's own doc.
+pub(crate) const SCRIPT: &str = include_str!("assets/ents.js");
+
/// Adapt a vendored Octicon to this UI: tag it with the `.icon` class the
/// stylesheet targets and mark it decorative for assistive tech. Every
/// vendored file opens with a bare `<svg …>` element, so a single prefix
crates/cli/ents-web/src/router.rs
@@ -65,6 +65,7 @@
.route("/comments/{id}", get(pages::comments::show::<O>))
.route("/inbox", get(pages::inbox::list::<O>))
.route("/style.css", get(style))
+ .route("/ents.js", get(script))
.layer(middleware::from_fn_with_state(
Arc::clone(&state),
session_middleware::<O>,
@@ -128,6 +129,17 @@
)
}
+/// `GET /ents.js`: the progressive-enhancement script
+/// [`pages::layout`]'s `head` loads with `defer` (`crate::assets::SCRIPT`)
+/// -- see [`crate::assets`]'s own doc for what it does. Served the same
+/// way, and under the same no-session-gating rule, as [`style`].
+async fn script() -> impl IntoResponse {
+ (
+ [(header::CONTENT_TYPE, "text/javascript; charset=utf-8")],
+ assets::SCRIPT,
+ )
+}
+
/// Bind a loopback-or-otherwise socket for [`serve_on`], returning the
/// listener before any request is served so a caller can read back
/// [`std::net::TcpListener::local_addr`] (necessary for `addr`'s port `0`,
crates/cli/ents-web/tests/router.rs
@@ -1233,6 +1233,43 @@
assert!(template.contains(&format!(r#"name="rev" value="{oid}""#)));
}
+/// `GET /ents.js` serves the client-side script `crate::pages::layout`
+/// loads with `defer` -- no session required (mirrors `GET /style.css`'s
+/// own stance), a JS content type, and a non-empty body.
+#[tokio::test]
+async fn ents_js_is_served_with_a_javascript_content_type() {
+ let state = build_state(FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ });
+ let router = ents_web::router(state);
+
+ let response = router
+ .oneshot(
+ Request::get("/ents.js")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::OK);
+ let content_type = response
+ .headers()
+ .get(header::CONTENT_TYPE)
+ .expect("content-type")
+ .to_str()
+ .expect("ascii")
+ .to_owned();
+ assert!(content_type.contains("javascript"));
+ let body = response
+ .into_body()
+ .collect()
+ .await
+ .expect("body")
+ .to_bytes();
+ assert!(!body.is_empty());
+}
+
/// The blob header bar (`crate::pages::files::blob_header`) shows a raw
/// source file's line count, human-formatted size, and detected language,
/// plus the "comment on this file" no-JS fallback link -- moved here from
crates/cli/ents-web/src/assets/ents.css
@@ -19,8 +19,9 @@
* blob view, which otherwise reuse `.card`/`.doc-body` as-is; and
* `.blob-header`/`.blob-actions`/`.entry-size` for
* `crate::pages::files::blob_header`/`dir_listing`'s own metadata, plus
- * `.composer-*` for its server-rendered comment-composer template --
- * pre-redo had no blob header bar and no comment system at all.
+ * `tr.sel`/`.blob-add`/`.composer-*` for `assets/ents.js`'s client-side
+ * line selection and inline comment composer -- pre-redo had neither a
+ * blob header bar nor any client-side script at all.
*/
:root {
--font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
@@ -321,13 +322,21 @@
.blob tr.blob-comment-row .card { margin: .5rem 1rem; }
.binary { padding: 2.5rem; text-align: center; font-family: var(--font-mono); font-size: .85rem; color: var(--color-text-muted); }
-/* Client-side line selection (`assets/ents.js`, wired up next): `.blob-nums
- * a:target` above is the no-JS fallback for a single anchored line; `tr.sel`
- * is the script's own richer selection, spanning a whole clicked/
- * shift-clicked range. */
+/* Client-side line selection (`assets/ents.js`): `.blob-nums a:target` above
+ * is the no-JS fallback for a single anchored line; `tr.sel` is the
+ * script's own richer selection, spanning a whole clicked/shift-clicked
+ * range. */
.blob tr.sel td { background: var(--color-accent-subtle); }
.blob tr.sel td.blob-nums { color: var(--color-accent); font-weight: 700; }
+/* The gutter's "+" comment affordance (`assets/ents.js`): injected once per
+ * line row, absolutely positioned inside the already-positioned (sticky)
+ * `.blob-nums` cell so it costs no layout of its own and never shifts line
+ * height, hidden until that row is hovered or the button itself is
+ * focused. */
+.blob-add { position: absolute; top: 50%; left: .2rem; transform: translateY(-50%); width: 15px; height: 15px; line-height: 14px; padding: 0; text-align: center; font-size: .78rem; font-weight: 700; color: var(--color-bg); background: var(--color-accent); border: none; border-radius: 4px; opacity: 0; cursor: pointer; transition: opacity .1s; }
+.blob tr:hover .blob-add, .blob-add:focus-visible { opacity: 1; }
+
/* The inline comment composer (`crate::pages::files::composer_template`,
* cloned and shown by `assets/ents.js`): mirrors `tr.blob-comment-row
* .card`'s own margin so it lands flush with the comment cards it
crates/cli/ents-web/src/pages/mod.rs
@@ -237,6 +237,7 @@
meta name="color-scheme" content="light dark";
title { "git ents: " (title) }
link rel="stylesheet" href="/style.css";
+ script src="/ents.js" defer {}
}
body {
nav.site-nav {
crates/cli/ents-web/src/assets/ents.js
@@ -1,0 +1,186 @@
+/*
+ * Progressive enhancement for `crate::pages::files`'s raw-source blob view
+ * (`div.blob[data-path][data-rev]`): click a gutter line number to select
+ * it, shift-click to extend the selection to a range, and open an inline
+ * comment composer cloned from the server-rendered
+ * `<template id="composer-template">`. Every behavior here layers on top
+ * of markup that already works with no script at all -- the plain `#L<n>`
+ * anchors and the header's "comment on this file" link -- so a disabled or
+ * failed script load never breaks the page, only the shortcut.
+ *
+ * Vanilla, dependency-free: no fetch/AJAX anywhere in this file. The
+ * composer's own submit is left as an ordinary form POST; only its Cancel
+ * button and the gutter's "+" affordance are wired up here.
+ */
+(function () {
+ "use strict";
+
+ var blob = document.querySelector("div.blob[data-path][data-rev]");
+ if (!blob) {
+ return;
+ }
+ var table = blob.querySelector("table");
+ if (!table) {
+ return;
+ }
+
+ var rows = Array.prototype.slice.call(table.querySelectorAll("tbody > tr"));
+
+ function lineNumber(tr) {
+ var a = tr.querySelector("td.blob-nums a");
+ return a ? parseInt(a.textContent, 10) : null;
+ }
+
+ var lineRows = rows.filter(function (tr) {
+ return lineNumber(tr) !== null;
+ });
+ var byNumber = {};
+ lineRows.forEach(function (tr) {
+ byNumber[lineNumber(tr)] = tr;
+ });
+
+ var anchorLine = null;
+
+ function selectRange(start, end) {
+ var lo = Math.min(start, end);
+ var hi = Math.max(start, end);
+ lineRows.forEach(function (tr) {
+ tr.classList.remove("sel");
+ });
+ for (var n = lo; n <= hi; n += 1) {
+ if (byNumber[n]) {
+ byNumber[n].classList.add("sel");
+ }
+ }
+ }
+
+ function setHash(start, end) {
+ var hash = start === end ? "#L" + start : "#L" + start + "-L" + end;
+ history.replaceState(null, "", hash);
+ }
+
+ function applyHash(hash, scroll) {
+ var match = /^#L(\d+)(?:-L(\d+))?$/.exec(hash);
+ if (!match) {
+ return;
+ }
+ var start = parseInt(match[1], 10);
+ var end = match[2] ? parseInt(match[2], 10) : start;
+ anchorLine = start;
+ selectRange(start, end);
+ if (scroll && byNumber[start] && byNumber[start].scrollIntoView) {
+ byNumber[start].scrollIntoView({ block: "center" });
+ }
+ }
+
+ if (location.hash) {
+ applyHash(location.hash, true);
+ }
+
+ table.querySelectorAll("td.blob-nums a").forEach(function (a) {
+ a.addEventListener("click", function (event) {
+ event.preventDefault();
+ var n = lineNumber(a.closest("tr"));
+ if (n === null) {
+ return;
+ }
+ if (event.shiftKey && anchorLine !== null) {
+ selectRange(anchorLine, n);
+ setHash(Math.min(anchorLine, n), Math.max(anchorLine, n));
+ } else {
+ anchorLine = n;
+ selectRange(n, n);
+ setHash(n, n);
+ }
+ });
+ });
+
+ function selectedRange() {
+ var numbers = lineRows
+ .filter(function (tr) {
+ return tr.classList.contains("sel");
+ })
+ .map(lineNumber);
+ if (numbers.length === 0) {
+ return null;
+ }
+ return [Math.min.apply(null, numbers), Math.max.apply(null, numbers)];
+ }
+
+ function openComposer() {
+ var range = selectedRange();
+ var template = document.getElementById("composer-template");
+ if (!range || !template) {
+ return;
+ }
+ var existing = table.querySelector("tr.blob-composer");
+ if (existing) {
+ existing.remove();
+ }
+
+ // Land below the last selected line's own row, and below any comment
+ // cards the server already interleaved after it.
+ var afterRow = byNumber[range[1]];
+ if (!afterRow) {
+ return;
+ }
+ while (
+ afterRow.nextElementSibling &&
+ afterRow.nextElementSibling.classList.contains("blob-comment-row")
+ ) {
+ afterRow = afterRow.nextElementSibling;
+ }
+
+ var tr = document.createElement("tr");
+ tr.className = "blob-composer";
+ var td = document.createElement("td");
+ td.colSpan = 2;
+
+ var fragment = template.content.cloneNode(true);
+ var form = fragment.querySelector("form");
+ var linesInput = form && form.querySelector('input[name="lines"]');
+ if (linesInput) {
+ linesInput.value =
+ range[0] === range[1] ? String(range[0]) : range[0] + ":" + range[1];
+ }
+ var cancel = fragment.querySelector(".composer-cancel");
+ if (cancel) {
+ cancel.addEventListener("click", function () {
+ tr.remove();
+ });
+ }
+
+ td.appendChild(fragment);
+ tr.appendChild(td);
+ afterRow.parentNode.insertBefore(tr, afterRow.nextElementSibling);
+ }
+
+ // One "+" affordance per line row, injected once -- CSS reveals it on
+ // row hover (`.blob tr:hover .blob-add`), so there is nothing to
+ // rebuild on each click.
+ lineRows.forEach(function (tr) {
+ var cell = tr.querySelector("td.blob-nums");
+ if (!cell) {
+ return;
+ }
+ var button = document.createElement("button");
+ button.type = "button";
+ button.className = "blob-add";
+ button.setAttribute("aria-label", "Comment on this line");
+ button.textContent = "+";
+ button.addEventListener("click", function (event) {
+ event.preventDefault();
+ var n = lineNumber(tr);
+ if (n === null) {
+ return;
+ }
+ if (anchorLine === null || !tr.classList.contains("sel")) {
+ anchorLine = n;
+ selectRange(n, n);
+ setHash(n, n);
+ }
+ openComposer();
+ });
+ cell.appendChild(button);
+ });
+})();