roots: render the repository overview with a README and language breakdown
commit
2b13384roots: 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
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
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)
+}