git-ents.gitmain
⌘K
foforge
commit 39a396c
roots: fix table/strip wrapping and the duplicated identity-chip label

The commits table reused .entity-list’s break-all rule (right for the members list’s long keys, wrong here), shredding oids, author names, and relative times mid-word; the overview freshness strip put `flex: 1; min-width: 0 on the author/ago span instead of the subject, so long author names wrapped instead of the subject ellipsizing. The identity chip showed "git-ents" — git-ents’s own `actor() hardcodes that as the commit-author name, and ents-web read it straight off actor() for the chip too, duplicating the site wordmark instead of naming who is signing.

roots: give the commits table its own nowrap discipline for oid/author/when, leaving subject to wrap normally roots: make the freshness strip a single non-wrapping row with an ellipsized subject roots: add SigningIdentity::label with a default falling back to actor().name roots: extract members::find_by_key from members::check and reuse it to resolve the serving signer’s own member for the identity chip roots: have LocalIdentity::label resolve the enrolled member, falling back to a key fingerprint when unenrolled 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/identity.rs @@ -49,6 +49,9 @@ /// /// let identity: Box<dyn SigningIdentity> = Box::new(Fixed); /// assert_eq!(identity.actor().name, "fixture"); +/// // `label` defaults to `actor().name` when a composition root has no +/// // better identifier (a resolved member's own username, for instance). +/// assert_eq!(identity.label(), "fixture"); /// ``` // @relation(roots.web-signing, roots.web-agnostic, scope=file) pub trait SigningIdentity: Send + Sync { @@ -65,6 +68,22 @@ /// acting, exactly as `git ents account create` resolves its own /// signer's member when `--member` is omitted. fn public_openssh(&self) -> String; + + /// This identity's display label for [`crate::pages::layout`]'s + /// `.id-chip` (`roots.web-signing`) -- the one place this crate names + /// "who is acting" for a human reader, as opposed to [`Self::actor`]'s + /// commit-authorship signature. + /// + /// Defaults to [`Self::actor`]'s own author name: good enough when a + /// composition root has nothing better to show. `git-ents`'s own + /// `LocalIdentity` overrides this with the enrolled member's username + /// resolved from the signer's public key (falling back to a short key + /// fingerprint when no member matches), since `actor().name` there is + /// a fixed wordmark ("git-ents"), not a signer identity -- showing it + /// in the chip would just duplicate the site logo next to it. + fn label(&self) -> String { + self.actor().name.to_string() + } } /// Build the [`ents_receive::Identity`] every mutation page hands to
crates/cli/git-ents/tests/serve.rs @@ -62,3 +62,74 @@ "git ents serve must never expose git's own smart-HTTP transport" ); } + +/// `roots.web-signing`: the identity chip shows the signer's own enrolled +/// member username, resolved via `commands::members::find_by_key` (the +/// same key-match loop `git ents members check` runs) -- not +/// `actor().name`, which is a fixed `"git-ents"` commit-author wordmark +/// that would otherwise just duplicate the site logo next to it. +#[tokio::test] +// @relation(roots.web-signing, scope=function, role=Verifies) +async fn serve_identity_chip_shows_the_signers_enrolled_member_username() { + let fixture = common::Fixture::new(1); + let root = LocalRoot::open(fixture.path()).expect("opens"); + members::add(&root, "jdc", None, Some(fixture.key_path.clone())).expect("bootstrap"); + + let root = LocalRoot::open(fixture.path()).expect("reopen for serve"); + let state = git_ents::commands::serve::build_state(root, Some(fixture.key_path.clone())) + .expect("builds state from the local root"); + let router = ents_web::router(state); + + let response = router + .oneshot(Request::get("/").body(Body::empty()).expect("request")) + .await + .expect("in-process call"); + assert_eq!(response.status(), StatusCode::OK); + let body = String::from_utf8( + axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("body") + .to_vec(), + ) + .expect("utf8 html"); + assert!( + body.contains(r#"class="id-chip" href="/account">jdc</a>"#), + "the id-chip must show the enrolled member's own username: {body}" + ); +} + +/// A signer whose key names no enrolled member falls back to a short key +/// fingerprint for the identity chip -- never `actor()`'s `"git-ents"` +/// wordmark, which would silently duplicate the site logo. +#[tokio::test] +// @relation(roots.web-signing, scope=function, role=Verifies) +async fn serve_identity_chip_falls_back_to_a_fingerprint_when_unenrolled() { + let fixture = common::Fixture::new(2); + let root = LocalRoot::open(fixture.path()).expect("opens"); + let state = git_ents::commands::serve::build_state(root, Some(fixture.key_path.clone())) + .expect("builds state from the local root"); + let router = ents_web::router(state); + + let response = router + .oneshot(Request::get("/").body(Body::empty()).expect("request")) + .await + .expect("in-process call"); + assert_eq!(response.status(), StatusCode::OK); + let body = String::from_utf8( + axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("body") + .to_vec(), + ) + .expect("utf8 html"); + let chip = body + .split(r#"class="id-chip" href="/account">"#) + .nth(1) + .and_then(|rest| rest.split("</a>").next()) + .expect("id-chip renders"); + assert_ne!( + chip, "git-ents", + "an unenrolled key must never fall back to the commit-author wordmark" + ); + assert!(!chip.is_empty(), "the fingerprint fallback is never blank"); +}
crates/cli/ents-web/src/assets/ents.css @@ -173,9 +173,11 @@ .lang-legend { list-style: none; display: flex; flex-direction: column; gap: .35rem; margin-top: .7rem; font-size: .78rem; } .lang-legend li { display: flex; align-items: center; gap: .45rem; } .lang-legend .pct { margin-left: auto; font-family: var(--font-mono); color: var(--color-text-muted); } +.freshness .card-row { flex-wrap: nowrap; } .freshness .card-row a { flex: none; text-decoration: none; } .freshness .card-row a:hover { text-decoration: underline; } -.freshness .muted { color: var(--color-text-muted); flex: 1; min-width: 0; } +.freshness-subject { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.freshness-meta { flex: none; white-space: nowrap; color: var(--color-text-muted); } .freshness-history { margin-left: auto; color: var(--color-text-muted); } .freshness-history:hover { color: var(--color-accent); } .blankslate { text-align: center; padding: 3rem 1.5rem; } @@ -203,6 +205,19 @@ .entity-list td a { color: inherit; text-decoration: underline; text-decoration-color: color-mix(in srgb, currentColor 25%, transparent); } .entity-list td a:hover { color: var(--color-accent); } +/* The commits list (`crate::pages::commits::list`) reuses `.entity-list`, + * but its oid/author/when columns are short tokens that should never + * shred mid-word the way a long unbroken key (`.entity-list`'s own + * break-all, correctly used by the members list's `key` column) needs to; + * only the subject column -- ordinary prose -- wraps at word boundaries. */ +.commits-table th:nth-child(1), .commits-table td:nth-child(1), +.commits-table th:nth-child(3), .commits-table td:nth-child(3), +.commits-table th:nth-child(4), .commits-table td:nth-child(4) { + white-space: nowrap; + word-break: normal; +} +.commits-table td:nth-child(2) { word-break: normal; } + .string-list { list-style: none; font-family: var(--font-mono); font-size: .9rem; } .string-list li { display: flex; align-items: center; gap: .65rem; padding: .7rem 1.1rem; } .string-list li + li { border-top: 1px solid var(--color-border); }
crates/cli/ents-web/src/pages/commits.rs @@ -89,7 +89,7 @@ } @else { div.card { div.card-header { "commits" } - table.entity-list { + table.entity-list.commits-table { thead { tr { th { "commit" } th { "subject" } th { "author" } th { "when" } } }
crates/cli/ents-web/src/pages/dashboard.rs @@ -140,12 +140,13 @@ (html! { (strip) (content) }, langs) } -/// The overview's latest-commit freshness strip, above the `README` card: -/// `HEAD`'s short oid linking to `crate::pages::commits::show`, its -/// subject, author, [`super::ago`] time, and a link into -/// `crate::pages::commits::list`'s full history. Renders nothing at all on -/// an unborn `HEAD` or any other read failure -- best-effort chrome, not a -/// reason to fail the page. +/// The overview's latest-commit freshness strip, above the `README` card, a +/// single non-wrapping flex row: `HEAD`'s short oid linking to +/// `crate::pages::commits::show`, its subject (ellipsized on overflow, the +/// row's only flexible cell), the author and [`super::ago`] time (muted, +/// never wrapping), and a link into `crate::pages::commits::list`'s full +/// history. Renders nothing at all on an unborn `HEAD` or any other read +/// failure -- best-effort chrome, not a reason to fail the page. fn freshness_strip(repo: &gix::Repository) -> Markup { let Ok(commit) = repo.head_commit() else { return html! {}; @@ -162,8 +163,8 @@ div.card.freshness { div.card-row { a href={ "/commit/" (oid) } { code { (super::short_oid(&oid)) } } - span { (message.title.to_str_lossy()) } - span.muted { (author.name.to_str_lossy()) " \u{b7} " (super::ago(seconds)) } + span.freshness-subject { (message.title.to_str_lossy()) } + span.freshness-meta { (author.name.to_str_lossy()) " \u{b7} " (super::ago(seconds)) } a.freshness-history href="/commits" { "history \u{2192}" } } }
crates/cli/ents-web/src/pages/mod.rs @@ -309,13 +309,12 @@ } /// The signing identity's display label for [`layout`]'s `.id-chip` -/// (`roots.web-signing`) -- [`crate::identity::SigningIdentity::actor`]'s -/// own author name, the cheapest accessor the trait exposes. Every page -/// reads this off `state` itself rather than `layout` reaching into -/// [`AppState`], so `layout` stays a pure function of the shell's own +/// (`roots.web-signing`) -- [`crate::identity::SigningIdentity::label`]. +/// Every page reads this off `state` itself rather than `layout` reaching +/// into [`AppState`], so `layout` stays a pure function of the shell's own /// chrome inputs (the same reason a [`Session`] is never threaded into it). pub(crate) fn identity_label<O>(state: &AppState<O>) -> String { - state.identity.actor().name.to_string() + state.identity.label() } /// A hidden CSRF input every form this crate renders carries
crates/cli/git-ents/src/commands/members.rs @@ -137,7 +137,19 @@ key: Option<std::path::PathBuf>, ) -> Result<Option<(String, MemberState)>> { let signer = signer(root, key)?; - let pubkey = signer.public_openssh(); + find_by_key(root, &signer.public_openssh()) +} + +/// Resolve `pubkey` to the enrolled member whose stored key matches it, if +/// any -- the shared match loop behind [`check`] and `git ents serve`'s own +/// identity-chip label (`crate::commands::serve::build_state`, +/// `roots.web-signing`): both need "which member owns this key," never a +/// bespoke re-scan of `list`'s own rows. +/// +/// # Errors +/// +/// Propagates a ref-store or object read failure. +pub fn find_by_key(root: &LocalRoot, pubkey: &str) -> Result<Option<(String, MemberState)>> { for (username, member) in list(root)? { if member.key == pubkey { return Ok(Some((username, member.state)));
crates/cli/git-ents/src/commands/serve.rs @@ -18,7 +18,12 @@ //! `user.signingkey`, else the default `~/.ssh/id_ed25519`) — no //! server-key indirection exists anywhere in this module, which is //! exactly what keeps `roots.web-signing`'s hosted-only indirection from -//! leaking into the local root. +//! leaking into the local root. [`LocalIdentity::label`] additionally +//! resolves the signer's own enrolled member (reusing +//! `crate::commands::members::find_by_key`, the same key-match loop +//! `git ents members check` runs), so the web shell's identity chip shows +//! a username instead of [`actor`]'s fixed `"git-ents"` commit-author +//! wordmark. use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::path::PathBuf; @@ -39,6 +44,11 @@ struct LocalIdentity { signer: Signer, actor: gix::actor::Signature, + /// The web shell's identity-chip label (see [`SigningIdentity::label`]'s + /// own doc): the signer's enrolled member username when one matches, + /// its short key fingerprint otherwise — resolved once in + /// [`build_state`], not per request. + label: String, } impl SigningIdentity for LocalIdentity { @@ -53,6 +63,10 @@ fn public_openssh(&self) -> String { self.signer.public_openssh() } + + fn label(&self) -> String { + self.label.clone() + } } /// The loopback address `git ents serve` binds -- `roots.local` forbids @@ -80,8 +94,18 @@ key: Option<PathBuf>, ) -> Result<Arc<AppState<crate::root::Objects>>> { let signer = super::signer(&root, key)?; + let pubkey = signer.public_openssh(); + // The identity chip's label (`roots.web-signing`): reuse the same + // key-match loop `git ents members check` runs (`find_by_key`) rather + // than re-scanning `refs/meta/member/*` by hand, falling back to the + // signer's own short fingerprint when no enrolled member's key matches + // (an unenrolled local key, still allowed to browse and sign). + let label = super::members::find_by_key(&root, &pubkey)? + .map(|(username, _state)| username) + .unwrap_or_else(|| super::short_fingerprint(&signer)); let identity = LocalIdentity { actor: actor(&signer), + label, signer, }; let mode = root.mode();