steid

@jamesgill /

feat: rebuild the profile page on flat navigation

The design settled in ui.md, built: tabs instead of stacked sections, one lead
item distinguished by weight and space rather than size, hairlines instead of
boxes, and a /{handle}/repos index for the Repositories tab to point at. The old
boxed repo_list is deleted rather than left to rot.

Two empty sections are gone with it. Writing and Projects were empty boxes on the
front page advertising incompleteness; they become tabs when they have something
to show, which is also what makes Writing shippable later without redesigning
around it.

The page needed data that did not exist. updated_at is a column touched on the
authorized write path — a fork per repository would have cost ~150ms on a
twelve-repo profile before rendering anything. It records that a push was
authorized rather than that it succeeded, which is the honest description of
where the touch sits, and a failed touch never fails the push.

The migration stamps existing rows with the migration time rather than leaving
them at 0: a repository pushed to for months would otherwise read "updated 56
years ago" on the exact page the column exists to order. Wrong by a bounded
amount and self-correcting, against wrong forever and looking broken.

The single-pin invariant is enforced in the use case, not by a constraint,
because the rule is "pinning this unpins that" and a unique index can only
refuse. The lead is picked out of the listing rather than fetched separately, so
the two cannot disagree.

Ordering moved from name to recency, which exposed two tests that had been
passing by accident — everything created inside one second tied and fell back to
the alphabet.

Two silent Topcoat traps are now in CLAUDE.md, both hit while building this: a
`let` binding named after a #[page] parses as a unit-struct pattern rather than a
binding, and forgetting `topcoat asset bundle` after cargo build renders a
broken-looking page rather than an error, because the new utility classes simply
are not there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZwc7URWKVhkAuRTWiDmjA
JamesPatrickGill authored 7 days agoparent10b155cBrowse filesef2386809a59b4487afbede7ffaa9c1b540ada8c

15 files changed+1205 −139

CLAUDE.md+10 −5View file
@@ -116,10 +116,13 @@ Working knowledge that is easy to get wrong and slow to rediscover:
116116 cannot build a view.** Make it `#[component] async fn helper(cx: &Cx, …)` — a
117117 component may declare `cx: &Cx` and it is *not* passed at the call site. The error is
118118 a bare "cannot find value `__cx`" pointing into the macro, which names nothing useful.
119- **A `#[component]`'s name becomes a unit struct in module scope**, so it shadows any
120 *parameter* of the same name elsewhere in the file. A component called `commits` broke
121 a separate `fn commit_log(commits: &[CommitSummary])` with "interpreted as a unit
122 struct, not a new binding".
119+- **A `#[page]` or `#[component]`'s name becomes a unit struct in module scope**, so it
120+ shadows anything of the same name elsewhere in the file — parameters *and* `let`
121+ bindings. A component called `commits` broke `fn commit_log(commits: &[CommitSummary])`;
122+ separately, `let profile = …` inside a module containing `#[page] async fn profile`
123+ parses as a **unit-struct pattern rather than a new binding**, and the error mentions
124+ neither the page nor the shadowing. Name locals for what they hold, not for the page
125+ they serve.
123126 - **Catch-all params are `{*path}`, read with `#[path_param] struct Path(str);`** — the
124127 `*` is not part of the name. The whole tail arrives as one percent-decoded string.
125128 Matching happens on the *raw* path, which is why `%2F` inside a `{rev}` segment
@@ -182,7 +185,9 @@ It is a rundown of capability, not of files touched — that is the commit messa
182185 it. A `cargo build`-only container image compiles cleanly and then fails at startup
183186 with `NotFound`. Found while writing the Dockerfile.
184187 - **`topcoat asset bundle` after a manual build**, or the CSS served is stale.
185 `topcoat dev` does it for you.
188+ `topcoat dev` does it for you. **The symptom is silently wrong layout, not an error** —
189+ new utility classes simply do not exist, so gaps collapse and sizes fall back to
190+ defaults, and the page looks like a design mistake rather than a stale build.
186191 - **The build needs network beyond crates.io**: `build.rs` downloads the standalone
187192 Tailwind CLI from GitHub releases, and those binaries are glibc-linked — which is why
188193 the container is Debian on both stages and a musl/Alpine builder fails at `cargo
migrations/20260829090000_add_repository_recency.sql+22 −0View file
@@ -0,0 +1,22 @@
1+-- Recency and pinning, the two things the profile page orders and leads with.
2+--
3+-- `updated_at` is unix seconds, moved when a push is authorized. Existing rows are
4+-- stamped with the time this migration runs rather than left at 0: a repository that
5+-- has been pushed to for months would otherwise read "updated 56 years ago" on the very
6+-- page this column exists to order, and there is no honest value to recover — git could
7+-- supply one, but at a fork per repository during a migration. "Migrated just now" is
8+-- wrong by a bounded amount and self-corrects on the next push. 1970 is wrong forever
9+-- and looks broken. Repositories that tie fall back to the name, so the listing is
10+-- stable rather than arbitrary until the first push.
11+--
12+-- `pinned` defaults to false for every existing row, so nothing becomes the profile's
13+-- lead without the owner saying so. Guessing a lead — the newest, the busiest — is the
14+-- editorial decision the flag exists to hand to a person.
15+--
16+-- At most one pinned repository per owner is enforced in `update_repo`, not by a partial
17+-- unique index here: the rule is "pinning this unpins that", and a constraint can only
18+-- refuse, never unpin.
19+alter table repositories add column updated_at integer not null default 0;
20+alter table repositories add column pinned integer not null default 0;
21+
22+update repositories set updated_at = cast(strftime('%s', 'now') as integer);
plans/current.md+8 −0View file
@@ -74,6 +74,14 @@ instance, and Steid's own source is pushed to it and browsable there.
7474 Raising it means a handful of concurrent requests can hold that much each.
7575 - **Renaming a repository** is still impossible, and now needs its own use case plus an
7676 answer for moving the directory under every existing clone.
77+- **The profile has no links.** `Organization` carries a display name and a bio and
78+ nothing else, so the design's links row is not backed by data and was left out rather
79+ than faked. A `links` field plus a settings control is the small feature that fixes it.
80+- **`create_repo` and `serve_git` read the clock internally** rather than taking a `now`,
81+ which is what `issue_token` does. Two call-site edits, or a `Clock` port if a third
82+ case appears.
83+- **A push touches `updated_at` twice** — once for the advertisement, once for the RPC.
84+ Harmless, same second, but two writes per push.
7785 - **A licence.** A public portfolio repository probably wants one, and the README
7886 deliberately says nothing about licensing rather than guessing.
7987 - **Blocked on a domain transfer** (noted 2026-08-29). `git.jpgilldev.com` is the
plans/progress.md+37 −1View file
@@ -2,7 +2,7 @@
22
33 ## This attempt (#3, Topcoat)
44
5421 tests. Active milestone in [current.md](current.md).
5+445 tests. Active milestone in [current.md](current.md).
66
77 ### Milestone 0 — Skeleton · done
88
@@ -523,6 +523,42 @@ Against a running instance with a deliberately hostile README: **zero real `<scr
523523 rendered, raw bytes SHA-256 identical with the headers above, the switcher listing a
524524 branch and a tag, and the Settings link visible to the owner and absent for anonymous.
525525
526+### The profile page, rebuilt · done
527+
528+Flat navigation, per [ui.md](ui.md#the-profile-page). Tabs instead of stacked sections,
529+one lead item distinguished by weight and space rather than size, hairlines instead of
530+boxes, and a `/{handle}/repos` index for the tab to point at.
531+
532+#### Decisions worth remembering
533+
534+- **Existing rows were stamped with the migration time, not left at 0.** A repository
535+ pushed to for months would otherwise read "updated 56 years ago" on the very page the
536+ column exists to order. Wrong by a bounded amount and self-correcting on the first
537+ push, against wrong forever and looking broken.
538+- **The single-pin invariant lives in the use case, not in a constraint.** The rule is
539+ "pinning this unpins that", and a unique index can only *refuse*, never unpin. The two
540+ writes are not one transaction; a crash between them leaves nothing pinned, which is
541+ the harmless direction.
542+- **`updated_at` is touched on the authorized write path**, before the protocol is
543+ reached — so a clone never moves it and neither does a refused push. It records that a
544+ push was *authorized*, not that it succeeded; waiting for the subprocess would be a
545+ much larger change for a small gain. A failed touch is logged and does not fail the
546+ push.
547+- **The lead is picked out of the listing**, not fetched separately, so the lead and the
548+ list cannot disagree about which repository is pinned.
549+- **Ordering changed from name to recency**, and two existing tests had been passing by
550+ accident: everything created inside one second tied and fell back to the alphabet.
551+
552+#### Two Topcoat traps, both silent
553+
554+- **`let profile = …` in a module with `#[page] async fn profile` is a unit-struct
555+ pattern, not a binding.** The page's name is a unit struct in module scope. The error
556+ points at neither the page nor the shadowing.
557+- **Forgetting `topcoat asset bundle` after `cargo build` produces a broken-looking
558+ page, not an error.** New utility classes are simply absent, so gaps collapse and type
559+ falls back to browser defaults — it reads as a design mistake. Cost one confused
560+ screenshot.
561+
526562 ---
527563
528564 ## Reference: what attempt #2 proved
src/application/browse.rs+3 −0View file
@@ -340,6 +340,8 @@ fn view_of(blob: Blob) -> FileView {
340340
341341 #[cfg(test)]
342342 mod tests {
343+ use std::time::SystemTime;
344+
343345 use super::*;
344346 use crate::{
345347 domain::{
@@ -388,6 +390,7 @@ mod tests {
388390 "steid",
389391 None,
390392 visibility,
393+ SystemTime::now(),
391394 )
392395 .expect("valid repository"),
393396 )
src/application/git.rs+121 −2View file
@@ -4,6 +4,8 @@
44 //! cannot decide: whether this actor may do this to this repository, settled **before**
55 //! the backend is spawned. Once pack data is moving, refusing is no longer an option.
66
7+use std::time::SystemTime;
8+
79 use crate::domain::{
810 Actor, DomainError, OrgName, RepoName,
911 repository::{MembershipRepository, OrgRepository, RepoRepository},
@@ -166,6 +168,28 @@ pub async fn serve_git(
166168 return Err(DomainError::Forbidden.into());
167169 }
168170
171+ if writing {
172+ // **Authorized write path only.** Every refusal above has already returned, and a
173+ // read never reaches here, so nothing a clone does can move a repository up the
174+ // profile.
175+ //
176+ // This records that a push was *authorized*, not that it succeeded: the pack may
177+ // still be rejected by the backend, by a hook, or by the client hanging up. The
178+ // honest alternative is to wait for the subprocess and inspect its exit — a far
179+ // larger change (the response streams, so nothing here sees the end of it) for a
180+ // small gain in accuracy on a timestamp that only orders a listing.
181+ //
182+ // A failure here must not fail the push, exactly as a directory that will not
183+ // delete does not fail `delete_repo`: the push is the thing the user asked for,
184+ // and a stale sort key is not worth refusing it over.
185+ if let Err(error) = repos.touch(&repo.id, SystemTime::now()).await {
186+ eprintln!(
187+ "steid: push to {}/{} authorized, but its updated_at could not be moved: {error}",
188+ org.name, repo.name
189+ );
190+ }
191+ }
192+
169193 let service = endpoint.service();
170194 let path_info = format!("/{}/{}.git", org.name, repo.name);
171195
@@ -213,6 +237,10 @@ mod tests {
213237 },
214238 };
215239
240+ /// When the fixture's repositories were created. Fixed, so a test can tell a
241+ /// timestamp that moved from one that never did without racing the clock.
242+ const CREATED: SystemTime = SystemTime::UNIX_EPOCH;
243+
216244 struct Fixture {
217245 orgs: InMemoryOrgRepo,
218246 memberships: InMemoryMembershipRepo,
@@ -253,8 +281,15 @@ mod tests {
253281 ] {
254282 repos
255283 .save(
256 &Repository::new(RepoId::generate(), org.id.clone(), name, None, visibility)
257 .expect("valid repo"),
284+ &Repository::new(
285+ RepoId::generate(),
286+ org.id.clone(),
287+ name,
288+ None,
289+ visibility,
290+ CREATED,
291+ )
292+ .expect("valid repo"),
258293 )
259294 .await
260295 .expect("save repo");
@@ -574,6 +609,90 @@ mod tests {
574609 assert!(served.is_none());
575610 }
576611
612+ // --- recency ------------------------------------------------------------------
613+
614+ impl Fixture {
615+ async fn updated_at(&self, name: &str) -> SystemTime {
616+ let org = self
617+ .orgs
618+ .find_by_name(&self.handle)
619+ .await
620+ .expect("lookup")
621+ .expect("the handle exists");
622+
623+ self.repos
624+ .find_by_org_and_name(&org.id, &RepoName::new(name).expect("valid name"))
625+ .await
626+ .expect("lookup")
627+ .expect("the repository exists")
628+ .updated_at
629+ }
630+ }
631+
632+ #[tokio::test]
633+ async fn an_authorized_push_dates_the_repository() {
634+ // What the profile orders by. Recorded when the push is *authorized*, so it is
635+ // set before a byte of pack data moves.
636+ let f = fixture().await;
637+ let before = SystemTime::now();
638+
639+ f.serve(&f.owner, "steid", advertise_push())
640+ .await
641+ .expect("should serve")
642+ .expect("should be allowed");
643+
644+ let updated = f.updated_at("steid").await;
645+ assert!(updated > CREATED, "the timestamp should have moved");
646+ assert!(updated >= before && updated <= SystemTime::now());
647+ }
648+
649+ #[tokio::test]
650+ async fn a_clone_does_not_date_the_repository() {
651+ // A portfolio ordered by who read it last would reorder itself under a crawler.
652+ let f = fixture().await;
653+
654+ for actor in [&Actor::Anonymous, &f.stranger, &f.member, &f.owner] {
655+ f.serve(actor, "steid", advertise_clone())
656+ .await
657+ .expect("should serve")
658+ .expect("visible");
659+ f.serve(actor, "steid", GitEndpoint::Rpc(GitService::UploadPack))
660+ .await
661+ .expect("should serve")
662+ .expect("visible");
663+ }
664+
665+ assert_eq!(f.updated_at("steid").await, CREATED);
666+ }
667+
668+ #[tokio::test]
669+ async fn a_refused_push_does_not_date_the_repository() {
670+ // Otherwise anyone at all could reorder someone's profile by attempting a push.
671+ let f = fixture().await;
672+
673+ for actor in [&Actor::Anonymous, &f.stranger, &f.member] {
674+ let _ = f.serve(actor, "steid", advertise_push()).await;
675+ }
676+
677+ assert_eq!(f.updated_at("steid").await, CREATED);
678+ }
679+
680+ #[tokio::test]
681+ async fn a_push_to_a_repository_the_actor_cannot_see_dates_nothing() {
682+ // The visibility gate returns before the touch, so a stranger cannot even prove
683+ // the private repository exists by watching the profile reorder.
684+ let f = fixture().await;
685+
686+ assert!(
687+ f.serve(&Actor::Anonymous, "secret", advertise_push())
688+ .await
689+ .expect("should serve")
690+ .is_none()
691+ );
692+
693+ assert_eq!(f.updated_at("secret").await, CREATED);
694+ }
695+
577696 // --- service parsing ---------------------------------------------------------
578697
579698 #[tokio::test]
src/application/repo.rs+300 −12View file
@@ -1,3 +1,5 @@
1+use std::time::SystemTime;
2+
13 use crate::domain::{
24 Actor, DomainError, OrgName, RepoId, RepoName, Repository, Visibility,
35 repository::{MembershipRepository, OrgRepository, RepoRepository},
@@ -60,6 +62,11 @@ pub async fn create_repo(
6062 spec.name.clone(),
6163 spec.description.clone(),
6264 spec.visibility,
65+ // Read here rather than taken as an argument, unlike `touch`: nothing needs to
66+ // create a repository *as of* a stated time, and a parameter no caller ever
67+ // varies is a parameter every caller has to think about. Revisit if a clock port
68+ // appears.
69+ SystemTime::now(),
6370 )?;
6471
6572 if repos
@@ -107,6 +114,13 @@ pub struct RepoView {
107114 pub name: RepoName,
108115 pub description: Option<String>,
109116 pub visibility: Visibility,
117+ /// When code last landed here. The repository page dates itself from this rather
118+ /// than asking git, which costs a fork.
119+ pub updated_at: SystemTime,
120+ /// Whether this repository leads the owner's profile. Here as well as on
121+ /// [`RepoSummary`] because the settings page renders the pin control from this view
122+ /// and would otherwise have to read the row a second time to know the box's state.
123+ pub pinned: bool,
110124 /// Whether the viewer may change this repository. Decided here so a page and
111125 /// `/api` cannot disagree about who sees a management control.
112126 pub viewer_is_owner: bool,
@@ -146,6 +160,8 @@ pub async fn view_repo(
146160 name: repo.name,
147161 description: repo.description,
148162 visibility: repo.visibility,
163+ updated_at: repo.updated_at,
164+ pinned: repo.pinned,
149165 viewer_is_owner: is_org_owner(&org, actor, memberships).await?,
150166 }))
151167 }
@@ -159,9 +175,15 @@ pub struct RepoSummary {
159175 pub name: RepoName,
160176 pub description: Option<String>,
161177 pub visibility: Visibility,
178+ pub updated_at: SystemTime,
179+ /// Whether this is the owner's lead repository. Carried in the listing because the
180+ /// profile picks the lead *out of* the listing rather than fetching it separately —
181+ /// one query, and no chance of the lead and the list disagreeing.
182+ pub pinned: bool,
162183 }
163184
164/// Every repository under a handle that the viewer is allowed to see, ordered by name.
185+/// Every repository under a handle that the viewer is allowed to see, most recently
186+/// updated first — the order [`RepoRepository::list_by_org`] defines.
165187 ///
166188 /// `Ok(None)` means no such handle — distinct from `Ok(Some(vec![]))`, which means the
167189 /// handle exists and the viewer can see nothing under it. A caller serving `/api` needs
@@ -196,6 +218,8 @@ pub async fn list_repos(
196218 name: repo.name,
197219 description: repo.description,
198220 visibility: repo.visibility,
221+ updated_at: repo.updated_at,
222+ pinned: repo.pinned,
199223 })
200224 .collect(),
201225 ))
@@ -257,6 +281,9 @@ async fn changeable_repo(
257281 pub struct RepoEdit {
258282 pub description: Option<String>,
259283 pub visibility: Visibility,
284+ /// Whether this repository should lead the owner's profile. Setting it unpins
285+ /// whichever repository held the spot — see [`update_repo`].
286+ pub pinned: bool,
260287 }
261288
262289 /// Changes a repository's description and visibility.
@@ -287,13 +314,39 @@ pub async fn update_repo(
287314 // length rule lives in exactly one place. The stored name goes back through
288315 // validation as a side effect — acceptable because it was validated on the way in
289316 // and has not changed, and the alternative is a second copy of the rule here.
290 let updated = Repository::new(
317+ let mut updated = Repository::new(
291318 existing.id,
292 existing.org_id,
319+ existing.org_id.clone(),
293320 existing.name.as_str(),
294321 edit.description.clone(),
295322 edit.visibility,
323+ // The existing timestamp, not the current time. `updated_at` means "code last
324+ // landed here" and orders a portfolio by what is being worked on; rewording a
325+ // description is not work on the repository and must not jump it to the top.
326+ existing.updated_at,
296327 )?;
328+ updated.pinned = edit.pinned;
329+
330+ // **At most one pinned repository per owner**, enforced here rather than in storage.
331+ // The rule is not "refuse a second pin" but "pinning this unpins that", and a
332+ // constraint — a partial unique index would express the shape exactly — can only
333+ // refuse. Enforcing it in the use case also keeps the two `RepoRepository`
334+ // implementations from having to agree on a behaviour neither of them is asked for.
335+ //
336+ // The two writes are not one transaction, so a crash between them can leave nothing
337+ // pinned. That is the harmless direction: no lead section, rather than two.
338+ if updated.pinned {
339+ for other in repos.list_by_org(&existing.org_id).await? {
340+ if other.pinned && other.id != updated.id {
341+ repos
342+ .save(&Repository {
343+ pinned: false,
344+ ..other
345+ })
346+ .await?;
347+ }
348+ }
349+ }
297350
298351 repos.save(&updated).await?;
299352
@@ -359,6 +412,10 @@ mod tests {
359412 },
360413 };
361414
415+ fn at(seconds: u64) -> SystemTime {
416+ SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(seconds)
417+ }
418+
362419 /// A `RepoRepository` whose `save` always fails, for exercising compensation.
363420 ///
364421 /// Test-local on purpose: fault injection does not belong in the shared fake, where
@@ -387,6 +444,10 @@ mod tests {
387444 Err(RepositoryError::backend("save failed on purpose"))
388445 }
389446
447+ async fn touch(&self, _id: &RepoId, _now: SystemTime) -> RepositoryResult<()> {
448+ Ok(())
449+ }
450+
390451 async fn delete(&self, _id: &RepoId) -> RepositoryResult<()> {
391452 Ok(())
392453 }
@@ -796,6 +857,20 @@ mod tests {
796857 .expect("lookup should not error")
797858 }
798859
860+ /// Creates a repository and dates it, so an ordering test states its own times
861+ /// rather than depending on how fast it runs.
862+ async fn create_dated(&self, name: &str, seconds: u64) -> Repository {
863+ let repo = self.create_with(Visibility::Public, name).await;
864+ self.repos
865+ .touch(&repo.id, at(seconds))
866+ .await
867+ .expect("touch");
868+ Repository {
869+ updated_at: at(seconds),
870+ ..repo
871+ }
872+ }
873+
799874 async fn create_with(&self, visibility: Visibility, name: &str) -> Repository {
800875 self.create(
801876 &self.owner,
@@ -945,12 +1020,21 @@ mod tests {
9451020 }
9461021 }
9471022
948 /// Two public and one private, created out of alphabetical order.
1023+ /// Two public and one private, dated so that recency and the alphabet disagree.
1024+ ///
1025+ /// `zebra` is the most recent and `alpha` the oldest, so a listing that came back
1026+ /// alphabetical would be visibly wrong rather than accidentally right.
9491027 async fn mixed() -> Fixture {
9501028 let f = fixture().await;
951 f.create_with(Visibility::Public, "zebra").await;
952 f.create_with(Visibility::Private, "secret").await;
953 f.create_with(Visibility::Public, "alpha").await;
1029+ f.create_dated("zebra", 3_000).await;
1030+ f.repos
1031+ .save(&Repository {
1032+ updated_at: at(2_000),
1033+ ..f.create_with(Visibility::Private, "secret").await
1034+ })
1035+ .await
1036+ .expect("date the private repo");
1037+ f.create_dated("alpha", 1_000).await;
9541038 f
9551039 }
9561040
@@ -962,7 +1046,7 @@ mod tests {
9621046 let listed = f.list(actor).await;
9631047 assert_eq!(
9641048 Fixture::names(&listed),
965 vec!["alpha", "zebra"],
1049+ vec!["zebra", "alpha"],
9661050 "{actor:?} should see only the public repositories"
9671051 );
9681052 }
@@ -976,23 +1060,51 @@ mod tests {
9761060 let listed = f.list(actor).await;
9771061 assert_eq!(
9781062 Fixture::names(&listed),
979 vec!["alpha", "secret", "zebra"],
1063+ vec!["zebra", "secret", "alpha"],
9801064 "{actor:?} should see everything"
9811065 );
9821066 }
9831067 }
9841068
9851069 #[tokio::test]
986 async fn listings_are_ordered_by_name() {
987 // Created zebra, secret, alpha — the order out is not the order in.
1070+ async fn listings_are_ordered_by_recency_not_by_name() {
1071+ // Changed deliberately from alphabetical: the profile is a portfolio, and
1072+ // alphabetical is a filing rule that puts `dotfiles` above the thing being built.
9881073 let f = mixed().await;
9891074
9901075 assert_eq!(
9911076 Fixture::names(&f.list(&f.owner).await),
992 vec!["alpha", "secret", "zebra"]
1077+ vec!["zebra", "secret", "alpha"]
1078+ );
1079+ }
1080+
1081+ #[tokio::test]
1082+ async fn repositories_updated_in_the_same_second_are_ordered_by_name() {
1083+ // Every repository that predates `updated_at` shares one timestamp, so without a
1084+ // tiebreak a profile would reshuffle itself between page loads.
1085+ let f = fixture().await;
1086+ for name in ["zebra", "alpha", "middle"] {
1087+ f.create_dated(name, 1_000).await;
1088+ }
1089+
1090+ assert_eq!(
1091+ Fixture::names(&f.list(&f.owner).await),
1092+ vec!["alpha", "middle", "zebra"]
9931093 );
9941094 }
9951095
1096+ #[tokio::test]
1097+ async fn a_new_repository_is_dated_when_it_was_created() {
1098+ let f = fixture().await;
1099+ let before = SystemTime::now();
1100+
1101+ let repo = f.create_with(Visibility::Public, "steid").await;
1102+
1103+ assert!(repo.updated_at >= before);
1104+ assert!(repo.updated_at <= SystemTime::now());
1105+ assert!(!repo.pinned, "nothing leads a profile by being created");
1106+ }
1107+
9961108 #[tokio::test]
9971109 async fn a_viewer_who_may_see_nothing_gets_an_empty_list() {
9981110 // Not a count, not a hint. Either would leak that private repositories exist
@@ -1044,6 +1156,10 @@ mod tests {
10441156 assert_eq!(summary.name.as_str(), "steid");
10451157 assert_eq!(summary.description.as_deref(), Some("A gitforge."));
10461158 assert_eq!(summary.visibility, Visibility::Private);
1159+ // The profile dates each row and picks its lead out of the listing, so both
1160+ // travel with the summary rather than costing a second lookup.
1161+ assert!(!summary.pinned);
1162+ assert!(summary.updated_at <= SystemTime::now());
10471163 }
10481164
10491165 #[tokio::test]
@@ -1061,6 +1177,7 @@ mod tests {
10611177 "theirs",
10621178 None,
10631179 Visibility::Public,
1180+ SystemTime::now(),
10641181 )
10651182 .expect("valid repo"),
10661183 )
@@ -1089,6 +1206,7 @@ mod tests {
10891206 &RepoEdit {
10901207 description: description.map(str::to_owned),
10911208 visibility,
1209+ pinned: false,
10921210 },
10931211 &self.orgs,
10941212 &self.memberships,
@@ -1272,6 +1390,7 @@ mod tests {
12721390 &RepoEdit {
12731391 description: None,
12741392 visibility: Visibility::Public,
1393+ pinned: false,
12751394 },
12761395 &f.orgs,
12771396 &f.memberships,
@@ -1343,6 +1462,175 @@ mod tests {
13431462 );
13441463 }
13451464
1465+ // --- pinning ---------------------------------------------------------------
1466+
1467+ impl Fixture {
1468+ /// Sets the pin, leaving everything else as stored.
1469+ async fn set_pin(&self, actor: &Actor, name: &str, pinned: bool) -> Result<Repository> {
1470+ let existing = self.stored(name).await.expect("the repository exists");
1471+
1472+ update_repo(
1473+ actor,
1474+ &self.handle,
1475+ &existing.name,
1476+ &RepoEdit {
1477+ description: existing.description.clone(),
1478+ visibility: existing.visibility,
1479+ pinned,
1480+ },
1481+ &self.orgs,
1482+ &self.memberships,
1483+ &self.repos,
1484+ )
1485+ .await
1486+ }
1487+
1488+ async fn pinned_names(&self) -> Vec<String> {
1489+ self.list(&self.owner)
1490+ .await
1491+ .into_iter()
1492+ .filter(|summary| summary.pinned)
1493+ .map(|summary| summary.name.to_string())
1494+ .collect()
1495+ }
1496+ }
1497+
1498+ #[tokio::test]
1499+ async fn the_owner_pins_a_repository() {
1500+ let f = fixture().await;
1501+ f.create_with(Visibility::Public, "steid").await;
1502+
1503+ let pinned = f
1504+ .set_pin(&f.owner, "steid", true)
1505+ .await
1506+ .expect("should pin");
1507+
1508+ assert!(pinned.pinned);
1509+ assert_eq!(f.pinned_names().await, vec!["steid".to_owned()]);
1510+ }
1511+
1512+ #[tokio::test]
1513+ async fn pinning_a_second_repository_unpins_the_first() {
1514+ // At most one lead per owner. Two would leave the profile with no rule for
1515+ // choosing between them.
1516+ let f = fixture().await;
1517+ f.create_with(Visibility::Public, "steid").await;
1518+ f.create_with(Visibility::Public, "dotfiles").await;
1519+ f.set_pin(&f.owner, "steid", true)
1520+ .await
1521+ .expect("should pin");
1522+
1523+ f.set_pin(&f.owner, "dotfiles", true)
1524+ .await
1525+ .expect("should pin");
1526+
1527+ assert_eq!(f.pinned_names().await, vec!["dotfiles".to_owned()]);
1528+ }
1529+
1530+ #[tokio::test]
1531+ async fn re_pinning_the_same_repository_leaves_it_pinned() {
1532+ // The unpin sweep skips the repository being saved; getting that wrong would
1533+ // make a second save of an unchanged form silently clear the pin.
1534+ let f = fixture().await;
1535+ f.create_with(Visibility::Public, "steid").await;
1536+ f.set_pin(&f.owner, "steid", true)
1537+ .await
1538+ .expect("should pin");
1539+
1540+ f.set_pin(&f.owner, "steid", true)
1541+ .await
1542+ .expect("should pin");
1543+
1544+ assert_eq!(f.pinned_names().await, vec!["steid".to_owned()]);
1545+ }
1546+
1547+ #[tokio::test]
1548+ async fn unpinning_leaves_nothing_pinned() {
1549+ let f = fixture().await;
1550+ f.create_with(Visibility::Public, "steid").await;
1551+ f.set_pin(&f.owner, "steid", true)
1552+ .await
1553+ .expect("should pin");
1554+
1555+ f.set_pin(&f.owner, "steid", false)
1556+ .await
1557+ .expect("should unpin");
1558+
1559+ assert!(f.pinned_names().await.is_empty());
1560+ }
1561+
1562+ #[tokio::test]
1563+ async fn pinning_only_reaches_the_owners_own_repositories() {
1564+ // The sweep is scoped to the org. Another owner's lead is not this owner's to
1565+ // clear.
1566+ let f = fixture().await;
1567+ f.create_with(Visibility::Public, "steid").await;
1568+
1569+ let other = Organization::new(OrgId::generate(), "other-org", None).expect("valid org");
1570+ f.orgs.save(&other).await.expect("save org");
1571+ let mut theirs = Repository::new(
1572+ RepoId::generate(),
1573+ other.id.clone(),
1574+ "theirs",
1575+ None,
1576+ Visibility::Public,
1577+ SystemTime::now(),
1578+ )
1579+ .expect("valid repo");
1580+ theirs.pinned = true;
1581+ f.repos.save(&theirs).await.expect("save repo");
1582+
1583+ f.set_pin(&f.owner, "steid", true)
1584+ .await
1585+ .expect("should pin");
1586+
1587+ assert!(
1588+ f.repos
1589+ .find_by_id(&theirs.id)
1590+ .await
1591+ .expect("lookup")
1592+ .expect("still there")
1593+ .pinned,
1594+ "another owner's lead should be untouched"
1595+ );
1596+ }
1597+
1598+ #[tokio::test]
1599+ async fn a_member_who_is_not_the_owner_cannot_pin() {
1600+ // Pinning is a mutation, and every repository mutation is owner-only.
1601+ let f = fixture().await;
1602+ f.create_with(Visibility::Public, "steid").await;
1603+
1604+ for actor in [&f.member, &f.stranger, &Actor::Anonymous] {
1605+ let error = f
1606+ .set_pin(actor, "steid", true)
1607+ .await
1608+ .expect_err("should refuse");
1609+
1610+ assert!(
1611+ matches!(error, Error::Domain(DomainError::Forbidden)),
1612+ "{actor:?} should be forbidden, got {error:?}"
1613+ );
1614+ }
1615+
1616+ assert!(f.pinned_names().await.is_empty());
1617+ }
1618+
1619+ #[tokio::test]
1620+ async fn editing_a_repository_does_not_move_its_place_in_the_listing() {
1621+ // `updated_at` means "code last landed here". Rewording a description must not
1622+ // jump a dormant repository to the top of a portfolio.
1623+ let f = fixture().await;
1624+ let repo = f.create_dated("steid", 1_000).await;
1625+
1626+ let updated = f
1627+ .update(&f.owner, "steid", Some("A gitforge."), Visibility::Public)
1628+ .await
1629+ .expect("should update");
1630+
1631+ assert_eq!(updated.updated_at, repo.updated_at);
1632+ }
1633+
13461634 // --- delete_repo -----------------------------------------------------------
13471635
13481636 /// Git storage whose `remove` always fails, for the best-effort path.
src/domain/repo.rs+50 −1View file
@@ -1,4 +1,4 @@
1use std::{fmt, str::FromStr};
1+use std::{fmt, str::FromStr, time::SystemTime};
22
33 use super::{DomainError, OrgId, RepoId};
44
@@ -143,6 +143,15 @@ pub struct Repository {
143143 pub name: RepoName,
144144 pub description: Option<String>,
145145 pub visibility: Visibility,
146+ /// When code last landed here — set at creation and moved when a push is
147+ /// authorized. The profile orders by it, so it means "recently worked on", not
148+ /// "row last written": editing a description does not move it.
149+ pub updated_at: SystemTime,
150+ /// Whether this is the one repository the owner leads their profile with.
151+ ///
152+ /// At most one per owner. Not a constructor argument: a repository becomes the lead
153+ /// by a later, deliberate choice, never by being created.
154+ pub pinned: bool,
146155 }
147156
148157 impl Repository {
@@ -150,12 +159,17 @@ impl Repository {
150159 pub const MAX_DESCRIPTION_LEN: usize = 300;
151160
152161 /// Creates a repository from user-supplied input.
162+ ///
163+ /// `now` is passed in rather than read here, following
164+ /// [`PersonalAccessToken::new`](super::PersonalAccessToken::new): a domain type that
165+ /// reads the clock cannot be tested against a fixed time.
153166 pub fn new(
154167 id: RepoId,
155168 org_id: OrgId,
156169 name: impl Into<String>,
157170 description: Option<String>,
158171 visibility: Visibility,
172+ now: SystemTime,
159173 ) -> Result<Self, DomainError> {
160174 let description = normalise_optional(description);
161175
@@ -174,16 +188,21 @@ impl Repository {
174188 name: RepoName::new(name)?,
175189 description,
176190 visibility,
191+ updated_at: now,
192+ pinned: false,
177193 })
178194 }
179195
180196 /// Reassembles a repository from storage, skipping validation.
197+ #[allow(clippy::too_many_arguments)]
181198 pub fn from_trusted(
182199 id: RepoId,
183200 org_id: OrgId,
184201 name: RepoName,
185202 description: Option<String>,
186203 visibility: Visibility,
204+ updated_at: SystemTime,
205+ pinned: bool,
187206 ) -> Self {
188207 Self {
189208 id,
@@ -191,6 +210,8 @@ impl Repository {
191210 name,
192211 description,
193212 visibility,
213+ updated_at,
214+ pinned,
194215 }
195216 }
196217 }
@@ -357,9 +378,36 @@ mod tests {
357378 "steid",
358379 description.map(str::to_owned),
359380 Visibility::Public,
381+ at(1_000),
360382 )
361383 }
362384
385+ fn at(seconds: u64) -> SystemTime {
386+ SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(seconds)
387+ }
388+
389+ #[test]
390+ fn a_new_repository_is_stamped_with_the_time_it_was_created() {
391+ let repo = Repository::new(
392+ RepoId::generate(),
393+ OrgId::generate(),
394+ "steid",
395+ None,
396+ Visibility::Public,
397+ at(1_700),
398+ )
399+ .expect("valid");
400+
401+ assert_eq!(repo.updated_at, at(1_700));
402+ }
403+
404+ #[test]
405+ fn a_new_repository_is_not_pinned() {
406+ // Pinning is a later, deliberate choice; creating a repository is not a claim
407+ // that it should lead the profile.
408+ assert!(!repo(None).expect("valid").pinned);
409+ }
410+
363411 #[test]
364412 fn a_repository_keeps_its_description() {
365413 let repo = repo(Some("A personal-first gitforge.")).expect("valid");
@@ -399,6 +447,7 @@ mod tests {
399447 "../escape",
400448 None,
401449 Visibility::Public,
450+ at(1_000),
402451 )
403452 .expect_err("should reject");
404453
src/domain/repository/repo_repo.rs+23 −1View file
@@ -1,3 +1,5 @@
1+use std::time::SystemTime;
2+
13 use super::RepositoryResult;
24 use crate::domain::{OrgId, RepoId, RepoName, Repository};
35
@@ -15,7 +17,13 @@ pub trait RepoRepository: Send + Sync {
1517 name: &RepoName,
1618 ) -> impl Future<Output = RepositoryResult<Option<Repository>>> + Send;
1719
18 /// Every repository owned by an organisation, ordered by name.
20+ /// Every repository owned by an organisation, most recently updated first, ties
21+ /// broken by name.
22+ ///
23+ /// Recency rather than alphabetical because the profile is a portfolio: alphabetical
24+ /// is a filing rule and puts `dotfiles` above the thing being built. The name
25+ /// tiebreak is what keeps a listing stable when several repositories share a second
26+ /// — every repository that existed before `updated_at` did shares one exactly.
1927 ///
2028 /// Returns them all regardless of visibility. Filtering is an authorization
2129 /// decision and belongs to the use case, so that the page and `/api` cannot end up
@@ -30,6 +38,20 @@ pub trait RepoRepository: Send + Sync {
3038 /// The owning organisation must already exist; the foreign key runs that direction.
3139 fn save(&self, repo: &Repository) -> impl Future<Output = RepositoryResult<()>> + Send;
3240
41+ /// Moves a repository's `updated_at` to `now`, doing nothing if it is gone.
42+ ///
43+ /// The time is supplied rather than read here, so a test can say when a push
44+ /// happened instead of racing the clock — and so the two implementations cannot
45+ /// disagree about which clock they read.
46+ ///
47+ /// Succeeding on a missing row keeps the caller out of the business of a repository
48+ /// deleted mid-push; there is nothing useful it could do about it either way.
49+ fn touch(
50+ &self,
51+ id: &RepoId,
52+ now: SystemTime,
53+ ) -> impl Future<Output = RepositoryResult<()>> + Send;
54+
3355 /// Deletes a repository, succeeding if there was nothing to delete.
3456 ///
3557 /// A delete rather than a flag, for the same reason token revocation is: a row that
src/infrastructure/repository/in_memory.rs+107 −3View file
@@ -179,7 +179,13 @@ impl TokenRepository for InMemoryTokenRepo {
179179
180180 #[cfg(test)]
181181 mod tests {
182+ use std::time::Duration;
183+
182184 use super::*;
185+
186+ fn at(seconds: u64) -> SystemTime {
187+ SystemTime::UNIX_EPOCH + Duration::from_secs(seconds)
188+ }
183189 use crate::domain::{MembershipId, PasswordHash, Role};
184190
185191 fn user(email: &str, org_id: &OrgId) -> User {
@@ -437,6 +443,7 @@ mod tests {
437443 "steid",
438444 None,
439445 crate::domain::Visibility::Public,
446+ at(1_000),
440447 )
441448 .expect("valid repo");
442449 repos.save(&repo).await.expect("save");
@@ -475,6 +482,7 @@ mod tests {
475482 name,
476483 None,
477484 crate::domain::Visibility::Public,
485+ at(1_000),
478486 )
479487 .expect("valid repo");
480488 repos.save(&repo).await.expect("save");
@@ -490,6 +498,89 @@ mod tests {
490498 );
491499 }
492500
501+ #[tokio::test]
502+ async fn the_fake_lists_newest_first_breaking_ties_by_name() {
503+ // The order the profile renders. It must match `SqliteRepoRepo::list_by_org`
504+ // exactly — a fake that sorted differently would let a use case pass here and
505+ // surprise someone in production.
506+ let repos = InMemoryRepoRepo::new();
507+ let org_id = OrgId::generate();
508+ for (name, seconds) in [
509+ ("alpha", 3_000),
510+ ("older", 1_000),
511+ ("newer", 2_000),
512+ ("also-older", 1_000),
513+ ] {
514+ repos
515+ .save(&repo_at(&org_id, name, seconds))
516+ .await
517+ .expect("save");
518+ }
519+
520+ let listed = repos.list_by_org(&org_id).await.expect("list");
521+
522+ assert_eq!(
523+ listed.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(),
524+ vec!["alpha", "newer", "also-older", "older"]
525+ );
526+ }
527+
528+ #[tokio::test]
529+ async fn touching_moves_a_repository_to_the_supplied_time() {
530+ let repos = InMemoryRepoRepo::new();
531+ let org_id = OrgId::generate();
532+ let repo = repo_at(&org_id, "steid", 1_000);
533+ repos.save(&repo).await.expect("save");
534+
535+ repos.touch(&repo.id, at(5_000)).await.expect("touch");
536+
537+ let found = repos
538+ .find_by_id(&repo.id)
539+ .await
540+ .expect("lookup")
541+ .expect("still there");
542+ assert_eq!(found.updated_at, at(5_000));
543+ }
544+
545+ #[tokio::test]
546+ async fn touching_a_repository_that_is_gone_succeeds() {
547+ // A push can outlive a delete; there is nothing useful the caller could do.
548+ let repos = InMemoryRepoRepo::new();
549+
550+ assert!(repos.touch(&RepoId::generate(), at(5_000)).await.is_ok());
551+ }
552+
553+ #[tokio::test]
554+ async fn the_fake_remembers_whether_a_repository_is_pinned() {
555+ let repos = InMemoryRepoRepo::new();
556+ let org_id = OrgId::generate();
557+ let mut repo = repo_at(&org_id, "steid", 1_000);
558+ repo.pinned = true;
559+
560+ repos.save(&repo).await.expect("save");
561+
562+ assert!(
563+ repos
564+ .find_by_id(&repo.id)
565+ .await
566+ .expect("lookup")
567+ .expect("still there")
568+ .pinned
569+ );
570+ }
571+
572+ fn repo_at(org_id: &OrgId, name: &str, seconds: u64) -> Repository {
573+ Repository::new(
574+ RepoId::generate(),
575+ org_id.clone(),
576+ name,
577+ None,
578+ crate::domain::Visibility::Public,
579+ at(seconds),
580+ )
581+ .expect("valid repo")
582+ }
583+
493584 #[tokio::test]
494585 async fn the_fake_forgets_a_deleted_token() {
495586 let tokens = InMemoryTokenRepo::new();
@@ -582,9 +673,14 @@ impl RepoRepository for InMemoryRepoRepo {
582673 .filter(|repo| &repo.org_id == org_id)
583674 .cloned()
584675 .collect();
585 // Sorted here as well as in SQL, so the two implementations agree and a test
586 // written against one holds for the other.
587 found.sort_by(|a, b| a.name.as_str().cmp(b.name.as_str()));
676+ // Sorted exactly as the SQL is — newest first, name ascending as a tiebreak.
677+ // A fake that ordered differently would let a use case pass in tests and
678+ // surprise someone in production.
679+ found.sort_by(|a, b| {
680+ b.updated_at
681+ .cmp(&a.updated_at)
682+ .then_with(|| a.name.as_str().cmp(b.name.as_str()))
683+ });
588684 Ok(found)
589685 }
590686
@@ -594,6 +690,14 @@ impl RepoRepository for InMemoryRepoRepo {
594690 Ok(())
595691 }
596692
693+ async fn touch(&self, id: &RepoId, now: SystemTime) -> RepositoryResult<()> {
694+ let mut repos = self.repos.lock().expect("lock poisoned");
695+ if let Some(repo) = repos.get_mut(id.as_str()) {
696+ repo.updated_at = now;
697+ }
698+ Ok(())
699+ }
700+
597701 async fn delete(&self, id: &RepoId) -> RepositoryResult<()> {
598702 let mut repos = self.repos.lock().expect("lock poisoned");
599703 repos.remove(id.as_str());
src/infrastructure/repository/sqlite.rs+193 −10View file
@@ -327,6 +327,8 @@ impl SqliteRepoRepo {
327327 RepoName::from_trusted(row.get::<String, _>("name")),
328328 row.get::<Option<String>, _>("description"),
329329 visibility,
330+ from_unix(row.get::<i64, _>("updated_at")),
331+ row.get::<i64, _>("pinned") != 0,
330332 ))
331333 }
332334 }
@@ -358,30 +360,38 @@ impl RepoRepository for SqliteRepoRepo {
358360 }
359361
360362 async fn list_by_org(&self, org_id: &OrgId) -> RepositoryResult<Vec<Repository>> {
361 let rows = sqlx::query("select * from repositories where org_id = ? order by name")
362 .bind(org_id.as_str())
363 .fetch_all(&self.pool)
364 .await
365 .map_err(backend)?;
363+ // Newest first, name ascending as the tiebreak — `InMemoryRepoRepo::list_by_org`
364+ // sorts identically, deliberately.
365+ let rows = sqlx::query(
366+ "select * from repositories where org_id = ? order by updated_at desc, name asc",
367+ )
368+ .bind(org_id.as_str())
369+ .fetch_all(&self.pool)
370+ .await
371+ .map_err(backend)?;
366372
367373 rows.iter().map(Self::map).collect()
368374 }
369375
370376 async fn save(&self, repo: &Repository) -> RepositoryResult<()> {
371377 sqlx::query(
372 "insert into repositories (id, org_id, name, description, visibility)
373 values (?, ?, ?, ?, ?)
378+ "insert into repositories (id, org_id, name, description, visibility, updated_at, pinned)
379+ values (?, ?, ?, ?, ?, ?, ?)
374380 on conflict (id) do update set
375381 org_id = excluded.org_id,
376382 name = excluded.name,
377383 description = excluded.description,
378 visibility = excluded.visibility",
384+ visibility = excluded.visibility,
385+ updated_at = excluded.updated_at,
386+ pinned = excluded.pinned",
379387 )
380388 .bind(repo.id.as_str())
381389 .bind(repo.org_id.as_str())
382390 .bind(repo.name.as_str())
383391 .bind(repo.description.as_deref())
384392 .bind(repo.visibility.as_str())
393+ .bind(to_unix(repo.updated_at))
394+ .bind(i64::from(repo.pinned))
385395 .execute(&self.pool)
386396 .await
387397 .map_err(backend)?;
@@ -389,6 +399,19 @@ impl RepoRepository for SqliteRepoRepo {
389399 Ok(())
390400 }
391401
402+ async fn touch(&self, id: &RepoId, now: SystemTime) -> RepositoryResult<()> {
403+ // A column update rather than a read-modify-write, so two pushes racing each
404+ // other cannot restore an older timestamp.
405+ sqlx::query("update repositories set updated_at = ? where id = ?")
406+ .bind(to_unix(now))
407+ .bind(id.as_str())
408+ .execute(&self.pool)
409+ .await
410+ .map_err(backend)?;
411+
412+ Ok(())
413+ }
414+
392415 async fn delete(&self, id: &RepoId) -> RepositoryResult<()> {
393416 sqlx::query("delete from repositories where id = ?")
394417 .bind(id.as_str())
@@ -486,6 +509,10 @@ impl TokenRepository for SqliteTokenRepo {
486509 #[cfg(test)]
487510 mod tests {
488511 use super::*;
512+
513+ fn at(seconds: u64) -> SystemTime {
514+ SystemTime::UNIX_EPOCH + Duration::from_secs(seconds)
515+ }
489516 use crate::{
490517 application::{OwnerSpec, claim_instance, port::PasswordHasher},
491518 domain::{SetupToken, TokenSecret},
@@ -755,8 +782,15 @@ mod tests {
755782 name: &str,
756783 visibility: Visibility,
757784 ) -> Repository {
758 let repo = Repository::new(RepoId::generate(), org.id.clone(), name, None, visibility)
759 .expect("valid repo");
785+ let repo = Repository::new(
786+ RepoId::generate(),
787+ org.id.clone(),
788+ name,
789+ None,
790+ visibility,
791+ at(1_000),
792+ )
793+ .expect("valid repo");
760794 repos.save(&repo).await.expect("save repo");
761795 repo
762796 }
@@ -774,6 +808,7 @@ mod tests {
774808 "steid",
775809 Some("A gitforge.".to_owned()),
776810 Visibility::Private,
811+ at(1_000),
777812 )
778813 .expect("valid repo");
779814 repos.save(&repo).await.expect("save");
@@ -819,6 +854,7 @@ mod tests {
819854 "steid",
820855 None,
821856 Visibility::Public,
857+ at(1_000),
822858 )
823859 .expect("valid repo");
824860
@@ -852,12 +888,159 @@ mod tests {
852888 "steid",
853889 None,
854890 Visibility::Public,
891+ at(1_000),
855892 )
856893 .expect("valid repo");
857894
858895 assert!(repos.save(&orphan).await.is_err(), "foreign key");
859896 }
860897
898+ #[tokio::test]
899+ async fn repositories_list_newest_first_breaking_ties_by_name() {
900+ // The order the profile renders, and the same order `InMemoryRepoRepo` produces
901+ // — the two are asserted against the same expectation on purpose.
902+ let pool = test_pool().await;
903+ let orgs = SqliteOrgRepo::new(pool.clone());
904+ let repos = SqliteRepoRepo::new(pool);
905+ let org = org_with(&orgs, "acme").await;
906+
907+ for (name, seconds) in [
908+ ("alpha", 3_000),
909+ ("older", 1_000),
910+ ("newer", 2_000),
911+ ("also-older", 1_000),
912+ ] {
913+ let mut repo = saved_repo(&repos, &org, name, Visibility::Public).await;
914+ repo.updated_at = at(seconds);
915+ repos.save(&repo).await.expect("restamp");
916+ }
917+
918+ let listed = repos.list_by_org(&org.id).await.expect("list");
919+
920+ assert_eq!(
921+ listed.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(),
922+ vec!["alpha", "newer", "also-older", "older"]
923+ );
924+ }
925+
926+ #[tokio::test]
927+ async fn touching_moves_a_repository_to_the_supplied_time() {
928+ let pool = test_pool().await;
929+ let orgs = SqliteOrgRepo::new(pool.clone());
930+ let repos = SqliteRepoRepo::new(pool);
931+ let org = org_with(&orgs, "acme").await;
932+ let repo = saved_repo(&repos, &org, "steid", Visibility::Public).await;
933+
934+ repos.touch(&repo.id, at(5_000)).await.expect("touch");
935+
936+ let found = repos
937+ .find_by_id(&repo.id)
938+ .await
939+ .expect("lookup")
940+ .expect("still there");
941+ assert_eq!(found.updated_at, at(5_000));
942+ assert_eq!(found.name, repo.name, "only the timestamp should move");
943+ }
944+
945+ #[tokio::test]
946+ async fn touching_a_repository_that_is_gone_succeeds() {
947+ let pool = test_pool().await;
948+ let repos = SqliteRepoRepo::new(pool);
949+
950+ assert!(repos.touch(&RepoId::generate(), at(5_000)).await.is_ok());
951+ }
952+
953+ #[tokio::test]
954+ async fn pinning_round_trips_through_an_integer_column() {
955+ // SQLite has no boolean; a column read as anything but 0/1 would silently unpin.
956+ let pool = test_pool().await;
957+ let orgs = SqliteOrgRepo::new(pool.clone());
958+ let repos = SqliteRepoRepo::new(pool);
959+ let org = org_with(&orgs, "acme").await;
960+ let mut repo = saved_repo(&repos, &org, "steid", Visibility::Public).await;
961+
962+ assert!(!repo.pinned, "a repository is not pinned by default");
963+
964+ repo.pinned = true;
965+ repos.save(&repo).await.expect("save");
966+
967+ assert!(
968+ repos
969+ .find_by_id(&repo.id)
970+ .await
971+ .expect("lookup")
972+ .expect("still there")
973+ .pinned
974+ );
975+ }
976+
977+ /// The migration, applied to a row that predates it.
978+ ///
979+ /// The dev database and any deployed instance already hold repositories, and this is
980+ /// the only chance to check what they look like afterwards: the table is created
981+ /// with the new columns from scratch everywhere else.
982+ #[tokio::test]
983+ async fn the_recency_migration_leaves_existing_rows_looking_current() {
984+ let pool = SqlitePool::connect("sqlite::memory:")
985+ .await
986+ .expect("in-memory database should open");
987+
988+ // The table as it stood before this migration, with a repository already in it.
989+ sqlx::query(
990+ "create table repositories (
991+ id text primary key,
992+ org_id text not null,
993+ name text not null collate nocase,
994+ description text,
995+ visibility text not null,
996+ unique (org_id, name)
997+ )",
998+ )
999+ .execute(&pool)
1000+ .await
1001+ .expect("old schema");
1002+ sqlx::query(
1003+ "insert into repositories (id, org_id, name, visibility)
1004+ values ('r1', 'o1', 'steid', 'public')",
1005+ )
1006+ .execute(&pool)
1007+ .await
1008+ .expect("an existing repository");
1009+
1010+ for statement in
1011+ include_str!("../../../migrations/20260829090000_add_repository_recency.sql")
1012+ .split(';')
1013+ .filter(|statement| !statement.trim().is_empty())
1014+ {
1015+ sqlx::query(statement)
1016+ .execute(&pool)
1017+ .await
1018+ .expect("the migration should apply");
1019+ }
1020+
1021+ let repo = SqliteRepoRepo::new(pool)
1022+ .find_by_id(&RepoId::from_trusted("r1"))
1023+ .await
1024+ .expect("lookup")
1025+ .expect("the row survives");
1026+
1027+ assert!(
1028+ !repo.pinned,
1029+ "nothing becomes the lead without being chosen"
1030+ );
1031+
1032+ // Stamped with the migration's own clock, not left at the epoch: an existing
1033+ // repository must not read "updated 56 years ago" on the page this column exists
1034+ // to order.
1035+ let age = SystemTime::now()
1036+ .duration_since(repo.updated_at)
1037+ .expect("stamped in the past");
1038+ assert!(
1039+ age < Duration::from_secs(60),
1040+ "expected a just-migrated timestamp, got one {age:?} old"
1041+ );
1042+ }
1043+
8611044 #[tokio::test]
8621045 async fn an_unreadable_visibility_surfaces_rather_than_defaulting() {
8631046 let pool = test_pool().await;
src/infrastructure/web/browse.rs+1 −1View file
@@ -529,7 +529,7 @@ fn size_of(bytes: u64) -> String {
529529 /// A commit's timestamp comes from whoever made it, so it can sit in the future — a
530530 /// skewed clock, or a rewritten history. That reads as "just now" rather than as a
531531 /// negative duration.
532fn ago(time: SystemTime) -> String {
532+pub(super) fn ago(time: SystemTime) -> String {
533533 let Ok(elapsed) = SystemTime::now().duration_since(time) else {
534534 return "just now".to_owned();
535535 };
src/infrastructure/web/profile.rs+276 −56View file
@@ -11,18 +11,21 @@ use topcoat::{
1111 error::{RouterErrorExt, not_found},
1212 page, path_param,
1313 },
14 view::view,
14+ view::{component, view},
1515 };
1616
1717 use crate::{
18 application::{PublicProfile, list_repos, view_profile},
19 components::button::{ButtonSize, ButtonVariant, button_variants},
18+ application::{PublicProfile, RepoSummary, list_repos, view_profile},
19+ components::{
20+ badge::{BadgeVariant, badge},
21+ button::{ButtonSize, ButtonVariant, button_variants},
22+ },
2023 domain::OrgName,
2124 };
2225
2326 use super::{
27+ browse::ago,
2428 context::{current_actor, memberships, orgs, repos, server_error},
25 repo::repo_list,
2629 };
2730
2831 /// `{handle}` from the path. The struct name snake-cased is the parameter name.
@@ -55,6 +58,173 @@ pub(super) async fn profile_for(cx: &Cx) -> Result<PublicProfile> {
5558 .ok_or_not_found()?)
5659 }
5760
61+/// How many repositories the overview previews before deferring to the index.
62+///
63+/// Small on purpose. The overview is a shopfront, not an inventory — the tab is where
64+/// completeness lives.
65+const PREVIEW: usize = 5;
66+
67+/// Which tab is lit.
68+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69+pub(super) enum Tab {
70+ Overview,
71+ Repositories,
72+}
73+
74+/// The identity block and the tab bar, shared by every page that is *about a handle*
75+/// rather than about a thing inside one.
76+///
77+/// Flat navigation rather than stacked sections: an empty section stops being a visible
78+/// failure on the front page and becomes a destination that happens to be empty. See
79+/// `plans/ui.md`.
80+/// The parameter is `identity`, not `profile`: the `#[page] async fn profile` below puts
81+/// a unit struct of that name in module scope, which shadows a parameter sharing it.
82+#[component]
83+pub(super) async fn profile_chrome(
84+ identity: &PublicProfile,
85+ active: Tab,
86+ repo_count: usize,
87+) -> Result {
88+ let handle = identity.handle.as_str();
89+ let overview = format!("/{handle}");
90+ let repositories = format!("/{handle}/repos");
91+ let settings = format!("/{handle}/settings");
92+ let count = repo_count.to_string();
93+
94+ view! {
95+ <header>
96+ <h1 class="text-xl font-semibold tracking-tight">(&identity.label)</h1>
97+ <p class="mt-0.5 font-mono text-[13px] text-muted-foreground">"@" (handle)</p>
98+ ({
99+ match &identity.bio {
100+ Some(bio) => view! {
101+ <p class="mt-3.5 max-w-lg text-sm leading-relaxed text-muted-foreground">(bio)</p>
102+ },
103+ None => view! {},
104+ }
105+ }?)
106+ </header>
107+
108+ <nav class="mt-8 flex items-center gap-6 border-b border-border text-[13px]">
109+ tab_link(
110+ href: overview.as_str(),
111+ label: "Overview",
112+ count: "",
113+ active: active == Tab::Overview,
114+ )
115+ tab_link(
116+ href: repositories.as_str(),
117+ label: "Repositories",
118+ count: count.as_str(),
119+ active: active == Tab::Repositories,
120+ )
121+
122+ if identity.viewer_is_owner {
123+ <a
124+ href=(settings.as_str())
125+ class="ml-auto -mb-px border-b-2 border-transparent pb-2.5 text-muted-foreground hover:text-foreground"
126+ >"Settings"</a>
127+ }
128+ </nav>
129+ }
130+}
131+
132+/// One tab. A plain link, so every tab is a real URL and none of this needs JavaScript.
133+#[component]
134+async fn tab_link(href: &str, label: &str, count: &str, active: bool) -> Result {
135+ view! {
136+ <a
137+ href=(href)
138+ class=(if active {
139+ "-mb-px border-b-2 border-foreground pb-2.5 font-medium text-foreground"
140+ } else {
141+ "-mb-px border-b-2 border-transparent pb-2.5 text-muted-foreground hover:text-foreground"
142+ })
143+ >
144+ (label)
145+ if !count.is_empty() {
146+ <span class="ml-1.5 text-xs text-muted-foreground/60">(count)</span>
147+ }
148+ </a>
149+ }
150+}
151+
152+/// The pinned repository, given weight by everything except size.
153+///
154+/// Eyebrow, a heavier weight, a roomier description and a rule beneath it —
155+/// `plans/ui.md` records why scale is deliberately not one of the devices.
156+#[component]
157+async fn lead(handle: &str, repo: &RepoSummary) -> Result {
158+ view! {
159+ <section class="mt-8 border-b border-border/60 pb-8">
160+ <p class="text-[11px] font-medium uppercase tracking-[0.1em] text-muted-foreground/70">
161+ "Currently building"
162+ </p>
163+ <div class="mt-2 flex items-baseline gap-2">
164+ <a
165+ href=(format!("/{handle}/repos/{}", repo.name))
166+ class="text-sm font-semibold hover:underline"
167+ >(repo.name.as_str())</a>
168+ if !repo.visibility.is_public() {
169+ badge(variant: BadgeVariant::Outline, "Private")
170+ }
171+ </div>
172+ ({
173+ match &repo.description {
174+ Some(description) => view! {
175+ <p class="mt-2 max-w-lg text-sm leading-relaxed text-muted-foreground">
176+ (description)
177+ </p>
178+ },
179+ None => view! {},
180+ }
181+ }?)
182+ <p class="mt-2.5 font-mono text-[11px] text-muted-foreground/80">
183+ "updated " (ago(repo.updated_at))
184+ </p>
185+ </section>
186+ }
187+}
188+
189+/// A flat list of repositories. Rows and hairlines, never boxes.
190+#[component]
191+pub(super) async fn repo_rows(handle: &str, repos: &[RepoSummary]) -> Result {
192+ view! {
193+ <div class="mt-1">
194+ for repo in repos {
195+ <a
196+ href=(format!("/{handle}/repos/{}", repo.name))
197+ class="group -mx-2.5 flex items-baseline justify-between gap-4 rounded px-2.5 py-2.5 hover:bg-surface"
198+ >
199+ <div>
200+ <div class="flex items-baseline gap-2">
201+ <span class="text-sm font-medium group-hover:underline">
202+ (repo.name.as_str())
203+ </span>
204+ if !repo.visibility.is_public() {
205+ badge(variant: BadgeVariant::Outline, "Private")
206+ }
207+ </div>
208+ ({
209+ match &repo.description {
210+ Some(description) => view! {
211+ <p class="mt-0.5 text-[13px] leading-snug text-muted-foreground">
212+ (description)
213+ </p>
214+ },
215+ None => view! {},
216+ }
217+ }?)
218+ </div>
219+ <span class="shrink-0 font-mono text-[11px] text-muted-foreground">
220+ (ago(repo.updated_at))
221+ </span>
222+ </a>
223+ }
224+ </div>
225+ }
226+}
227+
58228 #[page("/{handle}")]
59229 async fn profile(cx: &Cx) -> Result {
60230 let profile = profile_for(cx).await?;
@@ -72,65 +242,115 @@ async fn profile(cx: &Cx) -> Result {
72242 .map_err(server_error)?
73243 .unwrap_or_default();
74244
245+ // The lead comes out of the listing rather than a second query, so the two can
246+ // never disagree about which repository is pinned.
247+ let lead_repo = listed.iter().find(|repo| repo.pinned).cloned();
248+ let rest: Vec<RepoSummary> = listed
249+ .iter()
250+ .filter(|repo| !repo.pinned)
251+ .take(PREVIEW)
252+ .cloned()
253+ .collect();
254+ let more = listed
255+ .len()
256+ .saturating_sub(rest.len() + usize::from(lead_repo.is_some()));
257+ let handle = profile.handle.to_string();
258+
75259 view! {
76 <header class="mb-10">
77 <h1 class="text-2xl font-semibold tracking-tight">(&profile.label)</h1>
78 <p class="mt-1 font-mono text-sm text-muted-foreground">
79 "@" (profile.handle.as_str())
80 </p>
81 ({
82 match &profile.bio {
83 Some(bio) => view! { <p class="mt-3 text-sm leading-relaxed">(bio)</p> },
84 None => view! {},
85 }
86 }?)
87 ({
88 if profile.viewer_is_owner {
89 view! {
90 <p class="mt-4">
91 <a
92 href=(format!("/{}/settings", profile.handle))
93 class=(button_variants(ButtonVariant::Outline, ButtonSize::Sm))
94 >"Edit profile"</a>
95 </p>
260+ profile_chrome(identity: &profile, active: Tab::Overview, repo_count: listed.len())
261+
262+ ({
263+ match &lead_repo {
264+ Some(repo) => view! { lead(handle: handle.as_str(), repo: repo) },
265+ None => view! {},
266+ }
267+ }?)
268+
269+ if !rest.is_empty() {
270+ <section class="mt-8">
271+ <div class="flex items-baseline justify-between">
272+ <h2 class="text-[11px] font-medium uppercase tracking-[0.1em] text-muted-foreground/70">
273+ "Repositories"
274+ </h2>
275+ if more > 0 {
276+ <a
277+ href=(format!("/{handle}/repos"))
278+ class="text-xs text-muted-foreground hover:text-foreground"
279+ >"All " (listed.len().to_string()) " \u{2192}"</a>
96280 }
97 } else {
98 view! {}
99 }
100 }?)
101 </header>
281+ </div>
282+ repo_rows(handle: handle.as_str(), repos: &rest)
283+ </section>
284+ }
285+
286+ // Nothing to show. A visitor gets silence rather than an empty box advertising
287+ // incompleteness; the owner gets the way to fix it.
288+ if listed.is_empty() {
289+ if profile.viewer_is_owner {
290+ <div class="mt-8">
291+ <p class="text-sm text-muted-foreground">"No repositories yet."</p>
292+ <p class="mt-3">
293+ <a
294+ href=(format!("/{handle}/repos/new"))
295+ class=(button_variants(ButtonVariant::Outline, ButtonSize::Sm))
296+ >"New repository"</a>
297+ </p>
298+ </div>
299+ }
300+ }
301+ }
302+}
102303
103 <section class="mt-8">
104 <div class="flex items-center justify-between">
105 <h2 class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
106 "Repositories"
107 </h2>
108 if profile.viewer_is_owner {
304+/// Every repository under a handle that the viewer may see.
305+///
306+/// The tab's destination, and where completeness lives — the overview deliberately
307+/// previews only [`PREVIEW`] of them.
308+#[page("/{handle}/repos")]
309+async fn repos_index(cx: &Cx) -> Result {
310+ // Not `profile`: the `#[page] async fn profile` above is a unit struct in this
311+ // module, so `let profile = …` parses as a unit-struct pattern rather than a new
312+ // binding. The error names neither the page nor the shadowing.
313+ let identity = profile_for(cx).await?;
314+
315+ let listed = list_repos(
316+ &identity.handle,
317+ &current_actor(cx).await?,
318+ &orgs(cx),
319+ &memberships(cx),
320+ &repos(cx),
321+ )
322+ .await
323+ .map_err(server_error)?
324+ .unwrap_or_default();
325+
326+ let handle = identity.handle.to_string();
327+ let new_repo = format!("/{handle}/repos/new");
328+
329+ view! {
330+ profile_chrome(identity: &identity, active: Tab::Repositories, repo_count: listed.len())
331+
332+ if listed.is_empty() {
333+ <p class="mt-8 text-sm text-muted-foreground">"No repositories yet."</p>
334+ if identity.viewer_is_owner {
335+ <p class="mt-3">
109336 <a
110 href=(format!("/{}/repos/new", profile.handle))
337+ href=(new_repo.as_str())
111338 class=(button_variants(ButtonVariant::Outline, ButtonSize::Sm))
112339 >"New repository"</a>
340+ </p>
341+ }
342+ } else {
343+ <div class="mt-6">
344+ if identity.viewer_is_owner {
345+ <p class="mb-2 flex justify-end">
346+ <a
347+ href=(new_repo.as_str())
348+ class=(button_variants(ButtonVariant::Outline, ButtonSize::Sm))
349+ >"New repository"</a>
350+ </p>
113351 }
352+ repo_rows(handle: handle.as_str(), repos: &listed)
114353 </div>
115 repo_list(handle: profile.handle.as_str(), repos: &listed)
116 </section>
117
118 <section class="mt-8">
119 <h2 class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
120 "Writing"
121 </h2>
122 <p class="mt-2 rounded-lg border border-border px-4 py-6 text-center text-sm text-muted-foreground">
123 "Nothing here yet."
124 </p>
125 </section>
126
127 <section class="mt-8">
128 <h2 class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
129 "Projects"
130 </h2>
131 <p class="mt-2 rounded-lg border border-border px-4 py-6 text-center text-sm text-muted-foreground">
132 "Nothing here yet."
133 </p>
134 </section>
354+ }
135355 }
136356 }
src/infrastructure/web/repo.rs+1 −39View file
@@ -18,9 +18,7 @@ use topcoat::{
1818 };
1919
2020 use crate::{
21 application::{
22 Browsed, Error, FileView, NewRepo, RepoSummary, RepoView, create_repo, view_repo,
23 },
21+ application::{Browsed, Error, FileView, NewRepo, RepoView, create_repo, view_repo},
2422 components::{
2523 badge::{BadgeVariant, badge},
2624 button::{ButtonSize, ButtonVariant, button, button_variants},
@@ -504,42 +502,6 @@ pub(super) async fn clone_url(url: &str) -> Result {
504502 </div>
505503 }
506504 }
507
508#[component]
509pub(super) async fn repo_list(handle: &str, repos: &[RepoSummary]) -> Result {
510 view! {
511 if repos.is_empty() {
512 <p class="mt-2 rounded-lg border border-border px-4 py-6 text-center text-sm text-muted-foreground">
513 "Nothing here yet."
514 </p>
515 } else {
516 <ul class="mt-2 divide-y divide-border rounded-lg border border-border">
517 for repo in repos {
518 <li class="px-4 py-3">
519 <div class="flex items-baseline gap-2">
520 <a
521 href=(format!("/{handle}/repos/{}", repo.name))
522 class="font-medium hover:underline"
523 >(repo.name.as_str())</a>
524 if !repo.visibility.is_public() {
525 badge(variant: BadgeVariant::Outline, "Private")
526 }
527 </div>
528 ({
529 match &repo.description {
530 Some(description) => view! {
531 <p class="mt-0.5 text-sm text-muted-foreground">(description)</p>
532 },
533 None => view! {},
534 }
535 }?)
536 </li>
537 }
538 </ul>
539 }
540 }
541}
542
543505 #[cfg(test)]
544506 mod tests {
545507 use crate::domain::ObjectId;
src/infrastructure/web/repo_settings.rs+53 −8View file
@@ -44,6 +44,9 @@ use super::{
4444 struct SettingsForm {
4545 description: String,
4646 visibility: String,
47+ /// A checkbox: present when ticked, absent entirely when not. `Option` is the shape
48+ /// the browser actually sends, so it is the shape parsed.
49+ pinned: Option<String>,
4750 }
4851
4952 #[derive(Debug, Deserialize)]
@@ -88,8 +91,11 @@ async fn repo_settings_page(cx: &Cx) -> Result {
8891 settings_view(
8992 handle: repo.handle.as_str(),
9093 name: repo.name.as_str(),
91 description: repo.description.as_deref().unwrap_or(""),
92 visibility: repo.visibility,
94+ fields: Fields {
95+ description: repo.description.as_deref().unwrap_or(""),
96+ visibility: repo.visibility,
97+ pinned: repo.pinned,
98+ },
9399 saved: saved,
94100 error: "",
95101 )
@@ -122,6 +128,7 @@ async fn save(cx: &Cx, Form(submitted): Form<SettingsForm>) -> Result {
122128 &RepoEdit {
123129 description: optional(&submitted.description),
124130 visibility,
131+ pinned: submitted.pinned.is_some(),
125132 },
126133 &orgs(cx),
127134 &memberships(cx),
@@ -155,8 +162,11 @@ async fn save(cx: &Cx, Form(submitted): Form<SettingsForm>) -> Result {
155162 settings_view(
156163 handle: repo.handle.as_str(),
157164 name: repo.name.as_str(),
158 description: submitted.description.as_str(),
159 visibility: visibility,
165+ fields: Fields {
166+ description: submitted.description.as_str(),
167+ visibility,
168+ pinned: submitted.pinned.is_some(),
169+ },
160170 saved: false,
161171 error: message.as_str(),
162172 )
@@ -188,8 +198,11 @@ async fn delete(cx: &Cx, Form(submitted): Form<DeleteForm>) -> Result {
188198 settings_view(
189199 handle: repo.handle.as_str(),
190200 name: repo.name.as_str(),
191 description: repo.description.as_deref().unwrap_or(""),
192 visibility: repo.visibility,
201+ fields: Fields {
202+ description: repo.description.as_deref().unwrap_or(""),
203+ visibility: repo.visibility,
204+ pinned: repo.pinned,
205+ },
193206 saved: false,
194207 error: message.as_str(),
195208 )
@@ -220,6 +233,15 @@ async fn delete(cx: &Cx, Form(submitted): Form<DeleteForm>) -> Result {
220233 }
221234 }
222235
236+/// The three values the form edits, grouped so the view takes one argument for them
237+/// rather than three that must be kept in the same order at four call sites.
238+#[derive(Debug, Clone, Copy)]
239+struct Fields<'a> {
240+ description: &'a str,
241+ visibility: Visibility,
242+ pinned: bool,
243+}
244+
223245 /// The settings page.
224246 ///
225247 /// Values arrive as parameters rather than being read back from storage, so a rejected
@@ -228,11 +250,16 @@ async fn delete(cx: &Cx, Form(submitted): Form<DeleteForm>) -> Result {
228250 async fn settings_view(
229251 handle: &str,
230252 name: &str,
231 description: &str,
232 visibility: Visibility,
253+ fields: Fields<'_>,
233254 saved: bool,
234255 error: &str,
235256 ) -> Result {
257+ let Fields {
258+ description,
259+ visibility,
260+ pinned,
261+ } = fields;
262+
236263 view! {
237264 <h1 class="text-xl font-semibold tracking-tight">"Repository settings"</h1>
238265 <p class="mt-1 font-mono text-sm text-muted-foreground">
@@ -287,6 +314,24 @@ async fn settings_view(
287314 </p>
288315 </div>
289316
317+ <div class="space-y-2">
318+ <div class="flex items-start gap-2">
319+ <input
320+ id="pinned"
321+ name="pinned"
322+ type="checkbox"
323+ value="on"
324+ checked=(pinned)
325+ class="mt-0.5"
326+ />
327+ label(attrs: attributes! { for="pinned" }, "Lead my profile with this")
328+ </div>
329+ <p class="text-xs text-muted-foreground">
330+ "One repository at a time. Choosing this one lets go of whichever "
331+ "was chosen before."
332+ </p>
333+ </div>
334+
290335 <div class="flex items-center gap-3">
291336 button(attrs: attributes! { type="submit" }, "Save")
292337 <a