066a5208
- commit
- 066a520867b5609cc27783911f18264023484741
- parent
- author
- Christopher K. Schmitt <me@shmish.dev>
- date
- 2026-07-08
- committer
- Christopher K. Schmitt <me@shmish.dev>
- date
- 2026-07-08
- changes
- 9
- insertions
- 592
- deletions
- 10
MCargo.lock
~version = 4~~[[package]]+name = "adler2"+version = "2.0.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"++[[package]]~name = "aho-corasick"~version = "1.1.4"~source = "registry+https://github.com/rust-lang/crates.io-index"
~version = "0.1.10"~source = "registry+https://github.com/rust-lang/crates.io-index"~checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"+dependencies = [+ "serde",+]++[[package]]+name = "bincode"+version = "1.3.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"~dependencies = [~ "serde",+]++[[package]]+name = "bit-set"+version = "0.8.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"+dependencies = [+ "bit-vec",~]++[[package]]+name = "bit-vec"+version = "0.8.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"~~[[package]]~name = "bitflags"
~dependencies = [~ "libc",~ "windows-sys",+]++[[package]]+name = "fancy-regex"+version = "0.16.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f"+dependencies = [+ "bit-set",+ "regex-automata",+ "regex-syntax",~]~~[[package]]
~]~~[[package]]+name = "flate2"+version = "1.1.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"+dependencies = [+ "crc32fast",+ "miniz_oxide",+]++[[package]]~name = "fnv"~version = "1.0.7"~source = "registry+https://github.com/rust-lang/crates.io-index"
~checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"~~[[package]]+name = "miniz_oxide"+version = "0.8.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"+dependencies = [+ "adler2",+ "simd-adler32",+]++[[package]]~name = "mio"~version = "1.2.0"~source = "registry+https://github.com/rust-lang/crates.io-index"
~ "errno",~ "libc",~]++[[package]]+name = "simd-adler32"+version = "0.3.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"~~[[package]]~name = "slab"
~version = "1.0.2"~source = "registry+https://github.com/rust-lang/crates.io-index"~checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"++[[package]]+name = "syntect"+version = "5.3.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925"+dependencies = [+ "bincode",+ "fancy-regex",+ "flate2",+ "fnv",+ "once_cell",+ "regex-syntax",+ "serde",+ "serde_derive",+ "thiserror",+ "walkdir",+]~~[[package]]~name = "temp_name"
~ "jiff",~ "pulldown-cmark",~ "serde",+ "syntect",~ "thiserror",~ "tokio",~]
MCargo.toml
~jiff = "0.2.24"~pulldown-cmark = "0.13.3"~serde = { version = "1.0.228", features = ["derive"] }+syntect = { version = "5.2", default-features = false, features = ["default-syntaxes", "default-themes", "html", "regex-fancy"] }~thiserror = "2.0.18"~tokio = { version = "1.52.1", features = ["rt-multi-thread", "macros"] }
Msrc/cli.rs
~ /// precedence over --theme if provided.~ #[arg(long)]~ pub theme_file: Option<PathBuf>,++ /// Port to listen on.+ #[arg(long, default_value = "3000")]+ pub port: u16,~}
Msrc/main.rs
~ };~~ let router = routes::router(state);- let socket = TcpListener::bind("0.0.0.0:3000").await?;+ let addr = format!("0.0.0.0:{}", args.port);+ let socket = TcpListener::bind(&addr).await?;~~ axum::serve(socket, router).await?;~
Mstatic/base.css
~~ /* Branch/tag rows: remaining spans fixed right */~ details ul li a span:not(:first-child) { flex-shrink: 0; color: var(--secondary); }++ /* Tree listing */+ .tree-list { display: flex; flex-direction: column; border-top: 1px solid var(--secondary); border-bottom: 1px solid var(--highlight); }++ .tree-row {+ display: flex;+ align-items: center;+ gap: 1rem;+ padding: 0 1rem;+ min-height: 50px;+ text-decoration: none;+ color: inherit;+ font-family: var(--font-mono);+ }++ .tree-icon {+ flex-shrink: 0;+ width: 1rem;+ text-align: center;+ color: var(--secondary);+ }++ .tree-name {+ flex: 1;+ min-width: 0;+ overflow: hidden;+ text-overflow: ellipsis;+ white-space: nowrap;+ color: var(--primary);+ }++ .tree-row:hover .tree-name { text-decoration: underline; text-underline-offset: 3px; }+ .tree-row:active { background-color: var(--highlight); }+ .tree-row + .tree-row { border-top: 1px solid var(--highlight); }++ .tree-size { flex-shrink: 0; color: var(--secondary); }++ /* File view */+ .file-view > .tree-row {+ border-top: 1px solid var(--secondary);+ }++ .file-view > .tree-row:first-child .tree-icon {+ color: var(--primary);+ }++ .file-view > .repo-nav > .repo-nav-item {+ border-top-color: var(--highlight);+ }++ .file-view .repo-nav-item-arrow {+ margin-left: auto;+ }++ .file-content {+ margin: 0;+ }++ .file-content pre {+ font-family: var(--font-mono);+ line-height: 1.45;+ padding: 1rem 1rem 1rem 0;+ margin: 0;+ overflow-x: auto;+ overflow-y: hidden;+ tab-size: 4;+ }++ .file-content .st-pre {+ max-width: none;+ white-space: pre;+ background: transparent;+ }++ /* Syntax highlighting */+ .st-comment { color: var(--secondary); }+ .st-string { color: var(--green); }+ .st-keyword { color: var(--blue); }+ .st-type { color: var(--violet); }+ .st-constant { color: var(--orange); }+ .st-function { color: var(--cyan); }+ .st-variable { color: var(--primary); }+ .st-operator { color: var(--primary); }+ .st-entity { color: var(--violet); }+ .st-attribute { color: var(--yellow); }+ .st-number { color: var(--orange); }+ .st-punctuation { color: var(--secondary); }+ .st-tag { color: var(--blue); }+ .st-storage { color: var(--blue); }+ .st-support { color: var(--violet); }+ .st-invalid { color: var(--red); }+ .st-inserted { color: var(--green); }+ .st-deleted { color: var(--red); }+ .st-changed { color: var(--yellow); }+ .st-heading { color: var(--emphasized); }+ .st-bold { font-weight: bold; }+ .st-italic { font-style: italic; }+ .st-link { color: var(--blue); }+ .st-raw { color: var(--green); }+ .st-meta { color: var(--secondary); }+ .st-section { color: var(--blue); }+ .st-underline { text-decoration: underline; }+ .st-linenum {+ display: inline-block;+ min-width: 5ch;+ text-align: right;+ padding: 0 1.5ch 0 0.5ch;+ color: var(--secondary);+ user-select: none;+ }~}~~@layer accents {
Mtemplates/sprites.svg
~ <line x1="3" x2="9" y1="12" y2="12"/>~ <line x1="15" x2="21" y1="12" y2="12"/>~ </symbol>+ <symbol id="icon-directory" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">+ <path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H5a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>+ </symbol>+ <symbol id="icon-file" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">+ <path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/>+ <path d="M14 2v4a2 2 0 0 0 2 2h4"/>+ </symbol>+ <symbol id="icon-up-dir" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">+ <polyline points="9 14 4 9 9 4"/>+ <path d="M20 20v-7a4 4 0 0 0-4-4H4"/>+ </symbol>+ <symbol id="icon-raw" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>+ <polyline points="7 10 12 15 17 10"/>+ <line x1="12" y1="15" x2="12" y2="3"/>+ </symbol>~</svg>
Msrc/routes/archive.rs
~use std::process::{Command, Stdio};~~use axum::extract::{Path, State};-use axum::http::header;~use axum::http::HeaderValue;+use axum::http::header;~use axum::response::IntoResponse;~use serde::Deserialize;~use tokio::task::spawn_blocking;
~ };~~ let git_stdout = git.stdout.take().expect("stdout configured");- let mut xz = match Command::new("xz")+ let xz = match Command::new("xz")~ .stdin(git_stdout)~ .stdout(Stdio::piped())~ .stderr(Stdio::piped())
~ };~~ if !git_status.success() {- let msg = git.stderr.take()+ let msg = git+ .stderr+ .take()~ .and_then(|mut s| {~ let mut buf = String::new();~ s.read_to_string(&mut buf).ok()?;~ Some(buf)~ })~ .unwrap_or_default();- let msg = if msg.is_empty() { "git archive failed".into() } else { msg };+ let msg = if msg.is_empty() {+ "git archive failed".into()+ } else {+ msg+ };~ return (Vec::new(), false, msg);~ }~~ if !xz_output.status.success() {~ let msg = String::from_utf8_lossy(&xz_output.stderr);- let msg = if msg.is_empty() { "xz compression failed".into() } else { msg.into_owned() };+ let msg = if msg.is_empty() {+ "xz compression failed".into()+ } else {+ msg.into_owned()+ };~ return (Vec::new(), false, msg);~ }~
Msrc/routes/tree.rs
~//!~//! Covers two routes: `/{repo}/commits/{sha}/tree` and~//! `/{repo}/commits/{sha}/tree/{*path}`. Renders a directory listing for the-//! given tree object, with file entries linked to the raw handler and-//! sub-directory entries linked to deeper tree pages.+//! given tree object, with file entries syntax-highlighted inline and linked to+//! the raw handler. Sub-directory entries are linked to deeper tree pages.~+use std::path::Component;+use std::sync::LazyLock;++use askama::Template;+use askama_web::WebTemplate;+use axum::extract::{Path, State};~use axum::response::IntoResponse;+use gix::bstr::ByteSlice;+use serde::Deserialize;+use syntect::html::{ClassStyle, ClassedHTMLGenerator};+use syntect::parsing::SyntaxSet;++use crate::error::{Error, Result};+use crate::routes::{AppState, RepoName};++/// Syntax definitions loaded once and reused for every file view.+static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(|| SyntaxSet::load_defaults_newlines());++/// Path parameters for the tree page routes.+#[derive(Deserialize)]+pub(super) struct TreePath {+ sha: String,+ #[serde(default)]+ path: Option<String>,+}++/// A single entry in a directory listing.+struct TreeEntry {+ name: String,+ is_dir: bool,+ url: String,+ size: String,+}++/// Formats a byte count as a human-readable string.+fn format_size(bytes: u64) -> String {+ const UNITS: &[&str] = &["B", "KB", "MB", "GB"];+ let mut size = bytes as f64;+ let mut unit_idx = 0;+ while size >= 1024.0 && unit_idx < UNITS.len() - 1 {+ size /= 1024.0;+ unit_idx += 1;+ }+ if unit_idx == 0 {+ format!("{} {}", bytes, UNITS[unit_idx])+ } else {+ format!("{:.1} {}", size, UNITS[unit_idx])+ }+}++/// A breadcrumb segment for the navigation bar.+struct Breadcrumb {+ label: String,+ url: String,+}++/// Template context for the tree/file page.+#[derive(Template, WebTemplate)]+#[template(path = "tree.html")]+struct TreeTemplate {+ repo: String,+ sha: String,+ path: String,+ breadcrumbs: Vec<Breadcrumb>,+ is_file: bool,+ entries: Vec<TreeEntry>,+ content_html: String,+ raw_url: String,+ parent_url: String,+}++/// Renders a tree or file at `{sha}:{path}` in the given repository.+pub async fn handler(+ State(state): State<AppState>,+ RepoName(name): RepoName,+ Path(params): Path<TreePath>,+) -> Result<impl IntoResponse> {+ let path = params.path.unwrap_or_default();++ // Reject paths containing non-normal components (e.g. `..`) to prevent+ // directory traversal.+ if !path.is_empty()+ && std::path::Path::new(&path)+ .components()+ .any(|c| !matches!(c, Component::Normal(_)))+ {+ return Err(Error::BadRequest("invalid path".to_string()));+ }++ let git_repo =+ gix::open(state.root.join(&name)).map_err(|_| Error::RepoNotFound(name.clone()))?;++ let (object, resolved_path) = if path.is_empty() {+ // When path is empty, resolve the sha to a commit and get its tree.+ let oid = gix::ObjectId::from_hex(params.sha.as_bytes())+ .map_err(|_| Error::BadRequest(format!("invalid sha: {}", params.sha)))?;+ let commit = git_repo+ .find_object(oid)+ .map_err(|_| Error::NotFound(params.sha.clone()))?+ .try_into_commit()+ .map_err(|_| Error::BadRequest(format!("{} is not a commit", params.sha)))?;+ let tree_id = commit+ .tree_id()+ .map_err(|e| Error::GitCorrupt(e.to_string()))?;+ let tree = git_repo+ .find_object(tree_id)+ .map_err(|e| Error::GitCorrupt(e.to_string()))?;+ (tree, String::new())+ } else {+ let spec = format!("{}:{}", params.sha, path);+ let obj = git_repo+ .rev_parse_single(spec.as_str())+ .map_err(|_| Error::NotFound(spec.clone()))?+ .object()+ .map_err(|e| Error::GitCorrupt(e.to_string()))?;+ (obj, path.clone())+ };++ let breadcrumbs = build_breadcrumbs(&name, ¶ms.sha, &resolved_path);+ let raw_url = if resolved_path.is_empty() {+ String::new()+ } else {+ format!("/{name}/commits/{}/raw/{}", params.sha, resolved_path)+ };+ let parent_url = if resolved_path.is_empty() {+ String::new()+ } else {+ let parent_path = resolved_path.rsplit_once('/').map(|(p, _)| p).unwrap_or("");+ if parent_path.is_empty() {+ format!("/{name}/commits/{}/tree", params.sha)+ } else {+ format!("/{name}/commits/{}/tree/{parent_path}", params.sha)+ }+ };++ match object.kind {+ gix::object::Kind::Tree => {+ let tree = object.into_tree();+ let mut entries: Vec<TreeEntry> = tree+ .iter()+ .filter_map(|entry| {+ let entry = entry.ok()?;+ let entry_name = entry.filename().to_str_lossy().into_owned();+ let is_dir = entry.mode().is_tree() || entry.mode().is_commit();+ let size = if is_dir {+ String::new()+ } else {+ git_repo+ .find_object(entry.oid())+ .ok()+ .map(|o| format_size(o.data.len() as u64))+ .unwrap_or_default()+ };+ let url = if resolved_path.is_empty() {+ format!("/{name}/commits/{}/tree/{entry_name}", params.sha)+ } else {+ format!(+ "/{name}/commits/{}/tree/{resolved_path}/{entry_name}",+ params.sha+ )+ };+ Some(TreeEntry {+ name: entry_name,+ is_dir,+ url,+ size,+ })+ })+ .collect();++ // Sort: directories first, then files, alphabetically within each group.+ entries.sort_by(|a, b| {+ if a.is_dir != b.is_dir {+ b.is_dir.cmp(&a.is_dir)+ } else {+ a.name.cmp(&b.name)+ }+ });++ Ok(TreeTemplate {+ repo: name,+ sha: params.sha,+ path: resolved_path,+ breadcrumbs,+ is_file: false,+ entries,+ content_html: String::new(),+ raw_url,+ parent_url,+ }+ .into_response())+ }+ gix::object::Kind::Blob => {+ let blob = object.into_blob();+ let data = &blob.data;++ // Binary files: redirect to raw handler.+ if data.contains(&0) {+ return Ok(axum::response::Redirect::to(&raw_url).into_response());+ }++ let content = String::from_utf8_lossy(data).into_owned();++ let extension = resolved_path.rsplit('.').next().unwrap_or("");+ let syntax = SYNTAX_SET+ .find_syntax_by_extension(extension)+ .unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text());++ let mut generator = ClassedHTMLGenerator::new_with_class_style(+ syntax,+ &SYNTAX_SET,+ ClassStyle::SpacedPrefixed { prefix: "st-" },+ );+ // ClassedHTMLGenerator wraps tokens in `<span class="st-*">` using+ // CSS class mappings instead of inline hex colors, so the+ // highlighting adapts to the app theme via CSS variables.+ let mut hcontent = content;+ if !hcontent.ends_with('\n') {+ hcontent.push('\n');+ }+ for line in hcontent.split_inclusive('\n') {+ generator+ .parse_html_for_line_which_includes_newline(line)+ .map_err(|e| Error::BadRequest(e.to_string()))?;+ }+ let inner = generator.finalize();+ // Add line numbers by splitting on preserved newlines. Segments+ // ending with `\n` are content lines; the final `</span>` closing+ // the root scope is the only segment without a trailing newline.+ let mut numbered = String::new();+ let mut line_num = 1;+ for segment in inner.split_inclusive('\n') {+ if segment.ends_with('\n') {+ numbered.push_str(&format!(+ "<span class=\"st-linenum\">{line_num}</span>{segment}"+ ));+ line_num += 1;+ } else {+ numbered.push_str(segment);+ }+ }+ let content_html = format!("<pre class=\"st-pre\">{numbered}</pre>");++ Ok(TreeTemplate {+ repo: name,+ sha: params.sha,+ path: resolved_path,+ breadcrumbs,+ is_file: true,+ entries: Vec::new(),+ content_html,+ raw_url,+ parent_url,+ }+ .into_response())+ }+ _ => {+ let spec = if resolved_path.is_empty() {+ format!("{}:", params.sha)+ } else {+ format!("{}:{}", params.sha, resolved_path)+ };+ Err(Error::NotFound(spec))+ }+ }+}~-pub async fn handler() -> impl IntoResponse {- "Tree handler"+/// Builds breadcrumbs for a tree path.+///+/// The root breadcrumb is always "tree". Each path segment adds a breadcrumb+/// linking to its corresponding subdirectory.+fn build_breadcrumbs(repo: &str, sha: &str, path: &str) -> Vec<Breadcrumb> {+ let mut crumbs = Vec::new();+ crumbs.push(Breadcrumb {+ label: "tree".to_string(),+ url: format!("/{repo}/commits/{sha}/tree"),+ });+ if !path.is_empty() {+ let mut accumulated = String::new();+ for segment in path.split('/') {+ if !accumulated.is_empty() {+ accumulated.push('/');+ }+ accumulated.push_str(segment);+ crumbs.push(Breadcrumb {+ label: segment.to_string(),+ url: format!("/{repo}/commits/{sha}/tree/{accumulated}"),+ });+ }+ }+ crumbs~}
Atemplates/tree.html
+{% extends "base.html" %}++{% block title %}{{ repo }}/{{ path }}{% endblock %}++{% block breadcrumbs %}+<ul>+ <li><a href="/">{{ crate::hostname() }}</a></li>+ <li><a href="/{{ repo }}">{{ repo }}</a></li>+ <li><a href="/{{ repo }}/commits/{{ sha }}">{{ sha[..8] }}</a></li>+ {% for crumb in breadcrumbs %}+ <li>{% if loop.last %}{{ crumb.label }}{% else %}<a href="{{ crumb.url }}">{{ crumb.label }}</a>{% endif %}</li>+ {% endfor %}+</ul>+{% endblock %}++{% block content %}+{% if is_file %}+<div class="file-view">+ <a href="{{ parent_url }}" class="tree-row">+ <span class="tree-icon"><svg class="summary-icon"><use href="#icon-up-dir"/></svg></span>+ <span class="tree-name">Parent</span>+ <span class="tree-size">[>]</span>+ </a>+ <div class="repo-nav">+ <a href="{{ raw_url }}" class="repo-nav-item">+ <svg class="summary-icon"><use href="#icon-raw"/></svg>+ <span>Raw</span>+ <span class="repo-nav-item-arrow">[>]</span>+ </a>+ </div>+ <div class="file-content">+ {{ content_html|safe }}+ </div>+</div>+{% else %}+<div class="tree-list">+ {% if path.is_empty() %}+ {% else %}+ <a href="{{ parent_url }}" class="tree-row">+ <span class="tree-icon"><svg class="summary-icon"><use href="#icon-up-dir"/></svg></span>+ <span class="tree-name">..</span>+ <span class="tree-size">-</span>+ </a>+ {% endif %}+ {% if entries.is_empty() %}+ <p>Empty directory.</p>+ {% else %}+ {% for entry in entries %}+ <a href="{{ entry.url }}" class="tree-row">+ <span class="tree-icon"><svg class="summary-icon"><use href="#icon-{% if entry.is_dir %}directory{% else %}file{% endif %}"/></svg></span>+ <span class="tree-name">{% if entry.is_dir %}{{ entry.name }}/{% else %}{{ entry.name }}{% endif %}</span>+ <span class="tree-size">{% if entry.is_dir %}-{% else %}{{ entry.size }}{% endif %}</span>+ </a>+ {% endfor %}+ {% endif %}+</div>+{% endif %}+{% endblock %}++{% block scripts %}+{% if is_file %}+<script>+ // Scroll to the top of the file content after navigation+ document.querySelector('.file-content')?.scrollIntoView(true);+</script>+{% endif %}+{% endblock %}