git-ents.gitmain
⌘K
foforge
commit e2b4bba
feat: add repository header band, tab bar, overview aside, and releases

Introduce a shared repository shell — the path/branch/visibility header band, topic chips, Watch/Star actions, and the six-tab bar — wrapping every repository view as its own server-rendered route. Rebuild the overview into the README-and-aside grid (clone, about, releases, languages from real git data) and add the Releases tab as a tag-driven changelog timeline.

feat: add repository header band with path, pills, topics, and actions feat: add tab bar routing Overview/Files/Releases/Hooks/Issues/Settings feat: rebuild overview as README beside clone/about/releases/languages aside feat: compute language breakdown from tracked blob sizes feat: add releases timeline driven by git tags feat: reconstruct AsciiDoc doctitle dropped by acdc embedded mode

Joseph D. Carpinelli · 1 month ago

Reviews

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

Start a review

verdict

crates/git-ents-server/src/asciidoc.rs @@ -8,7 +8,8 @@ use acdc_converters_core::{Converter, Options as ConvertOptions}; use acdc_converters_html::{Processor, RenderOptions}; -use acdc_parser::Options as ParseOptions; +use acdc_parser::{Options as ParseOptions, inlines_to_string}; +use maud::html; /// File extensions that name an AsciiDoc document. const EXTENSIONS: [&str; 4] = ["adoc", "asciidoc", "asc", "adc"]; @@ -25,10 +26,34 @@ pub(crate) fn to_html(source: &str) -> Option<String> { let parsed = acdc_parser::parse(source, &ParseOptions::default()).ok()?; let doc = parsed.document(); + + // Embedded mode omits the document frame *and* the visible doctitle, so + // rebuild the title and subtitle from the parsed header — the README's h1 is + // the centerpiece of the overview. + let heading = doc + .header + .as_ref() + .filter(|h| !h.title.is_empty()) + .map(|h| { + let title = inlines_to_string(&h.title); + let subtitle = h.subtitle.as_ref().map(|s| inlines_to_string(s)); + html! { + h1 { (title) } + @if let Some(subtitle) = subtitle { + p.doc-subtitle { (subtitle) } + } + } + .into_string() + }); + let processor = Processor::new(ConvertOptions::default(), doc.attributes.clone()); let options = RenderOptions { embedded: true, ..RenderOptions::default() }; - processor.convert_to_string(doc, &options).ok() + let body = processor.convert_to_string(doc, &options).ok()?; + Some(match heading { + Some(heading) => heading + &body, + None => body, + }) }
crates/git-ents-server/src/web.rs @@ -122,7 +122,60 @@ .page-title a:hover { color: var(--color-accent); } .count { margin-left: auto; font-family: var(--font-mono); font-size: .8rem; color: var(--color-text-muted); } -.branch { font-family: var(--font-mono); font-size: .72rem; font-weight: 600; color: var(--color-accent); background: var(--color-accent-subtle); border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent); border-radius: var(--radius-pill); padding: .1rem .6rem; } +.branch { font-family: var(--font-mono); font-size: .72rem; font-weight: 600; color: var(--color-accent); background: var(--color-accent-subtle); border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent); border-radius: var(--radius-pill); padding: .1rem .6rem; display: inline-flex; align-items: center; gap: .3rem; } +.branch .icon { width: 13px; height: 13px; } + +.repo-header { display: flex; align-items: flex-start; gap: 1.5rem; flex-wrap: wrap; margin-bottom: 1.25rem; } +.repo-headline { flex: 1; min-width: 0; } +.repo-path { font-family: var(--font-mono); font-size: 1.18rem; display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; word-break: break-all; } +.repo-path .icon { color: var(--color-accent); } +.repo-path a { color: var(--color-text-muted); text-decoration: none; } +.repo-path a:hover { color: var(--color-accent); } +.repo-path .here { color: var(--color-accent); font-weight: 600; } +.repo-path .sep { color: var(--color-text-muted); opacity: .55; } +.pill-public { font-family: var(--font-mono); font-size: .7rem; font-weight: 500; color: var(--color-text-muted); background: var(--color-code-bg); border-radius: var(--radius-pill); padding: .1rem .55rem; } +.repo-desc { font-size: .98rem; color: var(--color-text); max-width: 40rem; margin-top: .65rem; } +.topics { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .75rem; } +.topic { font-family: var(--font-mono); font-size: .72rem; color: var(--color-link); background: var(--color-accent-subtle); border-radius: var(--radius-pill); padding: .12rem .6rem; text-decoration: none; } +.topic:hover { color: var(--color-link-hover); } +.repo-actions { display: flex; gap: .6rem; flex-shrink: 0; } +.action-btn { display: inline-flex; align-items: center; gap: .4rem; font-family: var(--font-sans); font-size: .82rem; font-weight: 500; color: var(--color-text); background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); box-shadow: var(--shadow-sm); padding: .4rem .8rem; cursor: pointer; transition: border-color .15s; } +.action-btn:hover { border-color: var(--color-accent); } +.action-btn .icon { color: var(--color-text-muted); } + +.tabs { display: flex; gap: .15rem; border-bottom: 1px solid var(--color-border); margin-bottom: 1.75rem; overflow-x: auto; } +.tab { display: inline-flex; align-items: center; gap: .4rem; padding: 10px 14px; font-size: .88rem; color: var(--color-text-muted); text-decoration: none; white-space: nowrap; position: relative; transition: color .15s; } +.tab:hover { color: var(--color-text); } +.tab.active { color: var(--color-text); font-weight: 600; } +.tab.active::after { content: ""; position: absolute; left: 0; right: 0; bottom: -1px; height: 2px; background: var(--color-accent); } +.tab-count { font-family: var(--font-mono); font-size: .68rem; color: var(--color-text-muted); background: var(--color-code-bg); border: 1px solid var(--color-border); border-radius: var(--radius-pill); padding: 0 .4rem; } +.tab-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--s-func); } + +.overview { display: grid; grid-template-columns: 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 .clone code { font-size: .76rem; padding: .6rem .8rem; } +.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 .icon { color: var(--color-text-muted); flex-shrink: 0; } +.aside-row .muted { color: var(--color-text-muted); } +.aside-row .count { margin-left: 0; font-family: var(--font-mono); font-weight: 600; color: var(--color-accent); } +.aside-row a { text-decoration: none; } +.lang-dot, .swatch { width: 9px; height: 9px; border-radius: 2px; flex-shrink: 0; } +.dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; } +.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-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); } +.latest { font-family: var(--font-mono); font-size: .8rem; } +.tag-pill { color: var(--color-accent); font-weight: 600; } + +@media (max-width: 860px) { + .overview { grid-template-columns: 1fr; } + .aside { position: static; } +} .repo-list { list-style: none; } .repo-list li + li { border-top: 1px solid var(--color-border); } @@ -205,6 +258,7 @@ .adoc-body > :first-child { margin-top: 0; } .adoc-body h1, .adoc-body h2, .adoc-body h3, .adoc-body h4 { font-family: var(--font-serif); font-weight: 700; letter-spacing: -.01em; line-height: 1.25; margin: 1.8rem 0 .9rem; } .adoc-body h1 { font-size: 2.4rem; letter-spacing: -.02em; } +.adoc-body .doc-subtitle { font-family: var(--font-serif); font-style: italic; font-size: 1.18rem; color: var(--color-text-muted); margin: -.4rem 0 1.2rem; } .adoc-body h2 { font-size: 1.4rem; font-weight: 600; position: relative; padding-bottom: .55rem; } .adoc-body h2::after { content: ""; position: absolute; left: 0; bottom: 0; width: 3rem; height: 2px; background: var(--color-accent); border-radius: 1px; } .adoc-body h3 { font-size: 1.15rem; font-weight: 600; } @@ -223,6 +277,25 @@ .adoc-body img { max-width: 100%; height: auto; } .adoc-body hr { border: none; border-top: 1px solid var(--color-border); margin: 1.8rem 0; } +.timeline { position: relative; padding-left: 30px; } +.timeline::before { content: ""; position: absolute; left: 5px; top: 0; bottom: 0; width: 2px; background: var(--color-border); } +.release { position: relative; margin-bottom: 1.5rem; } +.release::before { content: ""; position: absolute; left: -30px; top: 16px; width: 12px; height: 12px; border-radius: var(--radius-pill); background: var(--color-surface); border: 2px solid var(--color-border); box-shadow: 0 0 0 4px var(--color-bg); } +.release.latest::before { background: var(--color-accent); border-color: var(--color-accent); } +.release-head { display: flex; align-items: center; gap: .55rem; padding: .85rem 1.1rem; border-bottom: 1px solid var(--color-border); flex-wrap: wrap; } +.release-head .icon { color: var(--color-accent); } +.release-tag { font-family: var(--font-mono); font-size: .9rem; font-weight: 600; color: var(--color-accent); } +.release-name { font-family: var(--font-serif); font-size: 1.05rem; font-weight: 600; } +.release-date { margin-left: auto; font-size: .8rem; color: var(--color-text-muted); } +.badge-latest { font-family: var(--font-mono); font-size: .66rem; font-weight: 600; text-transform: uppercase; letter-spacing: .05em; color: var(--s-func); background: var(--color-code-bg); border-radius: var(--radius-pill); padding: .05rem .5rem; } +.release-body { padding: 1rem 1.1rem; } +.release-body p { white-space: pre-wrap; font-size: .9rem; } +.release-body .muted { color: var(--color-text-muted); } +.release-foot { display: flex; align-items: center; padding: .7rem 1.1rem; border-top: 1px solid var(--color-border); font-family: var(--font-mono); font-size: .74rem; } +.release-foot .sha { margin-left: auto; color: var(--color-text-muted); display: inline-flex; align-items: center; gap: .35rem; } + +.shell-note { color: var(--color-text-muted); font-size: .95rem; max-width: 44rem; margin-bottom: 1.5rem; } + .site-footer { border-top: 1px solid var(--color-border); color: var(--color-text-muted); font-size: .8rem; margin-top: auto; } .footer-inner { max-width: var(--max-width); margin: 0 auto; padding: 2rem 1.5rem; text-align: center; } .footer-inner a { color: var(--color-text-muted); text-decoration: none; } @@ -282,16 +355,159 @@ } /// Dispatch the part of the path that follows the repository to a browse view. +/// Each top-level tab is its own route, since the product is server-rendered +/// with no client JavaScript. async fn route(repo: &Path, rel: &str, rest: &[&str], host: Option<&str>) -> Response { + let meta = gather_meta(repo, rel).await; match rest.split_first() { - None => repo_page(repo, rel, host).await.into_response(), - Some((&"tree", sub)) => tree_page(repo, rel, sub).await, - Some((&"blob", sub)) => blob_page(repo, rel, sub).await, - Some((&"commit", &[sha])) => commit_page(repo, rel, sha).await, + None => repo_page(repo, &meta, host).await.into_response(), + Some((&"tree", sub)) => tree_page(repo, &meta, sub).await, + Some((&"blob", sub)) => blob_page(repo, &meta, sub).await, + Some((&"commit", &[sha])) => commit_page(repo, &meta, sha).await, + Some((&"releases", &[])) => releases_page(repo, &meta).await.into_response(), _ => not_found().into_response(), } } +/// The top-level tabs of a repository page. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Tab { + Overview, + Files, + Releases, + Hooks, + Issues, + Settings, +} + +/// Metadata shown in the repository header band and tab bar, gathered once per +/// request and shared by every view. +struct RepoMeta { + rel: String, + branch: Option<String>, + description: Option<String>, + topics: Vec<String>, + releases: usize, + issues: usize, + has_hooks: bool, +} + +/// Collect the header/tab metadata for the repository at `rel`. +async fn gather_meta(repo: &Path, rel: &str) -> RepoMeta { + let branch = git_output(repo, &["symbolic-ref", "--short", "HEAD"]) + .await + .map(|s| s.trim().to_owned()) + .filter(|s| !s.is_empty()); + let description = std::fs::read_to_string(repo.join("description")) + .ok() + .map(|s| s.trim().to_owned()) + .filter(|s| !s.is_empty() && !s.starts_with("Unnamed repository")); + let topics = git_output_bytes(repo, &["cat-file", "-p", "HEAD:.gitents/topics"]) + .await + .map(|b| { + String::from_utf8_lossy(&b) + .split([',', '\n', ' ', '\t']) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .collect() + }) + .unwrap_or_default(); + let releases = git_output(repo, &["tag", "--list"]) + .await + .map(|s| s.lines().filter(|l| !l.trim().is_empty()).count()) + .unwrap_or(0); + let has_hooks = git_output(repo, &["cat-file", "-t", "HEAD:.gitents/hooks.toml"]) + .await + .as_deref() + == Some("blob\n"); + RepoMeta { + rel: rel.to_owned(), + branch, + description, + topics, + releases, + issues: 0, + has_hooks, + } +} + +/// Wrap a repository view in the shared header band and tab bar, then the page +/// shell. `active` highlights the current tab. +fn repo_shell(meta: &RepoMeta, active: Tab, title: &str, body: Markup) -> Markup { + page( + title, + html! { (repo_header(meta)) (tab_bar(meta, active)) (body) }, + ) +} + +/// The repository header band: path line, branch and visibility pills, +/// description, topic chips, and the Watch/Star actions. +fn repo_header(meta: &RepoMeta) -> Markup { + let segments: Vec<&str> = meta.rel.split('/').collect(); + let last = segments.len().saturating_sub(1); + html! { + div.repo-header { + div.repo-headline { + div.repo-path { + (icon_folder()) + @for (i, seg) in segments.iter().enumerate() { + @if i > 0 { span.sep { "/" } } + @if i == last { + span.here { (seg) } + } @else { + @let href = format!("/{}", segments.get(..=i).unwrap_or_default().join("/")); + a href=(href) { (seg) } + } + } + @if let Some(branch) = &meta.branch { + span.branch { (icon_branch()) (branch) } + } + span.pill-public { "Public" } + } + @if let Some(desc) = &meta.description { + p.repo-desc { (desc) } + } + @if !meta.topics.is_empty() { + div.topics { + @for topic in &meta.topics { + span.topic { (topic) } + } + } + } + } + div.repo-actions { + button.action-btn type="button" { (icon_eye()) "Watch" } + button.action-btn type="button" { (icon_star()) "Star" } + } + } + } +} + +/// The tab bar with the active tab underlined. Tabs that have no backing data +/// yet still render so the navigation matches the design. +fn tab_bar(meta: &RepoMeta, active: Tab) -> Markup { + let rel = &meta.rel; + html! { + nav.tabs { + a.tab.active[active == Tab::Overview] href={ "/" (rel) } { "Overview" } + a.tab.active[active == Tab::Files] href={ "/" (rel) "/files" } { "Files" } + a.tab.active[active == Tab::Releases] href={ "/" (rel) "/releases" } { + "Releases" + @if meta.releases > 0 { span.tab-count { (meta.releases) } } + } + a.tab.active[active == Tab::Hooks] href={ "/" (rel) "/hooks" } { + "Hooks" + @if meta.has_hooks { span.tab-dot {} } + } + a.tab.active[active == Tab::Issues] href={ "/" (rel) "/issues" } { + "Issues" + @if meta.issues > 0 { span.tab-count { (meta.issues) } } + } + a.tab.active[active == Tab::Settings] href={ "/" (rel) "/settings" } { "Settings" } + } + } +} + /// Join the path segments of a browse view, rejecting empty or traversing /// components. The result is used only as a git tree path (`HEAD:<path>`), never /// touched on disk, but refusing `..` keeps the rendered links well-formed. @@ -338,119 +554,113 @@ ) } -/// A single repository's overview: default branch, recent commits, root tree. -async fn repo_page(repo: &Path, rel: &str, host: Option<&str>) -> Markup { - let branch = git_output(repo, &["symbolic-ref", "--short", "HEAD"]) +/// A single repository's overview: the rendered README beside an aside of +/// clone, about, releases, and language cards. +async fn repo_page(repo: &Path, meta: &RepoMeta, host: Option<&str>) -> Markup { + let rel = &meta.rel; + let updated = git_output(repo, &["log", "-1", "--format=%ar"]) .await .map(|s| s.trim().to_owned()) .filter(|s| !s.is_empty()); - let commits = recent_commits(repo).await; - let tree = root_tree(repo, branch.is_some()).await; + let is_empty = updated.is_none(); + let tree = root_tree(repo, !is_empty).await; let readme = readme(repo, &tree).await; let clone_url = clone_url(host, rel); + let langs = languages(repo).await; + let latest = latest_release(repo).await; let name = rel.rsplit('/').next().unwrap_or(rel); - page( - name, - html! { - div.page-header { - h1.page-title { - (icon_folder()) - a href={ "/" (rel) } { (rel) } - } - @if let Some(branch) = &branch { - span.branch { (branch) } + let main = html! { + @if is_empty { + div.blankslate { + h2 { "This repository is empty" } + p { "Push a commit to get started." } + } + } @else if let Some((file, html)) = &readme { + div.card { + div.card-header { (icon_file()) " " (file) } + article.adoc-body { (PreEscaped(html)) } + } + } @else if !tree.is_empty() { + div.card { + div.card-header { "Files" } + @for entry in &tree { + div.card-row.is-dir[entry.is_dir] { + @if entry.is_dir { (icon_folder()) } @else { (icon_file()) } + a href=(entry_href(rel, "", entry)) { (entry.name) } + } } } + } + }; + let aside = html! { + aside.aside { div.card { div.card-header { "Clone" } div.clone { - code { "git clone " (clone_url) } + code { (clone_url) } button.copy-btn data-copy={ "git clone " (clone_url) } { "Copy" } } } - - @if commits.is_empty() { - div.blankslate { - h2 { "This repository is empty" } - p { "Push a commit to get started." } - } - } @else { - @if let Some((file, html)) = &readme { - div.card { - div.card-header { (file) } - article.adoc-body { (PreEscaped(html)) } + div.card { + div.card-header { "About" } + @if let Some((lang, color, _)) = langs.first() { + div.aside-row { + span.dot style={ "background:" (color) } {} + span { (lang) } } } - @if !tree.is_empty() { - div.card { - div.card-header { "Files" } - @for entry in &tree { - div.card-row.is-dir[entry.is_dir] { - @if entry.is_dir { (icon_folder()) } @else { (icon_file()) } - a href=(entry_href(rel, "", entry)) { (entry.name) } - } - } + @if let Some(updated) = &updated { + div.aside-row { + (icon_clock()) + span.muted { "Updated " (updated) } } } + } + @if let Some(release) = &latest { div.card { - div.card-header { "Recent commits" } - @for commit in &commits { - div.commit { - div.commit-subject { - a href={ "/" (rel) "/commit/" (commit.hash) } { (commit.subject) } + div.card-header { "Releases" span.count { (meta.releases) } } + div.aside-row { + (icon_tag()) + a href={ "/" (rel) "/releases" } { span.tag-pill { (release.tag) } } + span.badge-latest { "Latest" } + } + div.aside-row { + span.muted { (release.title) " · " (release.date) } + } + } + } + @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) } {} } - div.commit-meta { - span.sha { (commit.short) } - (commit.author) " · " (commit.when) + } + ul.lang-legend { + @for (lang, color, pct) in &langs { + li { + span.lang-dot style={ "background:" (color) } {} + span { (lang) } + span.pct { (pct) "%" } + } } } } } } - }, - ) -} - -/// A parsed `git log` entry. -struct Commit { - hash: String, - short: String, - author: String, - when: String, - subject: String, -} - -/// The most recent commits reachable from `HEAD`, newest first. -async fn recent_commits(repo: &Path) -> Vec<Commit> { - let Some(log) = git_output( - repo, - &["log", "-n", "20", "--format=%H%x00%an%x00%ar%x00%s"], - ) - .await - else { - return Vec::new(); + } }; - log.lines() - .filter_map(|line| { - let mut parts = line.split('\u{0}'); - let hash = parts.next().unwrap_or_default(); - let author = parts.next().unwrap_or_default(); - let when = parts.next().unwrap_or_default(); - let subject = parts.next().unwrap_or_default(); - if hash.is_empty() { - return None; - } - Some(Commit { - hash: hash.to_owned(), - short: hash.get(..7).unwrap_or(hash).to_owned(), - author: author.to_owned(), - when: when.to_owned(), - subject: subject.to_owned(), - }) - }) - .collect() + + repo_shell( + meta, + Tab::Overview, + name, + html! { div.overview { div { (main) } (aside) } }, + ) } /// A single entry in the repository's root tree. @@ -484,6 +694,133 @@ Some((entry.name.clone(), html)) } +/// A language's display name, swatch color (a CSS custom property), and the +/// percentage of tracked bytes it accounts for. +type Language = (&'static str, &'static str, u8); + +/// Map a filename to a language name and swatch color by its extension, or +/// `None` for files that do not count toward the language breakdown. +fn classify_language(name: &str) -> Option<(&'static str, &'static str)> { + let ext = name.rsplit_once('.')?.1.to_ascii_lowercase(); + let lang = match ext.as_str() { + "rs" => ("Rust", "var(--s-type)"), + "html" | "htm" => ("HTML", "var(--s-func)"), + "css" => ("CSS", "var(--s-prop)"), + "js" | "mjs" | "cjs" => ("JavaScript", "var(--s-const)"), + "ts" | "tsx" => ("TypeScript", "var(--s-prop)"), + "py" => ("Python", "var(--s-string)"), + "go" => ("Go", "var(--s-prop)"), + "c" | "h" => ("C", "var(--s-const)"), + "cpp" | "cc" | "hpp" | "cxx" => ("C++", "var(--s-const)"), + "sh" | "bash" => ("Shell", "var(--s-func)"), + "toml" => ("TOML", "var(--s-type)"), + "yaml" | "yml" => ("YAML", "var(--s-prop)"), + "json" => ("JSON", "var(--s-const)"), + "md" | "adoc" | "asciidoc" => ("Prose", "var(--s-comment)"), + _ => return None, + }; + Some(lang) +} + +/// The language breakdown for `HEAD`, by tracked blob size, as the top few +/// languages with integer percentages summing to roughly 100. +async fn languages(repo: &Path) -> Vec<Language> { + let Some(out) = git_output(repo, &["ls-tree", "-r", "-l", "HEAD"]).await else { + return Vec::new(); + }; + let mut totals: Vec<(&'static str, &'static str, u64)> = Vec::new(); + let mut grand: u64 = 0; + for line in out.lines() { + let Some((meta, name)) = line.split_once('\t') else { + continue; + }; + let size: u64 = meta + .split_whitespace() + .nth(3) + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + let Some((lang, color)) = classify_language(name) else { + continue; + }; + grand = grand.saturating_add(size); + match totals.iter_mut().find(|(l, _, _)| *l == lang) { + Some(entry) => entry.2 = entry.2.saturating_add(size), + None => totals.push((lang, color, size)), + } + } + if grand == 0 { + return Vec::new(); + } + totals.sort_by_key(|b| std::cmp::Reverse(b.2)); + totals.truncate(4); + totals + .into_iter() + .map(|(lang, color, bytes)| { + let pct = bytes.saturating_mul(100).checked_div(grand).unwrap_or(0); + (lang, color, u8::try_from(pct).unwrap_or(100)) + }) + .filter(|(_, _, pct)| *pct > 0) + .collect() +} + +/// A tagged release: its tag, the release name and notes drawn from the tag (or +/// commit) message, the relative date, and the target commit's short hash. +struct Release { + tag: String, + title: String, + body: String, + date: String, + short: String, +} + +/// All tags as releases, newest first by creation date. +async fn releases(repo: &Path) -> Vec<Release> { + let Some(list) = git_output(repo, &["tag", "--sort=-creatordate", "--list"]).await else { + return Vec::new(); + }; + let mut out = Vec::new(); + for tag in list + .lines() + .map(str::trim) + .filter(|t| !t.is_empty()) + .take(40) + { + let Some(meta) = git_output(repo, &["log", "-1", "--format=%h%x00%ar", tag]).await else { + continue; + }; + let mut parts = meta.trim().split('\u{0}'); + let short = parts.next().unwrap_or_default().to_owned(); + let date = parts.next().unwrap_or_default().to_owned(); + let notes = git_output( + repo, + &[ + "tag", + "--list", + "--format=%(contents:subject)%00%(contents:body)", + tag, + ], + ) + .await + .unwrap_or_default(); + let mut np = notes.split('\u{0}'); + let title = np.next().unwrap_or_default().trim().to_owned(); + let body = np.next().unwrap_or_default().trim().to_owned(); + out.push(Release { + tag: tag.to_owned(), + title, + body, + date, + short, + }); + } + out +} + +/// The newest release, if any. +async fn latest_release(repo: &Path) -> Option<Release> { + releases(repo).await.into_iter().next() +} + /// The entries of the tree named by `spec` (a git tree-ish such as `HEAD` or /// `HEAD:src`), directories first then by name. Empty if `spec` is not a tree. async fn list_tree(repo: &Path, spec: &str) -> Vec<TreeEntry> { @@ -590,7 +927,8 @@ } /// A directory listing at `sub` within the repository. -async fn tree_page(repo: &Path, rel: &str, sub: &[&str]) -> Response { +async fn tree_page(repo: &Path, meta: &RepoMeta, sub: &[&str]) -> Response { + let rel = &meta.rel; let Some(dir) = browse_path(sub) else { return not_found().into_response(); }; @@ -604,7 +942,9 @@ return not_found().into_response(); } let name = rel.rsplit('/').next().unwrap_or(rel); - page( + repo_shell( + meta, + Tab::Files, name, html! { (crumbs(rel, &dir, false)) @@ -627,7 +967,8 @@ /// A single file's contents at `sub`, syntax-highlighted when the language is /// recognized and the file is text. -async fn blob_page(repo: &Path, rel: &str, sub: &[&str]) -> Response { +async fn blob_page(repo: &Path, meta: &RepoMeta, sub: &[&str]) -> Response { + let rel = &meta.rel; let Some(path) = browse_path(sub).filter(|p| !p.is_empty()) else { return not_found().into_response(); }; @@ -655,7 +996,9 @@ None => blob_body(name, &text), } }; - page( + repo_shell( + meta, + Tab::Files, name, html! { (crumbs(rel, &path, true)) @@ -710,11 +1053,11 @@ } /// A single commit: its metadata and a colorized unified diff. -async fn commit_page(repo: &Path, rel: &str, sha: &str) -> Response { +async fn commit_page(repo: &Path, meta: &RepoMeta, sha: &str) -> Response { if sha.is_empty() || sha.len() > 64 || !sha.bytes().all(|b| b.is_ascii_hexdigit()) { return not_found().into_response(); } - let Some(meta) = git_output( + let Some(info) = git_output( repo, &["show", "-s", "--format=%H%x00%an%x00%ar%x00%s%x00%b", sha], ) @@ -722,7 +1065,7 @@ else { return not_found().into_response(); }; - let mut parts = meta.split('\u{0}'); + let mut parts = info.split('\u{0}'); let hash = parts.next().unwrap_or_default().trim().to_owned(); let author = parts.next().unwrap_or_default().to_owned(); let when = parts.next().unwrap_or_default().to_owned(); @@ -733,15 +1076,13 @@ .await .unwrap_or_default(); - page( + repo_shell( + meta, + Tab::Files, &subject, html! { - div.page-header { - h1.page-title { a href={ "/" (rel) } { (rel) } } - span.branch { (short) } - } div.card { - div.card-header { "Commit" } + div.card-header { (icon_commit()) " Commit " span.sha { (short) } } div.commit { div.commit-subject { (subject) } @if !body.is_empty() { @@ -756,6 +1097,49 @@ .into_response() } +/// The Releases tab: tags presented as a changelog timeline, newest first. +async fn releases_page(repo: &Path, meta: &RepoMeta) -> Markup { + let releases = releases(repo).await; + repo_shell( + meta, + Tab::Releases, + "Releases", + html! { + div.page-header { h1.page-title { "Releases" } } + @if releases.is_empty() { + div.blankslate { + h2 { "No releases yet" } + p { "Push a tag to publish a release: " code { "git push <url> v1.0.0" } } + } + } @else { + div.timeline { + @for (i, release) in releases.iter().enumerate() { + article.release.latest[i == 0] { + div.card { + div.release-head { + (icon_tag()) + span.release-tag { (release.tag) } + @if !release.title.is_empty() && release.title != release.tag { + span.release-name { (release.title) } + } + @if i == 0 { span.badge-latest { "Latest" } } + span.release-date { (release.date) } + } + @if !release.body.is_empty() { + div.release-body { p { (release.body) } } + } + div.release-foot { + span.sha { (icon_commit()) (release.short) } + } + } + } + } + } + } + }, + ) +} + /// Render a unified diff, coloring each line by its leading marker. fn diff_view(patch: &str) -> Markup { if patch.trim().is_empty() { @@ -920,6 +1304,42 @@ ) } +fn icon_branch() -> Markup { + svg( + "M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z", + ) +} + +fn icon_eye() -> Markup { + svg( + "M8 2c1.981 0 3.671.992 4.933 2.078 1.27 1.091 2.187 2.345 2.637 3.023a1.62 1.62 0 0 1 0 1.798c-.45.678-1.367 1.932-2.637 3.023C11.67 13.008 9.981 14 8 14c-1.981 0-3.671-.992-4.933-2.078C1.797 10.831.88 9.577.43 8.9a1.62 1.62 0 0 1 0-1.798c.45-.678 1.367-1.932 2.637-3.023C4.33 2.992 6.019 2 8 2Zm0 1.5c-1.504 0-2.88.762-3.957 1.69-1.077.926-1.882 2.025-2.281 2.624a.12.12 0 0 0 0 .172c.4.599 1.204 1.698 2.28 2.624C5.121 11.738 6.497 12.5 8 12.5c1.504 0 2.88-.762 3.957-1.69 1.077-.926 1.882-2.025 2.281-2.624a.12.12 0 0 0 0-.172c-.4-.599-1.204-1.698-2.28-2.624C10.879 4.262 9.503 3.5 8 3.5ZM8 6a2 2 0 1 1 0 4 2 2 0 0 1 0-4Z", + ) +} + +fn icon_star() -> Markup { + svg( + "M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Z", + ) +} + +fn icon_tag() -> Markup { + svg( + "M1 7.775V2.75C1 1.784 1.784 1 2.75 1h5.025c.464 0 .91.184 1.238.513l6.25 6.25a1.75 1.75 0 0 1 0 2.474l-5.026 5.026a1.75 1.75 0 0 1-2.474 0l-6.25-6.25A1.75 1.75 0 0 1 1 7.775Zm1.5 0c0 .066.026.13.073.177l6.25 6.25a.25.25 0 0 0 .354 0l5.025-5.025a.25.25 0 0 0 0-.354l-6.25-6.25a.25.25 0 0 0-.177-.073H2.75a.25.25 0 0 0-.25.25ZM6 5a1 1 0 1 1 0 2 1 1 0 0 1 0-2Z", + ) +} + +fn icon_clock() -> Markup { + svg( + "M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm7-3.25v2.992l2.028.812a.75.75 0 0 1-.557 1.392l-2.5-1A.751.751 0 0 1 7 8.25v-3.5a.75.75 0 0 1 1.5 0Z", + ) +} + +fn icon_commit() -> Markup { + svg( + "M11.93 8.5a4.002 4.002 0 0 1-7.86 0H.75a.75.75 0 0 1 0-1.5h3.32a4.002 4.002 0 0 1 7.86 0h3.32a.75.75 0 0 1 0 1.5Zm-1.43-.75a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z", + ) +} + fn icon_tree() -> Markup { svg( "M8 0a4 4 0 0 1 .91 7.895A.749.749 0 0 1 8.75 8v2.5h2.75a1.75 1.75 0 0 1 1.75 1.75v1.25h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-1.25a.25.25 0 0 0-.25-.25H4.25a.25.25 0 0 0-.25.25v1.25h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5H2.5v-1.25c0-.966.784-1.75 1.75-1.75H7V8a.749.749 0 0 1-.16-.105A4 4 0 0 1 8 0Zm0 1.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5Z",