Route CI nav visibility through the handler, not the template
Templates render data; auth decisions belong at construction time.

Assisted-by: Claude Sonnet 4.6 via Claude Code
change ttwlkxpovtpkqqkqwmrznonnqpmswtpt
commit 7ecdec634961077bcdf5d256b3fdda4b1d10b9d8
author Alpha Chen <alpha@kejadlen.dev>
date
parent yvpmxkxu
diff --git a/quire-server/src/bin/quire/main.rs b/quire-server/src/bin/quire/main.rs
index 149e36a..18ac4ad 100644
--- a/quire-server/src/bin/quire/main.rs
+++ b/quire-server/src/bin/quire/main.rs
@@ -147,14 +147,17 @@ async fn main() -> Result<()> {
             let web_routes = {
                 let public = quire::quire::web::public_router(quire.clone());
                 let ci = quire::quire::web::ci_router(quire.clone());
-                let ci = if dev {
-                    ci
-                } else {
-                    ci.layer(axum::middleware::from_fn(
-                        quire::quire::web::auth::require_auth,
+                let ci = ci.layer(axum::middleware::from_fn(
+                    quire::quire::web::auth::require_auth,
+                ));
+                let merged = public.merge(ci);
+                if dev {
+                    merged.layer(axum::middleware::from_fn(
+                        quire::quire::web::auth::inject_dev_user,
                     ))
-                };
-                public.merge(ci)
+                } else {
+                    merged
+                }
             };
             let api_routes = quire::quire::web::api::router(quire.clone());
             commands::serve::run(&quire, web_routes, api_routes).await?
diff --git a/quire-server/src/quire/web/auth.rs b/quire-server/src/quire/web/auth.rs
index 01117ae..82f51cf 100644
--- a/quire-server/src/quire/web/auth.rs
+++ b/quire-server/src/quire/web/auth.rs
@@ -1,9 +1,35 @@
-//! Auth middleware for the web view.
+//! Auth middleware and extractor for the web view.
 
+use std::convert::Infallible;
+
+use axum::extract::FromRequestParts;
 use axum::http::StatusCode;
+use axum::http::request::Parts;
 use axum::middleware::Next;
 use axum::response::{IntoResponse, Response};
 
+/// Extractor that resolves to `true` when the `Remote-User` header is present.
+pub struct Auth(pub bool);
+
+impl<S: Send + Sync> FromRequestParts<S> for Auth {
+    type Rejection = Infallible;
+
+    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
+        Ok(Auth(parts.headers.contains_key("Remote-User")))
+    }
+}
+
+/// Dev-only middleware that injects a synthetic `Remote-User` header so the
+/// `Auth` extractor behaves as if a real user is present.
+#[cfg(feature = "dev")]
+pub async fn inject_dev_user(mut request: axum::extract::Request, next: Next) -> Response {
+    request.headers_mut().insert(
+        "Remote-User",
+        axum::http::HeaderValue::from_static("dev"),
+    );
+    next.run(request).await
+}
+
 /// Middleware that rejects unauthenticated requests.
 ///
 /// CI routes require auth per the access matrix in PLAN.md.
diff --git a/quire-server/src/quire/web/handlers/ci.rs b/quire-server/src/quire/web/handlers/ci.rs
index 5d90431..fc815c6 100644
--- a/quire-server/src/quire/web/handlers/ci.rs
+++ b/quire-server/src/quire/web/handlers/ci.rs
@@ -7,6 +7,7 @@ use axum::response::{IntoResponse, Response};
 use super::super::db;
 use super::super::templates::{
     Crumb, DetailJob, DetailRun, DetailShEvent, RunDetailTemplate, RunListRow, RunListTemplate,
+    nav_sections,
 };
 use super::git::RepoView;
 use super::{render, render_error};
@@ -60,12 +61,12 @@ pub async fn run_list(State(quire): State<Quire>, AxumPath(repo): AxumPath<Strin
         .collect();
 
     let tmpl = RunListTemplate {
+        sections: nav_sections(&repo_display, "ci", true),
         repo: repo_display,
         crumbs: vec![],
         runs: template_runs,
         bookmarks,
         tags,
-        active_section: "ci".to_string(),
     };
     render(&tmpl)
 }
@@ -210,6 +211,7 @@ pub async fn run_detail(
         Crumb::new(detail_run.sha_short()),
     ];
     let tmpl = RunDetailTemplate {
+        sections: nav_sections(&repo_display, "ci", true),
         repo: repo_display,
         crumbs,
         run: detail_run,
@@ -217,7 +219,6 @@ pub async fn run_detail(
         quire_ci_log,
         bookmarks,
         tags,
-        active_section: "ci".to_string(),
     };
     render(&tmpl)
 }
diff --git a/quire-server/src/quire/web/handlers/repo.rs b/quire-server/src/quire/web/handlers/repo.rs
index c53d63e..175273c 100644
--- a/quire-server/src/quire/web/handlers/repo.rs
+++ b/quire-server/src/quire/web/handlers/repo.rs
@@ -5,13 +5,18 @@ use axum::http::StatusCode;
 use axum::response::IntoResponse;
 use axum::response::Response;
 
+use super::super::auth::Auth;
 use super::super::db;
-use super::super::templates::{RepoHomeTemplate, RunListRow};
+use super::super::templates::{RepoHomeTemplate, RunListRow, nav_sections};
 use super::git::RepoView;
 use super::render;
 use crate::Quire;
 
-pub async fn repo_home(State(quire): State<Quire>, AxumPath(repo): AxumPath<String>) -> Response {
+pub async fn repo_home(
+    State(quire): State<Quire>,
+    auth: Auth,
+    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) {
@@ -52,6 +57,7 @@ pub async fn repo_home(State(quire): State<Quire>, AxumPath(repo): AxumPath<Stri
             .unwrap_or_default();
 
     let tmpl = RepoHomeTemplate {
+        sections: nav_sections(&repo_display, "overview", auth.0),
         repo: repo_display,
         crumbs: vec![],
         head,
@@ -60,7 +66,6 @@ pub async fn repo_home(State(quire): State<Quire>, AxumPath(repo): AxumPath<Stri
         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
index dea0b20..a633aa0 100644
--- a/quire-server/src/quire/web/handlers/tree.rs
+++ b/quire-server/src/quire/web/handlers/tree.rs
@@ -4,24 +4,30 @@ use axum::extract::{Path as AxumPath, State};
 use axum::http::StatusCode;
 use axum::response::{IntoResponse, Response};
 
+use super::super::auth::Auth;
 use super::super::db;
-use super::super::templates::{Crumb, TreeEntry, TreeEntryKind, TreeTemplate};
+use super::super::templates::{Crumb, TreeEntry, TreeEntryKind, TreeTemplate, nav_sections};
 use super::git::RepoView;
 use super::render;
 use crate::Quire;
 
-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(
+    State(quire): State<Quire>,
+    auth: Auth,
+    AxumPath(repo): AxumPath<String>,
+) -> Response {
+    tree_at_path(quire, repo, String::new(), auth.0).await
 }
 
 pub async fn tree_view_path(
     State(quire): State<Quire>,
+    auth: Auth,
     AxumPath((repo, path)): AxumPath<(String, String)>,
 ) -> Response {
-    tree_at_path(quire, repo, path).await
+    tree_at_path(quire, repo, path, auth.0).await
 }
 
-async fn tree_at_path(quire: Quire, repo: String, path: String) -> Response {
+async fn tree_at_path(quire: Quire, repo: String, path: String, authed: bool) -> 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) {
@@ -57,11 +63,11 @@ async fn tree_at_path(quire: Quire, repo: String, path: String) -> Response {
     };
 
     let tmpl = TreeTemplate {
+        sections: nav_sections(&repo_display, "tree", authed),
         repo: repo_display,
         crumbs,
         bookmarks,
         tags,
-        active_section: "tree".to_string(),
         path,
         bookmark: tree_data.bookmark,
         sha_short: tree_data.sha_short,
diff --git a/quire-server/src/quire/web/templates.rs b/quire-server/src/quire/web/templates.rs
index 20b9c8d..2fa1db5 100644
--- a/quire-server/src/quire/web/templates.rs
+++ b/quire-server/src/quire/web/templates.rs
@@ -9,6 +9,39 @@ fn pkg_version() -> &'static str {
     env!("QUIRE_VERSION")
 }
 
+/// A section nav link in the repo tab bar.
+pub struct SectionLink {
+    pub label: &'static str,
+    pub href: String,
+    pub active: bool,
+}
+
+/// Build the section nav links for a repo page.
+///
+/// CI is included only when `authed` — the decision belongs here, not in the
+/// template.
+pub fn nav_sections(repo: &str, active: &str, authed: bool) -> Vec<SectionLink> {
+    let mut sections = vec![
+        SectionLink { label: "overview", href: format!("/{repo}"), active: active == "overview" },
+        SectionLink { label: "tree", href: format!("/{repo}/tree"), active: active == "tree" },
+        SectionLink { label: "log", 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 {
+            label: "ci",
+            href: format!("/{repo}/ci"),
+            active: active == "ci",
+        });
+    }
+    sections
+}
+
 /// A navigation breadcrumb entry.
 ///
 /// When `href` is `Some`, the crumb renders as a clickable link.
@@ -43,7 +76,7 @@ pub struct RunListTemplate {
     pub runs: Vec<RunListRow>,
     pub bookmarks: Vec<BookmarkRow>,
     pub tags: Vec<TagRow>,
-    pub active_section: String,
+    pub sections: Vec<SectionLink>,
 }
 
 impl RunListTemplate {
@@ -104,7 +137,7 @@ pub struct RunDetailTemplate {
     pub quire_ci_log: String,
     pub bookmarks: Vec<BookmarkRow>,
     pub tags: Vec<TagRow>,
-    pub active_section: String,
+    pub sections: Vec<SectionLink>,
 }
 
 impl RunDetailTemplate {
@@ -246,7 +279,7 @@ pub struct RepoHomeTemplate {
     pub tags: Vec<TagRow>,
     pub recent_runs: Vec<RunListRow>,
     pub recent_changes: Vec<ChangeRow>,
-    pub active_section: String,
+    pub sections: Vec<SectionLink>,
 }
 
 impl RepoHomeTemplate {
@@ -373,7 +406,7 @@ pub struct TreeTemplate {
     pub crumbs: Vec<Crumb>,
     pub bookmarks: Vec<BookmarkRow>,
     pub tags: Vec<TagRow>,
-    pub active_section: String,
+    pub sections: Vec<SectionLink>,
     /// Current directory path relative to repo root ("" = root).
     pub path: String,
     /// Active bookmark name (e.g. "main").
diff --git a/quire-server/static/style.css b/quire-server/static/style.css
index ceea360..0de745e 100644
--- a/quire-server/static/style.css
+++ b/quire-server/static/style.css
@@ -801,6 +801,7 @@ pre code[data-lang] {
   border-right: 1px solid var(--rule);
   min-width: 0;
   padding-top: var(--space-xs);
+  padding-bottom: var(--space-xs);
 }
 
 .tree-row {
@@ -866,7 +867,7 @@ pre code[data-lang] {
 /* Tree sidebar — right column, recent commit log */
 
 .tree-sidebar {
-  padding: var(--space-2xs) var(--space-xl) 0 var(--space-l);
+  padding: var(--space-2xs) var(--space-s) var(--space-2xs) var(--space-s);
   font-family: var(--font-mono);
   font-size: 12.5px;
   min-width: 0;
@@ -966,8 +967,23 @@ pre code[data-lang] {
 
   .tree-sidebar {
     border-top: 1px solid var(--rule);
-    padding: var(--space-s) var(--space-xl) 0;
+    padding: var(--space-s) var(--space-s) var(--space-s);
   }
+
+  .tree-path-crumb,
+  .tree-commit-strip,
+  .tree-footer {
+    padding-left: var(--space-s);
+    padding-right: var(--space-s);
+  }
+
+  .tree-row {
+    grid-template-columns: 18px minmax(0, 1fr) max-content;
+    padding-left: var(--space-s);
+    padding-right: var(--space-s);
+  }
+
+  .tree-msg { display: none; }
 }
 
 /* ── Mobile (≤768px) ───────────────────────────────────────────── */
diff --git a/quire-server/templates/_repo_section_nav.html b/quire-server/templates/_repo_section_nav.html
index f8ada14..bb5d246 100644
--- a/quire-server/templates/_repo_section_nav.html
+++ b/quire-server/templates/_repo_section_nav.html
@@ -1,6 +1,2 @@
-<a class="section-link {% if active_section == "overview" %}section-link--active{% endif %}" href="/{{ repo }}">overview</a>
-<a class="section-link {% if active_section == "tree" %}section-link--active{% endif %}" href="/{{ repo }}/tree">tree</a>
-<a class="section-link {% if active_section == "log" %}section-link--active{% endif %}" href="/{{ repo }}/log">log</a>
-<a class="section-link {% if active_section == "bookmarks" %}section-link--active{% endif %}" href="/{{ repo }}/bookmarks">bookmarks{% if !bookmarks.is_empty() %} <span class="section-count">{{ bookmarks.len() }}</span>{% endif %}</a>
-<a class="section-link {% if active_section == "tags" %}section-link--active{% endif %}" href="/{{ repo }}/tags">tags{% if !tags.is_empty() %} <span class="section-count">{{ tags.len() }}</span>{% endif %}</a>
-<a class="section-link {% if active_section == "ci" %}section-link--active{% endif %}" href="/{{ repo }}/ci">ci</a>
+{% for s in sections %}<a class="section-link{% if s.active %} section-link--active{% endif %}" href="{{ s.href }}">{{ s.label }}</a>
+{% endfor %}
\ No newline at end of file