crates/cli/ents-web/src/pages/mod.rs
| 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 | |
| 35 | pub mod account; |
| 36 | pub mod comments; |
| 37 | pub mod commits; |
| 38 | pub mod dashboard; |
| 39 | pub mod effects; |
| 40 | pub mod files; |
| 41 | pub mod inbox; |
| 42 | pub mod issues; |
| 43 | pub mod login; |
| 44 | pub mod members; |
| 45 | pub mod meta; |
| 46 | pub mod redactions; |
| 47 | pub mod reviews; |
| 48 | pub mod search; |
| 49 | pub mod toolchains; |
| 50 | |
| 51 | use gix::bstr::ByteSlice as _; |
| 52 | use gix_hash::ObjectId; |
| 53 | use gix_object::{CommitRef, Find, Kind}; |
| 54 | use maud::{Markup, html}; |
| 55 | |
| 56 | use crate::error::{Error, Result}; |
| 57 | use crate::session::{CSRF_FIELD, Session}; |
| 58 | use 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). |
| 65 | pub(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. |
| 97 | pub(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)] |
| 142 | pub(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. |
| 160 | pub(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). |
| 174 | pub(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). |
| 208 | pub(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 | |
| 218 | impl 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. |
| 250 | pub(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. |
| 274 | fn 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. |
| 291 | pub(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. |
| 355 | pub(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. |
| 395 | pub(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. |
| 430 | pub(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). |
| 458 | pub(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. |
| 468 | pub(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. |
| 478 | pub(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) |
| 500 | pub(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 | |
| 515 | pub(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. |
| 530 | pub(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. |
| 556 | fn 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). |
| 570 | pub(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. |
| 587 | pub(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. |
| 611 | pub(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. |
| 625 | pub(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. |
| 635 | pub(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. |
| 648 | pub(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. |
| 656 | pub(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`. |
| 662 | pub(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`]. |
| 668 | pub(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). |
| 674 | pub(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. |
| 694 | pub(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. |
| 704 | fn 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 | } |