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
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 %}