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
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 }} <{{ email }}></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>