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
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 })
+}