roots: add the pre-redo forge repo-header band and line-numbered blobs
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
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 < 2"));
}
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 {