Add repo list, commit view, and breadcrumb improvements
- Repo list page at / replaces the plain text index
- Commit view at /<repo>/commit/<sha> with diff, metadata, parents
- Remove 'tree' root crumb from tree breadcrumbs; build proper
  hierarchical path crumbs instead
- Remove redundant path display from tree section nav
- Drop 'quire' wordmark text, keep only the SVG icon
- Point all commit SHA links to the new commit view
- Remove overlapping / route handler from server

Assisted-by: Owl Alpha via pi
change xqruvknuptunrltypqxrlxzlvszvmkqk
commit c68cae740071cb05fa2b4a1d72c14e8f7dce9bde
author Alpha Chen <alpha@kejadlen.dev>
date
parent rusqvnot
diff --git a/quire-server/src/bin/quire/server.rs b/quire-server/src/bin/quire/server.rs
index 0329d3b..7f4f83c 100644
--- a/quire-server/src/bin/quire/server.rs
+++ b/quire-server/src/bin/quire/server.rs
@@ -23,10 +23,6 @@ async fn health() -> &'static str {
     "ok"
 }
 
-async fn index() -> String {
-    format!("quire {}\n", crate::VERSION)
-}
-
 pub async fn run(quire: &Quire, web_routes: axum::Router, api_routes: axum::Router) -> Result<()> {
     let addr = SocketAddr::from(([0, 0, 0, 0], quire.config.port));
 
@@ -64,7 +60,6 @@ pub async fn run(quire: &Quire, web_routes: axum::Router, api_routes: axum::Rout
 
     let app = Router::new()
         .route("/health", get(health))
-        .route("/", get(index))
         .merge(web_routes)
         .nest("/api", api_routes)
         .layer(
diff --git a/quire-server/src/quire/web/handlers/commit.rs b/quire-server/src/quire/web/handlers/commit.rs
new file mode 100644
index 0000000..a9431c7
--- /dev/null
+++ b/quire-server/src/quire/web/handlers/commit.rs
@@ -0,0 +1,122 @@
+//! Handler for the commit detail page.
+
+use axum::extract::State;
+use axum::http::StatusCode;
+use axum::response::IntoResponse;
+use axum::response::Response;
+
+use super::super::templates::{CommitParent, CommitTemplate, Crumb, nav_sections};
+use super::git::RepoView;
+use super::render;
+use crate::Quire;
+use crate::quire::web::paths::CommitPath;
+
+pub async fn commit_view(
+    CommitPath { repo, sha }: CommitPath,
+    State(quire): State<Quire>,
+    auth: super::super::auth::Auth,
+) -> Response {
+    let repo_display = repo.trim_end_matches(".git").to_string();
+    let repo_name = super::super::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 sha_clone = sha.clone();
+    let result = tokio::task::spawn_blocking(move || {
+        let reader = RepoView::new(&git_repo);
+
+        // Resolve the short SHA to a full one.
+        let full_sha = reader
+            .run(&["rev-parse", &sha_clone])
+            .unwrap_or(sha_clone.clone());
+
+        let info = reader.run(&[
+            "log",
+            "-1",
+            "--format=%H%n%P%n%s%n%b%n%an%n%ae%n%at",
+            &full_sha,
+        ])?;
+
+        let mut lines = info.lines();
+        let sha = lines.next()?.to_string();
+        let parents_str = lines.next().unwrap_or("").to_string();
+        let subject = lines.next().unwrap_or("").to_string();
+
+        // Body is everything between subject and the last 3 lines (author, email, timestamp).
+        let remaining: Vec<&str> = lines.collect();
+        let n = remaining.len();
+        if n < 3 {
+            return None;
+        }
+        let author = remaining[n - 3].to_string();
+        let email = remaining[n - 2].to_string();
+        let timestamp_str = remaining[n - 1];
+        let body = if n > 3 {
+            remaining[..n - 3].join("\n")
+        } else {
+            String::new()
+        };
+
+        let timestamp_ms: i64 = timestamp_str.parse().ok().map(|secs: i64| secs * 1000)?;
+
+        let parents: Vec<CommitParent> = parents_str
+            .split_whitespace()
+            .filter(|s| !s.is_empty())
+            .map(|p| CommitParent { sha: p.to_string() })
+            .collect();
+
+        let diff = reader
+            .run(&["log", "-1", "--patch", "--format=", &full_sha])
+            .unwrap_or_default();
+
+        Some((
+            sha,
+            author,
+            email,
+            timestamp_ms,
+            subject,
+            body,
+            parents,
+            diff,
+        ))
+    })
+    .await
+    .unwrap_or(None);
+
+    let (sha, author, email, timestamp_ms, subject, body, parents, diff) = match result {
+        Some(data) => data,
+        None => return StatusCode::NOT_FOUND.into_response(),
+    };
+
+    let sha_short = if sha.len() >= 8 {
+        sha[..8].to_string()
+    } else {
+        sha.clone()
+    };
+
+    let crumbs = vec![Crumb::with_href("log", format!("/{repo_display}/log"))];
+    let tmpl = CommitTemplate {
+        sections: nav_sections(&repo_display, "log", auth.is_authenticated()),
+        repo: repo_display,
+        crumbs,
+        sha: sha.clone(),
+        sha_short,
+        sha_head: sha[..sha.len().min(4)].to_string(),
+        sha_tail: if sha.len() > 4 {
+            sha[4..sha.len().min(8)].to_string()
+        } else {
+            String::new()
+        },
+        author,
+        email,
+        date_relative: super::super::format::format_timestamp_relative(timestamp_ms),
+        date_iso: super::super::format::format_timestamp_iso(timestamp_ms),
+        subject,
+        body,
+        parents,
+        diff,
+    };
+    render(&tmpl)
+}
diff --git a/quire-server/src/quire/web/handlers/mod.rs b/quire-server/src/quire/web/handlers/mod.rs
index c7eb614..33328fb 100644
--- a/quire-server/src/quire/web/handlers/mod.rs
+++ b/quire-server/src/quire/web/handlers/mod.rs
@@ -1,16 +1,20 @@
 //! Route handlers for the web view.
 
 mod ci;
+mod commit;
 mod git;
 mod log_view;
 mod refs;
 mod repo;
+mod repo_list;
 mod tree;
 
 pub use ci::{run_detail, run_list};
+pub use commit::commit_view;
 pub use log_view::log_view;
 pub use refs::{bookmarks_view, tags_view};
 pub use repo::repo_home;
+pub use repo_list::repo_list;
 pub use tree::{tree_view, tree_view_path};
 
 use askama::Template;
diff --git a/quire-server/src/quire/web/handlers/repo_list.rs b/quire-server/src/quire/web/handlers/repo_list.rs
new file mode 100644
index 0000000..ea94657
--- /dev/null
+++ b/quire-server/src/quire/web/handlers/repo_list.rs
@@ -0,0 +1,36 @@
+//! Handler for the repository list (home) page.
+
+use axum::extract::State;
+use axum::response::Response;
+
+use super::super::templates::{ListedRepo, RepoListTemplate};
+use super::git::RepoView;
+use super::render;
+use crate::Quire;
+
+pub async fn repo_list(State(quire): State<Quire>) -> Response {
+    let repos: Vec<ListedRepo> = match quire.repos() {
+        Ok(iter) => iter
+            .map(|repo| {
+                let name = repo.name().to_string().trim_end_matches(".git").to_string();
+                let description = if repo.exists() {
+                    let reader = RepoView::new(&repo);
+                    reader.run(&["log", "-1", "--format=%s"])
+                } else {
+                    None
+                };
+                ListedRepo { name, description }
+            })
+            .collect(),
+        Err(e) => {
+            tracing::error!(
+                error = &e as &(dyn std::error::Error + 'static),
+                "failed to list repos"
+            );
+            vec![]
+        }
+    };
+
+    let tmpl = RepoListTemplate { repos };
+    render(&tmpl)
+}
diff --git a/quire-server/src/quire/web/handlers/tree.rs b/quire-server/src/quire/web/handlers/tree.rs
index f2401e3..9fe374f 100644
--- a/quire-server/src/quire/web/handlers/tree.rs
+++ b/quire-server/src/quire/web/handlers/tree.rs
@@ -115,11 +115,14 @@ struct TreeData {
 }
 
 fn build_tree_crumbs(repo: &str, path: &str) -> Vec<Crumb> {
-    let mut c = vec![Crumb::with_href("tree", format!("/{repo}/tree"))];
-    if !path.is_empty() {
-        c.push(Crumb::new(
-            path.split('/').next_back().unwrap_or(path).to_string(),
-        ));
+    let mut c = vec![];
+    if path.is_empty() {
+        return c;
+    }
+    let segments: Vec<&str> = path.split('/').collect();
+    for (i, seg) in segments.iter().enumerate() {
+        let href = format!("/{}/tree/{}", repo, segments[..=i].join("/"));
+        c.push(Crumb::with_href(*seg, href));
     }
     c
 }
diff --git a/quire-server/src/quire/web/mod.rs b/quire-server/src/quire/web/mod.rs
index d650299..5f822e8 100644
--- a/quire-server/src/quire/web/mod.rs
+++ b/quire-server/src/quire/web/mod.rs
@@ -19,13 +19,14 @@ use axum_extra::routing::RouterExt;
 use crate::{
     Quire,
     quire::web::handlers::{
-        bookmarks_view, config, log_view, repo_home, run_detail, run_list, stylesheet, tags_view,
-        tree_view, tree_view_path,
+        bookmarks_view, commit_view, config, log_view, repo_home, repo_list, run_detail, run_list,
+        stylesheet, tags_view, tree_view, tree_view_path,
     },
 };
 
 pub use paths::{
-    BookmarksPath, LogPath, RepoPath, RunDetailPath, RunListPath, TagsPath, TreePath, TreeRootPath,
+    BookmarksPath, CommitPath, LogPath, RepoPath, RunDetailPath, RunListPath, TagsPath, TreePath,
+    TreeRootPath,
 };
 
 pub mod paths {
@@ -81,6 +82,13 @@ pub mod paths {
     pub struct TagsPath {
         pub repo: String,
     }
+
+    #[derive(TypedPath, Deserialize)]
+    #[typed_path("/{repo}/commit/{sha}")]
+    pub struct CommitPath {
+        pub repo: String,
+        pub sha: String,
+    }
 }
 
 /// Routes that require authentication.
@@ -103,6 +111,8 @@ pub fn public_router(quire: Quire) -> Router {
         .typed_get(log_view)
         .typed_get(bookmarks_view)
         .typed_get(tags_view)
+        .typed_get(commit_view)
         .route("/config", get(config))
+        .route("/", get(repo_list))
         .with_state(quire)
 }
diff --git a/quire-server/src/quire/web/templates.rs b/quire-server/src/quire/web/templates.rs
index 23f1959..43ce791 100644
--- a/quire-server/src/quire/web/templates.rs
+++ b/quire-server/src/quire/web/templates.rs
@@ -272,6 +272,25 @@ impl DetailShEvent {
     }
 }
 
+// ── Repo list ─────────────────────────────────────────────────────
+
+#[derive(Template)]
+#[template(path = "repo_list.html")]
+pub struct RepoListTemplate {
+    pub repos: Vec<ListedRepo>,
+}
+
+impl RepoListTemplate {
+    pub fn version(&self) -> &'static str {
+        pkg_version()
+    }
+}
+
+pub struct ListedRepo {
+    pub name: String,
+    pub description: Option<String>,
+}
+
 // ── Repo Home ──────────────────────────────────────────────────────
 
 #[derive(Template)]
@@ -504,6 +523,53 @@ impl TreeEntry {
     }
 }
 
+// ── Commit view ───────────────────────────────────────────────────
+
+#[derive(Template)]
+#[template(path = "commit.html")]
+pub struct CommitTemplate {
+    pub repo: String,
+    pub crumbs: Vec<Crumb>,
+    pub sections: Vec<SectionLink>,
+    pub sha: String,
+    pub sha_short: String,
+    pub sha_head: String,
+    pub sha_tail: String,
+    pub author: String,
+    pub email: String,
+    pub date_relative: String,
+    pub date_iso: String,
+    pub subject: String,
+    pub body: String,
+    pub parents: Vec<CommitParent>,
+    pub diff: String,
+}
+
+impl CommitTemplate {
+    pub fn version(&self) -> &'static str {
+        pkg_version()
+    }
+}
+
+pub struct CommitParent {
+    pub sha: String,
+}
+
+impl CommitParent {
+    pub fn sha_full(&self) -> &str {
+        &self.sha
+    }
+
+    pub fn sha_head(&self) -> &str {
+        &self.sha[..self.sha.len().min(4)]
+    }
+
+    pub fn sha_tail(&self) -> &str {
+        let start = self.sha.len().min(4);
+        &self.sha[start..self.sha.len().min(8)]
+    }
+}
+
 // ── Commit log ────────────────────────────────────────────────────
 
 #[derive(Template)]
diff --git a/quire-server/templates/_nav.html b/quire-server/templates/_nav.html
index 39238ba..264773b 100644
--- a/quire-server/templates/_nav.html
+++ b/quire-server/templates/_nav.html
@@ -8,7 +8,7 @@
         <line x1="4.5" y1="10" x2="9"    y2="10" stroke="currentColor" stroke-width="0.8"/>
         <circle cx="11" cy="11" r="3" fill="none" stroke="currentColor" stroke-width="0.8" stroke-dasharray="1.2 1.2" opacity="0.35"/>
       </svg>
-      <span class="nav-wordmark-text">quire</span>
+
     </a>
     <span class="sep">/</span>
     <a class="nav-repo" href="/{{ repo }}">{{ repo }}</a>
diff --git a/quire-server/templates/ci/run_detail.html b/quire-server/templates/ci/run_detail.html
index 515dc11..ca8b964 100644
--- a/quire-server/templates/ci/run_detail.html
+++ b/quire-server/templates/ci/run_detail.html
@@ -11,7 +11,7 @@
 <nav class="repo-section-nav">
   {% include "_repo_section_nav.html" %}
   <span class="repo-position">
-    <span class="ci-commit-link">{{ run.sha_short() }}</span>
+    <a class="ci-commit-link" href="/{{ repo }}/commit/{{ run.sha }}">{{ run.sha_short() }}</a>
     <span class="repo-meta-dot">·</span>
     <span>{{ run.branch_short() }}</span>
     <span class="repo-meta-dot">·</span>
diff --git a/quire-server/templates/commit.html b/quire-server/templates/commit.html
new file mode 100644
index 0000000..cb330c2
--- /dev/null
+++ b/quire-server/templates/commit.html
@@ -0,0 +1,71 @@
+{% extends "_base.html" %}
+
+{% block title %}commit · {{ repo }} · {{ sha_short }}{% endblock %}
+
+{% block nav %}
+{% include "_nav.html" %}
+{% endblock %}
+
+{% block fullpage %}
+
+<nav class="repo-section-nav">
+  {% include "_repo_section_nav.html" %}
+  <span class="repo-position">
+    <span class="change-id" title="{{ sha }}">
+      <span class="change-head">{{ sha_head }}</span><span class="change-tail">{{ sha_tail }}</span>
+    </span>
+    <span class="repo-meta-dot">·</span>
+    <span>{{ author }}</span>
+    <span class="repo-meta-dot">·</span>
+    <span><time title="{{ date_iso }}">{{ date_relative }}</time></span>
+  </span>
+</nav>
+
+<div class="repo-body">
+
+  <article class="commit-detail">
+    <div class="commit-message">
+      <div class="commit-subject">{{ subject }}</div>
+      {% if !body.is_empty() %}
+      <pre class="commit-body">{{ body }}</pre>
+      {% endif %}
+    </div>
+
+    <div class="commit-meta-list">
+      <div class="commit-meta-row">
+        <span class="commit-meta-key">commit</span>
+        <span class="commit-meta-val">{{ sha }}</span>
+      </div>
+      <div class="commit-meta-row">
+        <span class="commit-meta-key">author</span>
+        <span class="commit-meta-val">{{ author }} &lt;{{ email }}&gt;</span>
+      </div>
+      <div class="commit-meta-row">
+        <span class="commit-meta-key">date</span>
+        <span class="commit-meta-val"><time title="{{ date_iso }}">{{ date_relative }}</time></span>
+      </div>
+      {% if !parents.is_empty() %}
+      <div class="commit-meta-row">
+        <span class="commit-meta-key">parent{% if parents.len() > 1 %}s{% endif %}</span>
+        <span class="commit-meta-val">
+          {% for p in parents %}
+          <a class="change-id" href="/{{ repo }}/commit/{{ p.sha_full() }}" title="commit {{ p.sha_full() }}">
+            <span class="change-head">{{ p.sha_head() }}</span><span class="change-tail">{{ p.sha_tail() }}</span>
+          </a>
+          {% endfor %}
+        </span>
+      </div>
+      {% endif %}
+    </div>
+
+    {% if !diff.is_empty() %}
+    <pre class="commit-diff">{{ diff }}</pre>
+    {% endif %}
+  </article>
+
+  <aside class="repo-sidebar">
+  </aside>
+
+</div>
+
+{% endblock %}
diff --git a/quire-server/templates/log.html b/quire-server/templates/log.html
index 8f42d77..2cee7fa 100644
--- a/quire-server/templates/log.html
+++ b/quire-server/templates/log.html
@@ -23,10 +23,10 @@
 <div class="log-body">
   {% for change in changes %}
   <div class="log-row">
-    <a class="log-sha" href="/{{ repo }}/log" title="commit {{ change.sha_full() }}">
+    <a class="log-sha" href="/{{ repo }}/commit/{{ change.sha_full() }}" title="commit {{ change.sha_full() }}">
       <span class="change-head">{{ change.change_head() }}</span><span class="change-tail">{{ change.change_tail() }}</span>
     </a>
-    <a class="log-subject" href="/{{ repo }}/log">
+    <a class="log-subject" href="/{{ repo }}/commit/{{ change.sha_full() }}">
       {% if change.description.is_empty() %}<span class="no-desc">(no description set)</span>{% else %}{{ change.description }}{% endif %}
     </a>
     <span class="log-age"><time>{{ change.age }}</time></span>
diff --git a/quire-server/templates/repo_home.html b/quire-server/templates/repo_home.html
index 9b7fe82..8bc2023 100644
--- a/quire-server/templates/repo_home.html
+++ b/quire-server/templates/repo_home.html
@@ -16,7 +16,7 @@
     <span class="bookmark-glyph">※</span>
     <span class="bookmark-name">{{ h.bookmark }}</span>
     <span class="repo-meta-sep">→</span>
-    <a class="change-id" href="/{{ repo }}/log" title="commit {{ h.sha_short() }}">
+    <a class="change-id" href="/{{ repo }}/commit/{{ h.sha }}" title="commit {{ h.sha_short() }}">
       <span class="change-head">{{ h.change_head() }}</span><span class="change-tail">{{ h.change_tail() }}</span>
     </a>
     <span class="commit-id-secondary">{{ h.sha_short() }}</span>
@@ -115,13 +115,15 @@
         {% for ch in recent_changes %}
         <div class="change-mini-row">
           <div class="change-mini-header">
-            <a class="change-id" href="/{{ repo }}/log" title="commit {{ ch.sha_full() }}">
+            <a class="change-id" href="/{{ repo }}/commit/{{ ch.sha_full() }}" title="commit {{ ch.sha_full() }}">
               <span class="change-head">{{ ch.change_head() }}</span><span class="change-tail">{{ ch.change_tail() }}</span>
             </a>
             <span class="change-mini-age">{{ ch.age }}</span>
           </div>
           <div class="change-mini-desc">
+            <a class="change-mini-desc-link" href="/{{ repo }}/commit/{{ ch.sha_full() }}">
             {% if ch.description.is_empty() %}<span class="no-desc">(no description set)</span>{% else %}{{ ch.description }}{% endif %}
+            </a>
           </div>
         </div>
         {% endfor %}
diff --git a/quire-server/templates/repo_list.html b/quire-server/templates/repo_list.html
new file mode 100644
index 0000000..f3f7fbf
--- /dev/null
+++ b/quire-server/templates/repo_list.html
@@ -0,0 +1,42 @@
+{% extends "_base.html" %}
+
+{% block title %}repositories{% endblock %}
+
+{% block nav %}
+<nav class="page-nav">
+  <div class="nav-bar">
+    <a class="nav-wordmark" href="/" aria-label="quire home">
+      <svg class="q-mark" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
+        <rect x="2" y="2" width="12" height="12" rx="1.2" fill="none" stroke="currentColor" stroke-width="1.2"/>
+        <line x1="4.5" y1="6"  x2="11.5" y2="6"  stroke="currentColor" stroke-width="0.8"/>
+        <line x1="4.5" y1="8"  x2="11.5" y2="8"  stroke="currentColor" stroke-width="0.8"/>
+        <line x1="4.5" y1="10" x2="9"    y2="10" stroke="currentColor" stroke-width="0.8"/>
+        <circle cx="11" cy="11" r="3" fill="none" stroke="currentColor" stroke-width="0.8" stroke-dasharray="1.2 1.2" opacity="0.35"/>
+      </svg>
+    </a>
+  </div>
+</nav>
+{% endblock %}
+
+{% block fullpage %}
+
+<div class="repo-list-page">
+  <h1 class="repo-list-heading">Repositories</h1>
+
+  {% if !repos.is_empty() %}
+  <div class="repo-list">
+    {% for r in repos %}
+    <a class="repo-list-item" href="/{{ r.name }}">
+      <span class="repo-list-name">{{ r.name }}</span>
+      {% if let Some(desc) = r.description %}
+      <span class="repo-list-desc">{{ desc }}</span>
+      {% endif %}
+    </a>
+    {% endfor %}
+  </div>
+  {% else %}
+  <p class="repo-list-empty">no repositories yet</p>
+  {% endif %}
+</div>
+
+{% endblock %}
diff --git a/quire-server/templates/tree.html b/quire-server/templates/tree.html
index f093399..eeb9db8 100644
--- a/quire-server/templates/tree.html
+++ b/quire-server/templates/tree.html
@@ -18,10 +18,7 @@
     <span class="change-id" title="{{ sha_short }}">
       <span class="change-head">{{ self.sha_head() }}</span><span class="change-tail">{{ self.sha_tail() }}</span>
     </span>
-    {% if !path.is_empty() %}
-    <span class="repo-meta-dot">·</span>
-    <span class="repo-position-path">{{ path }}</span>
-    {% endif %}
+
   </span>
 </nav>
 
@@ -72,7 +69,7 @@
   {# RIGHT — recent commit log for this ref #}
   <aside class="tree-sidebar">
     {% for change in recent_changes %}
-    <a class="tree-log-item" href="/{{ repo }}/log">
+    <a class="tree-log-item" href="/{{ repo }}/commit/{{ change.sha_full() }}">
       <div class="tree-log-subject">{{ change.description }}</div>
       <div class="tree-log-meta">
         <span class="tree-log-sha">{{ change.change_head() }}{{ change.change_tail() }}</span>