git-ents.gitmain
⌘K
foforge
commit 031802e
roots: add the pre-redo forge repo-header band and line-numbered blobs

Brings the served UI up to the pre-redo forge look. A repo-header breadcrumb band — the served repository’s name plus its HEAD branch pill — sits between the site nav and the tab strip, the site nav gains the pre-redo disabled search stub, and plain-text blobs render in the two-pane line-numbered gutter layout. The repo name comes from the served path and the branch from HEAD via gix, read once in RepoHeader::from_state and threaded through layout so every page renders the band identically.

roots: add a repo-header breadcrumb band naming the served repo and its branch roots: add the disabled nav search stub to the site header roots: render plain-text blobs with a line-numbered two-pane gutter roots: vendor the search and git-branch octicons for the shell chrome 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

.gitattributes @@ -2,3 +2,5 @@ crates/cli/ents-web/src/assets/icons/chevron-right.svg vendor=octicons crates/cli/ents-web/src/assets/icons/file-directory-fill.svg vendor=octicons crates/cli/ents-web/src/assets/icons/file.svg vendor=octicons +crates/cli/ents-web/src/assets/icons/git-branch.svg vendor=octicons +crates/cli/ents-web/src/assets/icons/search.svg vendor=octicons
.gitvendors @@ -5,4 +5,6 @@ pattern = icons/file-directory-fill-16.svg:crates/cli/ents-web/src/assets/icons/file-directory-fill.svg pattern = icons/file-16.svg:crates/cli/ents-web/src/assets/icons/file.svg pattern = icons/chevron-right-16.svg:crates/cli/ents-web/src/assets/icons/chevron-right.svg + pattern = icons/search-16.svg:crates/cli/ents-web/src/assets/icons/search.svg + pattern = icons/git-branch-16.svg:crates/cli/ents-web/src/assets/icons/git-branch.svg pattern = LICENSE:crates/cli/ents-web/src/assets/icons/LICENSE
crates/cli/ents-web/src/assets.rs @@ -9,7 +9,9 @@ //! The icon functions below are vendored Octicons (`.gitvendors`, MIT; see //! `assets/icons/LICENSE`), re-homed here from //! `pre-redo:crates/git-ents-server/src/web/icons/` for -//! [`crate::pages::files`]'s directory listing and breadcrumbs -- the same +//! [`crate::pages::files`]'s directory listing and breadcrumbs and for the +//! shell chrome [`crate::pages::layout`] draws (the `nav.site-nav` search +//! stub and the `.repo-header` branch pill) -- the same //! `include_str!`-and-tag pattern //! `pre-redo:crates/git-ents-server/src/web/icons.rs` used. @@ -51,4 +53,6 @@ icon_folder => "file-directory-fill", icon_file => "file", icon_chevron => "chevron-right", + icon_search => "search", + icon_branch => "git-branch", }
crates/cli/ents-web/tests/router.rs @@ -208,6 +208,71 @@ let body = String::from_utf8(body.to_vec()).expect("utf8 html"); assert!(body.contains("members")); assert!(body.contains("toolchains")); + // The shell chrome renders on every page: the disabled search stub in + // the top nav and the repo-header breadcrumb band above the tabs. + assert!(body.contains("nav-search")); + assert!(body.contains("Jump to file or symbol")); + assert!(body.contains("repo-header")); +} + +/// `roots.web-agnostic`: the shell's `.repo-header` band names the served +/// repository (its directory name) and, when `HEAD` resolves to a branch, +/// renders that branch in the `.branch` pill -- both read once off +/// `AppState.path`, so every page's chrome reflects the actual repository +/// being served rather than a placeholder. +#[tokio::test] +async fn repo_header_names_the_served_repo_and_its_head_branch() { + let dir = seed_repo(&[("README.md", "# hi\n")]); + // `git init` picks the default branch name (which varies by host git + // config); rename it so the pill's text is deterministic to assert. + let status = std::process::Command::new("git") + .arg("-C") + .arg(dir.path()) + .args(["branch", "-m", "trunk"]) + .status() + .expect("git runs"); + assert!(status.success(), "git branch -m failed"); + let repo_name = dir + .path() + .file_name() + .expect("tempdir has a name") + .to_string_lossy() + .into_owned(); + + 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("repo-header")); + assert!( + body.contains(&repo_name), + "the served repo's directory name {repo_name:?} must appear as the breadcrumb crumb" + ); + assert!( + body.contains("class=\"branch\""), + "a resolvable HEAD must render the branch pill" + ); + assert!( + body.contains("trunk"), + "the pill carries the short branch name" + ); } /// `roots.web-session`: a state-changing request with no CSRF token at @@ -492,8 +557,9 @@ assert!(body.contains("src")); } -/// `GET /files/<path>` on a plain-text blob renders an escaped -/// `<pre><code>` block -- no syntax highlighting, and no unescaped source. +/// `GET /files/<path>` on a plain-text blob renders a line-numbered, +/// escaped `pre.blob-code` source view -- no syntax highlighting, and no +/// unescaped source. #[tokio::test] async fn files_blob_view_renders_a_plain_text_file() { let dir = seed_repo(&[("src/main.rs", "fn main() {\n let ok = 1 < 2;\n}\n")]); @@ -522,7 +588,8 @@ .expect("body") .to_bytes(); let body = String::from_utf8(body.to_vec()).expect("utf8 html"); - assert!(body.contains("<pre><code>")); + assert!(body.contains("blob-nums")); + assert!(body.contains("<pre class=\"blob-code\"><code>")); assert!(body.contains("1 &lt; 2")); }
crates/cli/ents-web/src/assets/ents.css @@ -71,6 +71,23 @@ .nav-logo { display: inline-flex; align-items: center; gap: .5rem; font-family: var(--font-mono); font-weight: 700; font-size: 1.02rem; color: var(--color-text); letter-spacing: -.01em; text-decoration: none; white-space: nowrap; transition: color .15s; } .nav-mark { color: var(--color-accent); font-size: 1.1rem; } .nav-logo:hover { color: var(--color-accent); } +.nav-search { flex: 1; max-width: 24rem; margin: 0 auto; position: relative; display: flex; align-items: center; } +.nav-search .icon { position: absolute; left: .65rem; color: var(--color-text-muted); pointer-events: none; } +.nav-search input { width: 100%; font-family: var(--font-sans); font-size: .82rem; color: var(--color-text); background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); padding: .42rem .7rem .42rem 2rem; transition: border-color .15s; } +.nav-search input:focus { border-color: var(--color-accent); } +.nav-search:has(input:disabled) { opacity: .55; } +.nav-search:has(input:disabled) input { opacity: 1; } + +/* Repo-header band: the served repository's name breadcrumb and HEAD-branch + * pill, between the site nav and the tab strip (the Gitea-style repo header, + * trimmed to this single-repo crate's name + branch). */ +.repo-header { max-width: var(--max-width); margin: 0 auto; padding: 1.25rem 1.5rem .85rem; display: flex; align-items: flex-start; gap: 1.5rem; flex-wrap: wrap; } +.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 .here { color: var(--color-accent); font-weight: 600; } +.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; color: var(--color-accent); } .tabs { display: flex; gap: .15rem; max-width: var(--max-width); margin: 0 auto; padding: 0 1.5rem; border-bottom: 1px solid var(--color-border); 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; } @@ -143,8 +160,14 @@ .crumbs .sep { color: var(--color-text-muted); opacity: .55; } .crumbs .here { color: var(--color-text-muted); } -.blob { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); box-shadow: var(--shadow-sm); overflow: auto; margin-bottom: 1.5rem; } -.blob pre { font-family: var(--font-mono); font-size: .82rem; line-height: 1.55; margin: 0; padding: 1rem 1.25rem; white-space: pre; overflow-x: auto; } +.blob { display: grid; grid-template-columns: auto minmax(0, 1fr); background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); box-shadow: var(--shadow-sm); overflow: hidden; margin-bottom: 1.5rem; } +.blob pre { font-family: var(--font-mono); font-size: .82rem; line-height: 1.55; margin: 0; padding: 1rem 0; } +.blob pre.blob-nums { text-align: right; color: var(--color-text-muted); background: var(--color-code-bg); border-right: 1px solid var(--color-border); padding-left: 1ch; padding-right: 1ch; user-select: none; -webkit-user-select: none; } +.blob-nums a { display: block; color: inherit; text-decoration: none; } +.blob-nums a:hover { color: var(--color-accent); } +.blob-nums a:target { color: var(--color-accent); font-weight: 700; } +.blob-code { overflow-x: auto; min-width: 0; } +.blob-code code { display: block; font-family: inherit; padding: 0 1.25rem; white-space: pre; color: var(--color-text); } .binary { padding: 2.5rem; text-align: center; font-family: var(--font-mono); font-size: .85rem; color: var(--color-text-muted); } /* Rendered Markdown/AsciiDoc documents (`crate::markdown`, `crate::asciidoc`). */ @@ -174,6 +197,7 @@ html { font-size: 16px; } .content { padding: 1.5rem 1.25rem 2.5rem; } .nav-inner { gap: .75rem; padding: 0 1.25rem; } + .repo-header { padding: 1rem 1.25rem .75rem; } .tabs { padding: 0 1.25rem; } .page-title { font-size: 1.3rem; } .doc-body { padding: 1.75rem 1.5rem 2rem; }
crates/cli/ents-web/src/pages/account.rs @@ -52,6 +52,7 @@ .unwrap_or_else(|| html! { p { "no account created yet" } }); Ok(super::layout( + &super::RepoHeader::from_state(&state), super::Tab::Account, "account", html! {
crates/cli/ents-web/src/pages/comments.rs @@ -34,6 +34,7 @@ { let rows = comment::list(state.refs.as_ref(), &*state.objects())?; Ok(super::layout( + &super::RepoHeader::from_state(&state), super::Tab::Comments, "comments", html! { @@ -84,6 +85,7 @@ &query.rev, )?; Ok(super::layout( + &super::RepoHeader::from_state(&state), super::Tab::Comments, &id, html! {
crates/cli/ents-web/src/pages/dashboard.rs @@ -28,6 +28,7 @@ let toolchains = state.refs.iter_prefix("refs/meta/toolchains/")?.count(); Ok(super::layout( + &super::RepoHeader::from_state(&state), super::Tab::Dashboard, "dashboard", html! {
crates/cli/ents-web/src/pages/effects.rs @@ -28,6 +28,7 @@ { let rows = read_all(&state)?; Ok(super::layout( + &super::RepoHeader::from_state(&state), super::Tab::Effects, "effects", crate::render::list_table(&rows, "name", |id| format!("/effects/{id}")), @@ -57,6 +58,7 @@ Err(error) => format!("does not parse: {error}"), }; Ok(super::layout( + &super::RepoHeader::from_state(&state), super::Tab::Effects, &name, html! {
crates/cli/ents-web/src/pages/files.rs @@ -78,6 +78,7 @@ // repository had no `HEAD` yet. Err(_) if path.is_empty() => { return Ok(super::layout( + &super::RepoHeader::from_state(state), super::Tab::Files, "files", html! { @@ -96,6 +97,7 @@ if path.is_empty() { let entries = tree_entries(&head_tree)?; return Ok(super::layout( + &super::RepoHeader::from_state(state), super::Tab::Files, "files", html! { @@ -120,6 +122,7 @@ .map_err(|source| Error::Repo(source.to_string()))?; let entries = tree_entries(&subtree)?; Ok(super::layout( + &super::RepoHeader::from_state(state), super::Tab::Files, path, html! { @@ -135,6 +138,7 @@ .map_err(|source| Error::Repo(source.to_string()))?; let name = path.rsplit('/').next().unwrap_or(path); Ok(super::layout( + &super::RepoHeader::from_state(state), super::Tab::Files, path, html! { @@ -245,7 +249,13 @@ /// A single blob's contents: a Markdown/AsciiDoc document rendered as such /// via [`crate::markdown`]/[`crate::asciidoc`], a binary-content -/// placeholder, or an escaped `<pre><code>` block of the raw text. +/// placeholder, or a line-numbered, escaped source view of the raw text. +/// +/// The source view mirrors `pre-redo:crates/git-ents-server/src/web/pages.rs`'s +/// `blob_body`: a `.blob` grid pairing a `pre.blob-nums` gutter of +/// per-line `#L{n}` anchors with the escaped `pre.blob-code` code column +/// (no syntax highlighting is ported, so the code stays a plain escaped +/// `<code>` rather than pre-redo's highlighted spans). /// /// # Errors /// @@ -263,9 +273,17 @@ if crate::asciidoc::is_asciidoc(name) { return Ok(html! { div.card { div.doc-body { (crate::asciidoc::to_html(text)?) } } }); } + let lines = text.lines().count().max(1); Ok(html! { div.blob { - pre { code { (text) } } + pre.blob-nums { + @for n in 1..=lines { + a id={ "L" (n) } href={ "#L" (n) } { (n) } + } + } + pre.blob-code { + code { (text) } + } } }) } @@ -329,11 +347,12 @@ } #[test] - fn blob_view_escapes_plain_text_into_a_pre_code_block() { + fn blob_view_escapes_plain_text_into_a_line_numbered_code_block() { let rendered = blob_view("main.rs", b"fn main() { let x = 1 < 2; }") .expect("plain text renders") .into_string(); - assert!(rendered.contains("<pre><code>")); + assert!(rendered.contains("blob-nums")); + assert!(rendered.contains("<pre class=\"blob-code\"><code>")); assert!(rendered.contains("1 &lt; 2")); }
crates/cli/ents-web/src/pages/inbox.rs @@ -30,6 +30,7 @@ } } Ok(super::layout( + &super::RepoHeader::from_state(&state), super::Tab::Inbox, "inbox", crate::render::string_list(&rows, |_| "/inbox".to_owned()),
crates/cli/ents-web/src/pages/members.rs @@ -24,6 +24,7 @@ { let rows = read_all(&state)?; Ok(super::layout( + &super::RepoHeader::from_state(&state), super::Tab::Members, "members", crate::render::list_table(&rows, "username", |id| format!("/members/{id}")), @@ -49,6 +50,7 @@ what: format!("member {username}"), })?; Ok(super::layout( + &super::RepoHeader::from_state(&state), super::Tab::Members, &username, crate::render::view(&member),
crates/cli/ents-web/src/pages/mod.rs @@ -21,12 +21,14 @@ pub mod redactions; pub mod toolchains; +use gix::bstr::ByteSlice as _; use gix_hash::ObjectId; use gix_object::{CommitRef, Find, Kind}; use maud::{Markup, html}; use crate::error::{Error, Result}; use crate::session::{CSRF_FIELD, Session}; +use crate::state::AppState; /// The tree of the commit at `oid` -- every page that reads back a typed /// entity needs this; mirrors `git_ents::commands::commit_tree` and @@ -69,11 +71,52 @@ Inbox, } +/// The served repository's identity for the shell's `.repo-header` +/// breadcrumb band: its directory name and, when `HEAD` resolves to a +/// branch, that branch's short name (mirrors +/// `pre-redo:crates/git-ents-server/src/web/mod.rs`'s `RepoMeta`, trimmed +/// to the two fields this single-repo crate actually has a data surface +/// for -- no owner/name split, description, or topics). +pub(crate) struct RepoHeader { + /// The served repository's directory name, shown as the sole + /// breadcrumb crumb (this crate serves exactly one repository). + pub(crate) name: String, + /// The short name of `HEAD`'s branch, or `None` when `HEAD` is + /// detached, unborn, or the repository cannot be opened -- the + /// `.branch` pill is omitted in that case rather than guessed at. + pub(crate) branch: Option<String>, +} + +impl RepoHeader { + /// Read the served repository's name and current branch off `state` + /// once, so [`layout`]'s call sites stay one-liners and the + /// `gix::open`/`HEAD` logic lives in exactly this one place (the same + /// `gix::open(&state.path)` pattern [`crate::pages::files`] browses the + /// `HEAD` tree with). Never panics: an unopenable repository or a + /// detached/unborn `HEAD` degrades to no branch pill. + pub(crate) fn from_state<O>(state: &AppState<O>) -> Self { + let name = std::fs::canonicalize(&state.path) + .ok() + .as_deref() + .and_then(std::path::Path::file_name) + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "repository".to_owned()); + let branch = gix::open(&state.path).ok().and_then(|repo| { + repo.head_name() + .ok() + .flatten() + .map(|full| full.shorten().to_str_lossy().into_owned()) + }); + Self { name, branch } + } +} + /// Wrap `title` and `body` in the one page shell every route renders -/// through -- the pre-redo header bar and tab nav -/// (`pre-redo:crates/git-ents-server/src/web/style.css`'s `.site-nav`/ -/// `.tabs` rules), `active` naming which tab is current. -pub(crate) fn layout(active: Tab, title: &str, body: Markup) -> Markup { +/// through -- the pre-redo header bar, repo-header breadcrumb band, and tab +/// nav (`pre-redo:crates/git-ents-server/src/web/style.css`'s `.site-nav`/ +/// `.nav-search`/`.repo-header`/`.tabs` rules), `active` naming which tab +/// is current and `repo` the served repository the band names. +pub(crate) fn layout(repo: &RepoHeader, active: Tab, title: &str, body: Markup) -> Markup { html! { (maud::DOCTYPE) html lang="en" { @@ -91,6 +134,21 @@ nav.site-nav { div.nav-inner { a.nav-logo href="/" { span.nav-mark { "✳" } "git-ents" } + div.nav-search { + (crate::assets::icon_search()) + input type="search" placeholder="Jump to file or symbol" aria-label="Search" disabled title="Not available yet"; + } + } + } + div.repo-header { + div.repo-headline { + div.repo-path { + (crate::assets::icon_folder()) + span.here { (repo.name) } + @if let Some(branch) = &repo.branch { + span.branch { (crate::assets::icon_branch()) (branch) } + } + } } } nav.tabs {
crates/cli/ents-web/src/pages/redactions.rs @@ -23,6 +23,7 @@ { let rows = read_all(&state)?; Ok(super::layout( + &super::RepoHeader::from_state(&state), super::Tab::Redactions, "redactions", crate::render::list_table(&rows, "id", |id| format!("/redactions/{id}")), @@ -48,6 +49,7 @@ what: format!("redaction {id}"), })?; Ok(super::layout( + &super::RepoHeader::from_state(&state), super::Tab::Redactions, &id, crate::render::view(&redaction),
crates/cli/ents-web/src/pages/toolchains.rs @@ -29,6 +29,7 @@ { let names = toolchain::list(state.refs.as_ref())?; Ok(super::layout( + &super::RepoHeader::from_state(&state), super::Tab::Toolchains, "toolchains", crate::render::string_list(&names, |name| format!("/toolchains/{name}")), @@ -52,6 +53,7 @@ let (toolchain, recipe) = toolchain::view(state.refs.as_ref(), &*state.objects(), &name)?; let log = toolchain::log(state.refs.as_ref(), &*state.objects(), &name)?; Ok(super::layout( + &super::RepoHeader::from_state(&state), super::Tab::Toolchains, &name, html! {
crates/cli/ents-web/src/assets/icons/git-branch.svg @@ -1,0 +1,1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="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"/></svg>
crates/cli/ents-web/src/assets/icons/search.svg @@ -1,0 +1,1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"/></svg>