steid

@jamesgill /

fix: the rows line up, and a phone keeps the names rather than the extras

Looking at the built page: relative dates vary in width, so the sha
beside them zig-zagged down the list, and the default branch's row — the
one with no Compare link — pulled its whole metadata group right. Fixed
widths for the date and an empty compare slot on that row settle both.

At 375px those reserved widths were what pushed the row off the side of
the container, so from below `sm` the commit subject and the Compare link
are gone and the widths are not reserved. The row keeps what identifies a
branch and drops the secondary action rather than wrapping: a two-line
row destroys the column of names that makes the page scannable.

Handover notes for the orchestrator rather than edits to plans/, since
five agents appending to current.md at once would conflict every time.

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

2 files changed+122 −12

plans/handover-refs.md+93 −0View file
@@ -0,0 +1,93 @@
1+# Handover — `refs` (branches and tags pages)
2+
3+Written for the orchestrator to fold into `progress.md`, `current.md` and `ui.md` at
4+merge time. Branch `feat/refs-pages`.
5+
6+## What shipped
7+
8+`GET /{handle}/repos/{name}/branches` and `GET /{handle}/repos/{name}/tags`, plus the
9+wiring that turns the counts beside the revision switcher and the About sidebar's
10+Branches and Tags values into links to them.
11+
12+- `domain::{BranchRow, TagRow}` — a ref plus what one row of a page shows.
13+- `GitQuery::branches()` / `GitQuery::tags()`, in `DiskGitQuery` and `InMemoryGitQuery`.
14+ `list_refs` is untouched.
15+- `application::browse::{list_branches, list_tags}` returning `RefPage<Row>`, and the
16+ private `pin_default`.
17+- `infrastructure::web::refs` — the two pages, `branches_url`, `tags_url`, `compare_url`.
18+
19+## Decisions worth not rediscovering (progress.md)
20+
21+- **`%(HEAD)` is why the page is one process.** `for-each-ref` marks the branch `HEAD`
22+ names with `*`, so the default branch arrives in the same output as the branches
23+ themselves. Nothing calls `symbolic-ref` or `default_branch` on the happy path, and
24+ the compare link's base ref is read out of the pinned row.
25+- **Empty fields must be kept when splitting `for-each-ref` output.** `parse_latest_tag`
26+ can afford to filter empty fields because it reads two required ones; a multi-field
27+ record cannot. A lightweight tag's `%(*objectname)` is empty, and filtering shifts its
28+ date into the commit-id slot. `ref_fields` keeps them and drops only the trailing
29+ remainder holding git's own record-ending newline. There is a test for exactly this.
30+- **Ordering is git's, pinning is the use case's.** `--sort=-committerdate` /
31+ `--sort=-creatordate` cost nothing extra in the process that was being run anyway,
32+ and re-sorting in Rust would give the same answer. Pinning the default branch is a
33+ display decision — it is the branch a visitor came for and it is *not* reliably the
34+ most recently pushed — so it lives in `list_branches` as a stable partition, which is
35+ what keeps the date order beneath it intact.
36+- **A lightweight tag's `%(contents:subject)` is the commit's subject, not the tag's.**
37+ It is only read when `%(objecttype)` says `tag`; otherwise the page would attribute
38+ the commit's words to a tag that has none. `annotated` is its own field rather than
39+ `message.is_some()`, because an annotated tag may carry an empty message.
40+- **An annotated tag's `objectname` is the tag object.** The row reports the peeled
41+ `%(*objectname)` so the sha it shows is something a visitor can browse.
42+- **`RefPage.repo_is_empty` costs one extra process, only when the list is empty.**
43+ "No tags yet" and "nothing pushed yet" are different sentences and only the second
44+ wants the push snippet, and a tag list alone cannot tell them apart. `default_branch`
45+ answers it, and is asked nowhere else on these pages.
46+- **A timeout renders as a page state, not a 500.** `refs.rs::timed_out` matches
47+ `Error::GitQuery(_)` with `is_timeout()`. This is the **first** place in the web layer
48+ that distinguishes a timeout from a fault; every other page still maps both to
49+ `server_error`. If a second page wants it, the helper should move to `web::context`.
50+
51+## Holes and shortcuts (current.md)
52+
53+- **No ahead/behind counts on the branches page**, deliberately and by brief. It is a
54+ `rev-list` per branch — twenty branches would fork twenty extra processes to decorate
55+ one page. It stays out until something keeps git alive between questions. Commented in
56+ both `port.rs` and `refs.rs`.
57+- **The Compare link points at a page this branch does not contain.** The contract is
58+ `/{handle}/repos/{name}/compare/{base}...{head}`, each ref percent-encoded whole so a
59+ slash in a name stays inside its segment (`main...feature%2Flogin`). It is rendered on
60+ every non-default branch row and there are unit tests on the shape. **If the commits
61+ agent's compare route differs, `compare_url` in `refs.rs` is the one place to change.**
62+- **The tags page leaves the right-hand side short on purpose** for the archive agent's
63+ two download links; nothing is rendered there.
64+- **`web::browse::{encode, timestamp}` were widened to `pub(super)`.** No other change to
65+ those functions.
66+- **`repo_stats` in `repo.rs` gained `handle` and `name` parameters** so the sidebar's
67+ counts can be links. One call site.
68+- **A repository with commits but no branches** (tags pushed alone) says "No branches",
69+ and its tags page still works. Real but exotic; not tested against a live fixture.
70+- **Neither page is paginated.** A repository with a thousand branches renders a thousand
71+ rows. One process still, but a large page.
72+
73+## UX notes (ui.md)
74+
75+- The entry-point table's "Branches, Tags → the counts beside the switcher become links"
76+ row is **done**, and so is the note under *The repository page* saying the counts are
77+ plain text because the pages do not exist. Both need updating.
78+- **Both pages sit under the Code tab.** They are ways into the code rather than places
79+ of their own; a tab each for two lists would make the strip advertise plumbing. The
80+ cost is that the strip cannot say which of them you are on, which is why each page's
81+ heading carries a cross-link to the other (`Tags →` / `Branches →`).
82+- **The metadata columns are fixed-width and right-aligned** from `sm` up — a `w-24` date
83+ and a `w-16` compare slot, the latter kept empty on the default branch's row. Without
84+ them the sha zig-zags down the list, because relative dates vary in width and the one
85+ row with no Compare link pulls its whole group right. This is worth keeping in mind for
86+ any other list of rows with trailing metadata.
87+- **Below `sm` the commit subject and the Compare link are dropped**, not wrapped. The row
88+ is a scanning surface and a two-line row destroys the column of names that makes it
89+ scannable; at 375px the reserved widths were what pushed the row off the screen.
90+- Three empty states, deliberately distinct: an empty repository gets the landing page's
91+ push snippet, a repository with history but no tags gets a one-line "No tags yet" with
92+ `git push --tags` in mono, and a timeout gets its own panel saying the repository is
93+ fine.
src/infrastructure/web/refs.rs+29 −12View file
@@ -179,16 +179,27 @@ async fn branch_row(repo: &RepoView, row: &BranchRow, default: Option<&RefName>)
179179
180180 <span class="ml-auto flex shrink-0 items-center gap-3 text-xs text-muted-foreground sm:ml-0">
181181 <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 }
182+ // A fixed, right-aligned column: relative dates vary in width, and
183+ // without one the sha beside them zig-zags down a list of thirty rows.
184+ // Only from `sm` up — on a phone the reserved width is what pushes the
185+ // row off the side of the screen.
186+ <span class="sm:w-24 sm:text-right" title=(timestamp(row.committed_at))>
187+ (ago(row.committed_at))
188+ </span>
189+ // A fixed slot, kept even on the default branch's row: comparing a
190+ // branch with itself is an empty diff so that one row has no link, and
191+ // without the slot its sha and date would sit out of line with the rest.
192+ // Gone on a phone, with the commit subject: the row keeps what identifies
193+ // a branch and drops the secondary action rather than overflowing.
194+ <span class="hidden w-16 text-right sm:block">
195+ match default {
196+ Some(base) if base != &row.name => <a
197+ href=(compare_url(handle, name, base, &row.name))
198+ class="hover:text-foreground"
199+ >"Compare"</a>,
200+ _ => "",
201+ }
202+ </span>
192203 </span>
193204 </div>
194205 }
@@ -244,7 +255,11 @@ async fn tags_page(cx: &Cx) -> Result {
244255 if listed.repo_is_empty {
245256 empty_repo(url: clone.as_str())
246257 } else if listed.rows.is_empty() {
247 nothing_here("No tags yet. Push one with `git push --tags` to see it here.")
258+ nothing_here(
259+ "No tags yet. Push one with "
260+ <code class="font-mono text-foreground">"git push --tags"</code>
261+ " to see it here."
262+ )
248263 } else {
249264 <ul class="divide-y divide-border overflow-hidden rounded-lg border border-border">
250265 for row in &listed.rows {
@@ -295,7 +310,9 @@ async fn tag_row(repo: &RepoView, row: &TagRow) -> Result {
295310 <a href=(log.as_str()) class="font-mono hover:text-foreground">
296311 (row.commit.short())
297312 </a>
298 <span title=(timestamp(row.created_at))>(ago(row.created_at))</span>
313+ <span class="sm:w-24 sm:text-right" title=(timestamp(row.created_at))>
314+ (ago(row.created_at))
315+ </span>
299316 </span>
300317 </div>
301318 }