web: replace the workbench UI with the design handoff
commit bdffa49
web: replace the workbench UI with the design handoff
Recreate the whole ents-web surface from the "Workbench" Claude Design
handoff, retiring every legacy style. The crate stays server-rendered
maud with a no-JS baseline; this is a reskin, not a rewrite.
Foundation:
new ents.css keyed to the handoff’s tokens/geometry/type, keyed to the
existing class taxonomy, with a derived dark scheme
self-hosted IBM Plex (woff2 vendored under assets/fonts/, OFL), served
at GET /fonts/{name} so the design’s type ships with no network fetch
sprite.svg redrawn to the handoff’s 24x24 stroke icons
editor_open renders the signature teal EditorPill (file:line + a single
editor mark); id-chip gains an initials avatar; shared avatar() and
promoted split_scope/scope_class helpers
Every page reskinned to the shared vocabulary: dashboard desk, code
blob view, commits list, commit detail (checks, reviews, verdict picker,
diffs), issues master-detail, threads, members cards, meta, account,
login, and the generic reflection views.
Router tests updated for the intentional token/class renames. The
standalone /reviews surface (net-new backend) is deferred.
No reviews of this commit yet — record a verdict below.
Start a review
crates/cli/ents-web/src/assets.rs
@@ -1,10 +1,11 @@
//! Static assets embedded at compile time so the built binary stays
//! self-contained -- no runtime fetch, no separate asset bundle to ship
-//! alongside `git-ents`. `ents.css` is the hand-rolled pre-redo stylesheet
-//! (`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). `ents.js` is new to this crate
+//! alongside `git-ents`. `ents.css` is the hand-rolled workbench
+//! stylesheet, keyed to the design handoff's tokens and component specs;
+//! its `@font-face` rules load the [`FONTS`] IBM Plex faces this crate
+//! serves itself (`GET /fonts/{name}`) rather than fetching them from
+//! Google Fonts, so the design's exact type ships without a network
+//! dependency. `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
@@ -18,18 +19,14 @@
//! `include_str!`-and-tag pattern
//! `pre-redo:crates/git-ents-server/src/web/icons.rs` used. The workbench
//! shell's own chrome (the `.rail` page-family icons, the `.palette`
-//! search glass, the `.branch` pill) draws from [`sprite`] instead: one
-//! hand-rolled `<symbol>` sprite embedded per page by
-//! `crate::pages::layout`, each use site a tiny [`icon_use`] reference
-//! rather than a repeated inline SVG. Every sprite symbol -- the `i-ed-*`
-//! editor marks `crate::pages`'s `editor_open` uses included -- is an
-//! original drawing in the sprite's own stroke style, never a vendored
-//! asset, so no third-party icon license applies to the sprite. The
-//! editor marks are simplified glyphs *evoking* each product's logo
-//! (shown nominatively, naming the editor `$ENTS_EDITOR`/`$EDITOR`
-//! already configured), not copies of the trademarked logo files; the
-//! Octicons under `assets/icons/` remain this module's only third-party
-//! assets.
+//! search glass, the `.branch` pill, the `.editor-open` pill's `↗` mark)
+//! draws from [`sprite`] instead: one hand-rolled `<symbol>` sprite
+//! embedded per page by `crate::pages::layout`, each use site a tiny
+//! [`icon_use`] reference rather than a repeated inline SVG. Every sprite
+//! symbol is an original drawing in the design handoff's 24×24 stroke style
+//! (`stroke-width: 1.7`, round caps), never a vendored asset, so no
+//! third-party icon license applies to the sprite; the Octicons under
+//! `assets/icons/` remain this module's only third-party assets.
use std::sync::LazyLock;
@@ -41,6 +38,55 @@
/// [`crate::router`]'s `GET /ents.js` serves -- see this module's own doc.
pub(crate) const SCRIPT: &str = include_str!("assets/ents.js");
+/// The self-hosted IBM Plex webfonts the workbench renders in
+/// (`assets/fonts/`, SIL OFL, see `assets/fonts/LICENSE`), embedded so the
+/// design's exact type is served without a runtime Google Fonts fetch --
+/// the same "self-contained binary, no network dependency" rule
+/// [`OVERRIDES`] and the vendored Octicons already hold. `ents.css`'s
+/// `@font-face` rules name each by its `GET /fonts/{name}` URL; [`font`]
+/// resolves that name back to these bytes.
+pub(crate) const FONTS: &[(&str, &[u8])] = &[
+ (
+ "plex-sans-400.woff2",
+ include_bytes!("assets/fonts/plex-sans-400.woff2"),
+ ),
+ (
+ "plex-sans-500.woff2",
+ include_bytes!("assets/fonts/plex-sans-500.woff2"),
+ ),
+ (
+ "plex-sans-600.woff2",
+ include_bytes!("assets/fonts/plex-sans-600.woff2"),
+ ),
+ (
+ "plex-sans-700.woff2",
+ include_bytes!("assets/fonts/plex-sans-700.woff2"),
+ ),
+ (
+ "plex-mono-400.woff2",
+ include_bytes!("assets/fonts/plex-mono-400.woff2"),
+ ),
+ (
+ "plex-mono-500.woff2",
+ include_bytes!("assets/fonts/plex-mono-500.woff2"),
+ ),
+ (
+ "plex-mono-600.woff2",
+ include_bytes!("assets/fonts/plex-mono-600.woff2"),
+ ),
+];
+
+/// The embedded woff2 bytes for `name`, or `None` for a name no
+/// `@font-face` rule references -- [`crate::router`]'s `GET /fonts/{name}`
+/// handler serves the hit and 404s the miss, so an unknown path can never
+/// read outside this fixed table.
+pub(crate) fn font(name: &str) -> Option<&'static [u8]> {
+ FONTS
+ .iter()
+ .find(|(file, _)| *file == name)
+ .map(|(_, bytes)| *bytes)
+}
+
/// 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/editor.rs
@@ -13,7 +13,9 @@
//! `vscode://file/...`). Neovim has no scheme of its own, so its links
//! use the community `nvim://file/...` shape -- they work only where the
//! reader has registered a handler for it, which is stated here rather
-//! than hidden: the icon still names the editor the user configured.
+//! than hidden: the affordance's `title` names the editor the user
+//! configured (`crate::pages`'s `editor_open` renders the shared teal
+//! `↗` pill for every editor alike).
use std::path::Path;
use std::sync::LazyLock;
@@ -41,15 +43,6 @@
}
}
- /// The editor's `crate::assets::sprite` symbol id.
- pub(crate) fn icon(self) -> &'static str {
- match self {
- Self::Zed => "i-ed-zed",
- Self::VsCode => "i-ed-code",
- Self::Neovim => "i-ed-nvim",
- }
- }
-
/// The URL-scheme prefix up to and including `file` -- the deep link
/// is `<scheme>/<absolute path>[:<line>]`.
fn scheme(self) -> &'static str {
crates/cli/ents-web/src/router.rs
@@ -107,6 +107,7 @@
.route("/inbox", get(pages::inbox::list::<O>))
.route("/style.css", get(style))
.route("/ents.js", get(script))
+ .route("/fonts/{name}", get(font))
// Layer order: axum runs the last-added layer first, so the
// session middleware (added below) resolves the session before
// the auth middleware consults it.
@@ -266,6 +267,27 @@
)
}
+/// `GET /fonts/{name}`: one embedded IBM Plex woff2 face
+/// (`crate::assets::font`), named by `ents.css`'s own `@font-face` `src`
+/// URLs. Immutable and content-addressed by filename, so it carries a
+/// year-long `immutable` cache; a name outside the fixed [`assets::FONTS`]
+/// table is a plain 404, never a path escape. Served under the same
+/// no-session-gating rule as [`style`] -- a face is needed to render every
+/// page, including one reached before a session exists.
+async fn font(axum::extract::Path(name): axum::extract::Path<String>) -> Response {
+ match assets::font(&name) {
+ Some(bytes) => (
+ [
+ (header::CONTENT_TYPE, "font/woff2"),
+ (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
+ ],
+ bytes,
+ )
+ .into_response(),
+ None => axum::http::StatusCode::NOT_FOUND.into_response(),
+ }
+}
+
/// 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
@@ -400,7 +400,7 @@
.to_bytes();
let body = String::from_utf8(body.to_vec()).expect("utf8 css");
assert!(!body.is_empty());
- assert!(body.contains("--color-bg"));
+ assert!(body.contains("--bg"));
}
/// `roots.web-agnostic`: the dashboard actually renders real content
@@ -997,7 +997,7 @@
);
let detail = get_body(&router, &format!("/issues/{issue_id}")).await;
assert!(
- detail.contains(&format!("class=\"active\" href=\"/issues/{issue_id}\"")),
+ detail.contains(&format!("class=\"side-row active\" href=\"/issues/{issue_id}\"")),
"the viewed issue highlights in the sidebar"
);
}
@@ -2090,8 +2090,8 @@
let page = get_body(&router, &format!("/commit/{oid}")).await;
assert!(page.contains("Reviews"), "the reviews section renders");
assert!(
- page.contains("class=\"verdict\""),
- "the verdict renders prominently"
+ page.contains("class=\"verdict verdict-request-changes\""),
+ "the verdict renders prominently, colored by its value"
);
assert!(page.contains("request-changes"), "the verdict text renders");
assert!(page.contains("needs a test"), "the review body renders");
crates/cli/ents-web/src/pages/commits.rs
@@ -101,22 +101,10 @@
@if rows.is_empty() {
(blankslate())
} @else {
- div.card {
+ div.card.history {
div.card-header { "commits" }
- table.entity-list.commits-table {
- thead {
- tr { th { "commit" } th { "subject" } th { "author" } th { "when" } }
- }
- tbody {
- @for row in &rows {
- tr {
- td { a href={ "/commit/" (row.oid) } { code { (row.short) } } }
- td { (row.subject) }
- td { (row.author) }
- td { (row.ago) }
- }
- }
- }
+ @for row in &rows {
+ (commit_row(row))
}
}
@if let Some(from) = older {
@@ -196,6 +184,31 @@
(rows, older)
}
+/// One [`CommitRow`] as a `.card-row` (the design's `CommitRow` component,
+/// README's Commits/Commit-detail screens): its short oid as an accent mono
+/// link, a `.scope` chip ([`super::split_scope`]/[`super::scope_class`])
+/// when the subject carries a Scoped-Commits prefix, the (possibly
+/// stripped) description ellipsized in the remaining space, the author,
+/// and its relative age -- the one place a commit row's markup is spelled,
+/// so [`list`]'s pager reads the same row [`super::dashboard`]'s History
+/// card already renders.
+fn commit_row(row: &CommitRow) -> Markup {
+ html! {
+ div.card-row {
+ a href={ "/commit/" (row.oid) } { code { (row.short) } }
+ @match super::split_scope(&row.subject) {
+ Some((scope, rest)) => {
+ span class={ "scope " (super::scope_class(scope)) } { (scope) }
+ span.desk-subject { (rest) }
+ },
+ None => { span.desk-subject { (row.subject) } },
+ }
+ span.muted { (row.author) }
+ span.entry-size { (row.ago) }
+ }
+ }
+}
+
/// The empty-history placeholder ([`super::blankslate`]): an unborn
/// `HEAD`, or a repository this page could not open at all.
fn blankslate() -> Markup {
@@ -256,7 +269,7 @@
};
let empty_tree = repo.empty_tree();
let old_tree_ref = old_tree.as_ref().unwrap_or(&empty_tree);
- let (diff, truncated) = diff_sections(&repo, old_tree_ref, &new_tree);
+ let (diff, truncated) = diff_sections(&state, &repo, old_tree_ref, &new_tree);
let comments = super::comments::for_commit(&state, object_id);
let checks = checks_section(&state, object_id);
let reviews = reviews_section(&state, &session, object_id, &oid);
@@ -476,7 +489,8 @@
@for ((target, member), review) in &reviews {
div.card {
div.comment-meta {
- span.verdict { (review.verdict) }
+ span class={ "verdict verdict-" (review.verdict) } { (review.verdict) }
+ (super::avatar(member.as_str()))
span.author { (member) }
@let reviewer = ents_model::namespace::review_ref(target, member)
.ok()
@@ -584,19 +598,32 @@
}
/// The start-a-review form (`POST /commit/{oid}/review`): a verdict and
-/// a body. The verdict is a closed `select` over
-/// [`ents_forge::review::Verdict`]'s three variants -- `model.review`
-/// makes it a hard enum, unlike issue and comment states.
+/// a body. The verdict is a closed `.picker` (README's `VerdictPicker`) of
+/// radio inputs over [`ents_forge::review::Verdict`]'s three variants --
+/// `model.review` makes it a hard enum, unlike issue and comment states --
+/// defaulting to `approve`, the same default a bare `select`'s first option
+/// would submit.
fn start_review_form(session: &Session, oid: &str) -> Markup {
html! {
+ h3 { "Start a review" }
form method="post" action=(format!("/commit/{oid}/review")) {
(super::csrf_input(session))
- label {
- "verdict"
- select name="verdict" {
- option value="approve" { "approve" }
- option value="request-changes" { "request-changes" }
- option value="comment" { "comment" }
+ p.muted { "verdict" }
+ div.picker {
+ label.opt {
+ input type="radio" name="verdict" value="approve" checked;
+ span.dot {}
+ "approve"
+ }
+ label.opt {
+ input type="radio" name="verdict" value="request-changes";
+ span.dot {}
+ "request-changes"
+ }
+ label.opt {
+ input type="radio" name="verdict" value="comment";
+ span.dot {}
+ "comment"
}
}
label { "body" textarea name="body" {} }
@@ -720,7 +747,8 @@
/// which case the caller shows a truncation notice). Best-effort: a change
/// this function cannot read renders as a bare file header with no hunks
/// rather than failing the whole page.
-fn diff_sections(
+fn diff_sections<O>(
+ state: &AppState<O>,
repo: &gix::Repository,
old_tree: &gix::Tree<'_>,
new_tree: &gix::Tree<'_>,
@@ -742,7 +770,7 @@
if is_tree_change(&change) {
return Ok(std::ops::ControlFlow::Continue(()));
}
- let (section, bytes) = render_change(repo, &change);
+ let (section, bytes) = render_change(state, repo, &change);
total = total.saturating_add(bytes);
sections.push(section);
if total > MAX_DIFF_BYTES {
@@ -766,12 +794,18 @@
}
/// One changed file's `.diff` section: a `.file`-classed header naming the
-/// path (and, on a rename, the old path it moved from), followed by either
-/// a `.meta`-classed "binary file changed" notice or the colorized unified
-/// diff between its old and new blob content. Returns the section's
-/// rendered byte cost, so [`diff_sections`] can track the page's overall
-/// budget.
-fn render_change(repo: &gix::Repository, change: &Change<'_, '_, '_>) -> (Markup, usize) {
+/// path (and, on a rename, the old path it moved from) beside its own
+/// [`super::editor_open`] pill -- the web↔editor handoff motif beside every
+/// code location (README's `EditorPill` inventory entry names diff headers
+/// explicitly) -- followed by either a `.meta`-classed "binary file
+/// changed" notice or the colorized unified diff between its old and new
+/// blob content. Returns the section's rendered byte cost, so
+/// [`diff_sections`] can track the page's overall budget.
+fn render_change<O>(
+ state: &AppState<O>,
+ repo: &gix::Repository,
+ change: &Change<'_, '_, '_>,
+) -> (Markup, usize) {
let (old_id, new_id, path, rename_from) = match *change {
Change::Addition { location, id, .. } => (
None,
@@ -823,6 +857,8 @@
span.ln.file {
@if let Some(from) = &rename_from { (from) " \u{2192} " }
(path)
+ " "
+ (super::editor_open(state, &path, None))
"\n"
}
};
crates/cli/ents-web/src/pages/dashboard.rs
@@ -23,6 +23,7 @@
use maud::{Markup, html};
use crate::error::Result;
+use crate::pages::{scope_class, split_scope};
use crate::state::AppState;
/// How many commits the History card shows -- a dashboard lane, not the
@@ -66,19 +67,40 @@
);
let attention = attention_card(&state, &open_comments, open_issues.len());
- Ok(super::layout(
+ // A bespoke `.page-header` rather than [`super::layout`]'s plain
+ // title-only one: the desk's header carries a subtitle and a
+ // right-aligned "local · trusted deployment" line beside the title,
+ // which the shared helper has no slot for. [`super::layout_shell`] is
+ // the same chrome one layer down, so this still shares every other
+ // page's `.wb`/`.wb-bar` shell -- only the `.content` wrapper below is
+ // grown by hand to match.
+ Ok(super::layout_shell(
&repo,
&super::identity_label(&state),
super::Tab::Overview,
"Dashboard",
html! {
- div.desk {
- (working_tree_card(&state, changes.as_deref()))
- (attention)
- (issues_card(&open_issues))
- }
- div.desk-wide {
- (history_card(&history_title, &history))
+ main.content {
+ div.page-header {
+ div {
+ h1.page-title { "Dashboard" }
+ p.page-sub {
+ "Your morning desk \u{2014} " span.mono { "git status" } " for review and ticketing."
+ }
+ }
+ div.desk-status {
+ span.dot {}
+ "local \u{b7} trusted deployment"
+ }
+ }
+ div.desk {
+ (working_tree_card(&state, changes.as_deref()))
+ (attention)
+ (issues_card(&open_issues))
+ }
+ div.desk-wide {
+ (history_card(&history_title, &history))
+ }
}
},
))
@@ -86,26 +108,37 @@
/// The "Working tree" card: every changed file [`worktree_changes`] found,
/// each linking into the Files browser with its open-in-editor affordance
-/// ([`super::editor_open`]) beside it and its change kind right-aligned.
-/// `None` (the status walk itself failed) renders a note row; an empty
-/// list renders a "clean" row -- either way the card itself always
-/// renders, so the desk's shape is stable.
+/// ([`super::editor_open`]) beside it and its change kind chip
+/// ([`kind_chip_class`]) right-aligned. The path sits in a
+/// `.desk-path` container so a long path ellipsizes from the front
+/// (`direction: rtl`) rather than colliding with the editor pill and kind
+/// chip -- both of the latter render inside a plain wrapping `span` so
+/// `ents.css`'s blanket `.card-row a { flex: 1 }` rule (aimed at the path
+/// link) never reaches into them through the descendant selector. `None`
+/// (the status walk itself failed) renders a note row; an empty list
+/// renders a "clean" row -- either way the card itself always renders, so
+/// the desk's shape is stable.
fn working_tree_card<O: Find>(
state: &AppState<O>,
changes: Option<&[(String, &'static str)]>,
) -> Markup {
html! {
section.card {
- div.card-header { "Working tree" }
+ div.card-header {
+ "Working tree"
+ @if let Some(changes) = changes {
+ span.entry-size { (changes.len()) " changed" }
+ }
+ }
@match changes {
None => { div.card-row.muted { "Working-tree status unavailable." } },
Some([]) => { div.card-row.muted { "Clean \u{2014} no uncommitted changes." } },
Some(changes) => {
@for (path, kind) in changes {
div.card-row {
- a href={ "/files/" (path) } { (path) }
- (super::editor_open(state, path, None))
- span.entry-size { (kind) }
+ a.desk-path href={ "/files/" (path) } { (path) }
+ span { (super::editor_open(state, path, None)) }
+ span class={ "chip " (kind_chip_class(kind)) } { (kind) }
}
}
},
@@ -114,6 +147,24 @@
}
}
+/// The `.chip-*` color class for a working-tree row's change-kind label
+/// (README's ChangeKindLabel component): green for a new addition, amber
+/// for an ordinary modification, red for a deletion or an unresolved
+/// conflict, indigo for a rename, and the neutral gray fallback for
+/// anything else ([`change_kind`]'s "untracked" and "type changed" both
+/// land here, matching the prototype's own `kindStyle` fallback). `.chip`
+/// itself (`ents.css`) supplies the pill's shape; this only picks its
+/// color.
+fn kind_chip_class(kind: &str) -> &'static str {
+ match kind {
+ "added" => "chip-added",
+ "modified" => "chip-modified",
+ "deleted" | "conflict" => "chip-deleted",
+ "renamed" => "chip-renamed",
+ _ => "chip-untracked",
+ }
+}
+
/// The "Needs attention" card: every open comment thread, each linking to
/// its own page and naming where its anchor lands ([`comment_where`]),
/// closed by an open-issues count line when any issues are open.
@@ -130,7 +181,10 @@
}
@for (id, comment) in open_comments {
a.attention-row href={ "/comments/" (id) } {
- span.what { "open thread \u{2014} \u{201c}" (what_line(&comment.body)) "\u{201d}" }
+ span.what {
+ span.lead { "open thread" }
+ " \u{2014} \u{201c}" (what_line(&comment.body)) "\u{201d}"
+ }
span class="where" { (comment_where(state, comment)) }
}
}
@@ -153,7 +207,7 @@
section.card {
div.card-header {
"Issues"
- a.btn.btn-ghost href="/issues" { "New" }
+ a.btn.btn-ghost.btn-sm href="/issues" { "+ New" }
}
@if open_issues.is_empty() {
div.card-row.muted { "No open issues." }
@@ -188,7 +242,7 @@
},
None => { span.desk-subject { (row.subject) } },
}
- span.entry-size { (row.ago) }
+ span.desk-when { (row.ago) }
}
}
}
@@ -227,28 +281,6 @@
.unwrap_or_else(|| "unanchored".to_owned())
}
-/// Split a Scoped-Commits subject (`<scope>: <description>`,
-/// scopedcommits.com) into its scope and description -- `None` when the
-/// subject carries no `^[a-z-]+:` prefix, in which case the whole subject
-/// renders unchipped.
-fn split_scope(subject: &str) -> Option<(&str, &str)> {
- let (scope, rest) = subject.split_once(':')?;
- if scope.is_empty() || !scope.chars().all(|c| c.is_ascii_lowercase() || c == '-') {
- return None;
- }
- Some((scope, rest.trim_start()))
-}
-
-/// The `.scope-c{n}` color class for `scope`: a stable hash of the scope
-/// name onto the stylesheet's six `--s-*` syntax-token colors, so the same
-/// scope always chips the same color across pages and requests.
-fn scope_class(scope: &str) -> String {
- let hash = scope.bytes().fold(0u32, |acc, byte| {
- acc.wrapping_mul(31).wrapping_add(u32::from(byte))
- });
- format!("scope-c{}", hash.checked_rem(6).unwrap_or(0))
-}
-
/// Every changed path in the working tree against `HEAD` and the index --
/// `gix`'s own status walk (`gix::Repository::status`), deduplicated by
/// path (a file both staged and modified appears in the head-to-index and
crates/cli/ents-web/src/pages/effects.rs
@@ -179,13 +179,16 @@
})?;
let body = match effect {
Ok(effect) => {
- let query_status = match effect.trigger.parse::<Query>() {
- Ok(_) => "parses".to_owned(),
- Err(error) => format!("does not parse: {error}"),
+ let (label, status_class) = match effect.trigger.parse::<Query>() {
+ Ok(_) => ("parses".to_owned(), "status-pass"),
+ Err(error) => (format!("does not parse: {error}"), "status-fail"),
};
html! {
(crate::render::view(&effect))
- p { "trigger query: " (query_status) }
+ p {
+ "trigger query: "
+ span class={ "status " (status_class) } { (label) }
+ }
}
}
Err(detail) => crate::render::unreadable(&detail),
crates/cli/ents-web/src/pages/files.rs
@@ -246,13 +246,16 @@
}
/// The Code split's `.tree` sidebar (`crate::pages::layout_split`): a
-/// crumb trail back to the repository root, then the entries of the
-/// directory at `dir` (the viewed directory itself, or a viewed blob's
-/// parent), directories first -- not a full recursive tree, just enough
-/// context to move one level in any direction. `active` names the full
-/// path of the entry (or trailing crumb) being viewed. Best-effort: a
-/// subtree that fails to read renders an empty entry list rather than
-/// failing the page around it.
+/// crumb trail back to the repository root (plain labels, no icon -- the
+/// same bare style [`crumbs`] renders above the pane), then the entries of
+/// the directory at `dir` (the viewed directory itself, or a viewed blob's
+/// parent) each as an icon
+/// ([`assets::icon_folder`]/[`assets::icon_file`]) beside its name,
+/// directories first -- not a full recursive tree, just enough context to
+/// move one level in any direction. `active` names the full path of the
+/// entry (or trailing crumb) being viewed, and gets `.active`
+/// ([`tree_class`]). Best-effort: a subtree that fails to read renders an
+/// empty entry list rather than failing the page around it.
fn tree_sidebar(head_tree: &gix::Tree<'_>, dir: &str, active: &str) -> Markup {
let mut entries = if dir.is_empty() {
tree_entries(head_tree).unwrap_or_default()
@@ -292,6 +295,7 @@
@let full = if dir.is_empty() { name.clone() } else { format!("{dir}/{name}") };
a class=(tree_class(*is_dir, entry_depth, full == active))
href=(child_href(dir, name)) {
+ @if *is_dir { (assets::icon_folder()) } @else { (assets::icon_file()) }
(name) @if *is_dir { "/" }
}
}
@@ -614,8 +618,9 @@
editor: Markup,
}
-/// The header bar above every blob view -- the file's name and metadata on
-/// the left (`span.blob-title`/`span.blob-meta`), the actions that used to
+/// The header bar above every blob view -- a leading file icon
+/// ([`assets::icon_file`]), the file's name and metadata
+/// (`span.blob-title`/`span.blob-meta`), the actions that used to
/// trail [`crumbs`] on the right (`span.blob-actions`): a jump into
/// `crate::pages::commits`'s `GET /commits` history (the file browser's
/// one entry point into commit history, since history is a view of the
@@ -631,6 +636,7 @@
fn blob_header(meta: &BlobHeaderMeta<'_>) -> Markup {
html! {
div.blob-header {
+ (assets::icon_file())
span.blob-title { (meta.name) }
span.blob-meta {
@if let Some(lines) = meta.line_count {
@@ -789,18 +795,24 @@
/// The below-the-blob section for comments with no current line range to
/// interleave at (a whole-file anchor, or
-/// `ents_anchor::Projection::Outdated`) -- titled to distinguish it from
-/// the inline cards [`source_view`] interleaves directly into the blob,
-/// since every comment reaching here either predates line-level anchoring
-/// or has literally gone stale. Renders nothing at all when `comments` is
-/// empty (mirrors [`super::comments::comments_section`]'s identical
-/// stance).
+/// `ents_anchor::Projection::Outdated`) -- a warn-glyphed heading
+/// (`div.outdated-head`, [`assets::icon_use`]'s `"i-warn"`) distinguishing
+/// it from the inline cards [`source_view`] interleaves directly into the
+/// blob, since every comment reaching here either predates line-level
+/// anchoring or has literally gone stale, then each comment through the
+/// same [`super::comments::comment_card`] every other blob-adjacent comment
+/// renders through. Renders nothing at all when `comments` is empty
+/// (mirrors [`super::comments::comments_section`]'s identical stance).
fn outdated_comments_section(comments: &[(usize, &super::comments::FileComment)]) -> Markup {
if comments.is_empty() {
return html! {};
}
html! {
- h2 { "Outdated Comments" }
+ div.outdated-head {
+ (assets::icon_use("i-warn"))
+ span { "Outdated Comments" }
+ }
+ p.muted { "Anchored to lines that no longer map onto HEAD." }
@for &(index, comment) in comments {
(super::comments::comment_card(index, comment, super::comments::LinkMode::SameFile))
}
crates/cli/ents-web/src/pages/issues.rs
@@ -50,6 +50,7 @@
.into_iter()
.map(|entry| (entry.refname, entry.error))
.collect();
+ let labels = known_labels(&rows);
Ok(super::layout_split(
&super::RepoHeader::from_state(&state),
&super::identity_label(&state),
@@ -65,29 +66,49 @@
html! { "Open one with the form below." },
))
}
- h2 { "Open an Issue" }
- (new_form(&session))
+ div.card {
+ div.card-header { "Open an Issue" }
+ (new_form(&session, &labels))
+ }
(super::members_datalist(&state))
}
},
))
}
-/// The Issues split's `.tree` sidebar: every issue as a two-line row --
-/// its title, then a muted locator of its state, assignees, and labels --
-/// linking to its own page, `active` naming the viewed issue's id.
+/// The Issues split's `.tree` sidebar: a `.tree-head` naming the family and
+/// carrying the "+ New" link into the composer, then every issue as a
+/// two-line `.side-row` -- its title (`.side-title`), then a `.side-meta`
+/// locator of a state-colored `.dot`, its state, assignees, and labels --
+/// linking to its own page, `.active` naming the viewed issue's id.
fn issues_sidebar(rows: &[(String, ents_forge::Issue)], active: Option<&str>) -> Markup {
html! {
+ div.tree-head {
+ span { "Issues" }
+ a.btn.btn-sm.btn-ghost[active.is_some()] href="/issues" { "+ New" }
+ }
@if rows.is_empty() {
span.tree-note { "No issues yet." }
}
@for (id, issue) in rows {
- a.active[active == Some(id.as_str())] href={ "/issues/" (id) } {
- span { (issue.title) }
- span class="where" {
- (issue.state)
- @if !issue.assignees.is_empty() { " \u{b7} " (join_members(&issue.assignees)) }
- @if !issue.labels.is_empty() { " \u{b7} " (issue.labels.join(", ")) }
+ a.side-row.active[active == Some(id.as_str())] href={ "/issues/" (id) } {
+ span.side-title { (issue.title) }
+ span.side-meta {
+ (state_dot(&issue.state))
+ span.locator {
+ (issue.state)
+ " \u{b7} "
+ @if issue.assignees.is_empty() {
+ "unassigned"
+ } @else {
+ "@" (issue.assignees[0].as_str())
+ @if issue.assignees.len() > 1 {
+ " +" (issue.assignees.len() - 1)
+ }
+ }
+ " \u{b7} "
+ @if issue.labels.is_empty() { "no labels" } @else { (issue.labels.join(", ")) }
+ }
}
}
}
@@ -144,6 +165,7 @@
// navigation chrome, never a reason to fail the issue's own page.
let (rows, _unreadable) =
issue::list_all(state.refs.as_ref(), &*state.objects()).unwrap_or_default();
+ let labels = known_labels(&rows);
Ok(super::layout_split(
&super::RepoHeader::from_state(&state),
&super::identity_label(&state),
@@ -154,20 +176,47 @@
(super::child_crumbs("issues", "/issues", ents_forge::abbreviate_id(&id)))
div.readable {
div.card {
- dl {
- dt { "state" } dd { span.comment-state { (issue.state) } }
- dt { "assignees" } dd { (join_members(&issue.assignees)) }
- dt { "labels" } dd { (issue.labels.join(", ")) }
+ h1.commit-subject { (issue.title) }
+ dl.entity-view {
+ dt { "state" }
+ dd { (state_chip(&issue.state)) }
+ dt { "assignees" }
+ dd {
+ @if issue.assignees.is_empty() {
+ span { "unassigned" }
+ } @else {
+ @for assignee in &issue.assignees {
+ (super::avatar(assignee.as_str())) " @" (assignee.as_str()) " "
+ }
+ }
+ }
+ dt { "labels" }
+ dd {
+ @if issue.labels.is_empty() {
+ span { "none" }
+ } @else {
+ @for label in &issue.labels {
+ span.label-chip { (label) } " "
+ }
+ }
+ }
}
div.doc-body { (body) }
}
details {
- summary { "Edit" }
- (edit_form(&session, &issue))
+ summary { "Edit state, assignees, labels" }
+ (edit_form(&session, &issue, &labels))
(super::members_datalist(&state))
}
h2 { "Discussion" }
- (crate::pages::comments::thread_section(&state, &session, &thread, &return_to))
+ @if thread.is_empty() {
+ (super::blankslate(
+ "No comments yet",
+ html! { "Start the discussion below." },
+ ))
+ } @else {
+ (crate::pages::comments::thread_section(&state, &session, &thread, &return_to))
+ }
h2 { "Add a Comment" }
(comment_form(&session, &id))
}
@@ -350,65 +399,57 @@
Ok(Redirect::to(&format!("/issues/{id}")))
}
-/// The open-an-issue form (`POST /issues`). The `state` field is a free
-/// text input with a [`state_datalist`] of the conventional values, never
-/// a closed `select` -- `model.issue` keeps states an open vocabulary
-/// ("custom states are schema, not platform features"; see
-/// [`ents_forge::Issue`]'s own doc).
-fn new_form(session: &Session) -> Markup {
+/// The open-an-issue form (`POST /issues`). State picks from
+/// [`state_picker`]'s closed three-option enumeration (the redesign's
+/// `StatePicker`/`StateChip`, open / in-progress / closed) rather than the
+/// pre-redesign free-text-plus-datalist field: `model.issue` itself still
+/// stores state as an arbitrary string (`ents_forge::Issue`'s own doc,
+/// "custom states are schema, not a platform feature"), so this form
+/// narrowing its own three quick-pick buttons never closes that schema --
+/// a state outside the trio stays reachable through `git ents issue edit`
+/// or a direct edit, same as any other schema-level custom field.
+fn new_form(session: &Session, known_labels: &[String]) -> Markup {
html! {
form method="post" action="/issues" {
(super::csrf_input(session))
label { "title" input type="text" name="title"; }
- label {
- "state"
- input type="text" name="state" value="open" list="issue-states";
+ div {
+ label { "state" }
+ (state_picker("open"))
}
- (state_datalist())
label { "assignees" input type="text" name="assignees" placeholder="alice, bob" list="members"; }
- label { "labels" input type="text" name="labels" placeholder="bug, gate"; }
+ (label_picker(known_labels, &[]))
label { "body" textarea name="body" {} }
- button type="submit" { "Open Issue" }
+ div.composer-buttons {
+ a.composer-cancel href="/issues" { "Cancel" }
+ button type="submit" { "Open Issue" }
+ }
}
}
}
/// The edit-issue form (`POST /issues/{id}`), its fields pre-filled from
-/// the current issue. Its `state` field carries the same [`state_datalist`]
-/// as [`new_form`]'s, for the same open-vocabulary reason.
-fn edit_form(session: &Session, issue: &ents_forge::Issue) -> Markup {
+/// the current issue. Its `state` field carries the same
+/// [`state_picker`] as [`new_form`]'s, for the same reason (see
+/// [`new_form`]'s own doc).
+fn edit_form(session: &Session, issue: &ents_forge::Issue, known_labels: &[String]) -> Markup {
html! {
form method="post" action="" {
(super::csrf_input(session))
- label {
- "state"
- input type="text" name="state" value=(issue.state) list="issue-states";
+ div {
+ label { "state" }
+ (state_picker(&issue.state))
}
- (state_datalist())
label {
"assignees"
input type="text" name="assignees" value=(join_members(&issue.assignees)) list="members";
}
- label { "labels" input type="text" name="labels" value=(issue.labels.join(", ")); }
+ (label_picker(known_labels, &issue.labels))
button type="submit" { "Save" }
}
}
}
-/// The `datalist` of conventional issue states both forms above attach to
-/// their `state` input -- suggestions only, since `model.issue`'s state is
-/// an open string vocabulary, not an enum a `select` could close over.
-/// Rendered once per form; the two forms never share a page, so the id
-/// never collides.
-fn state_datalist() -> Markup {
- html! {
- datalist id="issue-states" {
- option value="open" {}
- option value="closed" {}
- }
- }
-}
-
/// The comment-on-this-issue form (`POST /issues/{id}/comment`).
fn comment_form(session: &Session, id: &str) -> Markup {
html! {
@@ -420,6 +461,162 @@
}
}
+/// The three conventional issue states the redesign's `StatePicker` closes
+/// over (`model.issue`'s own field stays an open string; see [`new_form`]'s
+/// doc for why the form narrows to these three anyway).
+const ISSUE_STATES: [&str; 3] = ["open", "in-progress", "closed"];
+
+/// The `.chip`/`.dot` color class for a known state, or `None` for a
+/// custom one this form's [`state_picker`] does not enumerate -- shared by
+/// [`state_chip`] (the detail card's big pill) and [`state_dot`] (the
+/// sidebar row's small status dot), so a state's color is spelled in
+/// exactly one place.
+fn state_class(state: &str) -> Option<&'static str> {
+ match state {
+ "open" => Some("state-open"),
+ "in-progress" => Some("state-in-progress"),
+ "closed" => Some("state-closed"),
+ _ => None,
+ }
+}
+
+/// The issue detail card's state `dd`: a `.chip.chip-pill` carrying a
+/// leading `.dot` and the state's own color (see [`state_class`]), plain
+/// neutral for a custom state outside the three [`ISSUE_STATES`].
+fn state_chip(state: &str) -> Markup {
+ let class = match state_class(state) {
+ Some(extra) => format!("chip chip-pill {extra}"),
+ None => "chip chip-pill".to_owned(),
+ };
+ html! {
+ span class=(class) {
+ span.dot {}
+ (state)
+ }
+ }
+}
+
+/// The sidebar row's status `.dot` (see [`issues_sidebar`]): green open,
+/// amber in-progress, grey closed or custom (see [`state_class`]).
+fn state_dot(state: &str) -> Markup {
+ let class = match state_class(state) {
+ Some(extra) => format!("dot {extra}"),
+ None => "dot".to_owned(),
+ };
+ html! { span class=(class) {} }
+}
+
+/// One [`state_picker`] option's class: `.opt`, plus `.active` and the
+/// state's own color class (see [`state_class`]) when it is `current`'s
+/// own value -- an inactive option stays the picker's plain neutral look
+/// (mirrors the design handoff's own `statePicker`, which colors only the
+/// selected option).
+fn picker_opt_class(current: &str, state: &str) -> String {
+ if current == state {
+ match state_class(state) {
+ Some(extra) => format!("opt active {extra}"),
+ None => "opt active".to_owned(),
+ }
+ } else {
+ "opt".to_owned()
+ }
+}
+
+/// The state `.picker` both [`new_form`] and [`edit_form`] render: three
+/// `name="state"` radios, one per [`ISSUE_STATES`] entry, styled as
+/// `.picker .opt` pills (a `hidden` native radio inside a `<label>` still
+/// toggles on click -- label-click activation reaches a `hidden` control
+/// same as any other -- so the pill shows no native radio glyph without
+/// needing any stylesheet change). Exactly one radio is always checked, so
+/// the field is never posted empty: `current` itself when it names one of
+/// the three, or a fourth unlabeled hidden radio carrying `current`
+/// verbatim when it does not (a custom state this form's picker does not
+/// enumerate stays intact until the reader deliberately picks a different
+/// one).
+fn state_picker(current: &str) -> Markup {
+ html! {
+ div.picker {
+ @for state in ISSUE_STATES {
+ label class=(picker_opt_class(current, state)) {
+ input type="radio" name="state" value=(state) checked[state == current] hidden;
+ span.dot {}
+ (state)
+ }
+ }
+ @if !ISSUE_STATES.contains(¤t) {
+ input type="radio" name="state" value=(current) checked hidden;
+ }
+ }
+ }
+}
+
+/// Every label already used across every issue in this repository, deduped
+/// and sorted -- [`label_picker`]'s "pick existing" set, derived from the
+/// same `issue::list_all` read [`issues_sidebar`] renders from rather than
+/// a second query of its own.
+fn known_labels(rows: &[(String, ents_forge::Issue)]) -> Vec<String> {
+ let mut labels: Vec<String> = Vec::new();
+ for (_, issue) in rows {
+ for label in &issue.labels {
+ if !labels.contains(label) {
+ labels.push(label.clone());
+ }
+ }
+ }
+ labels.sort();
+ labels
+}
+
+/// The labels field both [`new_form`] and [`edit_form`] render: `known`'s
+/// labels previewed as `.label-chip` (`.on` for one already in `current`),
+/// then the actual control -- a single free-text `input[name=labels]`
+/// pre-filled from `current` and completed by a [`label_datalist`] of
+/// `known`'s own names. `model.issue`'s labels stay this one
+/// comma/whitespace-separated string field end to end (`EditForm`/
+/// `NewForm`'s own `labels: String`, `parse_labels`), so unlike
+/// [`state_picker`]'s single-choice radios, real independently-toggleable
+/// checkboxes are not this control's shape here: a checkbox group needs
+/// either a repeating-key field (breaking the scalar `labels` the create/
+/// edit handlers and `tests/router.rs`'s own `seed_issue` already commit
+/// to) or client-side script to merge checkbox state into one text value
+/// (excluded -- this crate works with no JS at all). The datalist gives
+/// "pick existing" a real, working affordance; typing any other word is
+/// "type-and-create".
+fn label_picker(known: &[String], current: &[String]) -> Markup {
+ html! {
+ div {
+ label { "labels" }
+ @if !known.is_empty() {
+ div.picker {
+ @for label in known {
+ @let on = current.iter().any(|applied| applied == label);
+ span class={ "label-chip" (if on { " on" } else { "" }) } { (label) }
+ }
+ }
+ }
+ input
+ type="text"
+ name="labels"
+ value=(current.join(", "))
+ placeholder="bug, gate"
+ list="issue-labels";
+ (label_datalist(known))
+ }
+ }
+}
+
+/// The `datalist` of every already-used label [`label_picker`]'s free-text
+/// input completes from. Rendered once per form; the two forms never
+/// share a page, so the id never collides (same reasoning the
+/// pre-redesign `state_datalist` carried).
+fn label_datalist(known: &[String]) -> Markup {
+ html! {
+ datalist id="issue-labels" {
+ @for label in known { option value=(label) {} }
+ }
+ }
+}
+
/// Render a member set for display, comma-joined (`join_members(&[])` is the
/// empty string, so an unassigned issue shows a blank cell rather than a
/// stray separator).
crates/cli/ents-web/src/pages/login.rs
@@ -88,8 +88,8 @@
"Prove control of an enrolled member key. Run this "
"on your own machine -- the key never leaves it:"
}
- pre.signin-cmd {
- "git ents login https://" (realm.host) " " (display)
+ pre.login-code {
+ "git ents login https://" span.code { (realm.host) " " (display) }
}
p.muted {
"This page refreshes on its own; the code is "
crates/cli/ents-web/src/pages/members.rs
@@ -95,19 +95,21 @@
))
}
-/// One member's identity card: the username prominent (a link on the list
-/// page, plain on the member's own page), the key type as a badge, the
-/// state and provenance as muted badges, and the key material truncated
-/// through the middle ([`truncate_middle`]) with the full key line behind
-/// a `<details>` toggle -- no digest dependency, so no fingerprint; the
-/// truncated material plus the expandable full line is the identity a
-/// reader compares. Also `crate::pages::account`'s signed-in-as card, so
-/// "you" and "a member" render identically.
+/// One member's identity card: [`super::avatar`] beside the username
+/// prominent (a link on the list page, plain on the member's own page),
+/// the key type as a badge, the state and provenance as muted badges, and
+/// the key material truncated through the middle ([`truncate_middle`])
+/// with the full key line behind a `<details>` toggle -- no digest
+/// dependency, so no fingerprint; the truncated material plus the
+/// expandable full line is the identity a reader compares. Also
+/// `crate::pages::account`'s signed-in-as card, so "you" and "a member"
+/// render identically.
pub(crate) fn member_card(username: &str, member: &Member, link: bool) -> Markup {
let (key_type, material) = split_key(&member.key);
html! {
div.card.member-card {
div.member-head {
+ (super::avatar(username))
@if link {
a.member-name href={ "/members/" (username) } { (username) }
} @else {
crates/cli/ents-web/src/pages/meta.rs
@@ -1,8 +1,10 @@
//! `GET /meta`: the landing page for the `meta` tab -- a card listing
//! every page family in `super::META_SECTIONS` with its blurb, so the
//! tab resolves to something other than an arbitrary pick of its five
-//! children. The rail those children render beside their own content
-//! (`super::layout_meta`) is this same table; this page is its index.
+//! children. Rendered through [`super::layout_meta`], the same governance
+//! sub-rail every one of those five families renders beside its own
+//! content -- this page is that rail's own index, not a sixth thing beside
+//! it.
use std::sync::Arc;
@@ -17,14 +19,18 @@
where
O: Find + Write + Send + 'static,
{
- super::layout(
+ super::layout_meta(
&super::RepoHeader::from_state(&state),
&super::identity_label(&state),
- super::Tab::Meta,
+ "/meta",
"Meta",
html! {
+ p.muted {
+ "All project metadata -- members, effects, toolchains, "
+ "redactions, and the adoption inbox -- lives in this "
+ "repository as git objects."
+ }
div.card {
- div.card-header { "meta" }
@for section in super::META_SECTIONS {
div.card-row {
a href=(section.href) { (section.name) }
crates/cli/ents-web/src/pages/mod.rs
@@ -291,7 +291,7 @@
(crate::assets::sprite())
div.wb {
aside.rail {
- span.nav-mark { "✳" }
+ span.nav-mark { "ge" }
(rail_link(active, Tab::Overview, "/", "Dashboard", "i-home"))
(rail_link(active, Tab::Files, "/files", "Code", "i-files"))
(rail_link(active, Tab::Commits, "/commits", "Review", "i-commit"))
@@ -306,7 +306,7 @@
span.repo-path {
span.here { (repo.name) }
@if let Some(branch) = &repo.branch {
- span.branch { (crate::assets::icon_use("i-commit")) (branch) }
+ span.branch { (crate::assets::icon_use("i-branch")) (branch) }
}
}
form.palette method="get" action="/search" {
@@ -314,7 +314,7 @@
input type="search" name="q" placeholder="Jump to file, commit, issue, member…" aria-label="Search";
kbd { "⌘K" }
}
- a.id-chip href="/account" { (identity) }
+ a.id-chip href="/account" { (avatar(identity)) span { (identity) } }
}
(content)
}
@@ -406,13 +406,19 @@
};
let root = std::fs::canonicalize(&state.path).unwrap_or_else(|_io| state.path.clone());
let abs = root.join(path);
+ let name = path.rsplit('/').next().unwrap_or(path);
+ let loc = match line {
+ Some(line) => format!("{name}:{line}"),
+ None => name.to_owned(),
+ };
html! {
a.editor-open
href=(editor.deep_link(&abs, line))
data-editor-base=(editor.deep_link(&abs, None))
title={ "Open in " (editor.label()) }
{
- (crate::assets::icon_use(editor.icon()))
+ (crate::assets::icon_use("i-editor"))
+ span.ed-loc { (loc) }
}
}
}
@@ -426,6 +432,19 @@
state.identity.label()
}
+/// The design's initials avatar (`.avatar`): the first two characters of
+/// `label` on the shared indigo→teal gradient, the same mark the top bar's
+/// `.id-chip`, every comment card's author line, and an issue's assignee
+/// list all render beside a name (README: initials on a gradient, no image
+/// assets). Two characters because that is what the mock's own avatars show
+/// ("ada.lang" → "ad"); a shorter label renders however many it has.
+pub(crate) fn avatar(label: &str) -> Markup {
+ let initials: String = label.chars().take(2).collect();
+ html! {
+ span.avatar { (initials) }
+ }
+}
+
/// A hidden CSRF input every form this crate renders carries
/// (`roots.web-session`): the one place that field is spelled, so a form
/// can never omit it by a typo.
@@ -579,3 +598,29 @@
pub(crate) fn short_oid(oid: &ObjectId) -> String {
gix_hash::Prefix::new(oid, 7).map_or_else(|_| oid.to_string(), |prefix| prefix.to_string())
}
+
+/// Split a Scoped-Commits subject (`<scope>: <description>`,
+/// scopedcommits.com) into its scope and description -- `None` when the
+/// subject carries no `^[a-z-]+:` prefix, in which case the whole subject
+/// renders unchipped. Shared by [`super::dashboard`]'s history strip and
+/// [`super::commits`]'s commit rows, the two places a commit subject chips
+/// its scope, so both split it the same way.
+pub(crate) fn split_scope(subject: &str) -> Option<(&str, &str)> {
+ let (scope, rest) = subject.split_once(':')?;
+ if scope.is_empty() || !scope.chars().all(|c| c.is_ascii_lowercase() || c == '-') {
+ return None;
+ }
+ Some((scope, rest.trim_start()))
+}
+
+/// The `.scope-c{n}` color class for `scope`: a stable hash of the scope
+/// name onto the stylesheet's six deterministic chip colors (README's
+/// "deterministic-color, fixed 52px" [`ScopeChip`]), so the same scope
+/// always chips the same color across pages and requests. Shared with
+/// [`split_scope`] by every page that renders a commit subject.
+pub(crate) fn scope_class(scope: &str) -> String {
+ let hash = scope.bytes().fold(0u32, |acc, byte| {
+ acc.wrapping_mul(31).wrapping_add(u32::from(byte))
+ });
+ format!("scope-c{}", hash.checked_rem(6).unwrap_or(0))
+}
crates/cli/ents-web/src/pages/toolchains.rs
@@ -60,17 +60,7 @@
html! { "Import one with " code { "git ents toolchain import" } "." },
)
} else {
- html! {
- div.card {
- ul.string-list {
- @for name in &names {
- li {
- a href=(format!("/toolchains/{name}")) { (name) }
- }
- }
- }
- }
- }
+ crate::render::string_list(&names, |name| format!("/toolchains/{name}"))
};
Ok(super::layout_meta(
&super::RepoHeader::from_state(&state),
@@ -192,14 +182,35 @@
Ok((toolchain, recipe)) => {
let log = toolchain::log(state.refs.as_ref(), &*objects, &name).unwrap_or_default();
html! {
- dl {
- dt { "name" } dd { (toolchain.name) }
- dt { "recipe" } dd { (format!("{recipe:?}")) }
+ div.card {
+ dl.entity-view {
+ dt { "name" } dd { (toolchain.name) }
+ dt { "recipe" } dd { (format!("{recipe:?}")) }
+ }
}
h2 { "Import Log" }
- ul {
- @for oid in &log {
- li { (oid.to_string()) }
+ @if log.is_empty() {
+ (super::blankslate(
+ "No import log",
+ html! { "This toolchain has no recorded import history." },
+ ))
+ } @else {
+ div.card {
+ ul.string-list {
+ @for oid in &log {
+ li {
+ code { (super::short_oid(oid)) }
+ // Best-effort per entry: an unreadable
+ // commit in the chain (practically
+ // unreachable -- `toolchain::log` itself
+ // already errors on one) drops just its
+ // own authorship, not the whole log.
+ @if let Ok((author, seconds)) = super::commit_authorship(&*objects, *oid) {
+ span.muted { " · " (author) " · " (super::ago(seconds)) }
+ }
+ }
+ }
+ }
}
}
}