web: link configured mirrors in the repo sidebar
Mirrors were only discoverable by reading .quire/config.fnl at the
latest push; the sidebar makes the remotes visible where the clone
target already is.
Assisted-by: GLM-5.3 via pi
diff --git a/docs/STYLE_GUIDE.md b/docs/STYLE_GUIDE.md
index 6c15487..c19a820 100644
--- a/docs/STYLE_GUIDE.md
+++ b/docs/STYLE_GUIDE.md
@@ -338,6 +338,21 @@ Mono, 12.5px:
- Dot: 6×6, `border-radius: 3`. `ok` for pass, `bad` for fail.
- Change column shows the first 4 chars of the change-id in `accent`.
+### Mirror list (sidebar)
+
+Mono, 12.5px, line-height 1.8, one link per configured mirror remote:
+
+```
+[github.com/u/r ]
+[gitea.example/u/r ]
+```
+
+- Label is the mirror URL with the scheme and trailing `.git` stripped;
+ the full URL is the `href` and hover `title`.
+- `ink` at rest, `accent` on hover, no underline.
+- Long labels ellipsize; the block is hidden entirely when no mirrors
+ are configured.
+
### ChangeList row
Grid: `3px 74px 1fr auto auto`, gap 16, padding `6px 56px 6px 53px`.
diff --git a/quire-server/src/quire/web/handlers/git.rs b/quire-server/src/quire/web/handlers/git.rs
index 169cadd..6fc56c5 100644
--- a/quire-server/src/quire/web/handlers/git.rs
+++ b/quire-server/src/quire/web/handlers/git.rs
@@ -1,6 +1,6 @@
//! Shared git-reading helpers used by multiple handlers.
-use super::super::templates::{ChangeRow, CommitId, HeadInfo};
+use super::super::templates::{ChangeRow, CommitId, HeadInfo, MirrorLink};
use crate::quire::Repo;
pub(super) type GitData = (Option<HeadInfo>, Option<String>, Vec<ChangeRow>);
@@ -31,6 +31,32 @@ impl<'a> RepoView<'a> {
(self.head_info(), self.readme(), self.recent_changes(repo))
}
+ /// Mirror remotes configured at HEAD, as sidebar links.
+ ///
+ /// An unreadable or unparsable config yields no links — a broken
+ /// config.fnl must not take the repo page down with it — but is logged
+ /// so operators see it.
+ pub(super) fn mirrors(&self, head: Option<&HeadInfo>) -> Vec<MirrorLink> {
+ let Some(head) = head else {
+ return Vec::new();
+ };
+ match self.repo.repo_config(&head.id.sha) {
+ Ok(config) => {
+ let mut links: Vec<MirrorLink> =
+ config.mirrors.into_keys().map(MirrorLink::new).collect();
+ links.sort_by(|a, b| a.label.cmp(&b.label));
+ links
+ }
+ Err(error) => {
+ tracing::warn!(
+ error = &error as &(dyn std::error::Error + 'static),
+ "reading repo config for mirrors failed",
+ );
+ Vec::new()
+ }
+ }
+ }
+
pub(super) fn head_info(&self) -> Option<HeadInfo> {
let bookmark = self
.run(&["symbolic-ref", "--short", "HEAD"])
diff --git a/quire-server/src/quire/web/handlers/mod.rs b/quire-server/src/quire/web/handlers/mod.rs
index 3f71675..4f5e25a 100644
--- a/quire-server/src/quire/web/handlers/mod.rs
+++ b/quire-server/src/quire/web/handlers/mod.rs
@@ -105,7 +105,11 @@ mod tests {
git_dir(&["init", "-b", "main"]);
git_dir(&["config", "user.email", "test@example.com"]);
git_dir(&["config", "user.name", "Test"]);
- fs_err::write(workdir.join(name), content).expect("write file");
+ let file = workdir.join(name);
+ if let Some(parent) = file.parent() {
+ fs_err::create_dir_all(parent).expect("mkdir parent");
+ }
+ fs_err::write(&file, content).expect("write file");
git_dir(&["add", "."]);
git_dir(&["commit", "-m", "seed"]);
git_dir(&["push", bare.to_str().expect("utf-8 path"), "main"]);
@@ -222,6 +226,65 @@ mod tests {
assert_eq!(resp.headers()[axum::http::header::LOCATION], "/example");
}
+ #[tokio::test]
+ async fn repo_home_lists_configured_mirrors() {
+ let env = TestEnv::new();
+ env.populate_repo(
+ ".quire/config.fnl",
+ r#"{:mirrors {"https://github.com/u/r.git" :github-mirror
+ "https://gitea.example/u/r.git" :gitea-mirror}}"#,
+ );
+ let resp = env
+ .app()
+ .oneshot(
+ Request::builder()
+ .uri("/example")
+ .body(Body::empty())
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+ assert_eq!(resp.status(), StatusCode::OK);
+ let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
+ .await
+ .expect("read body");
+ let html = String::from_utf8_lossy(&body);
+ assert!(
+ html.contains(r#"href="https://github.com/u/r.git""#),
+ "missing github mirror link"
+ );
+ assert!(
+ html.contains(">github.com/u/r</a>"),
+ "missing trimmed github mirror label"
+ );
+ assert!(
+ html.contains(">gitea.example/u/r</a>"),
+ "missing gitea mirror label"
+ );
+ }
+
+ #[tokio::test]
+ async fn repo_home_hides_mirrors_block_without_config() {
+ let env = TestEnv::new();
+ env.populate_repo("README.md", "# example\n");
+ let resp = env
+ .app()
+ .oneshot(
+ Request::builder()
+ .uri("/example")
+ .body(Body::empty())
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+ assert_eq!(resp.status(), StatusCode::OK);
+ let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
+ .await
+ .expect("read body");
+ let html = String::from_utf8_lossy(&body);
+ assert!(!html.contains(">Mirrors<"), "mirrors block rendered");
+ }
+
#[tokio::test]
async fn run_list_returns_ok_for_known_repo() {
let env = TestEnv::new();
diff --git a/quire-server/src/quire/web/handlers/repo.rs b/quire-server/src/quire/web/handlers/repo.rs
index 6bfe078..6ba7092 100644
--- a/quire-server/src/quire/web/handlers/repo.rs
+++ b/quire-server/src/quire/web/handlers/repo.rs
@@ -51,8 +51,13 @@ pub async fn repo_home(
};
let rd = repo_display.clone();
- let (head, readme_html, recent_changes) =
- tokio::task::spawn_blocking(move || RepoView::new(&git_repo).read_all(&rd)).await?;
+ let (head, readme_html, recent_changes, mirrors) = tokio::task::spawn_blocking(move || {
+ let view = RepoView::new(&git_repo);
+ let (head, readme_html, recent_changes) = view.read_all(&rd);
+ let mirrors = view.mirrors(head.as_ref());
+ (head, readme_html, recent_changes, mirrors)
+ })
+ .await?;
let tmpl = RepoHomeTemplate {
sections: nav_sections(&repo_display, "overview", is_authed),
@@ -63,6 +68,7 @@ pub async fn repo_home(
recent_runs,
recent_changes,
clone_host: quire.config.host.clone(),
+ mirrors,
};
Ok(render(&tmpl))
}
diff --git a/quire-server/src/quire/web/templates.rs b/quire-server/src/quire/web/templates.rs
index e3b9aea..fc58143 100644
--- a/quire-server/src/quire/web/templates.rs
+++ b/quire-server/src/quire/web/templates.rs
@@ -343,6 +343,7 @@ pub struct RepoHomeTemplate {
pub recent_changes: Vec<ChangeRow>,
pub sections: Vec<SectionLink>,
pub clone_host: String,
+ pub mirrors: Vec<MirrorLink>,
}
impl RepoHomeTemplate {
@@ -383,6 +384,28 @@ pub struct ChangeRow {
pub commit_url: String,
}
+/// A mirror remote linked from the repo sidebar.
+pub struct MirrorLink {
+ /// The configured push URL, used as the link target.
+ pub url: String,
+ /// Display form: scheme and trailing `.git` stripped.
+ pub label: String,
+}
+
+impl MirrorLink {
+ pub fn new(url: String) -> Self {
+ let stripped = url
+ .strip_prefix("https://")
+ .or_else(|| url.strip_prefix("http://"))
+ .unwrap_or(&url);
+ let label = stripped
+ .trim_end_matches(".git")
+ .trim_end_matches('/')
+ .to_owned();
+ Self { url, label }
+ }
+}
+
// ── Config ─────────────────────────────────────────────────────────
#[derive(Template)]
diff --git a/quire-server/static/style.css b/quire-server/static/style.css
index 8ce9285..536f916 100644
--- a/quire-server/static/style.css
+++ b/quire-server/static/style.css
@@ -487,6 +487,20 @@ html.dark {
margin-bottom: 4px;
}
+/* Mirror block */
+.mirror-list { line-height: 1.8; }
+
+.mirror-link {
+ display: block;
+ color: var(--ink);
+ text-decoration: none;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.mirror-link:hover { color: var(--accent); }
+
/* Bookmark list */
.bookmark-list { line-height: 1.8; }
diff --git a/quire-server/templates/repo_home.html b/quire-server/templates/repo_home.html
index 9fc8c2c..5ee40ce 100644
--- a/quire-server/templates/repo_home.html
+++ b/quire-server/templates/repo_home.html
@@ -53,6 +53,17 @@
<div class="clone-url">{{ clone_url() }}</div>
</div>
+ {% if !mirrors.is_empty() %}
+ <div class="side-block">
+ <div class="side-block-title">Mirrors</div>
+ <div class="mirror-list">
+ {% for m in mirrors %}
+ <a class="mirror-link" href="{{ m.url }}" title="{{ m.url }}">{{ m.label }}</a>
+ {% endfor %}
+ </div>
+ </div>
+ {% endif %}
+
{% if !recent_runs.is_empty() %}
<div class="side-block">
<div class="side-block-title">CI</div>