Add log, bookmarks, and tags pages
Wire up handlers, routes, templates, and path structs for the three
missing pages referenced in the repo section nav. Log shows a compact
one-line-per-commit list. Bookmarks and tags list refs with kind badges
and ages. Remove bookmarks and tags from the section nav — they're
accessible from sidebars on other pages.

Assisted-by: Owl Alpha via pi
change pknoulkyuwpklwtrmsxkrnttrtntqszx
commit 29995da9f1ad4fe8a1c61ac094e171cc02a6bff4
author Alpha Chen <alpha@kejadlen.dev>
date
parent ottukxst
diff --git a/quire-server/src/quire/web/handlers/log_view.rs b/quire-server/src/quire/web/handlers/log_view.rs
new file mode 100644
index 0000000..2e946e5
--- /dev/null
+++ b/quire-server/src/quire/web/handlers/log_view.rs
@@ -0,0 +1,50 @@
+//! Handler for the repository commit log page.
+
+use axum::extract::State;
+use axum::http::StatusCode;
+use axum::response::IntoResponse;
+use axum::response::Response;
+
+use super::super::templates::{Crumb, LogTemplate, nav_sections};
+use super::git::RepoView;
+use super::render;
+use crate::Quire;
+use crate::quire::web::paths::LogPath;
+
+pub async fn log_view(
+    LogPath { repo }: LogPath,
+    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 (changes, bookmark, sha_short) = tokio::task::spawn_blocking(move || {
+        let reader = RepoView::new(&git_repo);
+        let changes = reader.recent_changes();
+        let bookmark = reader
+            .run(&["symbolic-ref", "--short", "HEAD"])
+            .unwrap_or_else(|| "main".to_string());
+        let sha_short = reader
+            .run(&["rev-parse", "--short", "HEAD"])
+            .unwrap_or_else(|| "unknown".to_string());
+        (changes, bookmark, sha_short)
+    })
+    .await
+    .unwrap_or_default();
+
+    let crumbs = vec![Crumb::new("log")];
+    let tmpl = LogTemplate {
+        sections: nav_sections(&repo_display, "log", auth.is_authenticated()),
+        repo: repo_display,
+        crumbs,
+        changes,
+        bookmark,
+        sha_short,
+    };
+    render(&tmpl)
+}
diff --git a/quire-server/src/quire/web/handlers/mod.rs b/quire-server/src/quire/web/handlers/mod.rs
index 05e7cf8..c7eb614 100644
--- a/quire-server/src/quire/web/handlers/mod.rs
+++ b/quire-server/src/quire/web/handlers/mod.rs
@@ -2,10 +2,14 @@
 
 mod ci;
 mod git;
+mod log_view;
+mod refs;
 mod repo;
 mod tree;
 
 pub use ci::{run_detail, run_list};
+pub use log_view::log_view;
+pub use refs::{bookmarks_view, tags_view};
 pub use repo::repo_home;
 pub use tree::{tree_view, tree_view_path};
 
diff --git a/quire-server/src/quire/web/handlers/refs.rs b/quire-server/src/quire/web/handlers/refs.rs
new file mode 100644
index 0000000..aa0da89
--- /dev/null
+++ b/quire-server/src/quire/web/handlers/refs.rs
@@ -0,0 +1,72 @@
+//! Handlers for the bookmarks and tags listing pages.
+
+use axum::extract::State;
+use axum::http::StatusCode;
+use axum::response::IntoResponse;
+use axum::response::Response;
+
+use super::super::templates::{BookmarksTemplate, Crumb, TagsTemplate, nav_sections};
+use super::git::RepoView;
+use super::render;
+use crate::Quire;
+use crate::quire::web::paths::{BookmarksPath, TagsPath};
+
+pub async fn bookmarks_view(
+    BookmarksPath { repo }: BookmarksPath,
+    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 (bookmarks, tags) = tokio::task::spawn_blocking(move || {
+        let reader = RepoView::new(&git_repo);
+        (reader.bookmarks(), reader.tags())
+    })
+    .await
+    .unwrap_or_default();
+
+    let crumbs = vec![Crumb::new("bookmarks")];
+    let tmpl = BookmarksTemplate {
+        sections: nav_sections(&repo_display, "bookmarks", auth.is_authenticated()),
+        repo: repo_display,
+        crumbs,
+        bookmarks,
+        tags,
+    };
+    render(&tmpl)
+}
+
+pub async fn tags_view(
+    TagsPath { repo }: TagsPath,
+    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 (bookmarks, tags) = tokio::task::spawn_blocking(move || {
+        let reader = RepoView::new(&git_repo);
+        (reader.bookmarks(), reader.tags())
+    })
+    .await
+    .unwrap_or_default();
+
+    let crumbs = vec![Crumb::new("tags")];
+    let tmpl = TagsTemplate {
+        sections: nav_sections(&repo_display, "tags", auth.is_authenticated()),
+        repo: repo_display,
+        crumbs,
+        bookmarks,
+        tags,
+    };
+    render(&tmpl)
+}
diff --git a/quire-server/src/quire/web/mod.rs b/quire-server/src/quire/web/mod.rs
index ed8c57c..d650299 100644
--- a/quire-server/src/quire/web/mod.rs
+++ b/quire-server/src/quire/web/mod.rs
@@ -19,11 +19,14 @@ use axum_extra::routing::RouterExt;
 use crate::{
     Quire,
     quire::web::handlers::{
-        config, repo_home, run_detail, run_list, stylesheet, tree_view, tree_view_path,
+        bookmarks_view, config, log_view, repo_home, run_detail, run_list, stylesheet, tags_view,
+        tree_view, tree_view_path,
     },
 };
 
-pub use paths::{RepoPath, RunDetailPath, RunListPath, TreePath, TreeRootPath};
+pub use paths::{
+    BookmarksPath, LogPath, RepoPath, RunDetailPath, RunListPath, TagsPath, TreePath, TreeRootPath,
+};
 
 pub mod paths {
     use axum_extra::routing::TypedPath;
@@ -60,6 +63,24 @@ pub mod paths {
         pub repo: String,
         pub path: String,
     }
+
+    #[derive(TypedPath, Deserialize)]
+    #[typed_path("/{repo}/log")]
+    pub struct LogPath {
+        pub repo: String,
+    }
+
+    #[derive(TypedPath, Deserialize)]
+    #[typed_path("/{repo}/bookmarks")]
+    pub struct BookmarksPath {
+        pub repo: String,
+    }
+
+    #[derive(TypedPath, Deserialize)]
+    #[typed_path("/{repo}/tags")]
+    pub struct TagsPath {
+        pub repo: String,
+    }
 }
 
 /// Routes that require authentication.
@@ -79,6 +100,9 @@ pub fn public_router(quire: Quire) -> Router {
         .typed_get(repo_home)
         .typed_get(tree_view)
         .typed_get(tree_view_path)
+        .typed_get(log_view)
+        .typed_get(bookmarks_view)
+        .typed_get(tags_view)
         .route("/config", get(config))
         .with_state(quire)
 }
diff --git a/quire-server/src/quire/web/templates.rs b/quire-server/src/quire/web/templates.rs
index eb95d86..23f1959 100644
--- a/quire-server/src/quire/web/templates.rs
+++ b/quire-server/src/quire/web/templates.rs
@@ -37,16 +37,6 @@ pub fn nav_sections(repo: &str, active: &str, authed: bool) -> Vec<SectionLink>
             href: format!("/{repo}/log"),
             active: active == "log",
         },
-        SectionLink {
-            label: "bookmarks",
-            href: format!("/{repo}/bookmarks"),
-            active: active == "bookmarks",
-        },
-        SectionLink {
-            label: "tags",
-            href: format!("/{repo}/tags"),
-            active: active == "tags",
-        },
     ];
     if authed {
         sections.push(SectionLink {
@@ -514,6 +504,70 @@ impl TreeEntry {
     }
 }
 
+// ── Commit log ────────────────────────────────────────────────────
+
+#[derive(Template)]
+#[template(path = "log.html")]
+pub struct LogTemplate {
+    pub repo: String,
+    pub crumbs: Vec<Crumb>,
+    pub sections: Vec<SectionLink>,
+    pub changes: Vec<ChangeRow>,
+    pub bookmark: String,
+    pub sha_short: String,
+}
+
+impl LogTemplate {
+    pub fn version(&self) -> &'static str {
+        pkg_version()
+    }
+
+    pub fn sha_head(&self) -> &str {
+        &self.sha_short[..self.sha_short.len().min(4)]
+    }
+
+    pub fn sha_tail(&self) -> &str {
+        let start = self.sha_short.len().min(4);
+        &self.sha_short[start..]
+    }
+}
+
+// ── Bookmarks ────────────────────────────────────────────────────
+
+#[derive(Template)]
+#[template(path = "bookmarks.html")]
+pub struct BookmarksTemplate {
+    pub repo: String,
+    pub crumbs: Vec<Crumb>,
+    pub bookmarks: Vec<BookmarkRow>,
+    pub tags: Vec<TagRow>,
+    pub sections: Vec<SectionLink>,
+}
+
+impl BookmarksTemplate {
+    pub fn version(&self) -> &'static str {
+        pkg_version()
+    }
+}
+
+// ── Tags ─────────────────────────────────────────────────────────
+
+#[derive(Template)]
+#[template(path = "tags.html")]
+pub struct TagsTemplate {
+    pub repo: String,
+    pub crumbs: Vec<Crumb>,
+    pub bookmarks: Vec<BookmarkRow>,
+    pub tags: Vec<TagRow>,
+    pub sections: Vec<SectionLink>,
+}
+
+impl TagsTemplate {
+    pub fn version(&self) -> &'static str {
+        pkg_version()
+    }
+}
+
 // ── Error ──────────────────────────────────────────────────────────
 
 #[derive(Template)]
diff --git a/quire-server/static/style.css b/quire-server/static/style.css
index 9287539..ebe4395 100644
--- a/quire-server/static/style.css
+++ b/quire-server/static/style.css
@@ -1138,6 +1138,52 @@ pre code[data-lang] {
   .tree-msg { display: none; }
 }
 
+/* ── Commit log ────────────────────────────────────────────────── */
+
+.log-body {
+  max-width: 820px;
+  padding-top: var(--space-xs);
+  padding-bottom: var(--space-xs);
+}
+
+.log-row {
+  display: grid;
+  grid-template-columns: auto minmax(0, 1fr) auto;
+  gap: var(--space-xs);
+  padding: var(--space-3xs) var(--space-xl);
+  align-items: baseline;
+  font-family: var(--font-mono);
+  font-size: 13px;
+}
+
+.log-subject {
+  color: var(--ink);
+  text-decoration: none;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.log-subject:hover {
+  color: var(--accent);
+}
+
+.log-sha {
+  text-decoration: none;
+  font-size: 12px;
+}
+
+.log-age {
+  color: var(--mutedFaint);
+  font-size: 12px;
+  white-space: nowrap;
+}
+
+.log-empty {
+  padding: var(--space-l) var(--space-xl);
+  color: var(--muted);
+}
+
 /* ── Mobile (≤768px) ───────────────────────────────────────────── */
 
 @media (max-width: 768px) {
diff --git a/quire-server/templates/bookmarks.html b/quire-server/templates/bookmarks.html
new file mode 100644
index 0000000..dec1f0b
--- /dev/null
+++ b/quire-server/templates/bookmarks.html
@@ -0,0 +1,51 @@
+{% extends "_base.html" %}
+
+{% block title %}bookmarks · {{ repo }}{% endblock %}
+
+{% block nav %}
+{% include "_nav.html" %}
+{% endblock %}
+
+{% block fullpage %}
+
+<nav class="repo-section-nav">
+  {% include "_repo_section_nav.html" %}
+</nav>
+
+<div class="repo-body">
+
+  <article class="ref-list">
+    {% for b in bookmarks %}
+    <div class="ref-row">
+      <span class="bookmark-kind {% if loop.first %}bookmark-kind--trunk{% endif %}">{% if loop.first %}trunk{% else %}local{% endif %}</span>
+      <a class="bookmark-link" href="/{{ repo }}/log">
+        <span class="bookmark-glyph-sm">※</span>{{ b.name }}
+      </a>
+      <span class="ref-sha">{{ b.sha_short }}</span>
+      <span class="ref-age">{{ b.age }}</span>
+    </div>
+    {% else %}
+    <p class="ref-empty">no bookmarks</p>
+    {% endfor %}
+  </article>
+
+  <aside class="repo-sidebar">
+    {% if !tags.is_empty() %}
+    <div class="side-block">
+      <div class="side-block-title">Tags</div>
+      <div class="bookmark-list">
+        {% for t in tags %}
+        <div class="bookmark-row">
+          <a class="bookmark-link" href="/{{ repo }}/tags">{{ t.name }}</a>
+          <span class="bookmark-age">{{ t.age }}</span>
+        </div>
+        {% endfor %}
+      </div>
+      <a class="side-more" href="/{{ repo }}/tags">all tags →</a>
+    </div>
+    {% endif %}
+  </aside>
+
+</div>
+
+{% endblock %}
diff --git a/quire-server/templates/log.html b/quire-server/templates/log.html
new file mode 100644
index 0000000..8f42d77
--- /dev/null
+++ b/quire-server/templates/log.html
@@ -0,0 +1,39 @@
+{% extends "_base.html" %}
+
+{% block title %}log · {{ repo }}{% 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="bookmark-glyph">※</span>
+    <span class="bookmark-name">{{ bookmark }}</span>
+    <span class="repo-meta-sep">→</span>
+    <span class="change-id" title="{{ sha_short }}">
+      <span class="change-head">{{ self.sha_head() }}</span><span class="change-tail">{{ self.sha_tail() }}</span>
+    </span>
+  </span>
+</nav>
+
+<div class="log-body">
+  {% for change in changes %}
+  <div class="log-row">
+    <a class="log-sha" href="/{{ repo }}/log" 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">
+      {% 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>
+  </div>
+  {% else %}
+  <p class="log-empty">no commits yet</p>
+  {% endfor %}
+</div>
+
+{% endblock %}
diff --git a/quire-server/templates/tags.html b/quire-server/templates/tags.html
new file mode 100644
index 0000000..b2680fc
--- /dev/null
+++ b/quire-server/templates/tags.html
@@ -0,0 +1,50 @@
+{% extends "_base.html" %}
+
+{% block title %}tags · {{ repo }}{% endblock %}
+
+{% block nav %}
+{% include "_nav.html" %}
+{% endblock %}
+
+{% block fullpage %}
+
+<nav class="repo-section-nav">
+  {% include "_repo_section_nav.html" %}
+</nav>
+
+<div class="repo-body">
+
+  <article class="ref-list">
+    {% for t in tags %}
+    <div class="ref-row">
+      <a class="bookmark-link" href="/{{ repo }}/log">{{ t.name }}</a>
+      <span class="ref-age">{{ t.age }}</span>
+    </div>
+    {% else %}
+    <p class="ref-empty">no tags</p>
+    {% endfor %}
+  </article>
+
+  <aside class="repo-sidebar">
+    {% if !bookmarks.is_empty() %}
+    <div class="side-block">
+      <div class="side-block-title">Bookmarks</div>
+      <div class="bookmark-list">
+        {% for b in bookmarks %}
+        <div class="bookmark-row">
+          <span class="bookmark-kind {% if loop.first %}bookmark-kind--trunk{% endif %}">{% if loop.first %}trunk{% else %}local{% endif %}</span>
+          <a class="bookmark-link" href="/{{ repo }}/bookmarks">
+            <span class="bookmark-glyph-sm">※</span>{{ b.name }}
+          </a>
+          <span class="bookmark-age">{{ b.age }}</span>
+        </div>
+        {% endfor %}
+      </div>
+      <a class="side-more" href="/{{ repo }}/bookmarks">all bookmarks →</a>
+    </div>
+    {% endif %}
+  </aside>
+
+</div>
+
+{% endblock %}