git-ents.gitmain
⌘K
foforge
commit dd1cd84
feat: add tree, blob, and commit browse views with syntax highlighting

Each repository page now links into three server-rendered browse views: a directory listing (tree) with breadcrumbs, a file view (blob) with a line-number gutter and arborium tree-sitter syntax highlighting, and a commit view showing the message and a CSS-colored unified diff. Highlighting is themed with the existing warm-gold palette and follows the system light/dark preference; files with no grammar or binary content fall back gracefully.

feat: add browsable tree, blob, and commit views to the web UI feat: highlight blob contents with arborium tree-sitter grammars feat: render commit diffs with per-line add/del/hunk coloring feat: claim browse routes from dumb-HTTP heuristics without stealing git services deps: add arborium with a curated grammar set including AsciiDoc Assisted-by: Claude:claude-opus-4-8

Joseph D. Carpinelli · 2 months ago

Reviews

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

Start a review

verdict

Cargo.lock @@ -61,6 +61,258 @@ "windows-sys", ] +[[package]] +name = "arborium" +version = "2.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d278263fcf7110e49a2d9ee2596ea989f36cae23d293628b8dbd48479190e29" +dependencies = [ + "arborium-asciidoc", + "arborium-bash", + "arborium-c", + "arborium-cpp", + "arborium-css", + "arborium-go", + "arborium-highlight", + "arborium-html", + "arborium-javascript", + "arborium-json", + "arborium-markdown", + "arborium-python", + "arborium-rust", + "arborium-theme", + "arborium-toml", + "arborium-tree-sitter", + "arborium-typescript", + "arborium-yaml", + "dlmalloc", +] + +[[package]] +name = "arborium-asciidoc" +version = "2.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c7118cc9cb21702349a3ffabd65e580161058cc01f90748684d625e5bc7a5ea" +dependencies = [ + "arborium-sysroot", + "cc", + "tree-sitter-language", +] + +[[package]] +name = "arborium-bash" +version = "2.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fa3697437d40b24ad72673e9bc39ae78986d9002babcc7adfbc30d20810ad7b" +dependencies = [ + "arborium-sysroot", + "cc", + "tree-sitter-language", +] + +[[package]] +name = "arborium-c" +version = "2.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f91dc3332dcf7e6e44942e6d6fef29a3fcd273e5ce491df7da8ca3c9b2015f05" +dependencies = [ + "arborium-sysroot", + "cc", + "tree-sitter-language", +] + +[[package]] +name = "arborium-cpp" +version = "2.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d9e3ba0f6d8cb83f1d50867204e7fb4c8b2f0ab177c2e9a1344005a6d4fbe6" +dependencies = [ + "arborium-c", + "arborium-sysroot", + "cc", + "tree-sitter-language", +] + +[[package]] +name = "arborium-css" +version = "2.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216eaa63b7784ee6adf3051cf5f991ba87a6d51bff6709672ae48e0ccda96829" +dependencies = [ + "arborium-sysroot", + "cc", + "tree-sitter-language", +] + +[[package]] +name = "arborium-go" +version = "2.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "609569482fe611af432b0a6a597ea76c1f8d272e53e56104ddc1bb074e9b4bd4" +dependencies = [ + "arborium-sysroot", + "cc", + "tree-sitter-language", +] + +[[package]] +name = "arborium-highlight" +version = "2.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef4c7ad06bea6a906f62d30787eeb22956a2c72dd093a8bbd3b7a08fae73c4a9" +dependencies = [ + "arborium-theme", + "arborium-tree-sitter", + "streaming-iterator", +] + +[[package]] +name = "arborium-html" +version = "2.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26be5da8ef16c1b354c415e49c5394050a487af103d367adc7a2fcfd7f706a0e" +dependencies = [ + "arborium-css", + "arborium-javascript", + "arborium-sysroot", + "cc", + "tree-sitter-language", +] + +[[package]] +name = "arborium-javascript" +version = "2.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8771bf5fee9ebc8a64bd534248ee1d7fc746fa3b37ee7780d7303bc08c6bf2c" +dependencies = [ + "arborium-jsdoc", + "arborium-sysroot", + "cc", + "tree-sitter-language", +] + +[[package]] +name = "arborium-jsdoc" +version = "2.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d7e41ef1d3da000a2364361c0a9df7c119164213640e87581272aab3db3f04f" +dependencies = [ + "arborium-sysroot", + "cc", + "tree-sitter-language", +] + +[[package]] +name = "arborium-json" +version = "2.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43e029f6e6f86b5fd7945cc62d9a51844b05f1ec64bb9a82c783cd5811a45cda" +dependencies = [ + "arborium-sysroot", + "cc", + "tree-sitter-language", +] + +[[package]] +name = "arborium-markdown" +version = "2.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddfca4251ca8fbe058634631742b8fe4e168a0692d2fd857ce3b2f1d9c15debc" +dependencies = [ + "arborium-html", + "arborium-sysroot", + "arborium-toml", + "arborium-yaml", + "cc", + "tree-sitter-language", +] + +[[package]] +name = "arborium-python" +version = "2.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aef6f35d9a9e54e5c1b80a352c8a4cf7a561c27e22e9d11bcee65858ed178a3" +dependencies = [ + "arborium-sysroot", + "cc", + "tree-sitter-language", +] + +[[package]] +name = "arborium-rust" +version = "2.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27e8f091b327ea55f1fb3ede1ce61068c0576a6cad85e0c44f0a54e2d88abc2b" +dependencies = [ + "arborium-sysroot", + "cc", + "tree-sitter-language", +] + +[[package]] +name = "arborium-sysroot" +version = "2.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b9ce9dee108d720f7433b99b0d17b15ea6870b89032ce53bd44e78c8687d514" +dependencies = [ + "cc", + "dlmalloc", +] + +[[package]] +name = "arborium-theme" +version = "2.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b513088effb64db7d471ddc13d9d4b487b4e74e9d0963c8afb567b3a11445525" + +[[package]] +name = "arborium-toml" +version = "2.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "930c118ffaa8593a1ebc6a2bb771c3bd708b9b0a6a90b17aba76655b8f513777" +dependencies = [ + "arborium-sysroot", + "cc", + "tree-sitter-language", +] + +[[package]] +name = "arborium-tree-sitter" +version = "2.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ad69424e8485446412c60b72f6ea33b682d46d85acd8a925e1902d15a4310a" +dependencies = [ + "arborium-sysroot", + "cc", + "regex", + "regex-syntax", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "arborium-typescript" +version = "2.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ecd0de15de9a036af8ca603b1561e0c169dd19f61e6cf55023f40db4591dfdf" +dependencies = [ + "arborium-javascript", + "arborium-sysroot", + "cc", + "tree-sitter-language", +] + +[[package]] +name = "arborium-yaml" +version = "2.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73080d216627bbd16feb55a7d64eba4de90bcc08f3bbe8d07835824273ce126f" +dependencies = [ + "arborium-sysroot", + "cc", + "tree-sitter-language", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -131,6 +383,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -193,6 +455,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "dlmalloc" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad5208a115eaba24916f7456929832e310a81518c641f93fee4f89aa93aa3675" +dependencies = [ + "cfg-if", + "libc", + "windows-sys", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -215,6 +488,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -294,6 +573,7 @@ name = "git-ents-server" version = "0.0.0" dependencies = [ + "arborium", "axum", "clap", "clap_mangen", @@ -732,6 +1012,12 @@ "serde", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -764,6 +1050,12 @@ "windows-sys", ] +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + [[package]] name = "strsim" version = "0.11.1" @@ -905,6 +1197,12 @@ "once_cell", ] +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + [[package]] name = "unicode-ident" version = "1.0.24"
Cargo.toml @@ -12,6 +12,23 @@ missing_docs = "warn" [workspace.dependencies] +arborium = { version = "2.18", default-features = false, features = [ + "lang-rust", + "lang-toml", + "lang-yaml", + "lang-json", + "lang-markdown", + "lang-asciidoc", + "lang-bash", + "lang-javascript", + "lang-typescript", + "lang-python", + "lang-c", + "lang-cpp", + "lang-go", + "lang-html", + "lang-css", +] } axum = "0.8" clap = { version = "4.5.60", features = ["derive"] } clap_mangen = "0.2.31"
crates/git-ents-server/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true [dependencies] +arborium = { workspace = true } axum = { workspace = true } clap = { workspace = true } clap_mangen = { workspace = true }
crates/git-ents-server/src/http.rs @@ -41,7 +41,7 @@ // A plain browser GET (anything that is not part of the git wire protocol) // is served the HTML web UI rather than handed to the CGI backend. - if method == Method::GET && !is_git_path(&path_info, &query_string) { + if method == Method::GET && wants_web_ui(&path_info, &query_string) { let host = header_value(&headers, "Host"); return crate::web::render(&state, &path_info, host.as_deref()).await; } @@ -180,6 +180,33 @@ /// Greatest repository nesting depth: `repo`, `org/repo`, or `org/team/repo`. const MAX_REPO_DEPTH: usize = 3; +/// Whether a GET should be answered with the HTML web UI rather than handed to +/// `git http-backend`. +/// +/// Anything that is not a git wire-protocol request is web. In addition, the +/// browse routes (`/tree/`, `/blob/`, `/commit/`) are claimed for the web UI +/// even when a file path within them happens to resemble a dumb-HTTP git path +/// (e.g. a file named `HEAD`, or a directory named `objects`) — but never when +/// it is an actual smart-HTTP service request, so a repository named `commit` +/// can still be pushed to and cloned. +fn wants_web_ui(path: &str, query: &str) -> bool { + !is_git_path(path, query) || (is_browse_route(path) && !is_service_request(path, query)) +} + +/// Whether `path` selects one of the web UI's browse views. +fn is_browse_route(path: &str) -> bool { + path.contains("/tree/") || path.contains("/blob/") || path.contains("/commit/") +} + +/// Whether `path`/`query` is an unambiguous smart-HTTP service request (the +/// ref advertisement or an upload-pack/receive-pack RPC). +fn is_service_request(path: &str, query: &str) -> bool { + path.ends_with("/info/refs") + || path.ends_with("/git-upload-pack") + || path.ends_with("/git-receive-pack") + || query.contains("service=") +} + /// Whether `path`/`query` belong to git's wire protocol (smart or dumb HTTP) /// rather than the browser-facing web UI. Anything matching here is delegated /// to `git http-backend`; everything else is rendered as HTML. @@ -474,4 +501,25 @@ fn detects_pushes(#[case] path: &str, #[case] query: &str, #[case] expected: bool) { assert_eq!(is_receive_pack(path, query), expected); } + + #[rstest] + // Plain browse pages and the index are always web. + #[case("/", "", true)] + #[case("/repo", "", true)] + #[case("/repo/tree/src", "", true)] + #[case("/repo/blob/src/main.rs", "", true)] + #[case("/repo/commit/abc123", "", true)] + // Browse routes win over the loose dumb-HTTP heuristics: a file named HEAD, + // or a directory named `objects`, still renders as HTML. + #[case("/repo/blob/HEAD", "", true)] + #[case("/repo/blob/src/objects/mod.rs", "", true)] + // Real smart-HTTP requests are never stolen, even for a repo named `commit`. + #[case("/commit/info/refs", "service=git-upload-pack", false)] + #[case("/commit/git-receive-pack", "", false)] + #[case("/repo/info/refs", "service=git-upload-pack", false)] + #[case("/repo/git-upload-pack", "", false)] + #[case("/repo/objects/12/abcdef", "", false)] + fn routes_browser_gets(#[case] path: &str, #[case] query: &str, #[case] expected: bool) { + assert_eq!(wants_web_ui(path, query), expected); + } }
crates/git-ents-server/src/web.rs @@ -6,6 +6,7 @@ use std::path::{Path, PathBuf}; use std::process::Stdio; +use arborium::{Config, Highlighter, HtmlFormat}; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use maud::{DOCTYPE, Markup, PreEscaped, html}; @@ -44,6 +45,16 @@ --radius-sm: 10px; --radius-pill: 100px; --glow: #b07d1012; + --s-comment: #9c8f74; + --s-keyword: #9d0006; + --s-func: #427b58; + --s-type: #b57614; + --s-string: #79740e; + --s-const: #8f3f71; + --s-op: #7c6f57; + --s-prop: #076678; + --diff-add: #4e9a0622; + --diff-del: #cc241d22; } @media (prefers-color-scheme: dark) { :root { @@ -60,6 +71,16 @@ --shadow-sm: 0 1px 3px #00000040; --shadow-md: 0 4px 16px #0000004d; --glow: #d4a03014; + --s-comment: #928374; + --s-keyword: #fb4934; + --s-func: #8ec07c; + --s-type: #fabd2f; + --s-string: #b8bb26; + --s-const: #d3869b; + --s-op: #a89984; + --s-prop: #83a598; + --diff-add: #b8bb2620; + --diff-del: #fb493420; } } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } @@ -131,6 +152,47 @@ .btn { display: inline-flex; align-items: center; gap: .4rem; margin-top: 1.25rem; font-size: .88rem; font-weight: 600; color: var(--color-accent); text-decoration: none; padding: .45rem 1rem; border-radius: var(--radius-sm); border: 1px solid var(--color-border); background: var(--color-surface); box-shadow: var(--shadow-sm); transition: border-color .15s, box-shadow .15s; } .btn:hover { text-decoration: none; border-color: var(--color-accent); box-shadow: var(--shadow-md); } +.card-row a { color: inherit; text-decoration: none; flex: 1; min-width: 0; word-break: break-all; } +.card-row a:hover { color: var(--color-accent); } +.commit-subject a { color: inherit; text-decoration: none; } +.commit-subject a:hover { color: var(--color-accent); } + +.crumbs { font-family: var(--font-mono); font-size: .92rem; margin-bottom: 1.25rem; display: flex; flex-wrap: wrap; align-items: center; gap: .3rem; word-break: break-all; } +.crumbs a { text-decoration: none; } +.crumbs .sep { color: var(--color-text-muted); opacity: .55; } +.crumbs .here { color: var(--color-text-muted); } + +.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-nums { text-align: right; color: var(--color-text-muted); background: var(--color-code-bg); border-right: 1px solid var(--color-border); padding-left: 1rem; padding-right: 1rem; user-select: none; -webkit-user-select: none; } +.blob-code { overflow-x: auto; min-width: 0; } +.blob-code code { display: block; font-family: inherit; padding: 0 1rem; 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); } + +.code .keyword, .code .macro, .code .tag { color: var(--s-keyword); } +.code .function, .code .constructor { color: var(--s-func); } +.code .type { color: var(--s-type); } +.code .string { color: var(--s-string); } +.code .number, .code .constant, .code .label { color: var(--s-const); } +.code .comment { color: var(--s-comment); font-style: italic; } +.code .operator, .code .punctuation { color: var(--s-op); } +.code .property, .code .attribute { color: var(--s-prop); } +.code .title { color: var(--s-keyword); font-weight: 700; } +.code .strong { font-weight: 700; } +.code .emphasis { font-style: italic; } +.code .link, .code .url, .code .reference { color: var(--s-prop); text-decoration: underline; } +.code .markup { color: var(--s-func); } + +.diff { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); box-shadow: var(--shadow-sm); overflow-x: auto; margin-bottom: 1.5rem; font-family: var(--font-mono); font-size: .82rem; line-height: 1.55; padding: .6rem 0; } +.diff .ln { display: block; padding: 0 1rem; white-space: pre; } +.diff .add { background: var(--diff-add); } +.diff .del { background: var(--diff-del); } +.diff .hunk { color: var(--s-prop); background: var(--color-code-bg); } +.diff .meta { color: var(--color-text-muted); } +.diff .file { color: var(--color-text); font-weight: 600; background: var(--color-code-bg); padding-top: .3rem; padding-bottom: .3rem; } + +.commit-msg { font-family: var(--font-mono); font-size: .9rem; white-space: pre-wrap; word-break: break-word; } + .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; } @@ -155,26 +217,59 @@ }); "#; -/// Render the page for `path`: the repository index at the root, or a single -/// repository's overview otherwise. `host` is the request's `Host` header, used -/// to build a copy-pasteable clone URL. +/// Render the page for `path`: the repository index at the root, a repository +/// overview, or one of its browse views (`tree`, `blob`, `commit`). `host` is +/// the request's `Host` header, used to build a copy-pasteable clone URL. pub(crate) async fn render(state: &AppState, path: &str, host: Option<&str>) -> Response { let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); if segments.is_empty() { return index(state).into_response(); } - if segments.len() > MAX_DEPTH || !segments.iter().all(|s| valid_segment(s)) { - return not_found().into_response(); + + // The repository is the shortest valid prefix (up to `MAX_DEPTH` segments) + // that names a bare repo on disk; anything after it selects a browse view. + // Resolving the boundary this way keeps a repo named `tree`/`blob`/`commit` + // distinct from the route markers of the same name. + let depth_limit = segments.len().min(MAX_DEPTH); + for depth in 1..=depth_limit { + let Some(repo_segs) = segments.get(..depth) else { + break; + }; + if !repo_segs.iter().all(|s| valid_segment(s)) { + break; + } + let relative: PathBuf = repo_segs.iter().collect(); + let repo = state.data_dir.join(&relative); + if !is_bare_repo(&repo) { + continue; + } + let rel = repo_segs.join("/"); + let rest = segments.get(depth..).unwrap_or_default(); + return route(&repo, &rel, rest, host).await; } - let relative: PathBuf = segments.iter().collect(); - let repo = state.data_dir.join(&relative); - if !is_bare_repo(&repo) { - return not_found().into_response(); - } + not_found().into_response() +} - let rel_str = segments.join("/"); - repo_page(&repo, &rel_str, host).await.into_response() +/// Dispatch the part of the path that follows the repository to a browse view. +async fn route(repo: &Path, rel: &str, rest: &[&str], host: Option<&str>) -> Response { + 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, + _ => not_found().into_response(), + } +} + +/// 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. +fn browse_path(sub: &[&str]) -> Option<String> { + if sub.iter().any(|s| s.is_empty() || *s == "." || *s == "..") { + return None; + } + Some(sub.join("/")) } /// The repository listing shown at `/`. @@ -257,7 +352,7 @@ @for entry in &tree { div.card-row.is-dir[entry.is_dir] { @if entry.is_dir { (icon_folder()) } @else { (icon_file()) } - span { (entry.name) } + a href=(entry_href(rel, "", entry)) { (entry.name) } } } } @@ -266,7 +361,9 @@ div.card-header { "Recent commits" } @for commit in &commits { div.commit { - div.commit-subject { (commit.subject) } + div.commit-subject { + a href={ "/" (rel) "/commit/" (commit.hash) } { (commit.subject) } + } div.commit-meta { span.sha { (commit.short) } (commit.author) " · " (commit.when) @@ -281,6 +378,7 @@ /// A parsed `git log` entry. struct Commit { + hash: String, short: String, author: String, when: String, @@ -308,6 +406,7 @@ 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(), @@ -328,7 +427,13 @@ if !has_head { return Vec::new(); } - let Some(out) = git_output(repo, &["ls-tree", "HEAD"]).await else { + list_tree(repo, "HEAD").await +} + +/// 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> { + let Some(out) = git_output(repo, &["ls-tree", spec]).await else { return Vec::new(); }; let mut entries: Vec<TreeEntry> = out @@ -346,6 +451,18 @@ entries } +/// The link to a tree entry: a `tree` view for directories, a `blob` view for +/// files. `dir` is the tree's path within the repo (empty at the root). +fn entry_href(rel: &str, dir: &str, entry: &TreeEntry) -> String { + let view = if entry.is_dir { "tree" } else { "blob" }; + let name = &entry.name; + if dir.is_empty() { + format!("/{rel}/{view}/{name}") + } else { + format!("/{rel}/{view}/{dir}/{name}") + } +} + /// Run `git -C <repo> <args>` and return its stdout, or `None` on failure. async fn git_output(repo: &Path, args: &[&str]) -> Option<String> { let out = Command::new("git") @@ -362,6 +479,23 @@ Some(String::from_utf8_lossy(&out.stdout).into_owned()) } +/// Run `git -C <repo> <args>` and return its raw stdout bytes, or `None` on +/// failure. Used for blob contents, which may not be valid UTF-8. +async fn git_output_bytes(repo: &Path, args: &[&str]) -> Option<Vec<u8>> { + let out = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .stderr(Stdio::null()) + .output() + .await + .ok()?; + if !out.status.success() { + return None; + } + Some(out.stdout) +} + /// All bare repositories under `root`, as relative slash paths, sorted. fn discover_repos(root: &Path) -> Vec<String> { let mut repos = Vec::new(); @@ -401,6 +535,245 @@ } } +/// A directory listing at `sub` within the repository. +async fn tree_page(repo: &Path, rel: &str, sub: &[&str]) -> Response { + let Some(dir) = browse_path(sub) else { + return not_found().into_response(); + }; + let spec = if dir.is_empty() { + "HEAD".to_owned() + } else { + format!("HEAD:{dir}") + }; + let entries = list_tree(repo, &spec).await; + if entries.is_empty() && !dir.is_empty() { + return not_found().into_response(); + } + let name = rel.rsplit('/').next().unwrap_or(rel); + page( + name, + html! { + (crumbs(rel, &dir, false)) + div.card { + div.card-header { "Files" } + @if dir.is_empty() && entries.is_empty() { + div.card-row { "Empty repository." } + } + @for entry in &entries { + div.card-row.is-dir[entry.is_dir] { + @if entry.is_dir { (icon_folder()) } @else { (icon_file()) } + a href=(entry_href(rel, &dir, entry)) { (entry.name) } + } + } + } + }, + ) + .into_response() +} + +/// 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 { + let Some(path) = browse_path(sub).filter(|p| !p.is_empty()) else { + return not_found().into_response(); + }; + let spec = format!("HEAD:{path}"); + if git_output(repo, &["cat-file", "-t", &spec]) + .await + .as_deref() + != Some("blob\n") + { + return not_found().into_response(); + } + let Some(bytes) = git_output_bytes(repo, &["cat-file", "-p", &spec]).await else { + return not_found().into_response(); + }; + let name = path.rsplit('/').next().unwrap_or(&path); + let body = if is_binary(&bytes) { + html! { div.blob { div.binary { "Binary file (" (bytes.len()) " bytes) not shown." } } } + } else { + blob_body(name, &String::from_utf8_lossy(&bytes)) + }; + page( + name, + html! { + (crumbs(rel, &path, true)) + (body) + }, + ) + .into_response() +} + +/// Render text file `source` with a line-number gutter, highlighting via +/// `arborium` when the filename maps to a known grammar. +fn blob_body(name: &str, source: &str) -> Markup { + let lines = source.lines().count().max(1); + let mut gutter = String::new(); + for n in 1..=lines { + gutter.push_str(&n.to_string()); + gutter.push('\n'); + } + let highlighted = highlight(name, source); + html! { + div.blob { + pre.blob-nums { (gutter) } + pre.blob-code { + @match highlighted { + Some(html) => code.code { (PreEscaped(html)) }, + None => code { (source) }, + } + } + } + } +} + +/// Highlighted HTML for `source`, or `None` when the filename has no grammar +/// (in which case the caller renders escaped plain text). The highlighter is +/// built and used synchronously so its non-`Send` grammar store is never held +/// across an `.await`. +fn highlight(name: &str, source: &str) -> Option<String> { + let language = arborium::detect_language(name)?; + let config = Config { + html_format: HtmlFormat::ClassNames, + ..Default::default() + }; + Highlighter::with_config(config) + .highlight(language, source) + .ok() +} + +/// Whether `bytes` looks like binary content (a NUL byte in the leading chunk, +/// the same heuristic git uses). +fn is_binary(bytes: &[u8]) -> bool { + bytes.iter().take(8000).any(|b| *b == 0) +} + +/// A single commit: its metadata and a colorized unified diff. +async fn commit_page(repo: &Path, rel: &str, 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( + repo, + &["show", "-s", "--format=%H%x00%an%x00%ar%x00%s%x00%b", sha], + ) + .await + else { + return not_found().into_response(); + }; + let mut parts = meta.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(); + let subject = parts.next().unwrap_or_default().to_owned(); + let body = parts.next().unwrap_or_default().trim_end().to_owned(); + let short = hash.get(..7).unwrap_or(&hash).to_owned(); + let patch = git_output(repo, &["show", "--no-color", "--format=", "--patch", sha]) + .await + .unwrap_or_default(); + + page( + &subject, + html! { + div.page-header { + h1.page-title { a href={ "/" (rel) } { (rel) } } + span.branch { (short) } + } + div.card { + div.card-header { "Commit" } + div.commit { + div.commit-subject { (subject) } + @if !body.is_empty() { + div.commit-msg { (body) } + } + div.commit-meta { (author) " · " (when) } + } + } + (diff_view(&patch)) + }, + ) + .into_response() +} + +/// Render a unified diff, coloring each line by its leading marker. +fn diff_view(patch: &str) -> Markup { + if patch.trim().is_empty() { + return html! {}; + } + html! { + div.diff { + @for line in patch.lines() { + span class={ "ln " (diff_class(line)) } { (line) "\n" } + } + } + } +} + +/// The CSS class for a diff line, chosen from its leading marker. +fn diff_class(line: &str) -> &'static str { + if line.starts_with("@@") { + "hunk" + } else if line.starts_with("+++") || line.starts_with("---") || line.starts_with("diff ") { + "file" + } else if line.starts_with("index ") + || line.starts_with("new file") + || line.starts_with("deleted file") + || line.starts_with("old mode") + || line.starts_with("new mode") + || line.starts_with("rename ") + || line.starts_with("similarity ") + || line.starts_with("Binary files") + { + "meta" + } else if line.starts_with('+') { + "add" + } else if line.starts_with('-') { + "del" + } else { + "ctx" + } +} + +/// One segment of a breadcrumb trail: a label and, unless it is the current +/// file, the link to its directory listing. +struct Crumb { + label: String, + href: Option<String>, +} + +/// Breadcrumb navigation from the repository root down through `path`. When +/// `is_file` is set, the final component is shown as plain text rather than a +/// link, since a file has no listing of its own. +fn crumbs(rel: &str, path: &str, is_file: bool) -> Markup { + let parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); + let mut acc = String::new(); + let mut trail: Vec<Crumb> = Vec::new(); + for (i, part) in parts.iter().enumerate() { + if !acc.is_empty() { + acc.push('/'); + } + acc.push_str(part); + let is_last = i.saturating_add(1) == parts.len(); + let href = (!(is_last && is_file)).then(|| format!("/{rel}/tree/{acc}")); + trail.push(Crumb { + label: (*part).to_owned(), + href, + }); + } + html! { + nav.crumbs { + a href={ "/" (rel) } { (rel) } + @for crumb in &trail { + span.sep { "/" } + @match &crumb.href { + Some(href) => a href=(href) { (crumb.label) }, + None => span.here { (crumb.label) }, + } + } + } + } +} + /// A `404` page. fn not_found() -> (StatusCode, Markup) { (