git-ents.gitmain
⌘K
foforge
commit 2b13384
roots: render the repository overview with a README and language breakdown

Replaces the dashboard’s bare link index with the pre-redo repository overview (pre-redo:crates/git-ents-server/src/web/pages.rs’s repo_page): the served repository’s rendered README fills the main column, beside a sticky aside holding a contents rail (one live per-namespace count, still the seam smoke test the old index was) and a language breakdown of the HEAD tree. README and language reads browse HEAD via gix off state.path, the same pattern the files browser uses; the breakdown is file-count based rather than pre-redo’s byte-weighted git-ls-tree, and drops the cards this single-repo local crate has no data for (clone URL, homepage, releases).

Assisted-by: Claude:claude-opus-4-8

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 @@ -275,6 +275,53 @@ ); } +/// `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. +#[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(), + ); + 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" + ); +} + /// `roots.web-session`: a state-changing request with no CSRF token at /// all is a bad request (axum's own `Form` rejection); one with the wrong /// token is refused by this crate's own check; the session cookie a `GET`
crates/cli/ents-web/src/assets/ents.css @@ -113,6 +113,34 @@ .card-row a.row-link { display: flex; align-items: center; gap: .65rem; text-decoration: none; } .card-row:has(.row-link):hover { background: var(--color-code-bg); } +/* 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: 78px; 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); } +.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; } +} + /* The generic reflection-driven views (`crate::render`): a definition list * for one entity, a table for a list of entities, a plain list for bare * name lists (toolchains, inbox). */
crates/cli/ents-web/src/pages/dashboard.rs @@ -1,17 +1,34 @@ -//! `GET /`: the dashboard -- entry points into every page family this -//! crate exposes, with a live count read from each namespace so the page +//! `GET /`: the repository overview -- 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. +//! 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). +//! +//! The `README` and language reads browse the repository's `HEAD` tree +//! through `gix`'s high-level `Repository`/`Tree` types, opened fresh per +//! request from `state.path`, exactly as [`crate::pages::files`] does (and +//! for the same reason: browsing arbitrary repository content is not the +//! `facet-git-tree` meta-ref convention the generic pages use). use std::sync::Arc; use axum::extract::State; +use gix::bstr::ByteSlice as _; use gix_object::{Find, Write}; -use maud::html; +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); + /// `GET /`. /// /// # Errors @@ -27,22 +44,265 @@ let comments = state.refs.iter_prefix("refs/meta/comments/")?.count(); let toolchains = state.refs.iter_prefix("refs/meta/toolchains/")?.count(); + let (main, langs) = repo_overview(&state); + Ok(super::layout( &super::RepoHeader::from_state(&state), super::Tab::Dashboard, - "dashboard", + "overview", html! { - div.card { - ul.string-list { - li { a href="/members" { "members" } span.badge { (members) } } - li { a href="/account" { "account" } } - li { a href="/effects" { "effects" } span.badge { (effects) } } - li { a href="/redactions" { "redactions" } span.badge { (redactions) } } - li { a href="/toolchains" { "toolchains" } span.badge { (toolchains) } } - li { a href="/comments" { "comments" } span.badge { (comments) } } - li { a href="/inbox" { "inbox" } } + 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) "%" } + } + } + } + } + } + } } } }, )) } + +/// 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 { + html! { + div.aside-row { + a href=(href) { (label) } + @if let Some(count) = count { + span.count { (count) } + } + } + } +} + +/// 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 main = 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) + } + }; + (main, langs) +} + +/// The empty-column placeholder shown when the repository has no `README`, +/// no readable root, or no `HEAD` at all. +fn blankslate() -> Markup { + html! { + div.card { + div.blankslate { + h2 { "Nothing to show yet" } + p { "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) + } + } + } + } + } +} + +/// The language breakdown of the whole `HEAD` tree: the top four languages +/// by file count, as `(name, color, percent)`, largest first. File-count +/// based rather than pre-redo's byte-weighted `git ls-tree -l` (which shells +/// out); the shape and the top-four cap match `pre-redo:.../git.rs`'s +/// `languages`. +fn languages(repo: &gix::Repository, tree: &gix::Tree<'_>) -> Vec<Lang> { + let mut names = Vec::new(); + collect_blob_names(repo, tree, &mut names); + let mut totals: Vec<(&'static str, &'static str, u64)> = Vec::new(); + let mut grand: u64 = 0; + for name in &names { + let Some((lang, color)) = classify(name) else { + continue; + }; + grand = grand.saturating_add(1); + match totals.iter_mut().find(|(existing, _, _)| *existing == lang) { + Some(entry) => entry.2 = entry.2.saturating_add(1), + None => totals.push((lang, color, 1)), + } + } + 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, count)| { + let pct = count.saturating_mul(100).checked_div(grand).unwrap_or(0); + (lang, color, u8::try_from(pct).unwrap_or(100)) + }) + .filter(|(_, _, pct)| *pct > 0) + .collect() +} + +/// Recurse `tree`, pushing every blob's filename onto `out`. 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_blob_names(repo: &gix::Repository, tree: &gix::Tree<'_>, out: &mut Vec<String>) { + 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_blob_names(repo, &subtree, out); + } + } else if entry.mode().is_blob() { + out.push(entry.filename().to_str_lossy().into_owned()); + } + } +} + +/// 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) +}