066a5208

Archive
Tree [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
Add --port flag and implement tree browser with syntax highlighting
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, &params.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">[&gt;]</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">[&gt;]</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 %}