git-ents.gitmain
⌘K
foforge
commit 91a8301
roots: rebuild the dashboard as the workbench desk

GET / becomes the work surface: a Working tree card of changed files via gix’s own status walk, a Needs attention feed of open comment threads naming their anchored paths, an open Tickets card with a New ghost button, and a full-width History card whose Scoped-Commits scopes chip in stable --s-* token colors. The README card moves to the Code root (/files), rendered below the listing; the old contents rail, language breakdown, and freshness strip retire with the overview.

Assisted-by: Claude:claude-fable-5

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

crates/cli/ents-web/tests/router.rs @@ -427,8 +427,8 @@ .expect("body") .to_bytes(); let body = String::from_utf8(body.to_vec()).expect("utf8 html"); - assert!(body.contains("members")); - assert!(body.contains("toolchains")); + assert!(body.contains("Working tree")); + assert!(body.contains("Tickets")); // The shell chrome renders on every page: the icon rail, the sticky // top bar, and the bar's palette search form. assert!(body.contains("class=\"rail\"")); @@ -497,59 +497,26 @@ ); } -/// `roots.web-agnostic`: the overview (`GET /`) renders the served -/// repository's `README` as HTML in its main column and a language -/// breakdown of the `HEAD` tree in its aside -- both read off `state.path` -/// with `gix`, so the dashboard reflects real repository content, not just -/// the meta-ref counts. +/// `roots.web-agnostic`: the workbench dashboard (`GET /`) renders its +/// four sections -- Working tree, Needs attention, Tickets, History -- +/// against a real repository, with real content in each: the dirty file +/// shows up as a working-tree row, the seeded open comment as a +/// needs-attention row (naming its anchored path), the seeded open issue +/// as a ticket, and the `HEAD` commit in the History card with its +/// Scoped-Commits scope chip. #[tokio::test] -async fn dashboard_renders_the_readme_and_a_languages_card() { - let dir = seed_repo(&[ - ("README.md", "# Welcome\n\nThe project overview.\n"), - ("src/main.rs", "fn main() {}\n"), - ("src/lib.rs", "pub fn f() {}\n"), - ]); - let state = build_state_at( - FixtureIdentity { - name: "local-user", - key: Keypair::from_seed(1), - }, - dir.path().to_owned(), +async fn dashboard_renders_the_four_sections_with_real_content() { + let dir = seed_repo(&[("src/main.rs", "line 1\nline 2\nline 3\n")]); + // Commit a scoped subject so the History card has a chip to parse. + commit_change( + dir.path(), + "src/main.rs", + "line 1\nline 2\nline 3\nline 4\n", + "model: grow main by a line", ); - let router = ents_web::router(state); - - let response = router - .oneshot(Request::get("/").body(Body::empty()).expect("request")) - .await - .expect("in-process call"); - assert_eq!(response.status(), StatusCode::OK); - let body = response - .into_body() - .collect() - .await - .expect("body") - .to_bytes(); - let body = String::from_utf8(body.to_vec()).expect("utf8 html"); - assert!( - body.contains("class=\"overview\""), - "the overview grid renders" - ); - assert!( - body.contains("<h1>Welcome</h1>"), - "the README renders as HTML, not raw markdown" - ); - assert!( - body.contains("lang-bar") && body.contains("Rust"), - "the language breakdown names the tree's languages" - ); -} - -/// `GET /` shows a freshness strip above the `README` card: the `HEAD` -/// commit's own subject and a link into its `/commit/{oid}` page. -#[tokio::test] -async fn dashboard_shows_a_freshness_strip_linking_to_the_latest_commit() { - let dir = seed_repo(&[("README.md", "# hi\n")]); let oid = head_oid(dir.path()); + // Dirty the working tree after the commit, for the Working tree lane. + std::fs::write(dir.path().join("src/main.rs"), "changed\n").expect("dirty the tree"); let state = build_state_at( FixtureIdentity { name: "local-user", @@ -557,30 +524,42 @@ }, dir.path().to_owned(), ); - let router = ents_web::router(state); + let router = ents_web::router(state.clone()); + let comment_id = + seed_comment(&router, &state, "src/main.rs", "worth a look", "2:2", &oid).await; + seed_issue(&router, &state, "Ship the desk", "open", "", "").await; - let response = router - .oneshot(Request::get("/").body(Body::empty()).expect("request")) - .await - .expect("in-process call"); - assert_eq!(response.status(), StatusCode::OK); - let body = response - .into_body() - .collect() - .await - .expect("body") - .to_bytes(); - let body = String::from_utf8(body.to_vec()).expect("utf8 html"); - assert!(body.contains("class=\"card freshness\"")); - assert!(body.contains(&format!("/commit/{oid}"))); - assert!(body.contains("seed"), "the HEAD commit's subject renders"); + let body = get_body(&router, "/").await; + for header in ["Working tree", "Needs attention", "Tickets", "History"] { + assert!(body.contains(header), "the {header} section renders"); + } + assert!( + body.contains("href=\"/files/src/main.rs\"") && body.contains("modified"), + "the dirty file lists as a working-tree change" + ); + assert!( + body.contains(&format!("/comments/{comment_id}")) && body.contains("src/main.rs:2"), + "the open comment links out and names its anchored path" + ); + assert!( + body.contains("Ship the desk"), + "the open issue lists as a ticket" + ); + assert!( + body.contains(&format!("/commit/{oid}")), + "the History card links the HEAD commit" + ); + assert!( + body.contains("class=\"scope scope-c") && body.contains(">model</span>"), + "the scoped subject chips its scope" + ); } /// `GET /` on an unborn `HEAD` (a freshly initialized, still-empty -/// repository) omits the freshness strip entirely, rather than rendering -/// a placeholder for a commit that does not exist. +/// repository) still renders all four sections, each degrading to its own +/// empty-state row rather than a placeholder commit or a 500. #[tokio::test] -async fn dashboard_omits_the_freshness_strip_on_an_unborn_head() { +async fn dashboard_degrades_every_section_on_an_unborn_head() { let dir = tempfile::tempdir().expect("tempdir"); let status = std::process::Command::new("git") .arg("-C") @@ -598,19 +577,12 @@ ); let router = ents_web::router(state); - let response = router - .oneshot(Request::get("/").body(Body::empty()).expect("request")) - .await - .expect("in-process call"); - assert_eq!(response.status(), StatusCode::OK); - let body = response - .into_body() - .collect() - .await - .expect("body") - .to_bytes(); - let body = String::from_utf8(body.to_vec()).expect("utf8 html"); - assert!(!body.contains("class=\"card freshness\"")); + let body = get_body(&router, "/").await; + for header in ["Working tree", "Needs attention", "Tickets", "History"] { + assert!(body.contains(header), "the {header} section renders"); + } + assert!(!body.contains("/commit/"), "no placeholder commit links"); + assert!(body.contains("No commits yet.")); } /// `roots.web-session`: a state-changing request with no CSRF token at @@ -895,6 +867,34 @@ assert!(body.contains("src")); } +/// `GET /files` renders the root `README` as a document card below the +/// listing -- re-homed from the old overview dashboard, so the repository +/// still introduces itself somewhere. +#[tokio::test] +async fn files_root_renders_the_readme_below_the_listing() { + let dir = seed_repo(&[ + ("README.md", "# Welcome\n\nThe project overview.\n"), + ("src/main.rs", "fn main() {}\n"), + ]); + let state = build_state_at( + FixtureIdentity { + name: "local-user", + key: Keypair::from_seed(1), + }, + dir.path().to_owned(), + ); + let router = ents_web::router(state); + + let body = get_body(&router, "/files").await; + assert!( + body.contains("<h1>Welcome</h1>"), + "the README renders as HTML, not raw markdown" + ); + let listing = body.find("href=\"/files/src\"").expect("listing renders"); + let readme = body.find("<h1>Welcome</h1>").expect("README renders"); + assert!(listing < readme, "the README card sits below the listing"); +} + /// `GET /files/<path>` on a plain-text blob with no recognized grammar /// renders a line-numbered, escaped `pre.blob-code` source view -- no /// syntax highlighting, and no unescaped source.
crates/cli/ents-web/src/assets/ents.css @@ -171,39 +171,47 @@ * entry. */ .entry-size { margin-left: auto; font-family: var(--font-mono); font-size: .82rem; color: var(--color-text-muted); white-space: nowrap; } -/* Repository overview (`GET /`): the rendered README beside a sticky aside - * of a contents rail and a language breakdown (pre-redo:.../style.css's - * `.overview`/`.aside`/`.lang`). */ -.overview { display: grid; grid-template-columns: minmax(0, 1fr) 19rem; gap: 34px; align-items: start; } -.aside { position: sticky; top: 64px; display: flex; flex-direction: column; gap: 18px; min-width: 0; } -.aside .card { margin-bottom: 0; } -.aside-row { display: flex; align-items: center; gap: .5rem; padding: .55rem 1.1rem; font-size: .82rem; } -.aside-row + .aside-row { border-top: 1px solid var(--color-border); } -.aside-row a { text-decoration: underline; text-decoration-color: color-mix(in srgb, currentColor 25%, transparent); } -.aside-row a:hover { color: var(--color-accent); } -.aside-row .count { margin-left: auto; font-family: var(--font-mono); font-weight: 600; color: var(--color-accent); } -.lang { padding: .8rem 1.1rem; } -.lang-bar { display: flex; height: 8px; border-radius: var(--radius-pill); overflow: hidden; background: var(--color-code-bg); } -.lang-bar span { display: block; height: 100%; } -.lang-dot { width: 9px; height: 9px; border-radius: 2px; flex-shrink: 0; } -.lang-legend { list-style: none; display: flex; flex-direction: column; gap: .35rem; margin-top: .7rem; font-size: .78rem; } -.lang-legend li { display: flex; align-items: center; gap: .45rem; } -.lang-legend .pct { margin-left: auto; font-family: var(--font-mono); color: var(--color-text-muted); } -.freshness .card-row { flex-wrap: nowrap; } -.freshness .card-row a { flex: none; text-decoration: none; } -.freshness .card-row a:hover { text-decoration: underline; } -.freshness-subject { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.freshness-meta { flex: none; white-space: nowrap; color: var(--color-text-muted); } -.freshness-history { margin-left: auto; color: var(--color-text-muted); } -.freshness-history:hover { color: var(--color-accent); } +/* The workbench dashboard (`GET /`, `crate::pages::dashboard`): three + * cards on a `.desk` grid (working tree, needs attention, tickets), then + * a full-width `.desk-wide` History card. `.content` already pads the + * column, so the desk itself only spaces its cards. */ +.desk { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1.25rem; align-items: start; margin-bottom: 1.25rem; } +.desk .card, .desk-wide .card { margin-bottom: 0; } +.card-header .btn-ghost { margin-left: auto; } +.card-row.muted { color: var(--color-text-muted); } +.btn { display: inline-block; font-size: .78rem; font-weight: 600; color: var(--color-bg); background: var(--color-accent); border: none; border-radius: 8px; padding: .3rem .8rem; cursor: pointer; text-decoration: none; } +.btn-ghost { color: var(--color-accent); background: transparent; border: 1px solid var(--color-border); text-transform: none; letter-spacing: 0; } +.btn-ghost:hover { border-color: var(--color-accent); color: var(--color-accent); } +/* Needs-attention / ticket rows: a block link pairing a `.what` line with + * a muted, mono `.where` locator. */ +.attention-row { display: block; padding: .7rem 1.1rem; color: inherit; text-decoration: none; } +.attention-row:hover { background: var(--color-code-bg); text-decoration: none; } +.attention-row + .attention-row { border-top: 1px solid var(--color-border); } +.attention-row .what { display: block; font-size: .88rem; } +.attention-row .where { display: block; font-family: var(--font-mono); font-size: .74rem; color: var(--color-text-muted); margin-top: .15rem; } +/* Scoped-Commits scope chips (`crate::pages::dashboard::scope_class`): + * `.scope-c{0..5}` maps a stable hash of the scope name onto the six + * `--s-*` syntax-token colors. */ +.scope { font-family: var(--font-mono); font-size: .68rem; font-weight: 600; border-radius: var(--radius-pill); padding: 0 .55rem; white-space: nowrap; } +.scope-c0 { color: var(--s-prop); background: color-mix(in srgb, var(--s-prop) 12%, transparent); } +.scope-c1 { color: var(--s-func); background: color-mix(in srgb, var(--s-func) 12%, transparent); } +.scope-c2 { color: var(--s-const); background: color-mix(in srgb, var(--s-const) 12%, transparent); } +.scope-c3 { color: var(--s-type); background: color-mix(in srgb, var(--s-type) 14%, transparent); } +.scope-c4 { color: var(--s-keyword); background: color-mix(in srgb, var(--s-keyword) 12%, transparent); } +.scope-c5 { color: var(--s-string); background: color-mix(in srgb, var(--s-string) 14%, transparent); } +/* History rows: the oid link stays its own width; only the subject cell + * flexes and ellipsizes. */ +.history .card-row { flex-wrap: nowrap; } +.history .card-row a { flex: none; text-decoration: none; } +.history .card-row a:hover { text-decoration: underline; } +.desk-subject { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-sans); font-size: .88rem; } .blankslate { text-align: center; padding: 3rem 1.5rem; } .blankslate h2 { font-family: var(--font-serif); font-size: 1.3rem; font-weight: 700; margin-bottom: .5rem; } .blankslate p { color: var(--color-text-muted); } .blankslate code { font-family: var(--font-mono); background: var(--color-code-bg); padding: .15rem .45rem; border-radius: 5px; font-size: .85rem; } -@media (max-width: 900px) { - .overview { grid-template-columns: minmax(0, 1fr); } - .aside { position: static; } +@media (max-width: 1100px) { + .desk { grid-template-columns: minmax(0, 1fr); } } /* The generic reflection-driven views (`crate::render`): a definition list
crates/cli/ents-web/src/pages/commits.rs @@ -61,18 +61,20 @@ from: Option<String>, } -/// One row of `GET /commits`. -struct CommitRow { +/// One row of `GET /commits` -- also what [`super::dashboard`]'s History +/// card renders, at its own smaller limit, so the two pages share one +/// history read. +pub(crate) struct CommitRow { /// The full commit id, the `/commit/{oid}` link target. - oid: ObjectId, + pub(crate) oid: ObjectId, /// [`super::short_oid`] of `oid`, the row's displayed, mono id. - short: String, + pub(crate) short: String, /// The commit message's title line. - subject: String, + pub(crate) subject: String, /// The commit author's display name. - author: String, + pub(crate) author: String, /// [`super::ago`] of the commit author's time. - ago: String, + pub(crate) ago: String, } /// `GET /commits`: the repository's commit history, newest first, 50 per @@ -89,7 +91,7 @@ where O: Find + Write + Send + 'static, { - let (rows, older) = commit_rows(&state, params.from.as_deref()); + let (rows, older) = commit_rows(&state, params.from.as_deref(), PAGE_SIZE); Ok(super::layout( &super::RepoHeader::from_state(&state), &super::identity_label(&state), @@ -127,12 +129,18 @@ )) } -/// Up to [`PAGE_SIZE`] rows starting at `from` (or `HEAD` when `from` is +/// Up to `limit` rows starting at `from` (or `HEAD` when `from` is /// `None`), newest first, plus the oid to continue from for an "older" -/// link when more commits remain. Best-effort: an unopenable repository, -/// an unborn `HEAD`, or an unparsable/unresolvable `from` all degrade to -/// an empty page rather than an error. -fn commit_rows<O>(state: &AppState<O>, from: Option<&str>) -> (Vec<CommitRow>, Option<String>) { +/// link when more commits remain -- [`list`] passes [`PAGE_SIZE`], +/// [`super::dashboard`]'s History card its own smaller cap. Best-effort: +/// an unopenable repository, an unborn `HEAD`, or an +/// unparsable/unresolvable `from` all degrade to an empty page rather +/// than an error. +pub(crate) fn commit_rows<O>( + state: &AppState<O>, + from: Option<&str>, + limit: usize, +) -> (Vec<CommitRow>, Option<String>) { let Ok(repo) = gix::open(&state.path) else { return (Vec::new(), None); }; @@ -161,7 +169,7 @@ let mut has_more = false; for info in walk.skip(skip) { let Ok(info) = info else { break }; - if rows.len() == PAGE_SIZE { + if rows.len() == limit { has_more = true; break; }
crates/cli/ents-web/src/pages/dashboard.rs @@ -1,257 +1,139 @@ -//! `GET /`: the repository overview -- a latest-commit freshness strip -//! above the rendered `README`, beside a sticky aside of a contents rail -//! (one live count per page family, which doubles as a smoke test that -//! every seam in [`crate::state::AppState`] actually reads) and a -//! language breakdown of the `HEAD` tree (`pre-redo:crates/git-ents-server/src/web/pages.rs`'s -//! `repo_page`, trimmed to the cards this single-repo, local crate has a -//! data surface for -- no clone URL, homepage, releases, or topics). +//! `GET /`: the workbench dashboard -- `git status` for review and +//! ticketing (`docs/web-workbench-plan.adoc`'s Phase C home page). Four +//! sections on a `.desk` grid: the working tree's changed files (a live +//! `gix` status of the repository at `state.path`), a needs-attention +//! feed of open comment threads, the open tickets, and a full-width +//! History card of recent commits with their Scoped-Commits scope chips. +//! The `README` this page used to render moved to `crate::pages::files`'s +//! root listing -- the dashboard is a work surface, not a document viewer. //! -//! The `README`, freshness-strip, and language reads browse the -//! repository's `HEAD` commit/tree through `gix`'s high-level -//! `Repository`/`Commit`/`Tree` types, opened fresh per request from +//! The status and history reads browse the repository through `gix`'s +//! high-level `Repository` types, opened fresh per request from //! `state.path`, exactly as [`crate::pages::files`]/[`crate::pages::commits`] //! do (and for the same reason: browsing arbitrary repository content is //! not the `facet-git-tree` meta-ref convention the generic pages use). +//! Every repository read here is best-effort: an unopenable repository or +//! a failed status walk degrades to an in-card note, never an error. use std::sync::Arc; use axum::extract::State; use gix::bstr::ByteSlice as _; -use gix_hash::ObjectId; use gix_object::{Find, Write}; use maud::{Markup, html}; -use crate::assets; use crate::error::Result; use crate::state::AppState; -/// A detected language's display name, swatch color (a literal CSS color, -/// since the pre-redo `--s-*` syntax palette those colors referenced was -/// not ported), and its share of the classified `HEAD` tree, as a -/// whole-number percentage. -type Lang = (&'static str, &'static str, u8); +/// How many commits the History card shows -- a dashboard lane, not the +/// full pager `crate::pages::commits::list` already is. +const HISTORY_LIMIT: usize = 8; + +/// How many characters of a comment's or issue's first line a `.what` +/// row shows before ellipsizing. +const WHAT_LIMIT: usize = 90; /// `GET /`. /// /// # Errors /// -/// Propagates a ref-store read failure. +/// Propagates a ref-store or object read failure on the comment and issue +/// listings; every repository read degrades in-card instead (see this +/// module's own doc). pub async fn show<O>(State(state): State<Arc<AppState<O>>>) -> Result<maud::Markup> where O: Find + Write + Send + 'static, { - let members = state.refs.iter_prefix("refs/meta/member/")?.count(); - let effects = state.refs.iter_prefix("refs/meta/effects/")?.count(); - let redactions = state.refs.iter_prefix("refs/meta/redactions/")?.count(); - let comments = state.refs.iter_prefix("refs/meta/comments/")?.count(); - let toolchains = state.refs.iter_prefix("refs/meta/toolchains/")?.count(); + let changes = worktree_changes(&state); + let (comments, _unreadable) = + ents_forge::comment::list_all(state.refs.as_ref(), &*state.objects())?; + let open_comments: Vec<(String, ents_forge::comment::Comment)> = comments + .into_iter() + .filter(|(_, comment)| comment.state == "open") + .collect(); + let (issues, _unreadable) = + ents_forge::issue::list_all(state.refs.as_ref(), &*state.objects())?; + let open_issues: Vec<(String, ents_forge::Issue)> = issues + .into_iter() + .filter(|(_, issue)| issue.state == "open") + .collect(); + let (history, _older) = super::commits::commit_rows(&state, None, HISTORY_LIMIT); - let (main, langs) = repo_overview(&state); + let repo = super::RepoHeader::from_state(&state); + let history_title = repo.branch.as_ref().map_or_else( + || "History".to_owned(), + |branch| format!("History \u{2014} {branch}"), + ); + let attention = attention_card(&state, &open_comments, open_issues.len()); Ok(super::layout( - &super::RepoHeader::from_state(&state), + &repo, &super::identity_label(&state), super::Tab::Overview, - "Overview", + "Dashboard", html! { - div.overview { - div { (main) } - aside.aside { - div.card { - div.card-header { "contents" } - (contents_row("members", "/members", Some(members))) - (contents_row("account", "/account", None)) - (contents_row("effects", "/effects", Some(effects))) - (contents_row("redactions", "/redactions", Some(redactions))) - (contents_row("toolchains", "/toolchains", Some(toolchains))) - (contents_row("comments", "/comments", Some(comments))) - (contents_row("inbox", "/inbox", None)) - } - @if !langs.is_empty() { - div.card { - div.card-header { "languages" } - div.lang { - div.lang-bar { - @for (_, color, pct) in &langs { - span style={ "width:" (pct) "%;background:" (color) } {} - } - } - ul.lang-legend { - @for (lang, color, pct) in &langs { - li { - span.lang-dot style={ "background:" (color) } {} - span { (lang) } - span.pct { (pct) "%" } - } - } - } - } - } - } - } + div.desk { + (working_tree_card(changes.as_deref())) + (attention) + (tickets_card(&open_issues)) + } + div.desk-wide { + (history_card(&history_title, &history)) } }, )) } -/// One row of the contents card: a link to a page family, with its live -/// count when the family is one this crate counts. -fn contents_row(label: &str, href: &str, count: Option<usize>) -> Markup { +/// The "Working tree" card: every changed file [`worktree_changes`] found, +/// each linking into the Files browser with 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. +fn working_tree_card(changes: Option<&[(String, &'static str)]>) -> Markup { html! { - div.aside-row { - a href=(href) { (label) } - @if let Some(count) = count { - span.count { (count) } + section.card { + div.card-header { "Working tree" } + @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) } + span.entry-size { (kind) } + } + } + }, } } } } -/// The overview's main column and the language breakdown of its `HEAD` -/// tree: the rendered `README` when the root holds one, else a listing of -/// the root, else an empty-repository blankslate. Best-effort -- an -/// unopenable repository or an unborn `HEAD` degrades to the blankslate -/// with no languages, never an error (the page's contents card still -/// renders). -fn repo_overview<O>(state: &AppState<O>) -> (Markup, Vec<Lang>) { - let Ok(repo) = gix::open(&state.path) else { - return (blankslate(), Vec::new()); - }; - let Ok(tree) = repo.head_tree() else { - return (blankslate(), Vec::new()); - }; - let langs = languages(&repo, &tree); - let strip = freshness_strip(&repo); - let content = if let Some((name, rendered)) = readme(&tree) { - html! { - div.card { - div.card-header { (assets::icon_file()) (name) } - div.doc-body { (rendered) } - } - } - } else { - let entries = root_entries(&tree); - if entries.is_empty() { - blankslate() - } else { - files_card(&entries) - } - }; - (html! { (strip) (content) }, langs) -} - -/// The overview's latest-commit freshness strip, above the `README` card, a -/// single non-wrapping flex row: `HEAD`'s short oid linking to -/// `crate::pages::commits::show`, its subject (ellipsized on overflow, the -/// row's only flexible cell), the author and [`super::ago`] time (muted, -/// never wrapping), and a link into `crate::pages::commits::list`'s full -/// history. Renders nothing at all on an unborn `HEAD` or any other read -/// failure -- best-effort chrome, not a reason to fail the page. -fn freshness_strip(repo: &gix::Repository) -> Markup { - let Ok(commit) = repo.head_commit() else { - return html! {}; - }; - let Ok(message) = commit.message() else { - return html! {}; - }; - let Ok(author) = commit.author() else { - return html! {}; - }; - let seconds = author.time().map(|time| time.seconds).unwrap_or(0); - let oid = commit.id().detach(); +/// 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-tickets count line when any tickets are open. +fn attention_card<O: Find>( + state: &AppState<O>, + open_comments: &[(String, ents_forge::comment::Comment)], + open_issue_count: usize, +) -> Markup { html! { - div.card.freshness { - div.card-row { - a href={ "/commit/" (oid) } { code { (super::short_oid(&oid)) } } - span.freshness-subject { (message.title.to_str_lossy()) } - span.freshness-meta { (author.name.to_str_lossy()) " \u{b7} " (super::ago(seconds)) } - a.freshness-history href="/commits" { "history \u{2192}" } + section.card { + div.card-header { "Needs attention" } + @if open_comments.is_empty() && open_issue_count == 0 { + div.card-row.muted { "Nothing waiting on you." } } - } - } -} - -/// The empty-column placeholder ([`super::blankslate`]) shown when the -/// repository has no `README`, no readable root, or no `HEAD` at all. -fn blankslate() -> Markup { - super::blankslate( - "Nothing to show yet", - html! { "Add a " code { "README" } " or browse the repository in " a href="/files" { "Files" } "." }, - ) -} - -/// The first root-tree blob whose stem is `README` and whose extension -/// this crate renders (Markdown or AsciiDoc), converted to HTML and paired -/// with its filename; `None` when there is none or it fails to render -/// (mirrors `pre-redo:.../pages.rs`'s `readme`). -fn readme(tree: &gix::Tree<'_>) -> Option<(String, Markup)> { - let name = root_readme_name(tree)?; - let entry = tree.lookup_entry_by_path(&name).ok()??; - let blob = entry.object().ok()?.try_into_blob().ok()?; - let text = String::from_utf8_lossy(&blob.data); - render_doc(&name, &text).map(|rendered| (name, rendered)) -} - -/// The filename of the root's `README`, if it has a renderable one. -fn root_readme_name(tree: &gix::Tree<'_>) -> Option<String> { - for entry in tree.iter() { - let Ok(entry) = entry else { continue }; - if !entry.mode().is_blob() { - continue; - } - let name = entry.filename().to_str_lossy(); - let is_readme = name - .rsplit_once('.') - .is_some_and(|(stem, _)| stem.eq_ignore_ascii_case("readme")); - if is_readme && (crate::markdown::is_markdown(&name) || crate::asciidoc::is_asciidoc(&name)) - { - return Some(name.into_owned()); - } - } - None -} - -/// `text` rendered as its prose format (Markdown or AsciiDoc), or `None` -/// when it is neither or AsciiDoc rendering fails. -fn render_doc(name: &str, text: &str) -> Option<Markup> { - if crate::markdown::is_markdown(name) { - Some(crate::markdown::to_html(text)) - } else if crate::asciidoc::is_asciidoc(name) { - crate::asciidoc::to_html(text).ok() - } else { - None - } -} - -/// The `(name, is_directory)` of each direct child of the root tree, in -/// tree order. -fn root_entries(tree: &gix::Tree<'_>) -> Vec<(String, bool)> { - tree.iter() - .filter_map(|entry| { - let entry = entry.ok()?; - Some(( - entry.filename().to_str_lossy().into_owned(), - entry.mode().is_tree(), - )) - }) - .collect() -} - -/// A root listing shown when there is no `README`: directories first, then -/// files, each linking into the Files browser. -fn files_card(entries: &[(String, bool)]) -> Markup { - let mut entries = entries.to_vec(); - entries.sort_by(|(a_name, a_is_dir), (b_name, b_is_dir)| { - b_is_dir.cmp(a_is_dir).then_with(|| a_name.cmp(b_name)) - }); - html! { - div.card { - div.card-header { "files" } - @for (name, is_dir) in &entries { - div.card-row.is-dir[*is_dir] { - a.row-link href={ "/files/" (name) } { - @if *is_dir { (assets::icon_folder()) } @else { (assets::icon_file()) } - (name) + @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 class="where" { (comment_where(state, comment)) } + } + } + @if open_issue_count > 0 { + a.attention-row href="/issues" { + span.what { + (open_issue_count) + @if open_issue_count == 1 { " open ticket" } @else { " open tickets" } } } } @@ -259,91 +141,206 @@ } } -/// The language breakdown of the whole `HEAD` tree: the top four languages -/// by total blob byte size, as `(name, color, percent)`, largest first -- -/// byte-weighted like `pre-redo:.../git.rs`'s own `languages` (which shelled -/// out to `git ls-tree -l` for the sizes), except every size here comes -/// from [`gix::Repository::find_header`], an odb header lookup that never -/// reads a blob's full content. The shape and the top-four cap match -/// pre-redo's own. -fn languages(repo: &gix::Repository, tree: &gix::Tree<'_>) -> Vec<Lang> { - let mut blobs = Vec::new(); - collect_blobs(repo, tree, &mut blobs); - let mut totals: Vec<(&'static str, &'static str, u64)> = Vec::new(); - let mut grand: u64 = 0; - for (name, oid) in &blobs { - let Some((lang, color)) = classify(name) else { +/// The "Tickets" card: every open issue linking to its own page, with a +/// ghost "New" button into the Tickets page's own composer. +fn tickets_card(open_issues: &[(String, ents_forge::Issue)]) -> Markup { + html! { + section.card { + div.card-header { + "Tickets" + a.btn.btn-ghost href="/issues" { "New" } + } + @if open_issues.is_empty() { + div.card-row.muted { "No open tickets." } + } + @for (id, issue) in open_issues { + a.attention-row href={ "/issues/" (id) } { + span.what { (what_line(&issue.title)) } + span class="where" { "#" (ents_forge::abbreviate_id(id)) " \u{b7} " (issue.state) } + } + } + } + } +} + +/// The full-width "History" card: the most recent commits, each with its +/// Scoped-Commits scope chip ([`split_scope`], [`scope_class`]) when its +/// subject carries one. +fn history_card(title: &str, rows: &[super::commits::CommitRow]) -> Markup { + html! { + section.card.history { + div.card-header { (title) } + @if rows.is_empty() { + div.card-row.muted { "No commits yet." } + } + @for row in rows { + div.card-row { + a href={ "/commit/" (row.oid) } { code { (row.short) } } + @match split_scope(&row.subject) { + Some((scope, rest)) => { + span class={ "scope " (scope_class(scope)) } { (scope) } + span.desk-subject { (rest) } + }, + None => { span.desk-subject { (row.subject) } }, + } + span.entry-size { (row.ago) } + } + } + } + } +} + +/// A body's first line, ellipsized past [`WHAT_LIMIT`] characters -- what +/// a `.what` row shows of a comment or ticket. +fn what_line(text: &str) -> String { + let line = text.lines().next().unwrap_or(""); + let mut shown: String = line.chars().take(WHAT_LIMIT).collect(); + if shown.len() < line.len() { + shown.push('\u{2026}'); + } + shown +} + +/// Where an open comment lives, for its `.where` line: its anchor's +/// `path:line` when it carries one this build can read back, else the +/// context entity it names, else a bare "unanchored". +fn comment_where<O: Find>(state: &AppState<O>, comment: &ents_forge::comment::Comment) -> String { + if let Some(raw) = &comment.anchor { + let objects = state.objects(); + if let Ok(anchor) = + facet_git_tree::deserialize::<ents_anchor::Anchor>(&raw.oid(), &*objects) + { + return match anchor.lines { + Some(range) => format!("{}:{}", anchor.path, range.start), + None => anchor.path, + }; + } + } + comment + .context + .clone() + .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 +/// index-to-worktree halves; the first classification wins) and sorted for +/// a stable render. `None` when the repository cannot be opened or the +/// walk cannot start at all -- [`working_tree_card`] renders a note row +/// then, never an error. +fn worktree_changes<O>(state: &AppState<O>) -> Option<Vec<(String, &'static str)>> { + let repo = gix::open(&state.path).ok()?; + let iter = repo + .status(gix::progress::Discard) + .ok()? + .into_iter(None) + .ok()?; + let mut by_path: std::collections::BTreeMap<String, &'static str> = + std::collections::BTreeMap::new(); + for item in iter.flatten() { + let Some(kind) = change_kind(&item) else { continue; }; - let size = repo - .find_header(*oid) - .map(|header| header.size()) - .unwrap_or(0); - grand = grand.saturating_add(size); - match totals.iter_mut().find(|(existing, _, _)| *existing == lang) { - Some(entry) => entry.2 = entry.2.saturating_add(size), - None => totals.push((lang, color, size)), - } + by_path + .entry(item.location().to_str_lossy().into_owned()) + .or_insert(kind); } - if grand == 0 { - return Vec::new(); - } - totals.sort_by_key(|entry| std::cmp::Reverse(entry.2)); - totals.truncate(4); - totals - .into_iter() - .map(|(lang, color, size)| { - let pct = size.saturating_mul(100).checked_div(grand).unwrap_or(0); - (lang, color, u8::try_from(pct).unwrap_or(100)) - }) - .filter(|(_, _, pct)| *pct > 0) - .collect() + Some(by_path.into_iter().collect()) } -/// Recurse `tree`, pushing every blob's `(filename, oid)` onto `out` -/// -- [`languages`] weighs each by its odb header size, not by count. -/// Subtree reads that fail are skipped rather than propagated -- a -/// language bar is advisory chrome, not a reason to fail the whole page. -fn collect_blobs(repo: &gix::Repository, tree: &gix::Tree<'_>, out: &mut Vec<(String, ObjectId)>) { - for entry in tree.iter() { - let Ok(entry) = entry else { continue }; - if entry.mode().is_tree() { - if let Ok(object) = repo.find_object(entry.oid().to_owned()) - && let Ok(subtree) = object.try_into_tree() - { - collect_blobs(repo, &subtree, out); +/// A status item's display kind, or `None` for one that is not a change a +/// reader acts on (a stat-only refresh, an ignored entry). +fn change_kind(item: &gix::status::Item) -> Option<&'static str> { + use gix::status::plumbing::index_as_worktree::{Change, EntryStatus}; + match item { + gix::status::Item::TreeIndex(change) => Some(match change { + gix::diff::index::ChangeRef::Addition { .. } => "added", + gix::diff::index::ChangeRef::Deletion { .. } => "deleted", + gix::diff::index::ChangeRef::Modification { .. } => "modified", + gix::diff::index::ChangeRef::Rewrite { .. } => "renamed", + }), + gix::status::Item::IndexWorktree(change) => match change { + gix::status::index_worktree::Item::Modification { status, .. } => match status { + EntryStatus::Conflict { .. } => Some("conflict"), + EntryStatus::Change(change) => Some(match change { + Change::Removed => "deleted", + Change::Type { .. } => "type changed", + Change::Modification { .. } | Change::SubmoduleModification(_) => "modified", + }), + EntryStatus::NeedsUpdate(_) => None, + EntryStatus::IntentToAdd => Some("added"), + }, + gix::status::index_worktree::Item::DirectoryContents { entry, .. } => { + matches!(entry.status, gix::dir::entry::Status::Untracked).then_some("untracked") } - } else if entry.mode().is_blob() { - out.push(( - entry.filename().to_str_lossy().into_owned(), - entry.oid().to_owned(), - )); - } + gix::status::index_worktree::Item::Rewrite { .. } => Some("renamed"), + }, } } -/// Map a filename to a language name and swatch color by its extension, or -/// `None` when the extension is not one this breakdown names (ported from -/// `pre-redo:.../git.rs`'s `classify_language`, its `var(--s-*)` colors -/// replaced with literals since that palette was not ported). -fn classify(name: &str) -> Option<(&'static str, &'static str)> { - let ext = name.rsplit_once('.')?.1.to_ascii_lowercase(); - let lang = match ext.as_str() { - "rs" => ("Rust", "#dea584"), - "html" | "htm" => ("HTML", "#e34c26"), - "css" => ("CSS", "#563d7c"), - "js" | "mjs" | "cjs" => ("JavaScript", "#f1e05a"), - "ts" | "tsx" => ("TypeScript", "#3178c6"), - "py" => ("Python", "#3572a5"), - "go" => ("Go", "#00add8"), - "c" | "h" => ("C", "#555555"), - "cpp" | "cc" | "hpp" | "cxx" => ("C++", "#f34b7d"), - "sh" | "bash" => ("Shell", "#89e051"), - "toml" => ("TOML", "#9c4221"), - "yaml" | "yml" => ("YAML", "#cb171e"), - "json" => ("JSON", "#cbcb41"), - "md" | "adoc" | "asciidoc" => ("Prose", "#a0a0a0"), - _ => return None, - }; - Some(lang) +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used, reason = "unit test")] + + use rstest::rstest; + + use super::*; + + #[rstest] + #[case::scoped("model: fix stale rustdoc", Some(("model", "fix stale rustdoc")))] + #[case::hyphenated("web-ui: polish", Some(("web-ui", "polish")))] + #[case::unscoped("Fix stale rustdoc", None)] + #[case::uppercase_prefix("Model: fix", None)] + #[case::no_colon("just a subject", None)] + #[case::empty_scope(": odd", None)] + fn split_scope_takes_only_a_lowercase_scope_prefix( + #[case] subject: &str, + #[case] expected: Option<(&str, &str)>, + ) { + assert_eq!(split_scope(subject), expected); + } + + #[test] + fn scope_class_is_stable_and_within_the_token_palette() { + let class = scope_class("model"); + assert_eq!(class, scope_class("model"), "same scope, same color"); + let index: usize = class + .strip_prefix("scope-c") + .expect("prefixed class") + .parse() + .expect("numeric suffix"); + assert!(index < 6, "always one of the six --s-* token colors"); + } + + #[test] + fn what_line_takes_the_first_line_and_ellipsizes_long_ones() { + assert_eq!(what_line("short\nrest"), "short"); + let long = "x".repeat(200); + let shown = what_line(&long); + assert!(shown.chars().count() <= WHAT_LIMIT.saturating_add(1)); + assert!(shown.ends_with('\u{2026}')); + } }
crates/cli/ents-web/src/pages/files.rs @@ -145,6 +145,7 @@ "Files", html! { (dir_listing(path, entries)) + (readme_card(&head_tree)) }, )); } @@ -218,10 +219,9 @@ /// One `(name, is_directory, size)` triple per direct child of `tree`, in /// tree order (not yet sorted -- [`dir_listing`] sorts for display). `size` /// is a blob entry's byte length, read from its odb header -/// ([`gix::Repository::find_header`], the same header-only read -/// `crate::pages::dashboard::languages` weighs its language breakdown -/// by -- never a full blob read just to size it) and best-effort (`None` -/// on a header-read failure, same as that function's own stance); always +/// ([`gix::Repository::find_header`], a header-only lookup -- never a +/// full blob read just to size it) and best-effort (`None` +/// on a header-read failure); always /// `None` for a directory entry, which [`dir_listing`] renders with no /// size cell at all. fn tree_entries(tree: &gix::Tree<'_>) -> Result<Vec<(String, bool, Option<u64>)>> { @@ -277,6 +277,65 @@ } } +/// The rendered `README` card below the root listing -- re-homed here +/// from the old overview dashboard (`crate::pages::dashboard` is a work +/// surface now; the Code root is where the repository introduces itself). +/// Renders nothing at all when the root holds no renderable `README`. +fn readme_card(tree: &gix::Tree<'_>) -> Markup { + let Some((name, rendered)) = readme(tree) else { + return html! {}; + }; + html! { + div.card { + div.card-header { (assets::icon_file()) (name) } + div.doc-body { (rendered) } + } + } +} + +/// The first root-tree blob whose stem is `README` and whose extension +/// this crate renders (Markdown or AsciiDoc), converted to HTML and paired +/// with its filename; `None` when there is none or it fails to render +/// (mirrors `pre-redo:.../pages.rs`'s `readme`). +fn readme(tree: &gix::Tree<'_>) -> Option<(String, Markup)> { + let name = root_readme_name(tree)?; + let entry = tree.lookup_entry_by_path(&name).ok()??; + let blob = entry.object().ok()?.try_into_blob().ok()?; + let text = String::from_utf8_lossy(&blob.data); + render_doc(&name, &text).map(|rendered| (name, rendered)) +} + +/// The filename of the root's `README`, if it has a renderable one. +fn root_readme_name(tree: &gix::Tree<'_>) -> Option<String> { + for entry in tree.iter() { + let Ok(entry) = entry else { continue }; + if !entry.mode().is_blob() { + continue; + } + let name = entry.filename().to_str_lossy(); + let is_readme = name + .rsplit_once('.') + .is_some_and(|(stem, _)| stem.eq_ignore_ascii_case("readme")); + if is_readme && (crate::markdown::is_markdown(&name) || crate::asciidoc::is_asciidoc(&name)) + { + return Some(name.into_owned()); + } + } + None +} + +/// `text` rendered as its prose format (Markdown or AsciiDoc), or `None` +/// when it is neither or AsciiDoc rendering fails. +fn render_doc(name: &str, text: &str) -> Option<Markup> { + if crate::markdown::is_markdown(name) { + Some(crate::markdown::to_html(text)) + } else if crate::asciidoc::is_asciidoc(name) { + crate::asciidoc::to_html(text).ok() + } else { + None + } +} + /// Breadcrumb navigation from the repository's files root down through /// `path`, `chevron-right` icons separating segments -- pure navigation, /// no trailing actions. The history/comment links that used to trail this @@ -315,8 +374,7 @@ /// Format a byte count the way [`blob_header`] and [`dir_listing`] both /// show a file's size: whole bytes under 1 KB, otherwise one decimal place /// of KB or MB -- integer-only throughout (`checked_div`/`checked_rem`/ -/// `saturating_mul`, this crate's own arithmetic idiom, e.g. -/// `crate::pages::dashboard::languages`'s percentage math) rather than a +/// `saturating_mul`, this crate's own arithmetic idiom) rather than a /// float division, so there is no rounding-mode or precision question to /// answer. fn human_size(bytes: u64) -> String {