steid

@jamesgill /

feat: branches and tags get pages, so the counts stop being dead ends

`plans/ui.md` reserved these two destinations and left the counts as
plain text because a dead link is worse than a number. They exist now, so
the toolbar counts and the About sidebar's Branches and Tags values
become links.

Both sit under the Code tab: they are ways of getting into the code
rather than places of their own, and a tab each for two lists would make
the strip advertise plumbing. A cross-link in each page's heading is the
navigation between them, since the strip cannot say which one you are on.

The branch rows carry a Compare link to a page that does not exist on
this branch yet — the URL is the contract that feature is being built to,
and rendering it now is what makes the two land as one working feature.

A read that runs out of time renders as a page state rather than a 500:
the repository is fine, and only that one question took too long.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GAynmcF54nLJ6MENddvUVC
JamesPatrickGill authored 15 hours agoparent7ba8b8eBrowse filesadac3a7bfd5694034684d003c5f4f1b0c34ef335

4 files changed+427 −12

src/infrastructure/web/browse.rs+13 −8View file
@@ -48,6 +48,7 @@ use crate::{
4848 use super::{
4949 context::{current_actor, memberships, orgs, queries, repos, server_error},
5050 layout::wide,
51+ refs::{branches_url, tags_url},
5152 repo::{Tab, clone_url_for, repo_for, repo_header},
5253 };
5354
@@ -479,7 +480,7 @@ fn rev_label(rev: &str, known: bool) -> String {
479480 /// Hand-rolled rather than pulled in as a dependency: it is the unreserved set from
480481 /// RFC 3986 and nothing else. `keep_slash` is the difference between a path, whose
481482 /// slashes are structure, and a revision, whose slashes are part of its name.
482fn encode(value: &str, keep_slash: bool) -> String {
483+pub(super) fn encode(value: &str, keep_slash: bool) -> String {
483484 let mut encoded = String::with_capacity(value.len());
484485
485486 for byte in value.bytes() {
@@ -549,7 +550,7 @@ pub(super) fn ago(time: SystemTime) -> String {
549550 }
550551
551552 /// The exact time, for the tooltip behind [`ago`].
552fn timestamp(time: SystemTime) -> String {
553+pub(super) fn timestamp(time: SystemTime) -> String {
553554 let seconds = time
554555 .duration_since(UNIX_EPOCH)
555556 .map(|since| since.as_secs() as i64)
@@ -595,10 +596,10 @@ fn civil_from_days(days: i64) -> (i64, u32, u32) {
595596
596597 /// The row above the file list on a repository's landing page.
597598 ///
598/// The revision switcher, then what else the repository has. The counts are **plain
599/// text, not links**: `/{handle}/repos/{name}/branches` and `/tags` are the URLs they
600/// will get, and neither page exists yet — a dead link is worse than a number. The
601/// right of the row is deliberately empty; a code-search box lands there.
599+/// The revision switcher, then what else the repository has. The counts are links now
600+/// that `/branches` and `/tags` exist — they were plain text only because a dead link is
601+/// worse than a number. The right of the row is deliberately empty; a code-search box
602+/// lands there.
602603 #[component]
603604 pub(super) async fn repo_toolbar(
604605 handle: &str,
@@ -619,9 +620,13 @@ pub(super) async fn repo_toolbar(
619620 icon(data: iconify_icon!("feather:git-branch"), attrs: attributes! {
620621 class="size-3.5"
621622 })
622 <span class="font-mono">(counted(refs.branches.len(), "branch", "branches"))</span>
623+ <a href=(branches_url(handle, name)) class="font-mono hover:text-foreground">
624+ (counted(refs.branches.len(), "branch", "branches"))
625+ </a>
623626 "·"
624 <span class="font-mono">(counted(refs.tags.len(), "tag", "tags"))</span>
627+ <a href=(tags_url(handle, name)) class="font-mono hover:text-foreground">
628+ (counted(refs.tags.len(), "tag", "tags"))
629+ </a>
625630 </span>
626631 </div>
627632 }
src/infrastructure/web/mod.rs+1 −0View file
@@ -10,6 +10,7 @@ pub mod markdown;
1010 pub mod pages;
1111 pub mod profile;
1212 pub mod rate_limit;
13+pub mod refs;
1314 pub mod repo;
1415 pub mod repo_settings;
1516 pub mod security_headers;
src/infrastructure/web/refs.rs+394 −0View file
@@ -0,0 +1,394 @@
1+//! The branches and tags pages — `/{handle}/repos/{name}/branches` and `/tags`.
2+//!
3+//! The destinations `plans/ui.md` reserved for the counts beside the revision switcher,
4+//! which were plain text until these existed because a dead link is worse than a
5+//! number.
6+//!
7+//! **One `git` process each**, and one more only when the list comes back empty, to
8+//! tell "nothing pushed yet" from "no tags yet". Everything a row shows — the tip
9+//! commit's subject, its sha, its date, which branch is the default — arrives in that
10+//! one `for-each-ref`; see [`list_branches`](crate::application::list_branches).
11+//!
12+//! Both pages sit under the Code tab. They are ways of getting *into* the code rather
13+//! than a place of their own, and a tab each for two lists would make the strip advertise
14+//! plumbing.
15+
16+use topcoat::{
17+ Result,
18+ context::Cx,
19+ icon::{icon, iconify::iconify_icon},
20+ router::{error::RouterErrorExt, page},
21+ view::{View, attributes, component, view},
22+};
23+
24+use crate::{
25+ application::{Error, RefPage, RepoView, list_branches, list_tags},
26+ components::badge::{BadgeVariant, badge},
27+ domain::{BranchRow, RefName, TagRow},
28+};
29+
30+use super::{
31+ browse::{ago, empty_repo, encode, log_url, timestamp, tree_url},
32+ context::{current_actor, memberships, orgs, queries, repos, server_error},
33+ layout::wide,
34+ repo::{Tab, clone_url_for, repo_for, repo_header},
35+};
36+
37+/// Where the branches page lives. Linked from the toolbar count and the About sidebar.
38+pub(super) fn branches_url(handle: &str, name: &str) -> String {
39+ format!("/{handle}/repos/{name}/branches")
40+}
41+
42+/// Where the tags page lives.
43+pub(super) fn tags_url(handle: &str, name: &str) -> String {
44+ format!("/{handle}/repos/{name}/tags")
45+}
46+
47+/// The comparison between the default branch and another one.
48+///
49+/// **This page does not exist on this branch.** The URL is the contract the compare
50+/// feature is being built to, and rendering the link now is what makes the two land as
51+/// one working feature rather than as a page nobody can reach.
52+///
53+/// The refs are encoded whole, slashes included, so `feature/login` stays one segment —
54+/// the `...` between them is then the only literal separator, exactly as `/-/` is on a
55+/// tree URL.
56+fn compare_url(handle: &str, name: &str, base: &RefName, head: &RefName) -> String {
57+ format!(
58+ "/{handle}/repos/{name}/compare/{}...{}",
59+ encode(base.as_str(), false),
60+ encode(head.as_str(), false)
61+ )
62+}
63+
64+/// Whether a failure was git being too slow rather than git being broken.
65+///
66+/// A timeout is the one read failure that is not a fault: the repository is fine and the
67+/// request asked more of it than one page's budget allows. It gets a page state so the
68+/// visitor is told what happened, rather than a 500 that says the instance is broken.
69+fn timed_out(error: &Error) -> bool {
70+ matches!(error, Error::GitQuery(query) if query.is_timeout())
71+}
72+
73+#[page("/{handle}/repos/{name}/branches")]
74+async fn branches_page(cx: &Cx) -> Result {
75+ let repo = repo_for(cx).await?;
76+ let clone = clone_url_for(cx, &repo);
77+ let other = tags_url(repo.handle.as_str(), repo.name.as_str());
78+
79+ let listed = list_branches(
80+ &repo.handle,
81+ &repo.name,
82+ &current_actor(cx).await?,
83+ &orgs(cx),
84+ &memberships(cx),
85+ &repos(cx),
86+ &queries(cx),
87+ )
88+ .await;
89+
90+ let listed: RefPage<BranchRow> = match listed {
91+ Ok(page) => page.ok_or_not_found()?,
92+ Err(error) if timed_out(&error) => {
93+ return view! {
94+ wide(
95+ repo_header(repo: &repo, rev: "", active: Tab::Code)
96+ heading(
97+ title: "Branches",
98+ count: None,
99+ other: "Tags",
100+ href: other.as_str(),
101+ )
102+ took_too_long(what: "branches")
103+ )
104+ };
105+ }
106+ Err(error) => return Err(server_error(error)),
107+ };
108+
109+ // The default branch is first, because the use case pinned it there. Its name is
110+ // also what a comparison is made *against*, so the whole page reads it from the row
111+ // rather than spending a process on `symbolic-ref`.
112+ let default = listed
113+ .rows
114+ .iter()
115+ .find(|row| row.is_default)
116+ .map(|row| row.name.clone());
117+
118+ let at = default.as_ref().map(RefName::as_str).unwrap_or_default();
119+
120+ view! {
121+ wide(
122+ repo_header(repo: &repo, rev: at, active: Tab::Code)
123+ heading(
124+ title: "Branches",
125+ count: Some(listed.rows.len()),
126+ other: "Tags",
127+ href: other.as_str(),
128+ )
129+
130+ if listed.repo_is_empty {
131+ empty_repo(url: clone.as_str())
132+ } else if listed.rows.is_empty() {
133+ nothing_here("No branches. Every commit in this repository is reachable only by tag or by sha.")
134+ } else {
135+ <ul class="divide-y divide-border overflow-hidden rounded-lg border border-border">
136+ for row in &listed.rows {
137+ <li>
138+ branch_row(repo: &repo, row: row, default: default.as_ref())
139+ </li>
140+ }
141+ </ul>
142+ }
143+ )
144+ }
145+}
146+
147+/// One branch, on one line.
148+///
149+/// The name is the thing being scanned for, so it leads and is the only thing at full
150+/// contrast. Everything after it — subject, sha, date — is the answer to "and what is on
151+/// it", and is muted so a column of thirty branches reads as a list of names.
152+///
153+/// **No ahead/behind count.** It is a `rev-list` per branch, so a repository with twenty
154+/// branches would fork twenty extra processes to decorate one page — the fork-per-fact
155+/// cost [0006](../../../plans/decisions/0006-git-binary-behind-narrow-ports.md) exists to
156+/// bound. It stays out until something keeps git alive between questions.
157+#[component]
158+async fn branch_row(repo: &RepoView, row: &BranchRow, default: Option<&RefName>) -> Result {
159+ let handle = repo.handle.as_str();
160+ let name = repo.name.as_str();
161+ let tree = tree_url(handle, name, &row.name, &crate::domain::RepoPath::root());
162+ let log = log_url(handle, name, row.name.as_str());
163+
164+ view! {
165+ <div class="flex items-center gap-3 px-4 py-2 text-sm">
166+ <a href=(tree.as_str()) class="max-w-[45%] shrink-0 truncate font-mono hover:underline">
167+ (row.name.as_str())
168+ </a>
169+ if row.is_default {
170+ badge(variant: BadgeVariant::Outline, "default")
171+ }
172+
173+ // Hidden on a phone rather than wrapped: the row is a scanning surface, and
174+ // a two-line row breaks the column of names that makes it scannable.
175+ <a
176+ href=(log.as_str())
177+ class="hidden min-w-0 flex-1 truncate text-muted-foreground hover:text-foreground sm:block"
178+ >(&row.summary)</a>
179+
180+ <span class="ml-auto flex shrink-0 items-center gap-3 text-xs text-muted-foreground sm:ml-0">
181+ <code class="font-mono">(row.commit.short())</code>
182+ <span title=(timestamp(row.committed_at))>(ago(row.committed_at))</span>
183+ match default {
184+ // Comparing the default branch with itself is an empty diff, so the
185+ // one row that cannot want this does not offer it.
186+ Some(base) if base != &row.name => <a
187+ href=(compare_url(handle, name, base, &row.name))
188+ class="hover:text-foreground"
189+ >"Compare"</a>,
190+ _ => "",
191+ }
192+ </span>
193+ </div>
194+ }
195+}
196+
197+#[page("/{handle}/repos/{name}/tags")]
198+async fn tags_page(cx: &Cx) -> Result {
199+ let repo = repo_for(cx).await?;
200+ let clone = clone_url_for(cx, &repo);
201+ let other = branches_url(repo.handle.as_str(), repo.name.as_str());
202+
203+ let listed = list_tags(
204+ &repo.handle,
205+ &repo.name,
206+ &current_actor(cx).await?,
207+ &orgs(cx),
208+ &memberships(cx),
209+ &repos(cx),
210+ &queries(cx),
211+ )
212+ .await;
213+
214+ let listed: RefPage<TagRow> = match listed {
215+ Ok(page) => page.ok_or_not_found()?,
216+ Err(error) if timed_out(&error) => {
217+ return view! {
218+ wide(
219+ repo_header(repo: &repo, rev: "", active: Tab::Code)
220+ heading(
221+ title: "Tags",
222+ count: None,
223+ other: "Branches",
224+ href: other.as_str(),
225+ )
226+ took_too_long(what: "tags")
227+ )
228+ };
229+ }
230+ Err(error) => return Err(server_error(error)),
231+ };
232+
233+ view! {
234+ wide(
235+ // The tags page names no revision: it is a list of them.
236+ repo_header(repo: &repo, rev: "", active: Tab::Code)
237+ heading(
238+ title: "Tags",
239+ count: Some(listed.rows.len()),
240+ other: "Branches",
241+ href: other.as_str(),
242+ )
243+
244+ if listed.repo_is_empty {
245+ empty_repo(url: clone.as_str())
246+ } else if listed.rows.is_empty() {
247+ nothing_here("No tags yet. Push one with `git push --tags` to see it here.")
248+ } else {
249+ <ul class="divide-y divide-border overflow-hidden rounded-lg border border-border">
250+ for row in &listed.rows {
251+ <li>tag_row(repo: &repo, row: row)</li>
252+ }
253+ </ul>
254+ }
255+ )
256+ }
257+}
258+
259+/// One tag, on one line.
260+///
261+/// The right of the row is deliberately short: the two archive links — `.tar.gz` and
262+/// `.zip` — land there once an archive endpoint exists, which is the slot `plans/ui.md`
263+/// reserves for them.
264+#[component]
265+async fn tag_row(repo: &RepoView, row: &TagRow) -> Result {
266+ let handle = repo.handle.as_str();
267+ let name = repo.name.as_str();
268+ let tree = tree_url(handle, name, &row.name, &crate::domain::RepoPath::root());
269+ let log = log_url(handle, name, row.name.as_str());
270+
271+ view! {
272+ <div class="flex items-center gap-3 px-4 py-2 text-sm">
273+ <span class="shrink-0 text-muted-foreground">
274+ icon(data: iconify_icon!("feather:tag"), attrs: attributes! { class="size-3.5" })
275+ </span>
276+ <a href=(tree.as_str()) class="max-w-[45%] shrink-0 truncate font-mono hover:underline">
277+ (row.name.as_str())
278+ </a>
279+ // Only an annotated tag carries one. A lightweight tag has no message of its
280+ // own, and showing the commit's subject here would put words in its mouth.
281+ if row.annotated {
282+ badge(variant: BadgeVariant::Outline, "annotated")
283+ }
284+
285+ match &row.message {
286+ Some(message) => <span
287+ class="hidden min-w-0 flex-1 truncate text-muted-foreground sm:block"
288+ >(message)</span>,
289+ None => <span class="hidden flex-1 sm:block"></span>,
290+ }
291+
292+ <span class="ml-auto flex shrink-0 items-center gap-3 text-xs text-muted-foreground sm:ml-0">
293+ // The commit the tag names, peeled through the tag object — the log at
294+ // the tag is where it can be seen in context.
295+ <a href=(log.as_str()) class="font-mono hover:text-foreground">
296+ (row.commit.short())
297+ </a>
298+ <span title=(timestamp(row.created_at))>(ago(row.created_at))</span>
299+ </span>
300+ </div>
301+ }
302+}
303+
304+/// The line above the list: what this page is, how many, and the other one.
305+///
306+/// The cross-link is the whole navigation between the two pages. Both live under the
307+/// Code tab, so the strip cannot say which of them you are on, and having arrived from
308+/// one count the visitor usually wants the other.
309+#[component]
310+async fn heading(title: &str, count: Option<usize>, other: &str, href: &str) -> Result {
311+ view! {
312+ <div class="mb-2 flex items-baseline gap-2.5">
313+ <h2 class="text-sm font-medium">(title)</h2>
314+ match count {
315+ Some(count) => <span class="font-mono text-xs text-muted-foreground">
316+ (count.to_string())
317+ </span>,
318+ None => "",
319+ }
320+ <a href=(href) class="ml-auto inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground">
321+ (other)
322+ icon(data: iconify_icon!("feather:arrow-right"), attrs: attributes! {
323+ class="size-3.5"
324+ })
325+ </a>
326+ </div>
327+ }
328+}
329+
330+/// A repository that has history but nothing of this kind in it.
331+///
332+/// Distinct from an empty repository, which gets the push snippet: this one is not
333+/// missing a setup step, so telling it how to push would be answering a question it did
334+/// not ask.
335+#[component]
336+async fn nothing_here(#[default] child: View) -> Result {
337+ view! {
338+ <p class="rounded-lg border border-border px-4 py-10 text-center text-sm text-muted-foreground">
339+ (child)
340+ </p>
341+ }
342+}
343+
344+/// What a read that ran out of time looks like.
345+///
346+/// A page state rather than a 500: the repository is fine, and the honest thing to say
347+/// is that this one question took too long — with reloading offered, because on a
348+/// repository this large it may well not the second time.
349+#[component]
350+async fn took_too_long(what: &str) -> Result {
351+ view! {
352+ <div class="rounded-lg border border-border px-4 py-10 text-center">
353+ <p class="text-sm text-muted-foreground">
354+ "Listing this repository's " (what) " took too long."
355+ </p>
356+ <p class="mt-1 text-xs text-muted-foreground">
357+ "Nothing is wrong with the repository — reading it simply ran out of time. Try again."
358+ </p>
359+ </div>
360+ }
361+}
362+
363+#[cfg(test)]
364+mod tests {
365+ use super::*;
366+
367+ fn rev(value: &str) -> RefName {
368+ RefName::new(value).expect("valid revision")
369+ }
370+
371+ #[test]
372+ fn the_pages_live_under_the_repository() {
373+ assert_eq!(branches_url("ada", "steid"), "/ada/repos/steid/branches");
374+ assert_eq!(tags_url("ada", "steid"), "/ada/repos/steid/tags");
375+ }
376+
377+ #[test]
378+ fn a_comparison_names_the_default_branch_first() {
379+ assert_eq!(
380+ compare_url("ada", "steid", &rev("main"), &rev("spike")),
381+ "/ada/repos/steid/compare/main...spike"
382+ );
383+ }
384+
385+ #[test]
386+ fn a_refs_slashes_are_encoded_so_the_separator_stays_the_only_one() {
387+ // Otherwise `feature/login` would look like extra path segments, which is the
388+ // same ambiguity `/-/` removes on a tree URL.
389+ assert_eq!(
390+ compare_url("ada", "steid", &rev("main"), &rev("feature/login")),
391+ "/ada/repos/steid/compare/main...feature%2Flogin"
392+ );
393+ }
394+}
src/infrastructure/web/repo.rs+19 −4View file
@@ -51,6 +51,7 @@ use super::{
5151 layout::{narrow, wide},
5252 markdown,
5353 profile::profile_for,
54+ refs::{branches_url, tags_url},
5455 };
5556
5657 /// `{name}` from the path, raw — validation is [`RepoName`]'s job.
@@ -458,7 +459,12 @@ async fn repo_about(repo: &RepoView, rev: &str, facts: Option<&RepoFacts>, clone
458459 </section>
459460
460461 match facts {
461 Some(facts) => repo_stats(facts: facts, pushed: repo.updated_at),
462+ Some(facts) => repo_stats(
463+ handle: handle,
464+ name: name,
465+ facts: facts,
466+ pushed: repo.updated_at,
467+ ),
462468 // With no commits there is nothing true to count, so the row that is still
463469 // true is shown on its own.
464470 None => <dl class="mt-4 space-y-1.5 border-t border-border pt-4 text-xs">
@@ -475,15 +481,24 @@ async fn repo_about(repo: &RepoView, rev: &str, facts: Option<&RepoFacts>, clone
475481 /// A list rather than a row of badges: every value is a different kind of thing, and
476482 /// the label is what makes each one readable at a glance.
477483 #[component]
478async fn repo_stats(facts: &RepoFacts, pushed: SystemTime) -> Result {
484+async fn repo_stats(handle: &str, name: &str, facts: &RepoFacts, pushed: SystemTime) -> Result {
479485 view! {
480486 <dl class="mt-4 space-y-1.5 border-t border-border pt-4 text-xs">
481487 fact(term: "Commits", <span class="font-mono">(facts.commits.to_string())</span>)
488+ // Links now that the pages exist — the sidebar is where a visitor reads
489+ // how much of a repository there is, so it is also where they ask to see it.
482490 fact(
483491 term: "Branches",
484 <span class="font-mono">(facts.refs.branches.len().to_string())</span>
492+ <a href=(branches_url(handle, name)) class="font-mono hover:underline">
493+ (facts.refs.branches.len().to_string())
494+ </a>
495+ )
496+ fact(
497+ term: "Tags",
498+ <a href=(tags_url(handle, name)) class="font-mono hover:underline">
499+ (facts.refs.tags.len().to_string())
500+ </a>
485501 )
486 fact(term: "Tags", <span class="font-mono">(facts.refs.tags.len().to_string())</span>)
487502 match &facts.latest_tag {
488503 Some(tag) => fact(
489504 term: "Latest tag",