roots: fix table/strip wrapping and the duplicated identity-chip label
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
No reviews of this commit yet — record a verdict below.
Start a review
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/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();