feat: add a generic WebComponent card for list-shaped meta-ref components
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
No reviews of this commit yet — record a verdict below.
Start a review
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()) } }
+ }
+ }
+ }
+}