git-ents.gitmain
⌘K
foforge
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

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

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); + }); +})();