Split handlers.rs into focused submodules
handlers/git.rs   — shared git-reading helpers (run_git, read_bookmarks, etc.)
handlers/repo.rs  — repo_home
handlers/ci.rs    — run_list, run_detail
handlers/tree.rs  — tree_view, tree_view_path
handlers/mod.rs   — render, render_error, stylesheet, config, tests

No behaviour changes; all existing tests pass.

https://claude.ai/code/session_01URZjpNNXYVjq99E1msKw1J
change
commit cfd62becbf765c9d6f661a25d17945c417ffa480
author Claude <noreply@anthropic.com>
date
parent 428b0c36
diff --git a/quire-server/src/quire/web/handlers/ci.rs b/quire-server/src/quire/web/handlers/ci.rs
new file mode 100644
index 0000000..d640a7f
--- /dev/null
+++ b/quire-server/src/quire/web/handlers/ci.rs
@@ -0,0 +1,237 @@
+//! Handlers for CI run list and run detail pages.
+
+use axum::extract::{Path as AxumPath, State};
+use axum::http::StatusCode;
+use axum::response::{IntoResponse, Response};
+
+use super::git::{read_bookmarks, read_tags};
+use super::{render, render_error};
+use super::super::db;
+use super::super::templates::{
+    Crumb, DetailJob, DetailRun, DetailShEvent, RunDetailTemplate, RunListRow, RunListTemplate,
+};
+use crate::Quire;
+
+pub async fn run_list(State(quire): State<Quire>, AxumPath(repo): AxumPath<String>) -> Response {
+    let repo_display = repo.trim_end_matches(".git").to_string();
+    let repo_name = db::resolve_repo_name(&repo);
+    let git_repo = match quire.repo(&repo_name) {
+        Ok(r) if r.exists() => r,
+        _ => return StatusCode::NOT_FOUND.into_response(),
+    };
+
+    let q = quire.clone();
+    let rn = repo_name.clone();
+    let runs_handle = tokio::task::spawn_blocking(move || db::load_runs(&q, &rn));
+    let refs_handle =
+        tokio::task::spawn_blocking(move || (read_bookmarks(&git_repo), read_tags(&git_repo)));
+
+    let runs = match runs_handle.await {
+        Ok(Ok(r)) => r,
+        Ok(Err(e)) => {
+            tracing::error!(repo = %repo, error = &e as &(dyn std::error::Error + 'static), "failed to load runs");
+            return render_error(
+                repo_display,
+                StatusCode::INTERNAL_SERVER_ERROR,
+                "Failed to load runs",
+                e.to_string(),
+            );
+        }
+        Err(_) => {
+            tracing::error!("spawn_blocking task panicked");
+            return StatusCode::INTERNAL_SERVER_ERROR.into_response();
+        }
+    };
+    let (bookmarks, tags) = refs_handle.await.unwrap_or_default();
+
+    let template_runs: Vec<RunListRow> = runs
+        .into_iter()
+        .map(|r| RunListRow {
+            id: r.id,
+            outcome: r.outcome,
+            sha: r.sha,
+            ref_name: r.ref_name,
+            created_at: r.created_at,
+            dispatched_at: r.dispatched_at,
+            resolved_at: r.resolved_at,
+        })
+        .collect();
+
+    let tmpl = RunListTemplate {
+        repo: repo_display,
+        crumbs: vec![],
+        runs: template_runs,
+        bookmarks,
+        tags,
+        active_section: "ci".to_string(),
+    };
+    render(&tmpl)
+}
+
+pub async fn run_detail(
+    State(quire): State<Quire>,
+    AxumPath((repo, run_id)): AxumPath<(String, String)>,
+) -> Response {
+    let repo_display = repo.trim_end_matches(".git").to_string();
+    let repo_name = db::resolve_repo_name(&repo);
+    let git_repo = match quire.repo(&repo_name) {
+        Ok(r) if r.exists() => r,
+        _ => return StatusCode::NOT_FOUND.into_response(),
+    };
+    if !db::is_valid_run_id(&run_id) {
+        return StatusCode::NOT_FOUND.into_response();
+    }
+
+    let refs_handle =
+        tokio::task::spawn_blocking(move || (read_bookmarks(&git_repo), read_tags(&git_repo)));
+
+    let q = quire.clone();
+    let rn = repo_name.clone();
+    let ri = run_id.clone();
+    let detail = match tokio::task::spawn_blocking(move || db::load_run_detail(&q, &rn, &ri)).await
+    {
+        Ok(Ok(d)) => d,
+        Ok(Err(ref e)) if is_no_rows(e) => return StatusCode::NOT_FOUND.into_response(),
+        Ok(Err(e)) => {
+            tracing::error!(repo = %repo, run_id = %run_id, error = &e as &(dyn std::error::Error + 'static), "failed to load run detail");
+            return render_error(
+                repo_display,
+                StatusCode::INTERNAL_SERVER_ERROR,
+                "Failed to load run",
+                e.to_string(),
+            );
+        }
+        Err(_) => {
+            tracing::error!("spawn_blocking task panicked");
+            return StatusCode::INTERNAL_SERVER_ERROR.into_response();
+        }
+    };
+
+    let detail_run = DetailRun {
+        outcome: detail.run.outcome,
+        sha: detail.run.sha,
+        ref_name: detail.run.ref_name,
+        created_at: detail.run.created_at,
+        dispatched_at: detail.run.dispatched_at,
+        resolved_at: detail.run.resolved_at,
+    };
+
+    // Group sh events by job_id, preserving DB order so positional index
+    // matches launch order.
+    let mut events_by_job: std::collections::HashMap<&str, Vec<&db::ShEvent>> =
+        std::collections::HashMap::new();
+    for ev in &detail.sh_events {
+        events_by_job.entry(&ev.job_id).or_default().push(ev);
+    }
+
+    let runs_base = quire.base_dir().join("runs").join(&repo_name);
+    let run_dir = runs_base.join(&run_id);
+    let job_dir_base = run_dir.join("jobs");
+
+    // Spawn quire-ci.log read concurrently with the per-sh log reads below.
+    let quire_ci_log_path = run_dir.join("quire-ci.log");
+    let quire_ci_log_handle: tokio::task::JoinHandle<String> =
+        tokio::spawn(async move { read_log(&quire_ci_log_path).await });
+
+    // Build a flat list of log paths so we can issue all reads concurrently
+    // and reassemble in order.
+    let mut log_handles: Vec<tokio::task::JoinHandle<String>> = Vec::new();
+
+    for job in &detail.jobs {
+        let job_events = events_by_job
+            .get(job.job_id.as_str())
+            .map(Vec::as_slice)
+            .unwrap_or(&[]);
+        let job_dir = db::is_safe_path_segment(&job.job_id).then(|| job_dir_base.join(&job.job_id));
+        if job_dir.is_none() && !job_events.is_empty() {
+            tracing::warn!(job_id = %job.job_id, "skipping CRI log reads for unsafe job_id");
+        }
+
+        for (i, _ev) in job_events.iter().enumerate() {
+            let sh_n = i + 1;
+            match &job_dir {
+                Some(dir) => {
+                    let path = dir.join(format!("sh-{sh_n}.log"));
+                    log_handles.push(tokio::spawn(async move { read_log(&path).await }));
+                }
+                None => {
+                    log_handles.push(tokio::spawn(async { String::new() }));
+                }
+            }
+        }
+    }
+
+    // Await all spawned reads in spawn order to preserve the index mapping.
+    let mut log_results: Vec<String> = Vec::with_capacity(log_handles.len());
+    for handle in log_handles {
+        log_results.push(handle.await.unwrap_or_default());
+    }
+
+    // Reassemble: walk jobs/events in the same order, pulling from log_results.
+    let mut log_idx = 0;
+    let mut detail_jobs: Vec<DetailJob> = Vec::with_capacity(detail.jobs.len());
+    for job in &detail.jobs {
+        let job_events = events_by_job
+            .get(job.job_id.as_str())
+            .map(Vec::as_slice)
+            .unwrap_or(&[]);
+
+        let mut detail_sh_events: Vec<DetailShEvent> = Vec::with_capacity(job_events.len());
+        for ev in job_events {
+            detail_sh_events.push(DetailShEvent {
+                started_at_ms: ev.started_at_ms,
+                finished_at_ms: ev.finished_at_ms,
+                exit_code: ev.exit_code,
+                cmd: ev.cmd.clone(),
+                log_content: log_results[log_idx].clone(),
+            });
+            log_idx += 1;
+        }
+
+        detail_jobs.push(DetailJob {
+            job_id: job.job_id.clone(),
+            state: job.state.clone(),
+            exit_code: job.exit_code,
+            started_at_ms: job.started_at_ms,
+            finished_at_ms: job.finished_at_ms,
+            sh_events: detail_sh_events,
+        });
+    }
+
+    let quire_ci_log = quire_ci_log_handle.await.unwrap_or_default();
+    let (bookmarks, tags) = refs_handle.await.unwrap_or_default();
+
+    let crumbs = vec![
+        Crumb::with_href("ci", format!("/{}/ci", repo_display)),
+        Crumb::new(detail_run.sha_short()),
+    ];
+    let tmpl = RunDetailTemplate {
+        repo: repo_display,
+        crumbs,
+        run: detail_run,
+        jobs: detail_jobs,
+        quire_ci_log,
+        bookmarks,
+        tags,
+        active_section: "ci".to_string(),
+    };
+    render(&tmpl)
+}
+
+fn is_no_rows(err: &crate::error::Error) -> bool {
+    matches!(
+        err,
+        crate::error::Error::Sql(rusqlite::Error::QueryReturnedNoRows)
+    )
+}
+
+async fn read_log(path: &std::path::Path) -> String {
+    match fs_err::tokio::read_to_string(path).await {
+        Ok(content) => content,
+        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
+        Err(e) => {
+            tracing::warn!(path = %path.display(), error = &e as &(dyn std::error::Error + 'static), "failed to read CRI log");
+            String::new()
+        }
+    }
+}
diff --git a/quire-server/src/quire/web/handlers/git.rs b/quire-server/src/quire/web/handlers/git.rs
new file mode 100644
index 0000000..c077c1b
--- /dev/null
+++ b/quire-server/src/quire/web/handlers/git.rs
@@ -0,0 +1,127 @@
+//! Shared git-reading helpers used by multiple handlers.
+
+use super::super::templates::{BookmarkRow, ChangeRow, HeadInfo, TagRow};
+use crate::quire::Repo;
+
+pub(super) type GitData = (
+    Option<HeadInfo>,
+    Option<String>,
+    Vec<BookmarkRow>,
+    Vec<TagRow>,
+    Vec<ChangeRow>,
+);
+
+/// Run a git command in `repo`, returning trimmed stdout or `None` on failure
+/// or empty output.
+pub(super) fn run_git(repo: &Repo, args: &[&str]) -> Option<String> {
+    let output = repo.git(args).output().ok()?;
+    if !output.status.success() {
+        return None;
+    }
+    let s = String::from_utf8(output.stdout).ok()?;
+    let s = s.trim().to_string();
+    if s.is_empty() { None } else { Some(s) }
+}
+
+/// Read summary data from a bare git repository for the repo home page.
+pub(super) fn read_git_data(repo: &Repo) -> GitData {
+    let head = read_head_info(repo);
+    let readme_html = read_readme(repo);
+    let bookmarks = read_bookmarks(repo);
+    let tags = read_tags(repo);
+    let recent_changes = read_recent_changes(repo);
+    (head, readme_html, bookmarks, tags, recent_changes)
+}
+
+pub(super) fn read_head_info(repo: &Repo) -> Option<HeadInfo> {
+    let bookmark =
+        run_git(repo, &["symbolic-ref", "--short", "HEAD"]).unwrap_or_else(|| "main".to_string());
+    // %H = full sha, %s = subject, %ar = relative age
+    let log = run_git(repo, &["log", "-1", "--format=%H%n%s%n%ar"])?;
+    let mut lines = log.lines();
+    let sha = lines.next()?.to_string();
+    let description = lines.next().unwrap_or("").to_string();
+    let age = lines.next().unwrap_or("").to_string();
+    Some(HeadInfo { sha, description, age, bookmark })
+}
+
+pub(super) fn read_readme(repo: &Repo) -> Option<String> {
+    let candidates = ["HEAD:README.md", "HEAD:readme.md", "HEAD:README"];
+    for candidate in &candidates {
+        if let Some(raw) = run_git(repo, &["show", candidate]) {
+            return Some(render_markdown(&raw));
+        }
+    }
+    None
+}
+
+fn render_markdown(markdown: &str) -> String {
+    use pulldown_cmark::{Options, Parser, html};
+    let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH;
+    let parser = Parser::new_ext(markdown, opts);
+    let mut output = String::new();
+    html::push_html(&mut output, parser);
+    output
+}
+
+pub(super) fn read_bookmarks(repo: &Repo) -> Vec<BookmarkRow> {
+    let out = run_git(
+        repo,
+        &[
+            "for-each-ref",
+            "--format=%(refname:short)|%(objectname:short)|%(committerdate:relative)",
+            "--sort=-committerdate",
+            "refs/heads/",
+        ],
+    )
+    .unwrap_or_default();
+
+    out.lines()
+        .filter_map(|line| {
+            let mut parts = line.splitn(3, '|');
+            Some(BookmarkRow {
+                name: parts.next()?.to_string(),
+                sha_short: parts.next()?.to_string(),
+                age: parts.next().unwrap_or("").to_string(),
+            })
+        })
+        .collect()
+}
+
+pub(super) fn read_tags(repo: &Repo) -> Vec<TagRow> {
+    let out = run_git(
+        repo,
+        &[
+            "for-each-ref",
+            "--format=%(refname:short)|%(committerdate:relative)",
+            "--sort=-creatordate",
+            "refs/tags/",
+        ],
+    )
+    .unwrap_or_default();
+
+    out.lines()
+        .filter_map(|line| {
+            let mut parts = line.splitn(2, '|');
+            Some(TagRow {
+                name: parts.next()?.to_string(),
+                age: parts.next().unwrap_or("").to_string(),
+            })
+        })
+        .collect()
+}
+
+pub(super) fn read_recent_changes(repo: &Repo) -> Vec<ChangeRow> {
+    let out = run_git(repo, &["log", "-12", "--format=%H|%s|%ar"]).unwrap_or_default();
+
+    out.lines()
+        .filter_map(|line| {
+            let mut parts = line.splitn(3, '|');
+            Some(ChangeRow {
+                sha: parts.next()?.to_string(),
+                description: parts.next().unwrap_or("").to_string(),
+                age: parts.next().unwrap_or("").to_string(),
+            })
+        })
+        .collect()
+}
diff --git a/quire-server/src/quire/web/handlers/mod.rs b/quire-server/src/quire/web/handlers/mod.rs
new file mode 100644
index 0000000..422b71b
--- /dev/null
+++ b/quire-server/src/quire/web/handlers/mod.rs
@@ -0,0 +1,271 @@
+//! Route handlers for the web view.
+
+mod ci;
+mod git;
+mod repo;
+mod tree;
+
+pub use ci::{run_detail, run_list};
+pub use repo::repo_home;
+pub use tree::{tree_view, tree_view_path};
+
+use askama::Template;
+use axum::http::{StatusCode, header};
+use axum::response::{Html, IntoResponse, Response};
+
+use super::db;
+use super::templates::{ConfigTemplate, Crumb, ErrorTemplate};
+use crate::Quire;
+
+/// Render a template into an HTML response, returning 500 on render failure.
+pub(super) fn render<T: Template>(tmpl: &T) -> Response {
+    match tmpl.render() {
+        Ok(body) => Html(body).into_response(),
+        Err(e) => {
+            tracing::error!(
+                error = &e as &(dyn std::error::Error + 'static),
+                "template render failed"
+            );
+            (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response()
+        }
+    }
+}
+
+/// Render the error template with the given status, falling back to plain
+/// text if the error template itself fails to render.
+pub(super) fn render_error(repo: String, status: StatusCode, title: &str, detail: String) -> Response {
+    let tmpl = ErrorTemplate {
+        repo,
+        crumbs: vec![Crumb::new("error")],
+        title: title.to_string(),
+        detail: detail.clone(),
+    };
+    match tmpl.render() {
+        Ok(body) => (status, Html(body)).into_response(),
+        Err(e) => {
+            tracing::error!(
+                error = &e as &(dyn std::error::Error + 'static),
+                "error template render failed"
+            );
+            (status, format!("{title}\n\n{detail}\n")).into_response()
+        }
+    }
+}
+
+/// Serve the compiled-in stylesheet.
+pub async fn stylesheet() -> Response {
+    (
+        [(header::CONTENT_TYPE, "text/css; charset=utf-8")],
+        include_str!("../../../../static/style.css"),
+    )
+        .into_response()
+}
+
+pub async fn config(State(quire): State<Quire>) -> Response {
+    let tmpl = ConfigTemplate {
+        crumbs: vec![Crumb::new("config")],
+        config: quire.config.clone(),
+    };
+    render(&tmpl)
+}
+
+use axum::extract::State;
+
+#[cfg(test)]
+mod tests {
+    use axum::body::Body;
+    use axum::http::{Request, StatusCode};
+    use tower::ServiceExt;
+
+    use crate::Quire;
+
+    struct TestEnv {
+        _dir: tempfile::TempDir,
+        quire: Quire,
+    }
+
+    impl TestEnv {
+        fn new() -> Self {
+            let dir = tempfile::tempdir().expect("tempdir");
+            let quire = Quire::load(dir.path().to_path_buf()).expect("load");
+
+            let repos_dir = quire.repos_dir();
+            let bare = repos_dir.join("example.git");
+            fs_err::create_dir_all(&bare).expect("mkdir bare repo");
+
+            let mut db = crate::db::open(&quire.db_path()).expect("db open");
+            crate::db::migrate(&mut db).expect("migrate");
+            drop(db);
+
+            Self { _dir: dir, quire }
+        }
+
+        fn insert_run(
+            &self,
+            id: &str,
+            outcome: Option<&str>,
+            sha: &str,
+            ref_name: &str,
+            created: i64,
+            dispatched: Option<i64>,
+            resolved: Option<i64>,
+        ) {
+            let db = self.quire.db_pool();
+            db.execute(
+                "INSERT INTO runs (id, repo, ref_name, sha, pushed_at_ms,
+                                  created_at, dispatched_at, resolved_at, outcome)
+                 VALUES (?1, 'example.git', ?2, ?3, ?4, ?4, ?5, ?6, ?7)",
+                rusqlite::params![id, ref_name, sha, created, dispatched, resolved, outcome],
+            )
+            .expect("insert run");
+        }
+
+        fn insert_job(
+            &self,
+            run_id: &str,
+            job_id: &str,
+            state: &str,
+            exit_code: Option<i32>,
+            started: Option<i64>,
+            finished: Option<i64>,
+        ) {
+            let db = self.quire.db_pool();
+            db.execute(
+                "INSERT INTO jobs (run_id, job_id, state, exit_code, started_at_ms, finished_at_ms)
+                 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
+                rusqlite::params![run_id, job_id, state, exit_code, started, finished],
+            )
+            .expect("insert job");
+        }
+
+        fn app(&self) -> axum::Router {
+            super::super::router(self.quire.clone())
+        }
+    }
+
+    const UUID1: &str = "aaaaaaaa-0000-0000-0000-000000000001";
+    const SHA1: &str = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+
+    #[tokio::test]
+    async fn repo_home_returns_ok_for_known_repo() {
+        let env = TestEnv::new();
+        let resp = env
+            .app()
+            .oneshot(Request::builder().uri("/example").body(Body::empty()).unwrap())
+            .await
+            .unwrap();
+        assert_eq!(resp.status(), StatusCode::OK);
+    }
+
+    #[tokio::test]
+    async fn repo_home_accepts_git_suffix() {
+        let env = TestEnv::new();
+        let resp = env
+            .app()
+            .oneshot(Request::builder().uri("/example.git").body(Body::empty()).unwrap())
+            .await
+            .unwrap();
+        assert_eq!(resp.status(), StatusCode::OK);
+    }
+
+    #[tokio::test]
+    async fn run_list_returns_ok_for_known_repo() {
+        let env = TestEnv::new();
+        env.insert_run(UUID1, Some("succeeded"), SHA1, "refs/heads/main", 1000, Some(2000), Some(3000));
+        let resp = env
+            .app()
+            .oneshot(Request::builder().uri("/example/ci").body(Body::empty()).unwrap())
+            .await
+            .unwrap();
+        assert_eq!(resp.status(), StatusCode::OK);
+    }
+
+    #[tokio::test]
+    async fn run_list_returns_404_for_unknown_repo() {
+        let env = TestEnv::new();
+        let resp = env
+            .app()
+            .oneshot(Request::builder().uri("/nonexistent/ci").body(Body::empty()).unwrap())
+            .await
+            .unwrap();
+        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
+    }
+
+    #[tokio::test]
+    async fn run_detail_returns_ok_for_existing_run() {
+        let env = TestEnv::new();
+        env.insert_run(UUID1, Some("succeeded"), SHA1, "refs/heads/main", 1000, Some(2000), Some(3000));
+        env.insert_job(UUID1, "build", "succeeded", Some(0), Some(2000), Some(3000));
+        let resp = env
+            .app()
+            .oneshot(
+                Request::builder()
+                    .uri(&format!("/example/ci/{UUID1}"))
+                    .body(Body::empty())
+                    .unwrap(),
+            )
+            .await
+            .unwrap();
+        assert_eq!(resp.status(), StatusCode::OK);
+    }
+
+    #[tokio::test]
+    async fn run_detail_returns_404_for_invalid_id() {
+        let env = TestEnv::new();
+        let resp = env
+            .app()
+            .oneshot(Request::builder().uri("/example/ci/not-a-uuid").body(Body::empty()).unwrap())
+            .await
+            .unwrap();
+        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
+    }
+
+    #[tokio::test]
+    async fn run_detail_returns_404_for_missing_run() {
+        let env = TestEnv::new();
+        let resp = env
+            .app()
+            .oneshot(
+                Request::builder()
+                    .uri(&format!("/example/ci/{UUID1}"))
+                    .body(Body::empty())
+                    .unwrap(),
+            )
+            .await
+            .unwrap();
+        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
+    }
+
+    #[tokio::test]
+    async fn tree_view_returns_ok_for_known_repo() {
+        let env = TestEnv::new();
+        let repo_path = env.quire.repos_dir().join("example.git");
+        std::process::Command::new("git")
+            .args(["init", "--bare"])
+            .current_dir(&repo_path)
+            .output()
+            .ok();
+        let resp = env
+            .app()
+            .oneshot(Request::builder().uri("/example/tree").body(Body::empty()).unwrap())
+            .await
+            .unwrap();
+        // Empty repo has no HEAD → 404; populated repo → 200.
+        assert!(
+            resp.status() == StatusCode::OK || resp.status() == StatusCode::NOT_FOUND,
+            "unexpected status: {}",
+            resp.status()
+        );
+    }
+
+    #[tokio::test]
+    async fn tree_view_returns_404_for_unknown_repo() {
+        let env = TestEnv::new();
+        let resp = env
+            .app()
+            .oneshot(Request::builder().uri("/nonexistent/tree").body(Body::empty()).unwrap())
+            .await
+            .unwrap();
+        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
+    }
+}
diff --git a/quire-server/src/quire/web/handlers/repo.rs b/quire-server/src/quire/web/handlers/repo.rs
new file mode 100644
index 0000000..da6078c
--- /dev/null
+++ b/quire-server/src/quire/web/handlers/repo.rs
@@ -0,0 +1,66 @@
+//! Handler for the repository home page.
+
+use axum::extract::{Path as AxumPath, State};
+use axum::response::Response;
+use axum::http::StatusCode;
+use axum::response::IntoResponse;
+
+use super::git::{read_bookmarks, read_git_data, read_tags};
+use super::render;
+use super::super::db;
+use super::super::templates::{RepoHomeTemplate, RunListRow};
+use crate::Quire;
+
+pub async fn repo_home(State(quire): State<Quire>, AxumPath(repo): AxumPath<String>) -> Response {
+    let repo_display = repo.trim_end_matches(".git").to_string();
+    let repo_name = db::resolve_repo_name(&repo);
+    let git_repo = match quire.repo(&repo_name) {
+        Ok(r) if r.exists() => r,
+        _ => return StatusCode::NOT_FOUND.into_response(),
+    };
+
+    let q = quire.clone();
+    let rn = repo_name.clone();
+    let recent_runs: Vec<RunListRow> = match tokio::task::spawn_blocking(move || {
+        db::load_runs(&q, &rn)
+    })
+    .await
+    {
+        Ok(Ok(runs)) => runs
+            .into_iter()
+            .take(5)
+            .map(|r| RunListRow {
+                id: r.id,
+                outcome: r.outcome,
+                sha: r.sha,
+                ref_name: r.ref_name,
+                created_at: r.created_at,
+                dispatched_at: r.dispatched_at,
+                resolved_at: r.resolved_at,
+            })
+            .collect(),
+        Ok(Err(e)) => {
+            tracing::warn!(repo = %repo, error = &e as &(dyn std::error::Error + 'static), "failed to load runs for home");
+            vec![]
+        }
+        Err(_) => vec![],
+    };
+
+    let (head, readme_html, bookmarks, tags, recent_changes) =
+        tokio::task::spawn_blocking(move || read_git_data(&git_repo))
+            .await
+            .unwrap_or_default();
+
+    let tmpl = RepoHomeTemplate {
+        repo: repo_display,
+        crumbs: vec![],
+        head,
+        readme_html,
+        bookmarks,
+        tags,
+        recent_runs,
+        recent_changes,
+        active_section: "overview".to_string(),
+    };
+    render(&tmpl)
+}
diff --git a/quire-server/src/quire/web/handlers/tree.rs b/quire-server/src/quire/web/handlers/tree.rs
new file mode 100644
index 0000000..ccb94a1
--- /dev/null
+++ b/quire-server/src/quire/web/handlers/tree.rs
@@ -0,0 +1,190 @@
+//! Handler for the repository tree browser.
+
+use axum::extract::{Path as AxumPath, State};
+use axum::http::StatusCode;
+use axum::response::{IntoResponse, Response};
+
+use super::git::{read_bookmarks, read_tags, run_git};
+use super::render;
+use super::super::db;
+use super::super::templates::{Crumb, PathCommit, TreeEntry, TreeEntryKind, TreeTemplate};
+use crate::Quire;
+use crate::quire::Repo;
+
+pub async fn tree_view(State(quire): State<Quire>, AxumPath(repo): AxumPath<String>) -> Response {
+    tree_at_path(quire, repo, String::new()).await
+}
+
+pub async fn tree_view_path(
+    State(quire): State<Quire>,
+    AxumPath((repo, path)): AxumPath<(String, String)>,
+) -> Response {
+    tree_at_path(quire, repo, path).await
+}
+
+async fn tree_at_path(quire: Quire, repo: String, path: String) -> Response {
+    let repo_display = repo.trim_end_matches(".git").to_string();
+    let repo_name = db::resolve_repo_name(&repo);
+    let git_repo = match quire.repo(&repo_name) {
+        Ok(r) if r.exists() => r,
+        _ => return StatusCode::NOT_FOUND.into_response(),
+    };
+
+    let path_clone = path.clone();
+    let result = tokio::task::spawn_blocking(move || {
+        let tree_data = read_tree_data(&git_repo, &path_clone)?;
+        let bookmarks = read_bookmarks(&git_repo);
+        let tags = read_tags(&git_repo);
+        Some((tree_data, bookmarks, tags))
+    })
+    .await
+    .unwrap_or(None);
+
+    let (tree_data, bookmarks, tags) = match result {
+        Some(v) => v,
+        None => return StatusCode::NOT_FOUND.into_response(),
+    };
+
+    let crumbs = {
+        let mut c = vec![Crumb::with_href("tree", format!("/{}/tree", repo_display))];
+        if !path.is_empty() {
+            c.push(Crumb::new(
+                path.split('/').last().unwrap_or(&path).to_string(),
+            ));
+        }
+        c
+    };
+
+    let tmpl = TreeTemplate {
+        repo: repo_display,
+        crumbs,
+        bookmarks,
+        tags,
+        active_section: "tree".to_string(),
+        path,
+        bookmark: tree_data.bookmark,
+        sha_short: tree_data.sha_short,
+        entries: tree_data.entries,
+        total_entries: tree_data.total_entries,
+        head_commit: tree_data.head_commit,
+        readme_preview: tree_data.readme_preview,
+    };
+    render(&tmpl)
+}
+
+struct TreeData {
+    bookmark: String,
+    sha_short: String,
+    entries: Vec<TreeEntry>,
+    total_entries: usize,
+    head_commit: Option<PathCommit>,
+    readme_preview: Option<String>,
+}
+
+fn read_tree_data(repo: &Repo, path: &str) -> Option<TreeData> {
+    let bookmark = run_git(repo, &["symbolic-ref", "--short", "HEAD"])
+        .unwrap_or_else(|| "main".to_string());
+
+    let sha_short =
+        run_git(repo, &["rev-parse", "--short", "HEAD"]).unwrap_or_else(|| "unknown".to_string());
+
+    let ls_target = if path.is_empty() {
+        "HEAD".to_string()
+    } else {
+        format!("HEAD:{}", path)
+    };
+
+    let ls_out = run_git(repo, &["ls-tree", &ls_target])?;
+
+    // Parse ls-tree output: "<mode> <type> <sha>\t<name>"
+    let mut raw: Vec<(TreeEntryKind, String)> = Vec::new();
+    for line in ls_out.lines() {
+        let Some((meta, name)) = line.split_once('\t') else {
+            continue;
+        };
+        let mut parts = meta.split_whitespace();
+        let mode = parts.next().unwrap_or("");
+        let obj_type = parts.next().unwrap_or("");
+        let kind = if mode == "160000" || obj_type == "commit" {
+            TreeEntryKind::Submodule
+        } else if obj_type == "tree" {
+            TreeEntryKind::Dir
+        } else {
+            TreeEntryKind::File
+        };
+        raw.push((kind, name.to_string()));
+    }
+
+    // Dirs/submodules first, then files; each group alphabetical.
+    raw.sort_by(|(ak, an), (bk, bn)| {
+        let ao = matches!(ak, TreeEntryKind::Dir | TreeEntryKind::Submodule) as u8;
+        let bo = matches!(bk, TreeEntryKind::Dir | TreeEntryKind::Submodule) as u8;
+        bo.cmp(&ao).then(an.cmp(bn))
+    });
+
+    let total_entries = raw.len();
+
+    let mut entries: Vec<TreeEntry> = Vec::new();
+
+    if !path.is_empty() {
+        entries.push(TreeEntry {
+            kind: TreeEntryKind::Up,
+            name: "..".to_string(),
+            last_msg: String::new(),
+            age: String::new(),
+        });
+    }
+
+    for (kind, name) in raw {
+        let entry_path = if path.is_empty() {
+            name.clone()
+        } else {
+            format!("{}/{}", path, name)
+        };
+        let commit_info =
+            run_git(repo, &["log", "-1", "--format=%s|%ar", "HEAD", "--", &entry_path]);
+        let (last_msg, age) = commit_info
+            .and_then(|s| {
+                let mut it = s.splitn(2, '|');
+                Some((it.next()?.to_string(), it.next()?.to_string()))
+            })
+            .unwrap_or_default();
+        entries.push(TreeEntry { kind, name, last_msg, age });
+    }
+
+    let head_commit = {
+        let fmt = "--format=%h|%s|%ar|%an";
+        let info = if path.is_empty() {
+            run_git(repo, &["log", "-1", fmt, "HEAD"])
+        } else {
+            run_git(repo, &["log", "-1", fmt, "HEAD", "--", path])
+        };
+        info.and_then(|s| {
+            let mut it = s.splitn(4, '|');
+            Some(PathCommit {
+                sha_short: it.next()?.to_string(),
+                description: it.next().unwrap_or("").to_string(),
+                age: it.next().unwrap_or("").to_string(),
+                author: it.next().unwrap_or("").to_string(),
+            })
+        })
+    };
+
+    let readme_preview = {
+        let readme_git_path = if path.is_empty() {
+            "HEAD:README.md".to_string()
+        } else {
+            format!("HEAD:{}/README.md", path)
+        };
+        run_git(repo, &["show", &readme_git_path]).map(|content| {
+            let trimmed = content.trim().to_string();
+            if trimmed.len() > 400 {
+                format!("{}…", &trimmed[..400])
+            } else {
+                trimmed
+            }
+        })
+    };
+
+    Some(TreeData { bookmark, sha_short, entries, total_entries, head_commit, readme_preview })
+}