git-ents.gitmain
⌘K
foforge
commit 70e8f5d
feat: add a generic WebComponent card for list-shaped meta-ref components

Check, Member, and Issue now implement a small WebComponent trait (title, empty state, sync load); one generic card() renders any of them (header, count badge, error/empty/per-item rows) and one load() wraps the spawn_blocking every page needed by hand. checks_page’s Configuration card and settings_page’s Members/Checks cards now share that one card renderer instead of three near-identical blocks, and issues_page reuses the same loader while keeping its own open/closed filter chrome, which does not fit the generic header.

feat: add web::component::{WebComponent, load, card} refactor: implement WebComponent for Check/Member/Issue refactor: share the generic card across checks_page/settings_page refactor: reuse the generic loader in issues_page 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/git-ents-server/src/web/mod.rs @@ -9,6 +9,7 @@ //! This file owns routing and the shared page shell. mod assets; +mod component; mod debug; mod git; mod icons;
crates/git-ents-server/src/web/pages.rs @@ -24,7 +24,7 @@ }; use super::icons::*; use super::render::Render; -use super::{RepoMeta, Tab, not_found, repo_shell}; +use super::{RepoMeta, Tab, component, not_found, repo_shell}; /// The largest blob or diff rendered in full. Past it a request would read an /// unbounded object into memory and highlight it, so the view shows a truncation @@ -854,7 +854,7 @@ /// the full history and the raw set, as before. pub(super) async fn checks_page(repo: &Path, meta: &RepoMeta) -> Markup { let rel = &meta.rel; - let checks = load_checks(repo).await; + let checks = component::load::<git_ents::checks::Check>(repo).await; let runs = load_runs(repo).await; let head = git_output(repo, &["rev-parse", "HEAD"]) .await @@ -919,25 +919,7 @@ } } } - div.card { - div.card-header { - "Configuration" - @if let Ok(checks) = &checks { span.count { (checks.len()) } } - } - @match &checks { - Err(err) => div.card-row.muted { "Could not read checks: " (err) } - Ok(checks) if checks.is_empty() => { - div.card-row.muted { - "No checks configured on " code { "refs/meta/checks" } "." - } - } - Ok(checks) => { - @for check in checks { - (check.render()) - } - } - } - } + (component::card(&checks)) } }, ) @@ -1105,17 +1087,7 @@ .collect() } -/// Load the configured check set off the async runtime, since `checks::load` -/// shells out to git and reads the object database synchronously. -async fn load_checks(repo: &Path) -> Result<Vec<git_ents::checks::Check>, String> { - let repo = repo.to_owned(); - tokio::task::spawn_blocking(move || git_ents::checks::load(&repo)) - .await - .map_err(|err| err.to_string())? - .map_err(|err| err.to_string()) -} - -/// Load the recorded runs off the async runtime, like [`load_checks`]. +/// Load the recorded runs off the async runtime, like [`component::load`]. async fn load_runs(repo: &Path) -> Result<Vec<git_ents::checks::CommitRuns>, String> { let repo = repo.to_owned(); tokio::task::spawn_blocking(move || git_ents::checks::runs(&repo)) @@ -1129,7 +1101,7 @@ /// derived from the labels that exist. Issue creation is a write path that does /// not exist yet, so the "New issue" button stays disabled. pub(super) async fn issues_page(repo: &Path, meta: &RepoMeta) -> Markup { - let tpl = match load_issues(repo).await { + let tpl = match component::load::<git_ents::issues::Issue>(repo).await { Err(err) => IssuesTemplate { icons: Icons, error: Some(err), @@ -1139,12 +1111,12 @@ closed_count: 0, }, Ok(issues) => { - let open: Vec<&(String, git_ents::issues::Issue)> = - issues.iter().filter(|(_id, i)| i.is_open()).collect(); + let open: Vec<&git_ents::issues::Issue> = + issues.iter().filter(|issue| issue.is_open()).collect(); let closed = issues.len().saturating_sub(open.len()); let mut labels: Vec<String> = issues .iter() - .flat_map(|(_id, i)| i.labels.iter().cloned()) + .flat_map(|issue| issue.labels.iter().cloned()) .collect(); labels.sort_unstable(); labels.dedup(); @@ -1156,7 +1128,7 @@ closed_count: closed, open: open .iter() - .map(|(_id, issue)| issue.render().into_string()) + .map(|issue| issue.render().into_string()) .collect(), } } @@ -1176,16 +1148,6 @@ closed_count: usize, } -/// Load the repository's issues off the async runtime, since `issues::list` -/// reads the object database synchronously. -async fn load_issues(repo: &Path) -> Result<Vec<(String, git_ents::issues::Issue)>, String> { - let repo = repo.to_owned(); - tokio::task::spawn_blocking(move || git_ents::issues::list(&repo)) - .await - .map_err(|err| err.to_string())? - .map_err(|err| err.to_string()) -} - /// The Settings tab: a projection over the repository's typed meta refs — /// `refs/meta/config` (General), `refs/meta/members` (Members), and the derived /// feature and check status. The General fields are editable in place by a @@ -1197,8 +1159,8 @@ auth: Option<&super::Auth>, editing: bool, ) -> Markup { - let members = load_members(repo).await; - let checks = load_checks(repo).await; + let members = component::load::<git_ents::members::Member>(repo).await; + let checks = component::load::<git_ents::checks::Check>(repo).await; let config = load_repo_config(repo).await; repo_shell( meta, @@ -1227,51 +1189,17 @@ (feature_row("Checks (CI)", "Run signed CI records on push.", matches!(&checks, Ok(c) if !c.is_empty()))) } - div.card { - div.card-header { - "Members" - @if let Ok(members) = &members { span.count { (members.len()) } } - } - p.shell-note { - "People on " code { "refs/meta/member/*" } " whose signed pushes are accepted " - "(" code { "git ents members list" } ")." - } - @match &members { - Err(err) => div.card-row.muted { "Could not read members: " (err) } - Ok(members) if members.is_empty() => { - div.card-row.muted { - "No members — pushes are open until the first key is added." - } - } - Ok(members) => { - @for member in members { - (member.render()) - } - } - } + p.shell-note { + "People on " code { "refs/meta/member/*" } " whose signed pushes are accepted " + "(" code { "git ents members list" } ")." } + (component::card(&members)) - div.card { - div.card-header { - "Checks" - @if let Ok(checks) = &checks { span.count { (checks.len()) } } - } - p.shell-note { - "Commands on " code { "refs/meta/checks" } " run against each push " - "(" code { "git ents checks list" } ")." - } - @match &checks { - Err(err) => div.card-row.muted { "Could not read checks: " (err) } - Ok(checks) if checks.is_empty() => { - div.card-row.muted { "No checks configured." } - } - Ok(checks) => { - @for check in checks { - (check.render()) - } - } - } + p.shell-note { + "Commands on " code { "refs/meta/checks" } " run against each push " + "(" code { "git ents checks list" } ")." } + (component::card(&checks)) div.card { div.card-header { "Roles" } @@ -1294,7 +1222,7 @@ ) } -/// Load `refs/meta/config` off the async runtime, like [`load_checks`]. +/// Load `refs/meta/config` off the async runtime, like [`component::load`]. async fn load_repo_config(repo: &Path) -> Result<git_ents::config::Config, String> { let repo = repo.to_owned(); tokio::task::spawn_blocking(move || git_ents::config::load(&repo)) @@ -1303,16 +1231,6 @@ .map_err(|err| err.to_string()) } -/// Load the member set off the async runtime, since `members::load_all` shells -/// out to git and reads the object database synchronously. -async fn load_members(repo: &Path) -> Result<Vec<git_ents::members::Member>, String> { - let repo = repo.to_owned(); - tokio::task::spawn_blocking(move || git_ents::members::load_all(&repo)) - .await - .map_err(|err| err.to_string())? - .map_err(|err| err.to_string()) -} - /// The settings authorization banner: who is signed in and whether they may /// edit this repository. When `editing` is unset the server cannot land edits at /// all, so a member is told editing is disabled rather than offered controls
crates/git-ents-server/src/web/render.rs @@ -8,6 +8,8 @@ //! Types whose presentation needs domain knowledge — a signer's shortened key, a //! run's one-line summary — override [`Render::render`] instead. +use std::path::Path; + use facet::{Def, Facet, Peek, Type, UserType}; use maud::{Markup, PreEscaped, html}; @@ -16,6 +18,7 @@ use git_ents::issues::Issue; use git_ents::members::Member; +use super::component::WebComponent; use crate::asciidoc; /// HTML rendering for a meta-ref value. The default walks the value's [`Facet`] @@ -48,6 +51,18 @@ } } +impl WebComponent for Check { + const TITLE: &'static str = "Checks"; + + fn empty() -> Markup { + html! { div.card-row.muted { "No checks configured on " code { "refs/meta/checks" } "." } } + } + + fn load(repo: &Path) -> Result<Vec<Self>, String> { + git_ents::checks::load(repo).map_err(|err| err.to_string()) + } +} + /// Config's editable fields (description, homepage, topics) get their own /// edit-form treatment in the settings page, so the only piece left to render /// here is `roles` — one row per role rather than the raw map the structural @@ -94,6 +109,22 @@ } } +impl WebComponent for Issue { + const TITLE: &'static str = "Bug reports"; + + fn empty() -> Markup { + html! { div.card-row.muted { "No bug reports yet." } } + } + + fn load(repo: &Path) -> Result<Vec<Self>, String> { + Ok(git_ents::issues::list(repo) + .map_err(|err| err.to_string())? + .into_iter() + .map(|(_id, issue)| issue) + .collect()) + } +} + /// A member renders one row per authorized key — the username as the key column, /// a short key label beside it — or a single `cert-authority` row for a pinned /// CA, rather than the raw keys and trust enum the structural walk would print. @@ -113,6 +144,20 @@ } } +impl WebComponent for Member { + const TITLE: &'static str = "Members"; + + fn empty() -> Markup { + html! { + div.card-row.muted { "No members — pushes are open until the first key is added." } + } + } + + fn load(repo: &Path) -> Result<Vec<Self>, String> { + git_ents::members::load_all(repo).map_err(|err| err.to_string()) + } +} + /// A run is a set of per-check outcomes; collapse them to one summary line /// rather than a row per outcome. impl Render for Run {
crates/git-ents-server/src/web/component.rs @@ -1,0 +1,60 @@ +//! The generic card every list-shaped meta-ref component renders with: load +//! its items off the async runtime, then a header (title, count badge), +//! an error row, an empty-state message, or one [`Render`]ed row per item. +//! +//! Not every component's page fits this shape — Issues filters to open-only +//! and shows dual open/closed counts in place of a single badge, chrome this +//! card does not have a hook for — so [`super::pages::issues_page`] reuses +//! only [`load`] and each issue's `Render` impl, keeping its own header and +//! body. Forcing that case through [`card`] would mean adding a header +//! override parameter used by exactly one component, which the component +//! plan's own trait-bloat rule rules out. + +use std::path::Path; + +use git_ents::component::Component; +use maud::{Markup, html}; + +use super::render::Render; + +/// A meta-ref component whose items list and render generically: identity +/// metadata and a [`Render`] impl (both from `git_ents::component`), plus how +/// its items load and what a card shows when there are none yet. +pub(super) trait WebComponent: Component + Render + Send + Sized + 'static { + /// The card/page title. + const TITLE: &'static str; + /// The card body's empty-state message. + fn empty() -> Markup; + /// The component's items. Sync — `git_ents::*::load`/`list` shell out to + /// git and read the object database synchronously — so callers wrap it in + /// exactly one [`load`]. + fn load(repo: &Path) -> Result<Vec<Self>, String>; +} + +/// Load `T`'s items off the async runtime, wrapping [`WebComponent::load`] in +/// the one `spawn_blocking` every component needs. +pub(super) async fn load<T: WebComponent>(repo: &Path) -> Result<Vec<T>, String> { + let repo = repo.to_owned(); + tokio::task::spawn_blocking(move || T::load(&repo)) + .await + .map_err(|err| err.to_string())? +} + +/// The card chrome every list-shaped component shares: a header with +/// `T::TITLE` and a count badge, then an error row, `T::empty()`, or one +/// rendered row per item. +pub(super) fn card<T: WebComponent>(items: &Result<Vec<T>, String>) -> Markup { + html! { + div.card { + div.card-header { + (T::TITLE) + @if let Ok(items) = items { span.count { (items.len()) } } + } + @match items { + Err(err) => div.card-row.muted { "Could not read " (T::PLURAL) ": " (err) } + Ok(items) if items.is_empty() => (T::empty()) + Ok(items) => { @for item in items { (item.render()) } } + } + } + } +}