git-ents.gitmain
⌘K
foforge
commit 03c1392
roots: add a file browser over the served repo

Adds crates/cli/ents-web/src/pages/files.rs and GET /files, GET /files/{*path}: a read-only directory listing and blob viewer over the HEAD tree of the repository git ents serve is serving, plus a files tab in the layout nav. Tree/blob reads go through gix high-level Repository/Tree/Blob types (repo.head_tree(), lookup_entry_by_path, Entry::object), opened fresh per request from state.path — the same pattern ents_forge::comment already uses to browse a live working tree, not the facet-git-tree convention the rest of this crate uses for typed meta-ref data.

A directory renders as a card of icon-and-name rows, sorted directories-first then alphabetically, with a chevron-separated breadcrumb trail. A blob renders through markdown::to_html for .md, asciidoc::to_html for .adoc/.asciidoc/.asc/.adc, or an escaped pre code block otherwise — no syntax highlighting is ported. A NUL-byte-in-the-leading-chunk heuristic (matching pre-redo, and git own heuristic) guards binary content, falling back to a placeholder instead of rendering it.

Session gating matches every other read-only GET page in this crate: none beyond the session middleware every route already runs behind.

Assisted-by: Claude:claude-sonnet-5

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/src/asciidoc.rs @@ -10,13 +10,6 @@ //! `acdc-converters-core` and `acdc-converters-html` are not on crates.io //! yet, so they are pinned as git dependencies on the same revision //! `pre-redo:Cargo.toml` pinned (see this crate's own `Cargo.toml`). -#![cfg_attr( - not(test), - expect( - dead_code, - reason = "wired into crate::pages::files's blob view in the next commit" - ) -)] use acdc_converters_core::{Converter, Options as ConvertOptions, inlines_to_string}; use acdc_converters_html::{Processor, RenderOptions};
crates/cli/ents-web/src/assets.rs @@ -5,6 +5,17 @@ //! than vendored. [`FONTS_HREF`] is this crate's one exception: the //! pre-redo brand type stack is only available from Google Fonts, so it is //! loaded at request time rather than embedded. +//! +//! 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 +//! `include_str!`-and-tag pattern +//! `pre-redo:crates/git-ents-server/src/web/icons.rs` used. + +use std::sync::LazyLock; + +use maud::{Markup, PreEscaped}; pub(crate) const OVERRIDES: &str = include_str!("assets/ents.css"); @@ -12,3 +23,32 @@ /// IBM Plex Mono, Lora) -- mirrors /// `pre-redo:crates/git-ents-server/src/web/assets.rs`'s `FONTS` const. pub(crate) const FONTS_HREF: &str = "https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&family=Lora:wght@500;600;700&display=swap"; + +/// Adapt a vendored Octicon to this UI: tag it with the `.icon` class the +/// stylesheet targets and mark it decorative for assistive tech. Every +/// vendored file opens with a bare `<svg …>` element, so a single prefix +/// swap suffices (mirrors +/// `pre-redo:crates/git-ents-server/src/web/icons.rs`'s own `inline`). +fn inline(svg: &str) -> String { + svg.replacen("<svg ", "<svg class=\"icon\" aria-hidden=\"true\" ", 1) +} + +/// Define an icon accessor per vendored Octicon file. Each prepares its +/// inline markup once and hands out a cheap clone on use. +macro_rules! icons { + ($($name:ident => $file:literal),* $(,)?) => { + $( + pub(crate) fn $name() -> Markup { + static HTML: LazyLock<String> = + LazyLock::new(|| inline(include_str!(concat!("assets/icons/", $file, ".svg")))); + PreEscaped(HTML.clone()) + } + )* + }; +} + +icons! { + icon_folder => "file-directory-fill", + icon_file => "file", + icon_chevron => "chevron-right", +}
crates/cli/ents-web/src/error.rs @@ -89,6 +89,12 @@ /// convert"). #[error("could not render asciidoc: {0}")] Asciidoc(String), + + /// `crate::pages::files` could not open the served repository or read + /// its `HEAD` tree/a tree or blob within it (`gix::open`, a tree + /// lookup, or a blob read). + #[error("could not read repository: {0}")] + Repo(String), } impl From<ents_forge::Error> for Error {
crates/cli/ents-web/src/markdown.rs @@ -5,13 +5,6 @@ //! rather than a plain-text listing. Output is an embedded fragment (no //! document frame) styled by [`crate::assets::OVERRIDES`]'s own //! `.doc-body` rules. -#![cfg_attr( - not(test), - expect( - dead_code, - reason = "wired into crate::pages::files's blob view in the next commit" - ) -)] use maud::{Markup, PreEscaped}; use pulldown_cmark::{Options, Parser, html};
crates/cli/ents-web/src/router.rs @@ -48,6 +48,8 @@ ) .route("/effects", get(pages::effects::list::<O>)) .route("/effects/{name}", get(pages::effects::show::<O>)) + .route("/files", get(pages::files::root::<O>)) + .route("/files/{*path}", get(pages::files::show::<O>)) .route("/redactions", get(pages::redactions::list::<O>)) .route("/redactions/{id}", get(pages::redactions::show::<O>)) .route("/toolchains", get(pages::toolchains::list::<O>))
crates/cli/ents-web/tests/router.rs @@ -60,6 +60,61 @@ )) } +/// Like [`build_state`], but `path` names a real, on-disk repository +/// rather than the shared system temp directory -- `crate::pages::files` +/// opens `state.path` directly with `gix::open`, so its tests need an +/// actual `HEAD` to browse, not just the in-memory ref/object store every +/// other test in this file exercises. +fn build_state_at( + identity: FixtureIdentity, + path: std::path::PathBuf, +) -> Arc<AppState<ObjectStore>> { + Arc::new(AppState::new( + Box::new(MemRefStore::default()), + ObjectStore::default(), + Box::new(NullEventSink), + Mode::Advisory, + Box::new(identity), + path, + )) +} + +/// Initialize a real git repository at a fresh tempdir, seed it with +/// `files` (path, contents), and commit them on `HEAD` -- what +/// `crate::pages::files`'s tests below browse. +fn seed_repo(files: &[(&str, &str)]) -> tempfile::TempDir { + let dir = tempfile::tempdir().expect("tempdir"); + let git = |args: &[&str]| { + let status = std::process::Command::new("git") + .arg("-C") + .arg(dir.path()) + .args(args) + .status() + .expect("git runs"); + assert!(status.success(), "git {args:?} failed"); + }; + git(&["init", "-q"]); + for (name, contents) in files { + let path = dir.path().join(name); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("mkdir -p"); + } + std::fs::write(&path, contents).expect("write fixture file"); + } + git(&["add", "-A"]); + git(&[ + "-c", + "user.name=t", + "-c", + "user.email=t@example.com", + "commit", + "-q", + "-m", + "seed", + ]); + dir +} + /// `roots.local`: this crate's route table never exposes git's own /// smart-HTTP transport -- a request that would name it (`info/refs` with /// a `service` query, exactly the URL stock `git clone`/`git fetch` sends @@ -401,3 +456,128 @@ assert_eq!(account.member, MemberId::new("jdc")); } } + +/// `GET /files` lists the served repository's root directory: every +/// top-level entry, directory or file, appears as a link. +#[tokio::test] +async fn files_root_lists_the_repository_root() { + let dir = seed_repo(&[ + ("README.adoc", "= Welcome\n\nHello.\n"), + ("docs/x.md", "# Doc Title\n\nSome text.\n"), + ("src/main.rs", "fn main() {\n let ok = 1 < 2;\n}\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("/files").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("README.adoc")); + assert!(body.contains("docs")); + 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. +#[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")]); + 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("/files/src/main.rs") + .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("<pre><code>")); + assert!(body.contains("1 &lt; 2")); +} + +/// `GET /files/<path>` renders a `.md` blob as Markdown and a `.adoc` blob +/// as AsciiDoc -- both a real rendered heading, not the raw source markup. +#[tokio::test] +async fn files_blob_view_renders_markdown_and_asciidoc_as_documents() { + let dir = seed_repo(&[ + ("README.adoc", "= Welcome\n\nHello.\n"), + ("docs/x.md", "# Doc Title\n\nSome text.\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 adoc_response = router + .clone() + .oneshot( + Request::get("/files/README.adoc") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("in-process call"); + assert_eq!(adoc_response.status(), StatusCode::OK); + let adoc_body = adoc_response + .into_body() + .collect() + .await + .expect("body") + .to_bytes(); + let adoc_body = String::from_utf8(adoc_body.to_vec()).expect("utf8 html"); + assert!(adoc_body.contains("<h1>Welcome</h1>")); + assert!(!adoc_body.contains("= Welcome")); + + let md_response = router + .oneshot( + Request::get("/files/docs/x.md") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("in-process call"); + assert_eq!(md_response.status(), StatusCode::OK); + let md_body = md_response + .into_body() + .collect() + .await + .expect("body") + .to_bytes(); + let md_body = String::from_utf8(md_body.to_vec()).expect("utf8 html"); + assert!(md_body.contains("<h1>Doc Title</h1>")); +}
crates/cli/ents-web/src/pages/mod.rs @@ -15,6 +15,7 @@ pub mod comments; pub mod dashboard; pub mod effects; +pub mod files; pub mod inbox; pub mod members; pub mod redactions; @@ -59,6 +60,7 @@ pub(crate) enum Tab { Dashboard, Members, + Files, Account, Effects, Redactions, @@ -94,6 +96,7 @@ nav.tabs { a.tab.active[active == Tab::Dashboard] href="/" { "dashboard" } a.tab.active[active == Tab::Members] href="/members" { "members" } + a.tab.active[active == Tab::Files] href="/files" { "files" } a.tab.active[active == Tab::Account] href="/account" { "account" } a.tab.active[active == Tab::Effects] href="/effects" { "effects" } a.tab.active[active == Tab::Redactions] href="/redactions" { "redactions" }
crates/cli/ents-web/src/pages/files.rs @@ -1,0 +1,353 @@ +//! `GET /files`, `GET /files/{*path}`: a read-only directory listing and +//! blob viewer over the `HEAD` tree of the repository `git ents serve` is +//! serving. A `.md` blob renders via [`crate::markdown`], a +//! `.adoc`/`.asciidoc`/`.asc`/`.adc` blob via [`crate::asciidoc`], and +//! everything else as an escaped `<pre><code>` block -- no syntax +//! highlighting is ported (`pre-redo:crates/git-ents-server/src/web/pages.rs`'s +//! `arborium`-based `highlight` has no equivalent here; see +//! `crate::assets::OVERRIDES`'s own doc for the rest of what pre-redo +//! carried that this crate does not). +//! +//! Tree/blob reads go through `gix`'s high-level `Repository`/`Tree`/`Blob` +//! types (`repo.head_tree()`, `Tree::lookup_entry_by_path`, +//! `Entry::object`), opened fresh per request from `state.path` -- the +//! same `gix::open(repo_path)` pattern `ents_forge::comment::add`/`show` +//! already use to browse a live working tree, not the +//! `facet-git-tree`/`gix_object::Find` convention the rest of this crate's +//! pages use to read typed meta-ref entities (`facet-git-tree` is for +//! structured meta-ref data; browsing arbitrary repository content is not +//! that). + +use std::sync::Arc; + +use axum::extract::{Path, State}; +use gix::bstr::ByteSlice as _; +use gix_object::{Find, Write}; +use maud::{Markup, html}; + +use crate::assets; +use crate::error::{Error, Result}; +use crate::state::AppState; + +/// `GET /files`: the repository root directory listing. +/// +/// # Errors +/// +/// Propagates a `gix::open`/tree-read failure. +pub async fn root<O>(State(state): State<Arc<AppState<O>>>) -> Result<Markup> +where + O: Find + Write + Send + 'static, +{ + at(&state, "") +} + +/// `GET /files/{*path}`: a directory listing or blob view at `path`. +/// +/// # Errors +/// +/// [`Error::NotFound`] if `path` does not name a tree or blob entry (or +/// contains a `.`/`..` component); otherwise propagates a +/// `gix::open`/tree-read failure. +pub async fn show<O>( + State(state): State<Arc<AppState<O>>>, + Path(path): Path<String>, +) -> Result<Markup> +where + O: Find + Write + Send + 'static, +{ + at(&state, &path) +} + +/// The shared implementation behind [`root`] and [`show`]: resolve `path` +/// against `HEAD`'s tree and render whichever of a directory listing or a +/// blob view it names. +fn at<O>(state: &AppState<O>, path: &str) -> Result<Markup> { + if !is_safe_path(path) { + return Err(Error::NotFound { + what: path.to_owned(), + }); + } + + let repo = gix::open(&state.path).map_err(|source| Error::Repo(source.to_string()))?; + let head_tree = match repo.head_tree() { + Ok(tree) => tree, + // An unborn HEAD (a freshly initialized, still-empty repository) + // reads as an empty root directory, not a failure -- mirrors + // `pre-redo:crates/git-ents-server/src/web/git.rs`'s `root_tree`, + // which returned an empty entry list rather than erroring when the + // repository had no `HEAD` yet. + Err(_) if path.is_empty() => { + return Ok(super::layout( + super::Tab::Files, + "files", + html! { + (crumbs(path)) + (dir_listing(path, Vec::new())) + }, + )); + } + Err(_) => { + return Err(Error::NotFound { + what: path.to_owned(), + }); + } + }; + + if path.is_empty() { + let entries = tree_entries(&head_tree)?; + return Ok(super::layout( + super::Tab::Files, + "files", + html! { + (crumbs(path)) + (dir_listing(path, entries)) + }, + )); + } + + let entry = head_tree + .lookup_entry_by_path(path) + .map_err(|source| Error::Repo(source.to_string()))? + .ok_or_else(|| Error::NotFound { + what: path.to_owned(), + })?; + + if entry.mode().is_tree() { + let subtree = entry + .object() + .map_err(|source| Error::Repo(source.to_string()))? + .try_into_tree() + .map_err(|source| Error::Repo(source.to_string()))?; + let entries = tree_entries(&subtree)?; + Ok(super::layout( + super::Tab::Files, + path, + html! { + (crumbs(path)) + (dir_listing(path, entries)) + }, + )) + } else if entry.mode().is_blob() { + let blob = entry + .object() + .map_err(|source| Error::Repo(source.to_string()))? + .try_into_blob() + .map_err(|source| Error::Repo(source.to_string()))?; + let name = path.rsplit('/').next().unwrap_or(path); + Ok(super::layout( + super::Tab::Files, + path, + html! { + (crumbs(path)) + (blob_view(name, &blob.data)?) + }, + )) + } else { + // A symlink or a submodule (gitlink) -- neither is a tree or a + // blob this browser can render. + Err(Error::NotFound { + what: path.to_owned(), + }) + } +} + +/// Whether `path` is safe to resolve against a tree: no empty, `.`, or +/// `..` component. The empty root path is itself safe. +fn is_safe_path(path: &str) -> bool { + path.is_empty() + || path + .split('/') + .all(|s| !s.is_empty() && s != "." && s != "..") +} + +/// One `(name, is_directory)` pair per direct child of `tree`, in tree +/// order (not yet sorted -- [`dir_listing`] sorts for display). +fn tree_entries(tree: &gix::Tree<'_>) -> Result<Vec<(String, bool)>> { + tree.iter() + .map(|entry| { + let entry = entry.map_err(|source| Error::Repo(source.to_string()))?; + Ok(( + entry.filename().to_str_lossy().into_owned(), + entry.mode().is_tree(), + )) + }) + .collect() +} + +/// The link to a child of the directory at `dir` (empty at the root). +fn child_href(dir: &str, name: &str) -> String { + if dir.is_empty() { + format!("/files/{name}") + } else { + format!("/files/{dir}/{name}") + } +} + +/// A directory listing at `dir`: entries sorted directories-first then +/// alphabetically, each an icon and a link one level deeper. +fn dir_listing(dir: &str, mut entries: Vec<(String, bool)>) -> Markup { + 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" } + @if entries.is_empty() { + div.card-row.muted { "Empty directory." } + } + @for (name, is_dir) in &entries { + div.card-row.is-dir[*is_dir] { + a.row-link href=(child_href(dir, name)) { + @if *is_dir { (assets::icon_folder()) } @else { (assets::icon_file()) } + (name) + } + } + } + } + } +} + +/// Breadcrumb navigation from the repository's files root down through +/// `path`, `chevron-right` icons separating segments. +fn crumbs(path: &str) -> Markup { + let parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); + let mut acc = String::new(); + let mut trail: Vec<(String, Option<String>)> = + vec![("files".to_owned(), Some("/files".to_owned()))]; + for (index, part) in parts.iter().enumerate() { + if !acc.is_empty() { + acc.push('/'); + } + acc.push_str(part); + let is_last = index.saturating_add(1) == parts.len(); + let href = (!is_last).then(|| format!("/files/{acc}")); + trail.push(((*part).to_owned(), href)); + } + html! { + nav.crumbs { + @for (index, (label, href)) in trail.iter().enumerate() { + @if index > 0 { span.sep { (assets::icon_chevron()) } } + @match href { + Some(href) => a href=(href) { (label) }, + None => span.here { (label) }, + } + } + } + } +} + +/// Whether `bytes` looks like binary content (a NUL byte in the leading +/// chunk -- the same heuristic git itself uses, carried over from +/// `pre-redo:crates/git-ents-server/src/web/pages.rs`'s own `is_binary`). +fn is_binary(bytes: &[u8]) -> bool { + bytes.iter().take(8000).any(|b| *b == 0) +} + +/// 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. +/// +/// # Errors +/// +/// Propagates [`crate::asciidoc::to_html`]'s own [`Error::Asciidoc`]. +fn blob_view(name: &str, bytes: &[u8]) -> Result<Markup> { + if is_binary(bytes) { + return Ok(html! { div.binary { "Binary file (" (bytes.len()) " bytes) not shown." } }); + } + let Ok(text) = std::str::from_utf8(bytes) else { + return Ok(html! { div.binary { "Binary file (" (bytes.len()) " bytes) not shown." } }); + }; + if crate::markdown::is_markdown(name) { + return Ok(html! { div.card { div.doc-body { (crate::markdown::to_html(text)) } } }); + } + if crate::asciidoc::is_asciidoc(name) { + return Ok(html! { div.card { div.doc-body { (crate::asciidoc::to_html(text)?) } } }); + } + Ok(html! { + div.blob { + pre { code { (text) } } + } + }) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used, reason = "unit test")] + + use rstest::rstest; + + use super::*; + + #[rstest] + #[case::empty("", true)] + #[case::simple("src/main.rs", true)] + #[case::nested("a/b/c", true)] + #[case::dot(".", false)] + #[case::dotdot("..", false)] + #[case::traversal("a/../b", false)] + #[case::trailing_slash("a/", false)] + #[case::double_slash("a//b", false)] + fn is_safe_path_rejects_dot_components_and_empty_segments( + #[case] path: &str, + #[case] expected: bool, + ) { + assert_eq!(is_safe_path(path), expected); + } + + #[test] + fn dir_listing_sorts_directories_first_then_alphabetically() { + let entries = vec![ + ("zeta.txt".to_owned(), false), + ("alpha".to_owned(), true), + ("beta.txt".to_owned(), false), + ("gamma".to_owned(), true), + ]; + let rendered = dir_listing("", entries).into_string(); + let alpha = rendered.find("alpha").expect("alpha listed"); + let gamma = rendered.find("gamma").expect("gamma listed"); + let beta = rendered.find("beta.txt").expect("beta listed"); + let zeta = rendered.find("zeta.txt").expect("zeta listed"); + assert!(alpha < gamma, "directories sort among themselves"); + assert!(gamma < beta, "every directory sorts before every file"); + assert!(beta < zeta, "files sort among themselves"); + } + + #[test] + fn blob_view_renders_markdown_as_a_heading_not_raw_markup() { + let rendered = blob_view("readme.md", b"# Title\n") + .expect("markdown renders") + .into_string(); + assert!(rendered.contains("<h1>Title</h1>")); + } + + #[test] + fn blob_view_renders_asciidoc_as_a_heading_not_raw_markup() { + let rendered = blob_view("readme.adoc", b"= Title\n\nBody.\n") + .expect("asciidoc renders") + .into_string(); + assert!(rendered.contains("<h1>Title</h1>")); + } + + #[test] + fn blob_view_escapes_plain_text_into_a_pre_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("1 &lt; 2")); + } + + #[test] + fn blob_view_shows_a_placeholder_for_binary_content() { + let rendered = blob_view("data.bin", b"\0\x01\x02binary") + .expect("binary placeholder renders") + .into_string(); + assert!(rendered.contains("Binary file")); + } + + #[test] + fn child_href_nests_under_the_current_directory() { + assert_eq!(child_href("", "src"), "/files/src"); + assert_eq!(child_href("src", "main.rs"), "/files/src/main.rs"); + } +}