steid

@jamesgill /

feat: a repository is one place, and its landing page says what it is

`/{handle}/repos/{name}` becomes two columns: the file list and README on the
left, an About sidebar on the right. Every other repository page — tree, blob,
log — picks up the same header and Code/Commits tab strip, so a repository reads
as one place rather than as three pages that happen to share a URL prefix.

The description now lives only in the sidebar. Two homes for it makes the header
tallest on exactly the repositories with the most to say, which pushes the code
below the fold on the pages that deserve it least. GitHub reached the same
answer; the reasoning is written down in ui.md rather than copied.

The sidebar is the portfolio pitch, which is why it exists at all: a visitor
arriving from a profile reads "what is this, may I use it, how much of it is
there" before they read any code. Hairlines between its sections, never boxed
cards — the rule the profile page settled on.

The slots matter as much as what fills them, since the next wave of features
lands in this frame: the tab strip takes the active tab so a new tab is one
line, the branch and tag counts are the text that becomes the /branches and
/tags links, the toolbar's right side is empty for a search box, and two
download links fit under the clone URL. ui.md carries the whole map.

Costs, measured with a logging `git` shim on PATH rather than counted by hand:
**15 subprocesses** for a repository with a README and a licence, 10 without,
1 for an empty one — up from 7. Accepted; the fix is 0006's kept-alive
`cat-file --batch`, not a smaller sidebar.

Two things found on the way. A `#[component]`'s name is a unit struct in module
scope and the copied-in registry components share that scope, so a parameter
called `label` silently parses as the `label` component — renamed to `term`.
And `bg-muted` is not a token, so three `<pre>` blocks have been rendering with
no background since they were written; the two on this page are fixed.

Light mode was looked at for the first time on these pages, by flipping the
layout's hardcoded `class="dark"` and rebuilding. Nothing was wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqTELeRD57xF54euni5VuH
JamesPatrickGill authored 16 hours agoparentdd5b600Browse files0aca94e8397dee9d96aa03a148b2e8481ab522cb

5 files changed+556 −165

plans/current.md+32 −3View file
@@ -165,15 +165,39 @@ instance, and Steid's own source is pushed to it and browsable there.
165165 constraint, but surfaces as an opaque storage error rather than "name taken".
166166 - **Bare repos created on macOS carry `ignorecase = true`.** A migration gotcha if the
167167 data directory ever moves to Linux.
168- **Light mode is still untested**, and now there is much more surface to get it wrong
169 on — the file tree, the blob view and the log all shipped without anyone looking at
170 them in light mode.
168+- ~~**Light mode is still untested**~~ — **looked at 2026-09-05** on the repository
169+ landing page, a tree and a blob, by temporarily flipping the layout's `class="dark"`
170+ and screenshotting. Nothing was wrong: every colour on these pages already comes from
171+ a token. The profile, the settings pages and the log have still not been checked, and
172+ **there is still no way for a visitor to choose** — the layout hardcodes `dark`.
171173 - **Submodule rendering was never seen**, only compiled: no fixture contained one.
172174 - **The `/log` page's switcher opens with nothing marked current** when no revision is
173175 in the URL, because `repo_log` still does not report the revision it resolved. Now more
174176 visible than before, since there is a switcher to look wrong.
175177 - **Task-list items keep their bullet** and footnotes render in place rather than
176178 collected at the end. Both cosmetic.
179+- **The landing page now makes 15 `git` processes** for a repository with a README and
180+ a licence (10 without, 1 for an empty one), up from 7. Five are the About sidebar's
181+ and run concurrently, so the *latency* is roughly one call — but it is fifteen forks
182+ per view, and this is now the most expensive page in Steid. The fix is 0006's
183+ kept-alive `cat-file --batch`, not trimming the sidebar.
184+- **`count_commits` costs two processes, not one.** It resolves the revision before
185+ running `rev-list --count`, because `rev-list` is fatal on an empty repository and on
186+ an unknown branch, and this module's rule is that a non-zero exit from git is always a
187+ real fault. `--ignore-missing` was tried: it covers a bad object id, not a bad
188+ revision *name*.
189+- **`bg-muted` is not a token and never was.** Three `<pre>` blocks used it, so they
190+ have been rendering with no background at all — silently, exactly as `ui.md` warns
191+ about classes Tailwind never ships. Fixed on the clone block and the empty-repository
192+ push snippet; **`token.rs`'s new-token block still has it** and was left alone as
193+ outside this step.
194+- **The licence sniffer reads 1 KiB, not the 200 bytes first sketched.** BSD-2 and
195+ BSD-3 differ only by a third clause about 900 bytes in. `LICENSE-MIT` and other
196+ suffixed spellings are not detected: the filename list is deliberately short, because
197+ every extra candidate is another speculative blob read.
198+- **Nothing links to a commit by its sha yet.** The latest-commit bar's sha points at
199+ the log, which is the only page that shows the commit in context. It becomes a link
200+ to the commit page the moment there is one.
177201 - **A per-file last-commit column is still absent**, deliberately — see
178202 [0006](decisions/0006-git-binary-behind-narrow-ports.md#amendment--20260829-the-milestone-5-read-path).
179203 Wanting it is the trigger to move to a kept-alive `cat-file --batch`, not to reopen
@@ -208,6 +232,11 @@ run several agents at once and the grouping is what decides what can run togethe
208232 browsing feature appends a method), `git_query.rs`, the repo sub-nav, and `plans/`
209233 itself. New tables touch `sqlite.rs` + `in_memory.rs` and should merge one at a time.
210234
235+**Done from section 1:** the **two-column repository landing page** — a shared header
236+and tab strip on every repository page, an About sidebar, a latest-commit bar, and
237+branch/tag counts. See [ui.md](ui.md#the-repository-page) for the layout and the
238+entry-point map, and [progress.md](progress.md) for what it cost.
239+
211240 1. **Read-only browsing** (no DB; parallel-safe with each other): commit page with
212241 diff · branches and tags pages · compare view (merge-base) · archive download ·
213242 syntax highlighting · blame · per-file last-commit column (blocked on the
plans/progress.md+53 −1View file
@@ -2,7 +2,7 @@
22
33 ## This attempt (#3, Topcoat)
44
5448 tests. Active milestone in [current.md](current.md).
5+467 tests. Active milestone in [current.md](current.md).
66
77 ### Milestone 0 — Skeleton · done
88
@@ -687,6 +687,58 @@ browser across visitor and owner states, including sign-out.
687687 user row). Fine over local SQLite; worth remembering if page queries are ever
688688 counted.
689689
690+### The repository landing page · done (2026-09-05)
691+
692+`/{handle}/repos/{name}` became two columns — the file list and README on the left, an
693+About sidebar on the right — and every repository page (tree, blob, log) picked up the
694+same header and `Code` / `Commits` tab strip. New: a toolbar row carrying the revision
695+switcher and branch/tag counts, a latest-commit bar, an About block with the
696+description and detected licence, a facts list (commits, branches, tags, latest tag,
697+pushed), and the clone URL moved into the sidebar.
698+
699+The layout and the map of where the next features slot in are in
700+[ui.md](ui.md#the-repository-page). What is here is what the next session should not
701+have to rediscover.
702+
703+**Verified** with the app running from a worktree on port 3100: this repository's own
704+history pushed into it with two annotated tags and three branches, plus an empty
705+repository and one with no README and no licence. Screenshots taken headless at 1280px
706+and 800px, in both colour schemes, of the landing page, the empty and sparse states, a
707+tree subpath, a blob and the log; the owner's view (with the Settings button) checked
708+signed in. `cargo test` 467 passing, clippy clean.
709+
710+#### Decisions worth remembering
711+
712+- **The subprocess count was measured, not estimated.** A `git` shim on `PATH` logging
713+ every invocation: **15 processes** for a repository with a README and a licence, **10**
714+ without them, **1** for an empty one — up from 7. Worth repeating the trick; it took
715+ two minutes and contradicted a careful hand-count.
716+- **The description has exactly one home, and it is the sidebar.** Putting it in the
717+ header too makes the header tallest on the repositories with the most to say, which
718+ pushes the code below the fold on exactly the pages that deserve it least.
719+- **`repo_summary` takes the revision and the root listing the page already read.** A
720+ self-contained use case would have spent two more processes re-reading what the caller
721+ had in hand. The five calls it does make run under one `tokio::try_join!`.
722+- **`organise_refs` was split out of `list_refs`** so the landing page gets the counts
723+ and the switcher's list from one `for-each-ref`. Two places deciding ref order is two
724+ places to change it.
725+- **A `#[component]`'s name is a unit struct in module scope, and the copied-in
726+ components are in that scope too.** A parameter named `label` inside `repo.rs` does
727+ not bind — it parses as the `label` component's unit struct, and the error says
728+ "expected `str`, found `label`" without naming the component. The fix was renaming the
729+ parameter to `term`. This is the same trap `CLAUDE.md` records for pages; registry
730+ components widen it to every file that imports one.
731+- **`bg-muted` is not a token.** Three `<pre>` blocks have been rendering with no
732+ background since they were written, silently — the exact failure `ui.md` describes for
733+ Tailwind classes the build never ships. `bg-surface` is the token. Two were fixed here;
734+ `token.rs` still has one.
735+- **The clone URL wraps rather than scrolls.** In an 18rem column an `overflow-x-auto`
736+ URL looks like a truncated one, and the part hidden is the repository's own name.
737+- **Light mode was checked by flipping the layout's `class="dark"` and rebuilding**,
738+ because there is no toggle. Nothing was wrong on these pages. That is the cheapest way
739+ to check it until a toggle exists, and it is worth doing before each UI step rather
740+ than after several.
741+
690742 ---
691743
692744 ## Reference: what attempt #2 proved
plans/ui.md+73 −0View file
@@ -67,6 +67,79 @@ company name where "steid" sits, maybe a footer line. That is an instance-level
6767 setting on `Organization` or config, not a theme; whatever answers the wordmark
6868 question should leave room for it rather than hardcoding either choice.
6969
70+## The repository page
71+
72+Settled 2026-09-05. `/{handle}/repos/{name}` is the only two-column page in a
73+repository: code on the left, an About sidebar on the right. Every page below it —
74+a tree subpath, a file, the log — stays a single `wide` column, the same way GitHub
75+narrows once you are inside the tree.
76+
77+```
78+@jamesgill /
79+steid [Settings]
80+Code Commits
81+──────────────────────────────────────────────────────────────────────────
82+main ▾ ⑂ 3 branches · 2 tags │ ABOUT
83+● docs: record the forge feature survey │ A personal-first gitforge…
84+ 31a6a7e · 2d │ 🕮 AGPL-3.0
85+┌──────────────────────────────────────┐ │ ─────────────────────────
86+│ steid │ │ Commits 80
87+│ deploy/ plans/ src/ │ │ Branches 3
88+│ Cargo.toml README.md LICENSE │ │ Tags 2
89+└──────────────────────────────────────┘ │ Latest tag ⌗ v0.2.0
90+┌──────────────────────────────────────┐ │ Pushed 2 days ago
91+│ README.md │ │ ─────────────────────────
92+└──────────────────────────────────────┘ │ CLONE
93+ │ https://…/steid.git
94+```
95+
96+**The sidebar's About block is the portfolio pitch.** A visitor arriving from a
97+profile reads it before they read any code — what this is, whether they may use it,
98+how much of it there is — so it gets the right-hand column and the description gets
99+its only home there. The header stays one line tall as a result: two homes for a
100+description means the header grows tallest on exactly the repositories with the most
101+to say, pushing the code below the fold.
102+
103+- **The header and tab strip are shared by every repository page**, so a tree, a file
104+ and a log read as one place rather than three. The strip takes the active tab as a
105+ parameter, so a new tab is one variant and one line.
106+- **The active tab is underlined in the primary colour.** Nothing else on the strip
107+ is coloured, so it reads as position rather than decoration. The only other use of
108+ the primary colour on the page is the latest-commit dot.
109+- **The latest-commit bar stands in for the per-file last-commit column** that
110+ [0006](decisions/0006-git-binary-behind-narrow-ports.md) defers. It says the same
111+ thing once instead of once per row, for one `git log --max-count=1`.
112+- **Counts beside the switcher are text, not links.** `/branches` and `/tags` do not
113+ exist; a dead link is worse than a number.
114+- **Sidebar sections are separated by hairlines, never boxed as cards** — the rule the
115+ profile page settled on, and what makes the page read as one surface. Below `lg` the
116+ sidebar drops beneath the file list and grows a rule of its own, so it reads as a new
117+ section rather than as more of the listing.
118+- **A licence is named only when its own text names it.** A licence file Steid cannot
119+ identify is linked to and labelled "Licence": naming the wrong one is a claim about
120+ somebody's legal terms.
121+
122+### Where the next features go
123+
124+The slots matter as much as what fills them today. Written down so the next feature
125+lands in the frame rather than beside it:
126+
127+| Feature | Where it goes |
128+|---|---|
129+| Issues, Pull requests | a tab each, beside Code and Commits |
130+| A commit page | the sha in the latest-commit bar and in the log |
131+| Branches, Tags | the counts beside the switcher become links |
132+| Compare | reached from a branch row on the branches page |
133+| Blame | a toggle in the blob's own header, beside Raw |
134+| Archive download | two small links under the clone URL |
135+| Code search | a box on the right of the toolbar row, which is empty for it |
136+| Per-file last commit | a column in the listing, once `cat-file --batch` is kept alive |
137+
138+**The cost is known and accepted.** The landing page makes **15 `git` processes** for a
139+repository with a README and a licence, 10 without them, and 1 for an empty one — up
140+from 7. Five of those are the sidebar's, run concurrently. That is the bill 0006 said
141+would come due; it comes due at a kept-alive `cat-file --batch`, not before.
142+
70143 ## The profile page
71144
72145 Settled 2026-08-29, after looking at the built page with real data rather than
src/infrastructure/web/browse.rs+41 −65View file
@@ -31,7 +31,7 @@ use topcoat::{
3131 header::{CONTENT_DISPOSITION, CONTENT_TYPE},
3232 page, path_param, route,
3333 },
34 view::{View, attributes, component, view},
34+ view::{attributes, component, view},
3535 };
3636
3737 use crate::{
@@ -47,7 +47,7 @@ use crate::{
4747 use super::{
4848 context::{current_actor, memberships, orgs, queries, repos, server_error},
4949 layout::wide,
50 repo::{clone_url, clone_url_for, repo_for},
50+ repo::{Tab, clone_url_for, repo_for, repo_header},
5151 };
5252
5353 /// `{rev}` from the path, raw — validation is [`RefName`]'s job.
@@ -58,13 +58,6 @@ struct Rev(str);
5858 #[path_param]
5959 struct Path(str);
6060
61/// Which page of a repository is being looked at, for the nav.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub(super) enum Tab {
64 Files,
65 Log,
66}
67
6861 /// The revision from the URL, or 404.
6962 ///
7063 /// A malformed revision is a page that does not exist rather than a bad request — the
@@ -224,18 +217,15 @@ async fn browsing(cx: &Cx, rev: Option<RefName>, path: RepoPath) -> Result {
224217
225218 view! {
226219 wide(
227 repo_bar(
220+ repo_header(
228221 repo: &repo,
229222 rev: at,
230 active: Tab::Files,
223+ active: Tab::Code,
231224 rev_switcher(current: at, known: known, branches: &branches, tags: &tags)
232225 )
233226
234227 match &browsed {
235 Browsed::Empty => {
236 clone_url(url: clone.as_str())
237 empty_repo(url: clone.as_str())
238 },
228+ Browsed::Empty => empty_repo(url: clone.as_str()),
239229 Browsed::Directory { rev, path, entries } => directory(
240230 handle: repo.handle.as_str(),
241231 name: repo.name.as_str(),
@@ -284,10 +274,10 @@ async fn history(cx: &Cx, rev: Option<RefName>) -> Result {
284274
285275 view! {
286276 wide(
287 repo_bar(
277+ repo_header(
288278 repo: &repo,
289279 rev: at,
290 active: Tab::Log,
280+ active: Tab::Commits,
291281 // At the default branch the URL names no revision and `repo_log` does not
292282 // report the one it resolved, so the switcher opens with nothing marked
293283 // current rather than guessing. Noted in `plans/current.md`.
@@ -299,7 +289,7 @@ async fn history(cx: &Cx, rev: Option<RefName>) -> Result {
299289 }
300290
301291 /// The revision a browse landed on, for display. Empty when there is none.
302fn browsed_rev(browsed: &Browsed) -> &str {
292+pub(super) fn browsed_rev(browsed: &Browsed) -> &str {
303293 match browsed {
304294 Browsed::Empty => "",
305295 Browsed::Directory { rev, .. } | Browsed::File { rev, .. } => rev.as_str(),
@@ -416,7 +406,7 @@ pub(super) fn raw_url(handle: &str, name: &str, rev: &RefName, path: &RepoPath)
416406 }
417407
418408 /// The commit log's URL, at a revision or at the default branch.
419fn log_url(handle: &str, name: &str, rev: &str) -> String {
409+pub(super) fn log_url(handle: &str, name: &str, rev: &str) -> String {
420410 if rev.is_empty() {
421411 format!("/{handle}/repos/{name}/log")
422412 } else {
@@ -602,59 +592,45 @@ fn civil_from_days(days: i64) -> (i64, u32, u32) {
602592
603593 // --- Views ------------------------------------------------------------------------
604594
605/// The bar every repository page carries: where you are, and what else there is.
595+/// The row above the file list on a repository's landing page.
606596 ///
607/// Kept out of the repository page's own header so that a tree, a file and a log all
608/// read as the same repository rather than as three unrelated pages.
597+/// The revision switcher, then what else the repository has. The counts are **plain
598+/// text, not links**: `/{handle}/repos/{name}/branches` and `/tags` are the URLs they
599+/// will get, and neither page exists yet — a dead link is worse than a number. The
600+/// right of the row is deliberately empty; a code-search box lands there.
609601 #[component]
610pub(super) async fn repo_bar(
611 repo: &RepoView,
602+pub(super) async fn repo_toolbar(
603+ handle: &str,
604+ name: &str,
612605 rev: &str,
613 active: Tab,
614 /// The revision control, when the page has one. Empty on a page that does not.
615 #[default]
616 child: View,
606+ path: &RepoPath,
607+ refs: &RefList,
617608 ) -> Result {
618 let handle = repo.handle.as_str();
619 let name = repo.name.as_str();
620 let tab = |current| {
621 if current {
622 "text-foreground border-foreground"
623 } else {
624 "text-muted-foreground border-transparent hover:text-foreground"
625 }
626 };
609+ let known = RefName::new(rev).is_ok_and(|at| refs.contains(&at));
610+ let switch = Switch::Tree(path);
611+ let branches = ref_links(handle, name, &refs.branches, rev, &switch);
612+ let tags = ref_links(handle, name, &refs.tags, rev, &switch);
627613
628614 view! {
629 <header class="mb-6 border-b border-border pb-3">
630 <p class="font-mono text-sm text-muted-foreground">
631 <a href=(format!("/{handle}")) class="hover:text-foreground">"@" (handle)</a>
632 " / "
633 <a href=(format!("/{handle}/repos/{name}")) class="text-foreground hover:underline">
634 (name)
635 </a>
636 if !repo.visibility.is_public() {
637 " "
638 badge(variant: BadgeVariant::Outline, "Private")
639 }
640 </p>
641
642 <nav class="mt-3 flex items-center gap-5 text-sm">
643 <a
644 href=(format!("/{handle}/repos/{name}"))
645 class=(format!("-mb-3 border-b-2 pb-2 {}", tab(active == Tab::Files)))
646 >"Files"</a>
647 <a
648 href=(log_url(handle, name, rev))
649 class=(format!("-mb-3 border-b-2 pb-2 {}", tab(active == Tab::Log)))
650 >"Commits"</a>
651
652 <span class="ml-auto">(child)</span>
653 </nav>
654 </header>
615+ <div class="mb-2 flex flex-wrap items-center gap-x-3 gap-y-2">
616+ rev_switcher(current: rev, known: known, branches: &branches, tags: &tags)
617+ <span class="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
618+ icon(data: iconify_icon!("feather:git-branch"), attrs: attributes! {
619+ class="size-3.5"
620+ })
621+ <span class="font-mono">(counted(refs.branches.len(), "branch", "branches"))</span>
622+ "·"
623+ <span class="font-mono">(counted(refs.tags.len(), "tag", "tags"))</span>
624+ </span>
625+ </div>
655626 }
656627 }
657628
629+/// A count and the thing it counts, pluralised.
630+fn counted(count: usize, one: &str, many: &str) -> String {
631+ format!("{count} {}", if count == 1 { one } else { many })
632+}
633+
658634 /// The branch and tag picker.
659635 ///
660636 /// A `<details>` disclosure, so it opens and closes with no scripting — the rest of
@@ -770,14 +746,14 @@ pub(super) async fn empty_repo(url: &str) -> Result {
770746 let push = format!("git remote add origin {url}\ngit branch -M main\ngit push -u origin main");
771747
772748 view! {
773 <div class="mt-6 rounded-lg border border-border px-4 py-5">
749+ <div class="rounded-lg border border-border px-4 py-5">
774750 <p class="text-sm text-muted-foreground">
775751 "This repository has no commits yet. Push one to see it here."
776752 </p>
777753 <p class="mt-4 text-xs font-medium uppercase tracking-wider text-muted-foreground">
778754 "Push an existing repository"
779755 </p>
780 <pre class="mt-2 overflow-x-auto rounded-lg border border-border bg-muted px-4 py-3 font-mono text-sm">(push)</pre>
756+ <pre class="mt-2 overflow-x-auto rounded-lg border border-border bg-surface px-4 py-3 font-mono text-sm">(push)</pre>
781757 </div>
782758 }
783759 }
src/infrastructure/web/repo.rs+357 −96View file
@@ -4,21 +4,27 @@
44 //! `new`. [`RepoName`] reserves it as well, so the two agree rather than relying on
55 //! routing order alone.
66
7+use std::time::SystemTime;
8+
79 use serde::Deserialize;
810 use topcoat::{
911 Result,
1012 context::Cx,
13+ icon::{icon, iconify::iconify_icon},
1114 router::{
1215 StatusCode,
1316 content::Form,
1417 error::{RouterErrorExt, forbidden, not_found},
1518 page, path_param,
1619 },
17 view::{attributes, component, view},
20+ view::{View, attributes, component, view},
1821 };
1922
2023 use crate::{
21 application::{Browsed, Error, FileView, NewRepo, RepoView, create_repo, view_repo},
24+ application::{
25+ Browsed, Error, FileView, NewRepo, RepoFacts, RepoView, create_repo, repo_summary,
26+ view_repo,
27+ },
2228 components::{
2329 badge::{BadgeVariant, badge},
2430 button::{ButtonSize, ButtonVariant, button, button_variants},
@@ -29,14 +35,18 @@ use crate::{
2935 textarea::textarea,
3036 },
3137 domain::{
32 DomainError, EntryKind, RefName, RepoName, RepoPath, Repository, TreeEntry, Visibility,
38+ CommitSummary, DomainError, EntryKind, RefName, RepoName, RepoPath, Repository, TreeEntry,
39+ Visibility,
3340 },
3441 };
3542
3643 use super::{
37 browse::{blob, browsed_at, directory, empty_repo, tree_url},
44+ browse::{
45+ ago, blob, browsed_at, browsed_rev, directory, empty_repo, log_url, repo_toolbar, tree_url,
46+ },
3847 context::{
39 current_actor, location, memberships, orgs, public_origin, repos, server_error, storage,
48+ current_actor, location, memberships, orgs, public_origin, queries, repos, server_error,
49+ storage,
4050 },
4151 layout::{narrow, wide},
4252 markdown,
@@ -167,99 +177,365 @@ async fn create(cx: &Cx, Form(submitted): Form<CreateForm>) -> Result {
167177 }
168178 }
169179
170/// The repository's own page: its default branch, at the root.
180+/// The repository's own page: its default branch, at the root, beside what the
181+/// repository is.
171182 ///
172/// The listing is the page rather than a link to one — the reason to open a repository
173/// is to see what is in it. An empty repository gets push instructions instead, which
174/// is the only useful thing to show someone who has just created one.
183+/// The only two-column page in a repository. Code on the left because the reason to
184+/// open a repository is to see what is in it; the About sidebar on the right because
185+/// this is the page a visitor arrives at from a profile, and "what is this and may I
186+/// use it" is the question they came with. Every page below this one — a tree subpath,
187+/// a file, the log — stays a single wide column, the same way GitHub narrows once you
188+/// are inside the tree.
175189 #[page("/{handle}/repos/{name}")]
176190 async fn repo_page(cx: &Cx) -> Result {
177191 let repo = repo_for(cx).await?;
178192 let clone = clone_url_for(cx, &repo);
179193 let browsed = browsed_at(cx, &repo, None, &RepoPath::root()).await?;
180194
195+ // Only a browsed root has a revision and a listing to summarise. An empty
196+ // repository has neither, so it is not asked — five more `git` processes to learn
197+ // that nothing is there is exactly the cost 0006 warns about.
198+ let facts = match &browsed {
199+ Browsed::Directory { rev, entries, .. } => Some(facts_of(cx, &repo, rev, entries).await?),
200+ _ => None,
201+ };
202+
203+ let at = browsed_rev(&browsed);
204+ let refs = facts
205+ .as_ref()
206+ .map(|facts| facts.refs.clone())
207+ .unwrap_or_default();
208+
181209 view! {
182210 wide(
183 <header class="mb-8">
184 <p class="font-mono text-sm text-muted-foreground">
185 <a href=(format!("/{}", repo.handle)) class="hover:text-foreground">
186 "@" (repo.handle.as_str())
187 </a>
188 " / "
189 </p>
190 <div class="mt-1 flex items-center gap-3">
191 <h1 class="text-2xl font-semibold tracking-tight">(repo.name.as_str())</h1>
192 if !repo.visibility.is_public() {
193 badge(variant: BadgeVariant::Outline, "Private")
194 }
195 // Only the owner can change a repository, so only the owner is offered
196 // the way in. The settings page enforces this again — this is the link
197 // not being a dead end, not the authorization.
198 if repo.viewer_is_owner {
199 <a
200 href=(format!("/{}/repos/{}/settings", repo.handle, repo.name))
201 class=(format!(
202 "ml-auto {}",
203 button_variants(ButtonVariant::Outline, ButtonSize::Sm)
204 ))
205 >"Settings"</a>
206 }
207 </div>
208 ({
209 match &repo.description {
210 Some(description) => view! {
211 <p class="mt-3 text-sm leading-relaxed">(description)</p>
211+ repo_header(repo: &repo, rev: at, active: Tab::Code)
212+
213+ <div class="lg:flex lg:items-start lg:gap-6">
214+ <div class="min-w-0 lg:flex-1">
215+ match &browsed {
216+ Browsed::Empty => empty_repo(url: clone.as_str()),
217+ Browsed::Directory { rev, path, entries } => {
218+ repo_toolbar(
219+ handle: repo.handle.as_str(),
220+ name: repo.name.as_str(),
221+ rev: rev.as_str(),
222+ path: path,
223+ refs: &refs,
224+ )
225+ match facts.as_ref().and_then(|facts| facts.latest_commit.as_ref()) {
226+ Some(commit) => latest_commit(
227+ handle: repo.handle.as_str(),
228+ name: repo.name.as_str(),
229+ rev: rev.as_str(),
230+ commit: commit,
231+ ),
232+ None => "",
233+ }
234+ directory(
235+ handle: repo.handle.as_str(),
236+ name: repo.name.as_str(),
237+ rev: rev,
238+ path: path,
239+ entries: entries,
240+ )
241+
242+ // No README means nothing here at all — an empty panel saying a
243+ // repository has no README is worse than the silence.
244+ match readme_of(entries) {
245+ Some(entry) => readme_card(repo: &repo, rev: rev, entry: entry),
246+ None => "",
247+ }
212248 },
213 None => view! {},
249+ // The root of a revision is always a directory, so this is unreachable in
250+ // practice — rendered rather than errored so it can never be a 500.
251+ Browsed::File { rev, path, file } => blob(
252+ handle: repo.handle.as_str(),
253+ name: repo.name.as_str(),
254+ rev: rev,
255+ path: path,
256+ file: file,
257+ ),
214258 }
215 }?)
216 </header>
217
218 clone_url(url: clone.as_str())
219
220 match &browsed {
221 Browsed::Empty => empty_repo(url: clone.as_str()),
222 Browsed::Directory { rev, path, entries } => {
223 <div class="mt-8 mb-3 flex items-center justify-between text-sm">
224 <span class="inline-flex items-center gap-1.5 font-mono text-xs text-muted-foreground">
225 (rev.as_str())
226 </span>
227 <a
228 href=(format!("/{}/repos/{}/log", repo.handle, repo.name))
229 class="text-muted-foreground hover:text-foreground"
230 >"Commits"</a>
231 </div>
232 directory(
233 handle: repo.handle.as_str(),
234 name: repo.name.as_str(),
235 rev: rev,
236 path: path,
237 entries: entries,
238 )
259+ </div>
239260
240 // No README means nothing here at all — an empty panel saying a
241 // repository has no README is worse than the silence.
242 match readme_of(entries) {
243 Some(entry) => readme_card(repo: &repo, rev: rev, entry: entry),
244 None => "",
245 }
246 },
247 // The root of a revision is always a directory, so this is unreachable in
248 // practice — rendered rather than errored so it can never be a 500.
249 Browsed::File { rev, path, file } => <div class="mt-8">
250 blob(
251 handle: repo.handle.as_str(),
252 name: repo.name.as_str(),
253 rev: rev,
254 path: path,
255 file: file,
261+ // Below `lg` the sidebar is not a sidebar — it follows the file list down
262+ // the page, so it needs a rule of its own to read as a new section rather
263+ // than as more of the listing.
264+ <aside class="mt-6 w-full shrink-0 border-t border-border pt-6 lg:sticky lg:top-6 lg:mt-0 lg:w-72 lg:border-t-0 lg:pt-0">
265+ repo_about(
266+ repo: &repo,
267+ rev: at,
268+ facts: facts.as_ref(),
269+ clone: clone.as_str(),
256270 )
257 </div>,
258 }
271+ </aside>
272+ </div>
259273 )
260274 }
261275 }
262276
277+/// The facts about a repository the viewer can already see, or 404.
278+///
279+/// **Five `git` processes**, run concurrently — see
280+/// [`repo_summary`](crate::application::repo_summary) and this step's note in
281+/// `plans/progress.md`. Only the landing page calls it.
282+async fn facts_of(
283+ cx: &Cx,
284+ repo: &RepoView,
285+ rev: &RefName,
286+ entries: &[TreeEntry],
287+) -> Result<RepoFacts> {
288+ Ok(repo_summary(
289+ &repo.handle,
290+ &repo.name,
291+ rev,
292+ entries,
293+ &current_actor(cx).await?,
294+ &orgs(cx),
295+ &memberships(cx),
296+ &repos(cx),
297+ &queries(cx),
298+ )
299+ .await
300+ .map_err(server_error)?
301+ .ok_or_not_found()?)
302+}
303+
304+// --- The repository frame ---------------------------------------------------------
305+
306+/// Which repository page is being looked at.
307+///
308+/// One variant per tab. Issues and pull requests each become a variant and a line in
309+/// [`repo_header`] when they exist, which is the point of the strip being a component.
310+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
311+pub(super) enum Tab {
312+ Code,
313+ Commits,
314+}
315+
316+/// The header every repository page carries: whose it is, what it is, and what else
317+/// there is.
318+///
319+/// Shared so a tree, a file and a log read as one place rather than as three unrelated
320+/// pages. Deliberately one line of title: the description lives in the About sidebar
321+/// and nowhere else, because two homes for it means the header grows on exactly the
322+/// repositories that have the most to say.
323+#[component]
324+pub(super) async fn repo_header(
325+ repo: &RepoView,
326+ rev: &str,
327+ active: Tab,
328+ /// The revision control, on the pages that carry it here. The landing page does
329+ /// not: its switcher belongs in the toolbar above the file list.
330+ #[default]
331+ child: View,
332+) -> Result {
333+ let handle = repo.handle.as_str();
334+ let name = repo.name.as_str();
335+
336+ // The primary colour marks the tab you are on. Nothing else on the strip is
337+ // coloured, so it reads as position rather than as decoration.
338+ let tab = |current| {
339+ if current {
340+ "border-primary text-foreground"
341+ } else {
342+ "border-transparent text-muted-foreground hover:text-foreground"
343+ }
344+ };
345+
346+ view! {
347+ <header class="mb-4">
348+ <p class="font-mono text-xs text-muted-foreground">
349+ <a href=(format!("/{handle}")) class="hover:text-foreground">"@" (handle)</a>
350+ " /"
351+ </p>
352+
353+ <div class="mt-0.5 flex items-center gap-2.5">
354+ <h1 class="text-2xl font-semibold tracking-tight">
355+ <a href=(format!("/{handle}/repos/{name}"))>(name)</a>
356+ </h1>
357+ if !repo.visibility.is_public() {
358+ badge(variant: BadgeVariant::Outline, "Private")
359+ }
360+ // Only the owner can change a repository, so only the owner is offered
361+ // the way in. The settings page enforces this again — this is the link
362+ // not being a dead end, not the authorization.
363+ if repo.viewer_is_owner {
364+ <a
365+ href=(format!("/{handle}/repos/{name}/settings"))
366+ class=(format!(
367+ "ml-auto {}",
368+ button_variants(ButtonVariant::Outline, ButtonSize::Sm)
369+ ))
370+ >"Settings"</a>
371+ }
372+ </div>
373+
374+ <nav class="mt-3 flex items-center gap-5 border-b border-border text-sm">
375+ <a
376+ href=(format!("/{handle}/repos/{name}"))
377+ class=(format!("-mb-px border-b-2 pb-2 {}", tab(active == Tab::Code)))
378+ >"Code"</a>
379+ <a
380+ href=(log_url(handle, name, rev))
381+ class=(format!("-mb-px border-b-2 pb-2 {}", tab(active == Tab::Commits)))
382+ >"Commits"</a>
383+
384+ <span class="ml-auto pb-2">(child)</span>
385+ </nav>
386+ </header>
387+ }
388+}
389+
390+/// The most recent commit, on one line above the listing.
391+///
392+/// This is what a per-file last-commit column would say if there were one, said once —
393+/// the column itself is deferred until a kept-alive `cat-file --batch` exists, per the
394+/// Milestone 5 amendment to
395+/// [0006](../../../plans/decisions/0006-git-binary-behind-narrow-ports.md). The dot is
396+/// the primary colour, which is the only mark on the row: a commit is the thing that
397+/// changed most recently, and the eye should land on it.
398+#[component]
399+async fn latest_commit(handle: &str, name: &str, rev: &str, commit: &CommitSummary) -> Result {
400+ view! {
401+ <div class="mb-2 flex items-center gap-2.5 rounded-lg border border-border px-4 py-2 text-sm">
402+ <span class="size-1.5 shrink-0 rounded-full bg-primary"></span>
403+ <span class="truncate">(&commit.summary)</span>
404+ <span class="ml-auto flex shrink-0 items-center gap-3 text-xs text-muted-foreground">
405+ // The log is where the rest of the history is, and the only place this
406+ // commit can currently be seen in context — there is no commit page yet.
407+ <a href=(log_url(handle, name, rev)) class="font-mono hover:text-foreground">
408+ (commit.id.short())
409+ </a>
410+ <span>(ago(commit.committed_at))</span>
411+ </span>
412+ </div>
413+ }
414+}
415+
416+// --- The About sidebar ------------------------------------------------------------
417+
418+/// What the repository is, in the column beside what is in it.
419+///
420+/// The portfolio pitch: a visitor arriving from a profile reads this before they read
421+/// any code. Sections are separated by hairlines rather than boxed as cards — the same
422+/// rule the profile page settled on, and the reason the page reads as one surface.
423+#[component]
424+async fn repo_about(repo: &RepoView, rev: &str, facts: Option<&RepoFacts>, clone: &str) -> Result {
425+ let handle = repo.handle.as_str();
426+ let name = repo.name.as_str();
427+
428+ view! {
429+ <section class="text-sm">
430+ <h2 class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
431+ "About"
432+ </h2>
433+ match &repo.description {
434+ Some(description) => <p class="mt-2 leading-relaxed">(description)</p>,
435+ // Shown rather than omitted, because an owner looking at their own
436+ // portfolio should see the gap they can fill in.
437+ None => <p class="mt-2 text-muted-foreground">"No description."</p>,
438+ }
439+ match facts.and_then(|facts| facts.licence.as_ref()) {
440+ Some(licence) => <p class="mt-2">
441+ <a
442+ href=(tree_url(handle, name, &RefName::from_trusted(rev), &licence.path))
443+ class="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
444+ >
445+ icon(data: iconify_icon!("feather:book"), attrs: attributes! {
446+ class="size-3.5"
447+ })
448+ // An unrecognised licence file is linked to and not named —
449+ // see `Licence`. "Licence" is then the honest label.
450+ (licence.name.unwrap_or("Licence"))
451+ </a>
452+ </p>,
453+ None => "",
454+ }
455+ </section>
456+
457+ match facts {
458+ Some(facts) => repo_stats(facts: facts, pushed: repo.updated_at),
459+ // With no commits there is nothing true to count, so the row that is still
460+ // true is shown on its own.
461+ None => <dl class="mt-4 space-y-1.5 border-t border-border pt-4 text-xs">
462+ fact(term: "Pushed", (ago(repo.updated_at)))
463+ </dl>,
464+ }
465+
466+ clone_block(url: clone)
467+ }
468+}
469+
470+/// The numbers, as a definition list.
471+///
472+/// A list rather than a row of badges: every value is a different kind of thing, and
473+/// the label is what makes each one readable at a glance.
474+#[component]
475+async fn repo_stats(facts: &RepoFacts, pushed: SystemTime) -> Result {
476+ view! {
477+ <dl class="mt-4 space-y-1.5 border-t border-border pt-4 text-xs">
478+ fact(term: "Commits", <span class="font-mono">(facts.commits.to_string())</span>)
479+ fact(
480+ term: "Branches",
481+ <span class="font-mono">(facts.refs.branches.len().to_string())</span>
482+ )
483+ fact(term: "Tags", <span class="font-mono">(facts.refs.tags.len().to_string())</span>)
484+ match &facts.latest_tag {
485+ Some(tag) => fact(
486+ term: "Latest tag",
487+ <span class="inline-flex items-center gap-1.5 font-mono">
488+ icon(data: iconify_icon!("feather:tag"), attrs: attributes! {
489+ class="size-3 text-muted-foreground"
490+ })
491+ (tag.name.as_str())
492+ </span>
493+ ),
494+ // A repository with no releases says nothing rather than "none": an
495+ // empty value reads as a thing that is missing.
496+ None => "",
497+ }
498+ fact(term: "Pushed", (ago(pushed)))
499+ </dl>
500+ }
501+}
502+
503+/// One label-and-value row of [`repo_stats`].
504+///
505+/// The parameter is `term`, not `label`: the copied-in `label` component is a unit
506+/// struct in this module's scope and would shadow a binding of that name.
507+#[component]
508+async fn fact(term: &str, #[default] child: View) -> Result {
509+ view! {
510+ <div class="flex items-baseline justify-between gap-3">
511+ <dt class="text-muted-foreground">(term)</dt>
512+ <dd class="min-w-0 truncate">(child)</dd>
513+ </div>
514+ }
515+}
516+
517+/// The clone address, ready to copy.
518+///
519+/// Shown for every repository a viewer can see, including an empty one — an empty
520+/// repository is exactly when someone needs this, because it is what they push to.
521+///
522+/// The URL alone rather than `git clone <url>`: at sidebar width the command wraps or
523+/// scrolls, and the address is the part being copied. It wraps rather than scrolls —
524+/// a horizontally scrolled URL looks like a truncated one, and the part cut off is the
525+/// repository's own name. The two small download links — `.tar.gz` and `.zip` — belong
526+/// under it once an archive endpoint exists.
527+#[component]
528+async fn clone_block(url: &str) -> Result {
529+ view! {
530+ <div class="mt-4 border-t border-border pt-4">
531+ <p class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
532+ "Clone"
533+ </p>
534+ <pre class="mt-2 rounded-lg border border-border bg-surface px-3 py-2 font-mono text-xs break-all whitespace-pre-wrap">(url)</pre>
535+ </div>
536+ }
537+}
538+
263539 // --- README -----------------------------------------------------------------------
264540
265541 /// Extensions a README may carry, in the order they are preferred.
@@ -492,21 +768,6 @@ pub(super) fn clone_url_for(cx: &Cx, repo: &RepoView) -> String {
492768 )
493769 }
494770
495/// The clone address, ready to copy.
496///
497/// Shown for every repository a viewer can see, including an empty one — an empty
498/// repository is exactly when someone needs this, because it is what they push to.
499#[component]
500pub(super) async fn clone_url(url: &str) -> Result {
501 view! {
502 <div class="mt-6">
503 <p class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
504 "Clone"
505 </p>
506 <pre class="mt-2 overflow-x-auto rounded-lg border border-border bg-muted px-4 py-3 font-mono text-sm">"git clone " (url)</pre>
507 </div>
508 }
509}
510771 #[cfg(test)]
511772 mod tests {
512773 use crate::domain::ObjectId;