commits 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
approve request-changes comment
Body Start a Review 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 < 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 < 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");
+ }
+}