git-ents.gitmain
⌘K
foforge
mod.rs717 lines · 29.4 KB · rusthistorycomment on this file
1//! One module per page family -- `crate::router`'s handlers given a
2//! body, mirroring `git_ents::commands`'s "one module per subcommand
3//! family" convention on the web side.
4//!
5//! [`account`], [`effects`], [`redactions`],
6//! and [`inbox`] are the generic pages: they read a kernel entity and
7//! render it through [`crate::render`]'s reflection-driven mechanism,
8//! never matching on which entity type they were handed. [`dashboard`],
9//! [`toolchains`], [`comments`], [`issues`], and [`members`] are
10//! legitimate custom pages
11//! (`ents-kiln`'s recipe provenance, `ents-forge`'s anchor projection
12//! and issue threads, and a member's SSH-key identity card all need
13//! domain-specific rendering no generic
14//! reflection walk should grow special cases for). [`members`],
15//! [`effects`], [`toolchains`], [`redactions`], and [`inbox`] additionally
16//! share one `meta` rail item and `META_SECTIONS` rail rather than each
17//! carrying its own top-level entry (see `Tab`'s own doc); [`meta`] is that
18//! group's `GET /meta` landing page. [`commits`], [`reviews`], and
19//! [`issues`] are rail items of their own -- `Tab::Commits`,
20//! `Tab::Reviews`, and `Tab::Issues` in [`layout`]'s icon rail,
21//! alongside the dashboard, code, threads, and meta items. [`reviews`]'s
22//! `list` (`GET /reviews`) and `show` (`GET /reviews/{target}/{member}`)
23//! are a read-only aggregate and a per-review detail page over the same
24//! [`ents_forge::review`] entities [`commits::reviews_section`] renders
25//! scoped to one commit; starting a review still only ever posts through
26//! `commits`'s own route (`POST /commit/{oid}/review`), but withdrawing one
27//! (`POST /reviews/{target}/{member}/withdraw`) and commenting on one
28//! (`POST /reviews/{target}/{member}/comment`, `commits::review_comment`)
29//! are reachable from either page -- a review's own page and its home
30//! commit's page render the identical thread and composer, never two.
31//! [`search`]
32//! renders with no rail item active at all; it is reached from the
33//! `.wb-bar`'s own `.palette` search form rather than any rail item.
34
35pub mod account;
36pub mod comments;
37pub mod commits;
38pub mod dashboard;
39pub mod effects;
40pub mod files;
41pub mod inbox;
42pub mod issues;
43pub mod login;
44pub mod members;
45pub mod meta;
46pub mod redactions;
47pub mod reviews;
48pub mod search;
49pub mod toolchains;
50
51use gix::bstr::ByteSlice as _;
52use gix_hash::ObjectId;
53use gix_object::{CommitRef, Find, Kind};
54use maud::{Markup, html};
55
56use crate::error::{Error, Result};
57use crate::session::{CSRF_FIELD, Session};
58use crate::state::AppState;
59
60/// The tree of the commit at `oid` -- every page that reads back a typed
61/// entity needs this; mirrors `git_ents::commands::commit_tree` and
62/// `ents_forge::comment::command`'s own identical, independently
63/// duplicated helper (that module's own doc names this the accepted
64/// pattern in this codebase).
65pub(crate) fn commit_tree(objects: &impl Find, oid: ObjectId) -> Result<ObjectId> {
66 let mut buf = Vec::new();
67 let data = objects
68 .try_find(&oid, &mut buf)
69 .map_err(|source| Error::InvalidArgument(source.to_string()))?
70 .ok_or_else(|| Error::NotFound {
71 what: oid.to_string(),
72 })?;
73 if data.kind != Kind::Commit {
74 return Err(Error::NotFound {
75 what: oid.to_string(),
76 });
77 }
78 let commit = CommitRef::from_bytes(data.data, oid.kind())
79 .map_err(|source| Error::InvalidArgument(source.to_string()))?;
80 Ok(commit.tree())
81}
82
83/// The commit author's display name and commit time (epoch seconds) for
84/// the commit at `oid` -- the meta-ref counterpart to
85/// `crate::pages::commits`'s identical read of an ordinary history
86/// commit, shared by any page that needs to know who mutated a meta-ref
87/// entity and when rather than a stored field (`model.comment`'s own rule
88/// that authorship lives in the commit chain, not the entity: see
89/// `ents_forge::comment::Comment`'s own doc).
90///
91/// A second, independent fetch-and-parse from [`commit_tree`]'s own
92/// (same file, same pattern) rather than a shared parse step: `CommitRef`
93/// borrows from a caller-owned buffer, so factoring the parse out would
94/// need either an owned copy or a callback -- this module's own doc on
95/// [`commit_tree`] already names three such near-identical copies as the
96/// accepted pattern here.
97pub(crate) fn commit_authorship(objects: &impl Find, oid: ObjectId) -> Result<(String, i64)> {
98 let mut buf = Vec::new();
99 let data = objects
100 .try_find(&oid, &mut buf)
101 .map_err(|source| Error::InvalidArgument(source.to_string()))?
102 .ok_or_else(|| Error::NotFound {
103 what: oid.to_string(),
104 })?;
105 if data.kind != Kind::Commit {
106 return Err(Error::NotFound {
107 what: oid.to_string(),
108 });
109 }
110 let commit = CommitRef::from_bytes(data.data, oid.kind())
111 .map_err(|source| Error::InvalidArgument(source.to_string()))?;
112 let author = commit
113 .author()
114 .map_err(|source| Error::InvalidArgument(source.to_string()))?;
115 let seconds = author.time().map(|time| time.seconds).unwrap_or(0);
116 Ok((author.name.to_str_lossy().into_owned(), seconds))
117}
118
119/// The rail-nav page families this crate exposes -- one variant per icon
120/// in [`layout`]'s `.rail`, so a handler can name which rail item it
121/// renders behind without `layout` re-deriving it from the request path
122/// (the pre-redo `Tab` enum, carried through the workbench restructure:
123/// the horizontal tab strip became the vertical icon rail, but the
124/// "handler names its own section" contract is unchanged). The rail reads,
125/// top to bottom: Dashboard (`Overview`), Code (`Files`), Commits, Reviews,
126/// Issues, Threads
127/// (`Comments`); then, past the spacer, Repo & governance
128/// (`Meta`) and Account. Commits and Reviews are two rail items, not one,
129/// even though every review still lives on its own commit's page
130/// (`super::commits::reviews_section`) -- browsing history and judging a
131/// specific commit are different reasons to be on this rail, so they get
132/// their own icons (`super::reviews` is the read-only aggregate list; no
133/// mutation route lives there). `Meta` covers five page families
134/// ([`super::members`], [`super::effects`], [`super::toolchains`],
135/// [`super::redactions`], [`super::inbox`]) behind one rail item and the
136/// [`META_SECTIONS`] rail (see [`layout_meta`]) rather than an item each --
137/// unrelated to the Commits/Reviews split above: those five are one page
138/// family each with no reason to be found separately, unlike Commits and
139/// Reviews. `None` highlights nothing at all, for a page that is not part
140/// of any rail item's own section ([`super::search`]'s results page).
141#[derive(Clone, Copy, PartialEq, Eq)]
142pub(crate) enum Tab {
143 Overview,
144 Files,
145 Commits,
146 Reviews,
147 Issues,
148 Comments,
149 Meta,
150 Account,
151 None,
152}
153
154/// One entry in the `meta` tab's registry: a page family reachable from
155/// both [`meta::show`]'s index card and the `.meta-rail` every page in
156/// that family renders beside its own content (see [`layout_meta`]). This
157/// table is the entire registry -- growing the `meta` group means adding
158/// one entry here, never touching [`layout`], [`crate::router`]'s route
159/// table beyond the new route itself, or a per-page CSS hook.
160pub(crate) struct MetaSection {
161 /// The section's name, shown as both the rail link text and the
162 /// `/meta` index card's link text.
163 pub(crate) name: &'static str,
164 /// The section's own list-page URL. A `/{id}` child page (e.g.
165 /// `/members/{username}`) highlights this same entry rather than
166 /// failing to match anything (see [`layout_meta`]'s own doc).
167 pub(crate) href: &'static str,
168 /// One line describing the section, shown only on the `/meta` index
169 /// card.
170 pub(crate) blurb: &'static str,
171}
172
173/// The `meta` tab's registry (see [`MetaSection`]'s own doc).
174pub(crate) const META_SECTIONS: &[MetaSection] = &[
175 MetaSection {
176 name: "members",
177 href: "/members",
178 blurb: "Enrolled members and their signing keys.",
179 },
180 MetaSection {
181 name: "effects",
182 href: "/effects",
183 blurb: "Registered effects and their trigger queries.",
184 },
185 MetaSection {
186 name: "toolchains",
187 href: "/toolchains",
188 blurb: "Recorded toolchain recipes and their import provenance.",
189 },
190 MetaSection {
191 name: "redactions",
192 href: "/redactions",
193 blurb: "Recorded redactions.",
194 },
195 MetaSection {
196 name: "inbox",
197 href: "/inbox",
198 blurb: "Entries awaiting adoption.",
199 },
200];
201
202/// The served repository's identity for the shell's `.wb-bar` top bar:
203/// its directory name and, when `HEAD` resolves to a
204/// branch, that branch's short name (mirrors
205/// `pre-redo:crates/git-ents-server/src/web/mod.rs`'s `RepoMeta`, trimmed
206/// to the two fields this single-repo crate actually has a data surface
207/// for -- no owner/name split, description, or topics).
208pub(crate) struct RepoHeader {
209 /// The served repository's directory name, shown as the sole
210 /// breadcrumb crumb (this crate serves exactly one repository).
211 pub(crate) name: String,
212 /// The short name of `HEAD`'s branch, or `None` when `HEAD` is
213 /// detached, unborn, or the repository cannot be opened -- the
214 /// `.branch` pill is omitted in that case rather than guessed at.
215 pub(crate) branch: Option<String>,
216}
217
218impl RepoHeader {
219 /// Read the served repository's name and current branch off `state`
220 /// once, so [`layout`]'s call sites stay one-liners and the
221 /// `gix::open`/`HEAD` logic lives in exactly this one place (the same
222 /// `gix::open(&state.path)` pattern [`crate::pages::files`] browses the
223 /// `HEAD` tree with). Never panics: an unopenable repository or a
224 /// detached/unborn `HEAD` degrades to no branch pill.
225 pub(crate) fn from_state<O>(state: &AppState<O>) -> Self {
226 let name = std::fs::canonicalize(&state.path)
227 .ok()
228 .as_deref()
229 .and_then(std::path::Path::file_name)
230 .map(|name| name.to_string_lossy().into_owned())
231 .unwrap_or_else(|| "repository".to_owned());
232 let branch = gix::open(&state.path).ok().and_then(|repo| {
233 repo.head_name()
234 .ok()
235 .flatten()
236 .map(|full| full.shorten().to_str_lossy().into_owned())
237 });
238 Self { name, branch }
239 }
240}
241
242/// Wrap `title` and `body` in the one page shell every route renders
243/// through -- the workbench chrome (see [`layout_shell`]) around a
244/// `main.content` column carrying the page's own `.page-header` title and
245/// `body`. `active` names which rail item is current and `repo` the served
246/// repository the top bar names. `identity` is the signing identity's
247/// display label (see [`identity_label`]), rendered as the bar's
248/// right-aligned `.id-chip` link to `/account` -- the same place the
249/// rail's own account icon leads.
250pub(crate) fn layout(
251 repo: &RepoHeader,
252 identity: &str,
253 active: Tab,
254 title: &str,
255 body: Markup,
256) -> Markup {
257 layout_shell(
258 repo,
259 identity,
260 active,
261 title,
262 html! {
263 main.content {
264 div.page-header { h1.page-title { (title) } }
265 (body)
266 }
267 },
268 )
269}
270
271/// One `.rail` item: an icon-only link into a page family, `title`-tipped
272/// (the rail carries no text labels at all), highlighted when `tab` is the
273/// page's own `active` section.
274fn rail_link(active: Tab, tab: Tab, href: &str, title: &str, icon: &str) -> Markup {
275 html! {
276 a.active[active == tab] href=(href) title=(title) { (crate::assets::icon_use(icon)) }
277 }
278}
279
280/// The workbench shell itself (the "Proposal C" chrome,
281/// `docs/web-workbench-plan.adoc`): a `.wb` grid pairing the sticky icon
282/// `.rail` (Dashboard / Code / Review / Issues / Threads, then
283/// governance and account past the spacer -- see [`Tab`]'s own doc) with a `.wb-main`
284/// column whose sticky `.wb-bar` top bar names the served repository and
285/// its branch pill, carries the `.palette` search form (a plain GET to
286/// `/search` for now -- the `⌘K` kbd is a hint at the palette phase, not
287/// yet wired), and ends in the `.id-chip` identity link. `content` renders
288/// below the bar as-is: [`layout`] passes the ordinary padded
289/// `main.content` column, while a master-detail page passes its own
290/// full-bleed `.split` instead.
291pub(crate) fn layout_shell(
292 repo: &RepoHeader,
293 identity: &str,
294 active: Tab,
295 title: &str,
296 content: Markup,
297) -> Markup {
298 html! {
299 (maud::DOCTYPE)
300 html lang="en" {
301 head {
302 meta charset="utf-8";
303 meta name="viewport" content="width=device-width, initial-scale=1";
304 meta name="color-scheme" content="light dark";
305 title { "git ents: " (title) }
306 link rel="stylesheet" href="/style.css";
307 script src="/ents.js" defer {}
308 }
309 body {
310 (crate::assets::sprite())
311 div.wb {
312 aside.rail {
313 span.nav-mark { "ge" }
314 (rail_link(active, Tab::Overview, "/", "Dashboard", "i-home"))
315 (rail_link(active, Tab::Files, "/files", "Code", "i-files"))
316 (rail_link(active, Tab::Commits, "/commits", "Commits", "i-commit"))
317 (rail_link(active, Tab::Reviews, "/reviews", "Reviews", "i-review"))
318 (rail_link(active, Tab::Issues, "/issues", "Issues", "i-issue"))
319 (rail_link(active, Tab::Comments, "/comments", "Threads", "i-comment"))
320 span.spacer {}
321 (rail_link(active, Tab::Meta, "/meta", "Repo & governance", "i-meta"))
322 (rail_link(active, Tab::Account, "/account", "Account", "i-person"))
323 }
324 div.wb-main {
325 div.wb-bar {
326 span.repo-path {
327 span.here { (repo.name) }
328 @if let Some(branch) = &repo.branch {
329 span.branch { (crate::assets::icon_use("i-branch")) (branch) }
330 }
331 }
332 form.palette method="get" action="/search" {
333 (crate::assets::icon_use("i-search"))
334 input type="search" name="q" placeholder="Jump to file, commit, issue, member…" aria-label="Search";
335 kbd { "⌘K" }
336 }
337 a.id-chip href="/account" { (avatar(identity)) span { (identity) } }
338 }
339 (content)
340 }
341 }
342 }
343 }
344 }
345}
346
347/// Wrap `body` in the [`META_SECTIONS`] rail, then in [`layout`] itself
348/// with `Meta` active -- the thin wrapper every meta-namespace page
349/// ([`super::members`], [`super::effects`], [`super::toolchains`],
350/// [`super::redactions`], [`super::inbox`]) calls instead of [`layout`]
351/// directly, so the rail markup lives in exactly one place. `active_href`
352/// names which [`META_SECTIONS`] entry to highlight -- a page family's own
353/// `href`, not the request's actual path, so a `/{id}` child page (e.g.
354/// `/members/{username}`) highlights the same rail entry as its list page.
355pub(crate) fn layout_meta(
356 repo: &RepoHeader,
357 identity: &str,
358 active_href: &str,
359 title: &str,
360 body: Markup,
361) -> Markup {
362 layout(
363 repo,
364 identity,
365 Tab::Meta,
366 title,
367 html! {
368 div.meta-layout {
369 nav.meta-rail {
370 @for section in META_SECTIONS {
371 a.active[section.href == active_href] href=(section.href) { (section.name) }
372 }
373 }
374 div { (body) }
375 }
376 },
377 )
378}
379
380/// Wrap `title`, `sidebar`, and `pane` in the master-detail split every
381/// selection-heavy page family renders through ([`super::files`]'s tree
382/// beside a blob, [`super::commits`]'s compact history beside a diff,
383/// [`super::issues`]'s issue list beside an issue): the workbench chrome
384/// ([`layout_shell`]) around a full-bleed `.split` grid -- a sticky
385/// `nav.tree` sidebar on the left, a padded `main.pane` (carrying the
386/// page's own `.page-header` title and `pane` body) on the right. Every
387/// selection in the sidebar is a real URL and the sidebar always renders,
388/// so the split stays SSR-friendly (`docs/web-workbench-plan.adoc`).
389///
390/// `path_title` marks `title` itself as a repository-relative path
391/// (`super::files`'s tree/blob views, the only pages whose title is a path
392/// rather than a name) so the title renders in `.page-title.path`'s
393/// monospace, matching the `.crumbs` trail underneath it instead of
394/// clashing with it in the ordinary heading font.
395pub(crate) fn layout_split(
396 repo: &RepoHeader,
397 identity: &str,
398 active: Tab,
399 title: &str,
400 path_title: bool,
401 sidebar: Markup,
402 pane: Markup,
403) -> Markup {
404 layout_shell(
405 repo,
406 identity,
407 active,
408 title,
409 html! {
410 div.split {
411 nav.tree { (sidebar) }
412 main.pane {
413 div.page-header { h1.page-title.path[path_title] { (title) } }
414 (pane)
415 }
416 }
417 },
418 )
419}
420
421/// The "open in editor" affordance rendered beside a code location: a
422/// deep link into the serving user's own editor
423/// ([`crate::editor::detected`]: `$ENTS_EDITOR`, then `$EDITOR`), its
424/// icon naming which one. Renders nothing at all when no recognized
425/// editor is configured -- the affordance is the escalation back to the
426/// desk the reader came from (`docs/web-workbench-plan.adoc`), never a
427/// dead link. The line-less deep link rides along as `data-editor-base`
428/// so `ents.js` can retarget the blob header's affordance at the
429/// currently selected line without rebuilding the URL client-side.
430pub(crate) fn editor_open<O>(state: &AppState<O>, path: &str, line: Option<u64>) -> Markup {
431 let Some(editor) = crate::editor::detected() else {
432 return html! {};
433 };
434 let root = std::fs::canonicalize(&state.path).unwrap_or_else(|_io| state.path.clone());
435 let abs = root.join(path);
436 let name = path.rsplit('/').next().unwrap_or(path);
437 let loc = match line {
438 Some(line) => format!("{name}:{line}"),
439 None => name.to_owned(),
440 };
441 html! {
442 a.editor-open
443 href=(editor.deep_link(&abs, line))
444 data-editor-base=(editor.deep_link(&abs, None))
445 title={ "Open in " (editor.label()) }
446 {
447 (crate::assets::icon_use("i-editor"))
448 span.ed-loc { (loc) }
449 }
450 }
451}
452
453/// The signing identity's display label for [`layout`]'s `.id-chip`
454/// (`roots.web-signing`) -- [`crate::identity::SigningIdentity::label`].
455/// Every page reads this off `state` itself rather than `layout` reaching
456/// into [`AppState`], so `layout` stays a pure function of the shell's own
457/// chrome inputs (the same reason a [`Session`] is never threaded into it).
458pub(crate) fn identity_label<O>(state: &AppState<O>) -> String {
459 state.identity.label()
460}
461
462/// The design's initials avatar (`.avatar`): the first two characters of
463/// `label` on the shared indigo→teal gradient, the same mark the top bar's
464/// `.id-chip`, every comment card's author line, and an issue's assignee
465/// list all render beside a name (README: initials on a gradient, no image
466/// assets). Two characters because that is what the mock's own avatars show
467/// ("ada.lang" → "ad"); a shorter label renders however many it has.
468pub(crate) fn avatar(label: &str) -> Markup {
469 let initials: String = label.chars().take(2).collect();
470 html! {
471 span.avatar { (initials) }
472 }
473}
474
475/// A hidden CSRF input every form this crate renders carries
476/// (`roots.web-session`): the one place that field is spelled, so a form
477/// can never omit it by a typo.
478pub(crate) fn csrf_input(session: &Session) -> Markup {
479 html! {
480 input type="hidden" name=(CSRF_FIELD) value=(session.csrf);
481 }
482}
483
484/// Verify `submitted` matches `session`'s own CSRF token
485/// (`roots.web-session`): every state-changing handler calls this before
486/// acting on a form body.
487///
488/// # Errors
489///
490/// [`Error::BadCsrf`] if `submitted` does not match.
491// @relation(roots.web-session, scope=function)
492/// The author signature an attributed mutation carries
493/// (`receive.attributed-author`): the session's signed-in member, stamped
494/// with the current time, or `None` when the session holds no member --
495/// every `Trusted` deployment, and a hosted request that somehow reached a
496/// mutation anonymously (the auth middleware refuses those first). The
497/// synthetic email domain is reserved (RFC 2606): a member record carries
498/// no email of its own.
499// @relation(receive.attributed-author, scope=function)
500pub(crate) fn member_author(session: &Session) -> Option<gix::actor::Signature> {
501 let member = session.member.as_ref()?;
502 let seconds = std::time::SystemTime::now()
503 .duration_since(std::time::UNIX_EPOCH)
504 .unwrap_or_default()
505 .as_secs()
506 .try_into()
507 .unwrap_or_default();
508 Some(gix::actor::Signature {
509 name: member.username.clone().into(),
510 email: format!("{}@members.invalid", member.username).into(),
511 time: gix::date::Time { seconds, offset: 0 },
512 })
513}
514
515pub(crate) fn require_csrf(session: &Session, submitted: &str) -> Result<()> {
516 if submitted == session.csrf {
517 Ok(())
518 } else {
519 Err(Error::BadCsrf)
520 }
521}
522
523/// A unix timestamp rendered as a relative "time ago" label, measured
524/// against the current time -- hand-rolled from epoch seconds rather than
525/// pulling in a date-formatting dependency, mirroring
526/// `pre-redo:crates/git-ents-server/src/web/pages.rs`'s own `ago`/
527/// `ago_seconds`. Shared by [`super::dashboard`]'s freshness strip and
528/// [`super::commits`]'s list/show pages, the only places this crate names
529/// a commit's age.
530pub(crate) fn ago(then_seconds: i64) -> String {
531 let now = std::time::SystemTime::now()
532 .duration_since(std::time::UNIX_EPOCH)
533 .map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
534 .unwrap_or(0);
535 let secs = now.saturating_sub(then_seconds).max(0);
536 let mins = secs.checked_div(60).unwrap_or(0);
537 let hours = mins.checked_div(60).unwrap_or(0);
538 let days = hours.checked_div(24).unwrap_or(0);
539 if mins == 0 {
540 "just now".to_owned()
541 } else if hours == 0 {
542 ago_plural(mins, "minute")
543 } else if days == 0 {
544 ago_plural(hours, "hour")
545 } else if days < 30 {
546 ago_plural(days, "day")
547 } else if days < 365 {
548 ago_plural(days.checked_div(30).unwrap_or(0), "month")
549 } else {
550 ago_plural(days.checked_div(365).unwrap_or(0), "year")
551 }
552}
553
554/// Format `n` whole `unit`s with an "ago" suffix, pluralizing as needed --
555/// [`ago`]'s own helper.
556fn ago_plural(n: i64, unit: &str) -> String {
557 if n == 1 {
558 format!("1 {unit} ago")
559 } else {
560 format!("{n} {unit}s ago")
561 }
562}
563
564/// The shared empty-state card (`ents.css`'s `.blankslate`): a short
565/// title and one explanatory line, rendered instead of a bare list or a
566/// header-only table when a page family has nothing to show yet. `line`
567/// is markup, not text, so a page can point at its own create form or
568/// link a next step ([`super::dashboard`]'s README pointer does the
569/// same).
570pub(crate) fn blankslate(title: &str, line: Markup) -> Markup {
571 html! {
572 div.card {
573 div.blankslate {
574 h2 { (title) }
575 p { (line) }
576 }
577 }
578 }
579}
580
581/// A `<datalist id="members">` of every enrolled username
582/// (`refs/meta/member/*`), for forms whose text field names a member --
583/// an issue's assignees completes by id in place; richer matching (by
584/// key, fuzzy) stays with the palette. Best-effort: a ref-store read
585/// failure renders an empty datalist rather than failing the page the
586/// form sits on.
587pub(crate) fn members_datalist<O>(state: &AppState<O>) -> Markup {
588 let mut names = Vec::new();
589 if let Ok(entries) = state.refs.iter_prefix("refs/meta/member/") {
590 for (name, _tip) in entries.flatten() {
591 let path = name.as_bstr().to_string();
592 if let Some(username) = path.strip_prefix("refs/meta/member/") {
593 names.push(username.to_owned());
594 }
595 }
596 }
597 html! {
598 datalist id="members" {
599 @for name in &names { option value=(name) {} }
600 }
601 }
602}
603
604/// The one-level breadcrumb trail every `/{id}` child page renders above
605/// its own content -- "parent \u{203a} here", reusing the `.crumbs` markup
606/// pattern [`super::files`]'s own multi-level path trail already renders
607/// (same `nav.crumbs`/`span.sep`/`span.here` classes, so the stylesheet
608/// needs no second breadcrumb rule). `parent` links back to the family's
609/// list page at `parent_href`; `here` is the child's own display name, a
610/// plain non-link "you are here" crumb.
611pub(crate) fn child_crumbs(parent: &str, parent_href: &str, here: &str) -> Markup {
612 html! {
613 nav.crumbs {
614 a href=(parent_href) { (parent) }
615 span.sep { (crate::assets::icon_chevron()) }
616 span.here { (here) }
617 }
618 }
619}
620
621/// A commit id shortened to seven hex characters for display -- mirrors
622/// `pre-redo:crates/git-ents-server/src/web/pages.rs`'s own `short_oid`.
623/// Falls back to the full id on the (practically unreachable) case that a
624/// 7-character prefix is invalid for `oid`'s hash kind.
625pub(crate) fn short_oid(oid: &ObjectId) -> String {
626 gix_hash::Prefix::new(oid, 7).map_or_else(|_| oid.to_string(), |prefix| prefix.to_string())
627}
628
629/// Split a Scoped-Commits subject (`<scope>: <description>`,
630/// scopedcommits.com) into its scope and description -- `None` when the
631/// subject carries no `^[a-z-]+:` prefix, in which case the whole subject
632/// renders unchipped. Shared by [`super::dashboard`]'s history strip and
633/// [`super::commits`]'s commit rows, the two places a commit subject chips
634/// its scope, so both split it the same way.
635pub(crate) fn split_scope(subject: &str) -> Option<(&str, &str)> {
636 let (scope, rest) = subject.split_once(':')?;
637 if scope.is_empty() || !scope.chars().all(|c| c.is_ascii_lowercase() || c == '-') {
638 return None;
639 }
640 Some((scope, rest.trim_start()))
641}
642
643/// The `.scope-c{n}` color class for `scope`: a stable hash of the scope
644/// name onto the stylesheet's six deterministic chip colors (README's
645/// "deterministic-color, fixed 52px" [`ScopeChip`]), so the same scope
646/// always chips the same color across pages and requests. Shared with
647/// [`split_scope`] by every page that renders a commit subject.
648pub(crate) fn scope_class(scope: &str) -> String {
649 let hash = scope.bytes().fold(0u32, |acc, byte| {
650 acc.wrapping_mul(31).wrapping_add(u32::from(byte))
651 });
652 format!("scope-c{}", hash.checked_rem(6).unwrap_or(0))
653}
654
655/// A `.status-<class>` chip: `label` as its text, `class` as its CSS suffix.
656pub(crate) fn status_chip_labeled(label: &str, class: &str) -> Markup {
657 html! { span class={ "status status-" (class) } { (label) } }
658}
659
660/// The `.status-<word>` chip for a closed pass/fail/error [`ents_model::Status`],
661/// its word taken straight from the type's own `Display`.
662pub(crate) fn status_chip(status: ents_model::Status) -> Markup {
663 let word = status.to_string();
664 status_chip_labeled(&word, &word)
665}
666
667/// A `.verdict-<word>` chip for a review's own [`ents_forge::review::Verdict`].
668pub(crate) fn verdict_chip(verdict: ents_forge::review::Verdict) -> Markup {
669 html! { span class={ "verdict verdict-" (verdict) } { (verdict) } }
670}
671
672/// A sidebar `.tree-head`: `name` plus a "+ New" link into `new_href`,
673/// ghost-styled while `viewing_one` (a child page is open).
674pub(crate) fn tree_head(name: &str, new_href: &str, viewing_one: bool) -> Markup {
675 html! {
676 div.tree-head {
677 span { (name) }
678 a.btn.btn-sm.btn-ghost[viewing_one] href=(new_href) { "+ New" }
679 }
680 }
681}
682
683/// The acting session's member id -- the composite review key's
684/// `<member>` segment -- resolved the same way
685/// [`account::resolve_member_by_key`] does, falling back to a short hash of
686/// the public key when no enrolled member matches: mirrors
687/// `git_ents::commands::serve::build_state`'s identical fallback
688/// (`roots.web-signing`: an unenrolled local identity may still review or
689/// withdraw a review, exactly as it may still browse and comment). Shared
690/// by [`super::commits`] (starting a review) and [`super::reviews`]
691/// (withdrawing one) -- both need the same "which member is this session,
692/// as far as the review namespace is concerned" answer, so it lives here
693/// rather than in either page module.
694pub(crate) fn reviewer_member_id<O: Find>(state: &AppState<O>) -> ents_model::MemberId {
695 let pubkey = state.identity.public_openssh();
696 account::resolve_member_by_key(state, &pubkey)
697 .map(|(id, _member)| id)
698 .unwrap_or_else(|_source| ents_model::MemberId::new(short_key_fingerprint(&pubkey)))
699}
700
701/// The first twelve characters of `pubkey`'s key-material token --
702/// mirrors `git_ents::commands::short_fingerprint`'s identical fallback
703/// label. [`reviewer_member_id`]'s own helper.
704fn short_key_fingerprint(pubkey: &str) -> String {
705 let hex: String = pubkey
706 .split_whitespace()
707 .nth(1)
708 .unwrap_or(pubkey)
709 .chars()
710 .take(12)
711 .collect();
712 if hex.is_empty() {
713 "member".to_owned()
714 } else {
715 hex
716 }
717}