roots: rebuild the dashboard as the workbench desk
commit
91a8301roots: 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
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
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 {