steid

@jamesgill /

feat: create and view repositories through the browser

`view_repo` plus `/{handle}/repos/new` and `/{handle}/repos/{name}`. The owner creates a
repository through a form and lands on its page; a bare repo appears on disk.

`list_repos` is deliberately not here. It has no consumer until the profile's
Repositories section, and the last three steps were each a layer with no caller — this
way the remaining steps each ship something visible.

A repository the viewer may not see and one that does not exist are the same answer:
`view_repo` returns `None` for both and the page 404s identically. A 403 would confirm a
private repository exists and reveal its name. Private repos are visible to any member,
not only the owner — seeing is weaker than changing.

Browser verification caught a bug that has been shipped since Milestone 2: `redirect()`
is a **307**, which preserves the method, so POSTing the settings form redirected the
browser into re-POSTing to the target. Every test passed, because the tests are on the
use case and the bug is in the reply. Post/redirect/get needs a 303, but `see_other()`
is a response type and `#[page]` must return a view for the layout to wrap a failure
re-render; `RedirectError::new` is private and the error-to-response path only downcasts
topcoat's own types, so a custom 303 error becomes a 500. The way through is a
`StatusCode` and `Location` pair inside `view!`, wrapped as `context::location`. Both
forms now reply 303 and both were checked by following the redirect.

CLAUDE.md's Topcoat note is corrected — as written it pointed straight at the bug.

`select` and `badge` copied in from the registry; `select` needs the `icon-iconify`
feature and the `feather` set staged in build.rs for its chevron. No new crates.

182 tests, clippy clean. Verified against a throwaway database and data directory: the
dev database and ./data were not touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JamesPatrickGill authored 24 days agoparent11e7a39Browse files285f5fd29aabe8814c7b94bca3a89b3e176410e8

18 files changed+841 −32

CLAUDE.md+10 −3View file
@@ -99,12 +99,19 @@ Working knowledge that is easy to get wrong and slow to rediscover:
9999 Handle(str);` — and the struct name snake-cased is the URL parameter.
100100 - **`#[query_params]` needs `error = …`** to be usable with `?`; otherwise the error
101101 borrows from `cx` and escapes the handler.
102- **`redirect()` is an error type, `see_other()` is a response type.** A page returning
103 a view redirects with `Err(redirect(..).into())`.
102+- **`redirect()` is a 307 and preserves the method**, so it must never end a form POST —
103+ the browser re-POSTs to the target. Post/redirect/get needs a 303. `see_other()` is
104+ that status but is a *response* type, and `#[page]` must return a view so the layout
105+ can wrap a failure re-render, so use `web::context::location` with
106+ `StatusCode::SEE_OTHER` inside `view!`. `Err(redirect(..).into())` is still right for
107+ a **GET** guard sending a visitor elsewhere.
104108 - **Static routes beat parameterised ones**, so `/auth/login` still wins over
105109 `/{handle}`.
106110 - **Forms redirect on success and re-render on failure.** Redirecting after a validation
107 error discards what was typed and hides the reason.
111+ error discards what was typed and hides the reason. The success redirect is a 303 —
112+ see above.
113+- **Boolean attributes need a value** — `required=(true)`, not bare `required`. A `false`
114+ omits the attribute entirely, so `selected=(bool)` is correct.
108115 - **UI components reference theme tokens, never raw colours** — see `styles.css`. A
109116 hardcoded colour follows neither a palette change nor the colour scheme. Registry
110117 components are copied in by `topcoat ui add`, not depended on.
Cargo.lock+4 −0View file
@@ -2311,9 +2311,13 @@ version = "0.5.0"
23112311 source = "registry+https://github.com/rust-lang/crates.io-index"
23122312 checksum = "156f6c4fa5b119a8d0d9702d4824d998622c4f8b99dc5ef2678f68ff535b3471"
23132313 dependencies = [
2314+ "serde",
2315+ "serde_json",
2316+ "thiserror",
23142317 "topcoat-core",
23152318 "topcoat-view",
23162319 "topcoat-view-macro",
2320+ "ureq",
23172321 ]
23182322
23192323 [[package]]
Cargo.toml+2 −2View file
@@ -12,11 +12,11 @@ serde = { version = "1.0.229", features = ["derive"] }
1212 sqlx = { version = "0.9.0", default-features = false, features = ["runtime-tokio", "sqlite", "macros", "migrate"] }
1313 subtle = "2.6.1"
1414 tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "process", "fs"] }
15topcoat = { version = "0.5.0", features = ["tailwind", "ui"] }
15+topcoat = { version = "0.5.0", features = ["icon-iconify", "tailwind", "ui"] }
1616 uuid = { version = "1.24.0", features = ["v4"] }
1717
1818 [build-dependencies]
19topcoat = { version = "0.5.0", default-features = false, features = ["tailwind"] }
19+topcoat = { version = "0.5.0", default-features = false, features = ["tailwind", "icon-iconify"] }
2020
2121 [dev-dependencies]
2222 tempfile = "3.27.0"
build.rs+12 −3View file
@@ -1,11 +1,20 @@
1//! Runs Tailwind over `styles.css`.
1+//! Build-time asset generation.
22 //!
3//! Topcoat wraps the standalone Tailwind CLI — no Node, no PostCSS. The generated CSS
4//! lands in `OUT_DIR` and is served by the asset bundler with a content-hashed URL.
3+//! Two steps: Tailwind over `styles.css`, and staging the Iconify sets that components
4+//! reference. Topcoat wraps the standalone Tailwind CLI — no Node, no PostCSS. The
5+//! generated CSS lands in `OUT_DIR` and is served by the asset bundler with a
6+//! content-hashed URL.
57
68 fn main() {
79 topcoat::tailwind::BuildConfig::new()
810 .input("styles.css")
911 .render()
1012 .unwrap();
13+
14+ // `feather` is what the copied-in `select` component draws its chevron from.
15+ // Icons are staged at build time and embedded, so nothing is fetched at runtime.
16+ topcoat::icon::iconify::BuildConfig::new()
17+ .icon_set("feather")
18+ .stage()
19+ .unwrap();
1120 }
components.toml+8 −0View file
@@ -8,6 +8,10 @@ registry = "topcoat"
88 hash = "sha256:c7bdcb5ea4ff757611ba616e92178169b262be98155c06dfa2407584d51e7459"
99 file = "styles.css"
1010
11+[registries.topcoat.components.badge]
12+hash = "sha256:ee86a6d59d019450aad3274da752d9ad490885d58edf434c56a95f4afdaaa95e"
13+file = "src/components/badge.rs"
14+
1115 [registries.topcoat.components.button]
1216 hash = "sha256:8dcc9609183d0a51138c7a15de06d5525388f25a3958071fab25b96efaa2b160"
1317 file = "src/components/button.rs"
@@ -24,6 +28,10 @@ file = "src/components/input.rs"
2428 hash = "sha256:cd8e7569717c82abad745f0586f03fbc759e6000988a82c8dce15182a409d0d6"
2529 file = "src/components/label.rs"
2630
31+[registries.topcoat.components.select]
32+hash = "sha256:5d6bc5c890c12fad1b004569212b6bb83fda3a1e2bd5da261225845419dfbc85"
33+file = "src/components/select.rs"
34+
2735 [registries.topcoat.components.textarea]
2836 hash = "sha256:852407d012c458ea3b43fafa69fda9fa09287071dc0e1d2367671fe2c9173309"
2937 file = "src/components/textarea.rs"
plans/current.md+20 −3View file
@@ -24,9 +24,10 @@ problem twice as interesting, so not in the first pass.
2424 - [x] Infrastructure: `DiskGitStorage`, shelling out to `git init --bare`
2525 - [x] Application: `create_repo` use case — owner only, validates, creates record and
2626 bare repo
27- [ ] Application: `list_repos` / `view_repo` read models — visibility-aware
28- [ ] Web: `/{handle}/repos/new` form, `/{handle}/repos/{name}` page
29- [ ] Web: the profile's Repositories section lists what the viewer may see
27+- [x] Application: `view_repo` read model — visibility-aware
28+- [x] Web: `/{handle}/repos/new` form, `/{handle}/repos/{name}` page
29+- [ ] Application: `list_repos` + the profile's Repositories section listing what the
30+ viewer may see
3031 - [ ] `/api/users/{handle}/repos`
3132
3233 ### Done when
@@ -38,6 +39,15 @@ milestone 4.
3839
3940 ### Settled
4041
42+- **`list_repos` was split from `view_repo`** and moved to the listing step. `view_repo`
43+ had a consumer immediately; `list_repos` would have been a third read model with no
44+ caller, which is what the previous three steps already were.
45+- **An invisible private repo is `None`, not `Forbidden`.** `view_repo` answers the same
46+ way for "does not exist" and "not allowed to see", and the page 404s identically. A
47+ 403 would confirm the repository exists and leak its name.
48+- **Private repos are visible to any member**, not only the owner — seeing is weaker
49+ than changing, matching attempt #2's clone rule.
50+
4151 - **Repo name rules:** `OrgName`'s, plus `.` and `_` for names like `.github` and
4252 `foo.js`. Lowercased, max 100. Also rejects a name of nothing but dots and any name
4353 ending `.git` — the first is traversal, the second would live at `foo.git.git`.
@@ -61,6 +71,13 @@ Nothing open. `GitStorage`'s shape and how git is invoked are recorded in
6171
6272 ### Watch for
6373
74+- **`redirect()` is a 307 and re-POSTs.** Post/redirect/get needs a 303. `see_other()`
75+ is the right status but is a *response* type, and `#[page]` must return a view so the
76+ layout can wrap a failure re-render. The way to get both from one handler is a
77+ `StatusCode` and a `Location` pair inside `view!` — wrapped as
78+ `context::location`. This shipped broken in Milestone 2's settings form and was
79+ caught by browser verification, not by any test.
80+
6481 - **An orphaned directory is indistinguishable from a duplicate to the visitor.**
6582 `create_repo` maps `GitStorageError::AlreadyExists` to "that name is taken", which is
6683 true from outside but hides the inconsistency from the operator. There is no logging
plans/progress.md+20 −1View file
@@ -2,7 +2,7 @@
22
33 ## This attempt (#3, Topcoat)
44
5174 tests. Active milestone in [current.md](current.md).
5+182 tests. Active milestone in [current.md](current.md).
66
77 ### Milestone 0 — Skeleton · done
88
@@ -134,6 +134,25 @@ it. How git is invoked is recorded in
134134 - **`tempfile` for test fixtures, not `target/`.** Parallel-safe by construction and
135135 self-cleaning on panic. Debris under `target/` would be actively harmful here, since
136136 `init_bare` refuses a path that already exists.
137+- **`redirect()` is a 307, and 307 preserves the method.** Post/redirect/get needs a
138+ 303, or the browser re-POSTs the form to its redirect target. Milestone 2's settings
139+ form shipped with this and nothing caught it — every test passed, because the tests
140+ are on the use case and the bug is in the reply. Found by following the redirect with
141+ curl. The fix is a `StatusCode::SEE_OTHER` plus a `Location` pair inside `view!`,
142+ wrapped as `web::context::location`, because `see_other()` is a response type and
143+ `#[page]` must return a view for the layout to wrap the failure re-render.
144+ `RedirectError::new` is private, so a 303 cannot be built as an error, and the
145+ error-to-response path only downcasts topcoat's own error types — a custom one
146+ becomes a 500.
147+- **`#[page]` returns a view; `#[route]` returns a response.** That is the whole reason
148+ the redirect is spelled awkwardly: a form handler needs both a redirect and a
149+ full-page re-render, and only the view path gets the layout.
150+- **Boolean HTML attributes take an explicit value in `view!`** — `required=(true)`, not
151+ bare `required`, which fails to parse. `false` omits the attribute entirely, so
152+ `selected=(bool)` on an `<option>` is correct rather than rendering `selected="false"`.
153+- **`topcoat ui add select` needs the `icon-iconify` feature and a staged icon set.**
154+ The chevron comes from `feather`, staged in `build.rs`. No new crates, but the build
155+ fails with a clear message until the set is staged.
137156 - **`is_org_owner` moved to `application/authz.rs`** on its second caller. Owner-ness
138157 gates the profile edit, repo creation, and later PATs and push; two copies of an
139158 authorization predicate drift, and the direction they drift is open.
src/application/authz.rs+17 −0View file
@@ -27,3 +27,20 @@ pub(crate) async fn is_org_owner(
2727 .await?
2828 .is_some_and(|membership| membership.role == Role::Owner))
2929 }
30+
31+/// Whether the actor belongs to this organisation at all, in any role.
32+///
33+/// The weaker predicate: it gates *seeing* a private repository, where
34+/// [`is_org_owner`] gates changing things. Keeping them separate is what stops a
35+/// read rule and a write rule from being accidentally satisfied by the same check.
36+pub(crate) async fn is_org_member(
37+ org: &Organization,
38+ actor: &Actor,
39+ memberships: &impl MembershipRepository,
40+) -> Result<bool> {
41+ let Some(user_id) = actor.user_id() else {
42+ return Ok(false);
43+ };
44+
45+ Ok(memberships.find(&org.id, user_id).await?.is_some())
46+}
src/application/mod.rs+1 −1View file
@@ -20,5 +20,5 @@ pub use error::{Error, Result};
2020 pub use identity::{Identity, describe_identity};
2121 pub use login::login;
2222 pub use profile::{PublicProfile, update_profile, view_profile};
23pub use repo::{NewRepo, create_repo};
23+pub use repo::{NewRepo, RepoView, create_repo, view_repo};
2424 pub use session::{SESSION_LIFETIME, end_session, record_session, resolve_actor, sweep_expired};
src/application/repo.rs+197 −2View file
@@ -1,10 +1,10 @@
11 use crate::domain::{
2 Actor, DomainError, OrgName, RepoId, Repository, Visibility,
2+ Actor, DomainError, OrgName, RepoId, RepoName, Repository, Visibility,
33 repository::{MembershipRepository, OrgRepository, RepoRepository},
44 };
55
66 use super::{
7 authz::is_org_owner,
7+ authz::{is_org_member, is_org_owner},
88 error::{Error, Result},
99 port::{GitStorage, GitStorageError},
1010 };
@@ -99,6 +99,57 @@ pub async fn create_repo(
9999 Ok(repo)
100100 }
101101
102+/// A repository as a viewer is allowed to see it.
103+#[derive(Debug, Clone, PartialEq, Eq)]
104+pub struct RepoView {
105+ /// The owning handle, carried so a page can build links without a second lookup.
106+ pub handle: OrgName,
107+ pub name: RepoName,
108+ pub description: Option<String>,
109+ pub visibility: Visibility,
110+ /// Whether the viewer may change this repository. Decided here so a page and
111+ /// `/api` cannot disagree about who sees a management control.
112+ pub viewer_is_owner: bool,
113+}
114+
115+/// Resolves a handle and name into a repository the viewer may see.
116+///
117+/// `Ok(None)` covers **both** "no such repository" and "not allowed to see it", and
118+/// the caller must render them identically. Distinguishing them would confirm that a
119+/// private repository exists and reveal its name, which is the thing being protected —
120+/// a private repo has to be absent, not merely unlinked.
121+///
122+/// Private repositories are visible to any member of the owning organisation, not only
123+/// its owner: seeing is weaker than changing.
124+pub async fn view_repo(
125+ handle: &OrgName,
126+ name: &RepoName,
127+ actor: &Actor,
128+ orgs: &impl OrgRepository,
129+ memberships: &impl MembershipRepository,
130+ repos: &impl RepoRepository,
131+) -> Result<Option<RepoView>> {
132+ let Some(org) = orgs.find_by_name(handle).await? else {
133+ return Ok(None);
134+ };
135+
136+ let Some(repo) = repos.find_by_org_and_name(&org.id, name).await? else {
137+ return Ok(None);
138+ };
139+
140+ if !repo.visibility.is_public() && !is_org_member(&org, actor, memberships).await? {
141+ return Ok(None);
142+ }
143+
144+ Ok(Some(RepoView {
145+ handle: org.name.clone(),
146+ name: repo.name,
147+ description: repo.description,
148+ visibility: repo.visibility,
149+ viewer_is_owner: is_org_owner(&org, actor, memberships).await?,
150+ }))
151+}
152+
102153 fn taken() -> Error {
103154 DomainError::AlreadyExists {
104155 entity: "repository",
@@ -536,4 +587,148 @@ mod tests {
536587 );
537588 assert!(dir.path().join("acme").join("steid.git").is_dir());
538589 }
590+
591+ // --- view_repo -------------------------------------------------------------
592+
593+ impl Fixture {
594+ async fn view(&self, actor: &Actor, name: &str) -> Option<RepoView> {
595+ view_repo(
596+ &self.handle,
597+ &RepoName::new(name).expect("valid name"),
598+ actor,
599+ &self.orgs,
600+ &self.memberships,
601+ &self.repos,
602+ )
603+ .await
604+ .expect("lookup should not error")
605+ }
606+
607+ async fn create_with(&self, visibility: Visibility, name: &str) -> Repository {
608+ self.create(
609+ &self.owner,
610+ &NewRepo {
611+ name: name.to_owned(),
612+ description: None,
613+ visibility,
614+ },
615+ )
616+ .await
617+ .expect("should create")
618+ }
619+ }
620+
621+ #[tokio::test]
622+ async fn a_public_repository_is_visible_to_anyone() {
623+ let f = fixture().await;
624+ f.create_with(Visibility::Public, "steid").await;
625+
626+ for actor in [&Actor::Anonymous, &f.stranger, &f.member, &f.owner] {
627+ assert!(
628+ f.view(actor, "steid").await.is_some(),
629+ "public repo should be visible to {actor:?}"
630+ );
631+ }
632+ }
633+
634+ #[tokio::test]
635+ async fn a_private_repository_is_absent_for_outsiders() {
636+ // `None`, not an error: distinguishing "forbidden" from "missing" would confirm
637+ // the repository exists and reveal its name.
638+ let f = fixture().await;
639+ f.create_with(Visibility::Private, "secret").await;
640+
641+ assert!(f.view(&Actor::Anonymous, "secret").await.is_none());
642+ assert!(f.view(&f.stranger, "secret").await.is_none());
643+ }
644+
645+ #[tokio::test]
646+ async fn a_private_repository_is_visible_to_any_member() {
647+ // Seeing is weaker than changing: a member who may not create repositories may
648+ // still read the private ones.
649+ let f = fixture().await;
650+ f.create_with(Visibility::Private, "secret").await;
651+
652+ assert!(f.view(&f.member, "secret").await.is_some());
653+ assert!(f.view(&f.owner, "secret").await.is_some());
654+ }
655+
656+ #[tokio::test]
657+ async fn viewer_is_owner_tracks_the_actor() {
658+ let f = fixture().await;
659+ f.create_with(Visibility::Public, "steid").await;
660+
661+ assert!(
662+ f.view(&f.owner, "steid")
663+ .await
664+ .expect("visible")
665+ .viewer_is_owner
666+ );
667+ for actor in [&Actor::Anonymous, &f.stranger, &f.member] {
668+ assert!(
669+ !f.view(actor, "steid")
670+ .await
671+ .expect("visible")
672+ .viewer_is_owner,
673+ "{actor:?} should not be treated as owner"
674+ );
675+ }
676+ }
677+
678+ #[tokio::test]
679+ async fn an_unknown_repository_is_absent() {
680+ let f = fixture().await;
681+ f.create_with(Visibility::Public, "steid").await;
682+
683+ assert!(f.view(&f.owner, "nothing-here").await.is_none());
684+ }
685+
686+ #[tokio::test]
687+ async fn an_unknown_handle_is_absent() {
688+ let f = fixture().await;
689+ let missing = OrgName::new("nobody").expect("valid handle");
690+
691+ let found = view_repo(
692+ &missing,
693+ &RepoName::new("steid").expect("valid"),
694+ &f.owner,
695+ &f.orgs,
696+ &f.memberships,
697+ &f.repos,
698+ )
699+ .await
700+ .expect("lookup should not error");
701+
702+ assert!(found.is_none());
703+ }
704+
705+ #[tokio::test]
706+ async fn the_view_carries_what_a_page_needs() {
707+ let f = fixture().await;
708+ f.create(
709+ &f.owner,
710+ &NewRepo {
711+ name: "steid".to_owned(),
712+ description: Some("A gitforge.".to_owned()),
713+ visibility: Visibility::Private,
714+ },
715+ )
716+ .await
717+ .expect("should create");
718+
719+ let view = f.view(&f.owner, "steid").await.expect("visible");
720+
721+ assert_eq!(view.handle.as_str(), "acme");
722+ assert_eq!(view.name.as_str(), "steid");
723+ assert_eq!(view.description.as_deref(), Some("A gitforge."));
724+ assert_eq!(view.visibility, Visibility::Private);
725+ }
726+
727+ #[tokio::test]
728+ async fn lookup_is_case_insensitive_through_the_name_type() {
729+ let f = fixture().await;
730+ f.create_with(Visibility::Public, "MyRepo").await;
731+
732+ assert!(f.view(&Actor::Anonymous, "myrepo").await.is_some());
733+ }
539734 }
src/components.rs+2 −0View file
@@ -11,9 +11,11 @@
1111 //! `topcoat ui add` rewrites the module list below, so keep hand-written entries
1212 //! alphabetical among the rest and expect it to reorder them.
1313
14+pub mod badge;
1415 pub mod button;
1516 pub mod card;
1617 pub mod flash;
1718 pub mod input;
1819 pub mod label;
20+pub mod select;
1921 pub mod textarea;
src/components/badge.rs+86 −0View file
@@ -0,0 +1,86 @@
1+use topcoat::{
2+ Result,
3+ view::{Attributes, View, class, component, view},
4+};
5+
6+/// The visual style of a [`badge`].
7+///
8+/// [`Default`] is `BadgeVariant::Primary`, used when no variant is given.
9+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
10+#[allow(dead_code)]
11+pub enum BadgeVariant {
12+ /// The primary-filled badge for highlighted statuses.
13+ #[default]
14+ Primary,
15+ /// A muted, tinted fill for neutral statuses.
16+ Secondary,
17+ /// A hairline-bordered badge on the page background.
18+ Outline,
19+ /// A destructive-filled badge for errors and warnings.
20+ Destructive,
21+}
22+
23+impl BadgeVariant {
24+ /// The Tailwind classes for this variant.
25+ ///
26+ /// Each variant sets its own border color rather than inheriting a
27+ /// transparent one from [`BASE`]: with two border-color classes on the
28+ /// same element, stylesheet order (not class order) would decide the
29+ /// winner.
30+ fn classes(self) -> &'static str {
31+ match self {
32+ Self::Primary => "border-transparent bg-primary text-primary-foreground",
33+ Self::Secondary => "border-transparent bg-foreground/5 text-foreground",
34+ Self::Outline => "border-border text-foreground",
35+ Self::Destructive => "border-transparent bg-destructive text-destructive-foreground",
36+ }
37+ }
38+}
39+
40+/// The classes shared by every badge, regardless of variant.
41+///
42+/// Every badge carries a border (colored per variant) so that the `Outline`
43+/// variant, which only recolors it, does not change the badge's dimensions.
44+const BASE: &str = "inline-flex w-fit shrink-0 items-center justify-center gap-1 rounded-md \
45+ border px-2 py-0.5 text-xs font-medium whitespace-nowrap";
46+
47+/// Builds the full class string for a badge of the given `variant`.
48+///
49+/// Use it to give badge styling to another element, such as a link:
50+///
51+/// ```ignore
52+/// view! {
53+/// <a href="/releases/v2" class=(badge_variants(BadgeVariant::Outline))>"v2.0"</a>
54+/// }
55+/// ```
56+#[must_use]
57+pub fn badge_variants(variant: BadgeVariant) -> String {
58+ format!("{BASE} {}", variant.classes())
59+}
60+
61+/// A badge component: a small inline pill for statuses, counts, and tags.
62+///
63+/// The `variant` parameter selects the styling, defaulting to `Primary`. The
64+/// `attrs` (such as `class` or `title`) are forwarded to the underlying
65+/// `<span>`; a `class` among them is appended to the computed classes. Child
66+/// nodes become the badge's content.
67+///
68+/// ```ignore
69+/// view! {
70+/// badge(variant: BadgeVariant::Destructive, "Failed")
71+/// }
72+/// ```
73+///
74+/// To style another element like a badge, use [`badge_variants`] directly.
75+#[component]
76+pub async fn badge(
77+ #[default] variant: BadgeVariant,
78+ #[default] mut attrs: Attributes,
79+ #[default] child: View,
80+) -> Result {
81+ view! {
82+ <span class=(class!(BASE, variant.classes(), attrs.remove("class"))) (attrs)>
83+ (child)
84+ </span>
85+ }
86+}
src/components/select.rs+124 −0View file
@@ -0,0 +1,124 @@
1+use topcoat::{
2+ Result,
3+ context::Cx,
4+ icon::{IconData, icon, iconify::iconify_icon},
5+ view::{Attributes, View, attributes, class, component, view},
6+};
7+
8+/// The classes for the native `<select>` inside the [`select`] component.
9+///
10+/// Sized to match the input control. The native dropdown arrow is suppressed
11+/// so the component can draw its own chevron, which keeps the control looking
12+/// the same across browsers; the extra right padding reserves the chevron's
13+/// space.
14+const SELECT: &str = "h-9 w-full appearance-none items-center rounded-lg border border-border \
15+ bg-background pr-8 pl-3 text-left text-sm shadow-xs transition-colors outline-none \
16+ focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 \
17+ focus-visible:ring-offset-background disabled:pointer-events-none";
18+
19+/// The classes restyling the drop-down picker, for browsers that support
20+/// customizable selects (`appearance: base-select`, set on the `<select>` by
21+/// the component's wrapper).
22+///
23+/// The panel and its option rows take after the dropdown menu's content and
24+/// items: the same raised surface, the same ghost-tinted hover and focus
25+/// states, and the checked option marked by a checkmark on the row's right
26+/// edge: the [`CHECKMARK`] icon, masked over the theme's muted foreground
27+/// (see [`checkmark_style`]). The browser's own picker icon is hidden in
28+/// favor of the component's chevron. On browsers without support every rule
29+/// here is inert and the operating system's picker shows instead.
30+const PICKER: &str = "[&::picker(select)]:[appearance:base-select] \
31+ [&::picker(select)]:mt-1 [&::picker(select)]:rounded-lg \
32+ [&::picker(select)]:border [&::picker(select)]:border-border \
33+ [&::picker(select)]:bg-background [&::picker(select)]:p-1 \
34+ [&::picker(select)]:text-foreground [&::picker(select)]:shadow-sm \
35+ [&::picker-icon]:hidden \
36+ [&_option]:flex [&_option]:items-center [&_option]:gap-2 [&_option]:rounded-md \
37+ [&_option]:px-2 [&_option]:py-1.5 [&_option]:text-sm [&_option]:outline-none \
38+ [&_option:hover]:bg-foreground/5 [&_option:focus]:bg-foreground/5 \
39+ [&_option:checked]:font-medium \
40+ [&_option::checkmark]:order-1 [&_option::checkmark]:ml-auto \
41+ [&_option::checkmark]:size-4 [&_option::checkmark]:shrink-0 \
42+ [&_option::checkmark]:content-[''] [&_option::checkmark]:bg-muted-foreground \
43+ [&_option::checkmark]:[mask-size:100%_100%] \
44+ [&_option::checkmark]:[mask-image:var(--select-checkmark)]";
45+
46+/// The icon marking the picker's checked option.
47+const CHECKMARK: IconData = iconify_icon!("feather:check");
48+
49+/// The inline style for the [`select`] wrapper, carrying [`CHECKMARK`] as a
50+/// data URI in the `--select-checkmark` custom property. The indirection
51+/// exists because the `::checkmark` pseudo-element can only take the icon
52+/// through a stylesheet, as a mask image, while the icon's markup is only
53+/// available here.
54+fn checkmark_style(cx: &Cx) -> String {
55+ let svg = format!(
56+ r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="{}">{}</svg>"#,
57+ CHECKMARK.view_box(),
58+ CHECKMARK.into_body().render(cx),
59+ );
60+ let mut style = String::from(r#"--select-checkmark: url("data:image/svg+xml,"#);
61+ // Percent-encode the characters that cannot appear in a double-quoted
62+ // CSS url().
63+ for char in svg.chars() {
64+ match char {
65+ '%' => style.push_str("%25"),
66+ '"' => style.push_str("%22"),
67+ '#' => style.push_str("%23"),
68+ _ => style.push(char),
69+ }
70+ }
71+ style.push_str(r#"")"#);
72+ style
73+}
74+
75+/// A select component: a themed native `<select>`.
76+///
77+/// Child nodes become the `<select>`'s content, typically `<option>` and
78+/// `<optgroup>` elements. The `attrs` (such as `name`, `disabled`, or event
79+/// handlers) are forwarded to the `<select>`; a `class` among them is appended
80+/// to the wrapping element's classes, so width utilities size the whole
81+/// control. Like the input, it fills its container by default.
82+///
83+/// On browsers with customizable select support the drop-down picker is
84+/// restyled to match the dropdown menu component, and the chevron flips while
85+/// it is open; other browsers keep the operating system's picker. The control
86+/// itself looks the same everywhere.
87+///
88+/// ```ignore
89+/// view! {
90+/// select(
91+/// attrs: attributes! { name="region" },
92+/// <option>"eu-central-1"</option>
93+/// <option>"us-east-1"</option>
94+/// )
95+/// }
96+/// ```
97+#[component]
98+pub async fn select(cx: &Cx, #[default] mut attrs: Attributes, #[default] child: View) -> Result {
99+ // `appearance: base-select` opts into the customizable picker. It is set
100+ // from the wrapper because the descendant selector outranks the
101+ // `appearance-none` fallback in specificity, making the outcome
102+ // independent of stylesheet order; browsers without support drop the
103+ // invalid declaration and keep the fallback.
104+ view! {
105+ <span
106+ class=(class!(
107+ "relative block has-[:disabled]:opacity-50 \
108+ [&>select]:[appearance:base-select] \
109+ [&:has(select:open)>svg]:rotate-180",
110+ attrs.remove("class"),
111+ ))
112+ style=(checkmark_style(cx))
113+ >
114+ <select class=(class!(SELECT, PICKER)) (attrs)>(child)</select>
115+ icon(
116+ data: iconify_icon!("feather:chevron-down"),
117+ attrs: attributes! {
118+ class="pointer-events-none absolute top-1/2 right-3 size-4 \
119+ -translate-y-1/2 text-muted-foreground transition-transform"
120+ }
121+ )
122+ </span>
123+ }
124+}
src/infrastructure/web/context.rs+34 −4View file
@@ -10,15 +10,18 @@ use sqlx::SqlitePool;
1010 use topcoat::{
1111 Result,
1212 context::{Cx, app_context},
13 router::error::internal_server_error,
13+ router::{HeaderName, HeaderValue, error::internal_server_error, header::LOCATION},
1414 session,
1515 };
1616
1717 use crate::{
18 application::{Identity, describe_identity, is_claimed, resolve_actor},
18+ application::{AppConfig, Identity, describe_identity, is_claimed, resolve_actor},
1919 domain::{Actor, SessionTokenHash},
20 infrastructure::repository::{
21 SqliteMembershipRepo, SqliteOrgRepo, SqliteSessionRepo, SqliteUserRepo,
20+ infrastructure::{
21+ git::DiskGitStorage,
22+ repository::{
23+ SqliteMembershipRepo, SqliteOrgRepo, SqliteRepoRepo, SqliteSessionRepo, SqliteUserRepo,
24+ },
2225 },
2326 };
2427
@@ -49,6 +52,15 @@ pub fn memberships(cx: &Cx) -> SqliteMembershipRepo {
4952 SqliteMembershipRepo::new(pool(cx).clone())
5053 }
5154
55+pub fn repos(cx: &Cx) -> SqliteRepoRepo {
56+ SqliteRepoRepo::new(pool(cx).clone())
57+}
58+
59+/// Bare repositories on disk, rooted at the configured data directory.
60+pub fn storage(cx: &Cx) -> DiskGitStorage {
61+ DiskGitStorage::new(app_context::<AppConfig>(cx).data_dir.clone())
62+}
63+
5264 /// Who is making this request.
5365 ///
5466 /// Resolves to [`Actor::Anonymous`] when there is no session, the session is unknown,
@@ -93,6 +105,24 @@ pub fn setup_token(cx: &Cx) -> Option<&crate::domain::SetupToken> {
93105 topcoat::context::try_app_context::<SetupState>(cx).map(|state| &state.0)
94106 }
95107
108+/// The `Location` header for a post-redirect-get reply.
109+///
110+/// Pair it with `StatusCode::SEE_OTHER` inside a `view!` to redirect from a page that
111+/// otherwise renders a view:
112+///
113+/// ```ignore
114+/// view! { (StatusCode::SEE_OTHER) (location("/somewhere")?) }
115+/// ```
116+///
117+/// The awkward-looking route to a 303: `redirect()` is a **307**, which preserves the
118+/// method, so a browser re-POSTs the form to its target instead of fetching it.
119+/// `see_other()` is the right status but is a response type, and `#[page]` handlers
120+/// must return a view so the layout can wrap the failure re-render. Setting the status
121+/// and header inside `view!` is the documented way to get both from one handler.
122+pub fn location(uri: &str) -> Result<(HeaderName, HeaderValue)> {
123+ Ok((LOCATION, HeaderValue::try_from(uri).map_err(server_error)?))
124+}
125+
96126 /// Renders a `TokenHash` as lowercase hex for storage.
97127 pub fn hex(hash: &session::TokenHash) -> String {
98128 hash.iter().map(|byte| format!("{byte:02x}")).collect()
src/infrastructure/web/mod.rs+1 −0View file
@@ -5,6 +5,7 @@ pub mod context;
55 pub mod layout;
66 pub mod pages;
77 pub mod profile;
8+pub mod repo;
89 pub mod session_cookie;
910 pub mod settings;
1011 pub mod setup;
src/infrastructure/web/profile.rs+12 −3View file
@@ -78,9 +78,18 @@ async fn profile(cx: &Cx) -> Result {
7878 </header>
7979
8080 <section class="mt-8">
81 <h2 class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
82 "Repositories"
83 </h2>
81+ <div class="flex items-center justify-between">
82+ <h2 class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
83+ "Repositories"
84+ </h2>
85+ if profile.viewer_is_owner {
86+ <a
87+ href=(format!("/{}/repos/new", profile.handle))
88+ class=(button_variants(ButtonVariant::Outline, ButtonSize::Sm))
89+ >"New repository"</a>
90+ }
91+ </div>
92+ // Still a placeholder: listing arrives with `list_repos` in the next step.
8493 <p class="mt-2 rounded-lg border border-border px-4 py-6 text-center text-sm text-muted-foreground">
8594 "Nothing here yet."
8695 </p>
src/infrastructure/web/repo.rs+278 −0View file
@@ -0,0 +1,278 @@
1+//! Repository pages — `/{handle}/repos/new` and `/{handle}/repos/{name}`.
2+//!
3+//! `new` is a static segment and `{name}` a parameterised one, so the router prefers
4+//! `new`. [`RepoName`] reserves it as well, so the two agree rather than relying on
5+//! routing order alone.
6+
7+use serde::Deserialize;
8+use topcoat::{
9+ Result,
10+ context::Cx,
11+ router::{
12+ StatusCode,
13+ content::Form,
14+ error::{RouterErrorExt, forbidden, not_found},
15+ page, path_param,
16+ },
17+ view::{attributes, component, view},
18+};
19+
20+use crate::{
21+ application::{Error, NewRepo, RepoView, create_repo, view_repo},
22+ components::{
23+ badge::{BadgeVariant, badge},
24+ button::button,
25+ flash::{FlashKind, flash},
26+ input::input,
27+ label::label,
28+ select::select,
29+ textarea::textarea,
30+ },
31+ domain::{DomainError, RepoName, Repository, Visibility},
32+};
33+
34+use super::{
35+ context::{current_actor, location, memberships, orgs, repos, server_error, storage},
36+ profile::profile_for,
37+};
38+
39+/// `{name}` from the path, raw — validation is [`RepoName`]'s job.
40+#[path_param]
41+struct Name(str);
42+
43+#[derive(Debug, Deserialize)]
44+struct CreateForm {
45+ name: String,
46+ description: String,
47+ visibility: String,
48+}
49+
50+/// Blank input means unset, which is what the domain stores.
51+fn optional(value: &str) -> Option<String> {
52+ Some(value.trim().to_owned()).filter(|value| !value.is_empty())
53+}
54+
55+/// Resolves `{handle}/repos/{name}` into a repository the viewer may see, or 404.
56+///
57+/// A repository the viewer may not see and one that does not exist are the same
58+/// answer here, deliberately — see [`view_repo`].
59+async fn repo_for(cx: &Cx) -> Result<RepoView> {
60+ let profile = profile_for(cx).await?;
61+ let name = RepoName::new(path_param::<Name>(cx)).map_err(|_| not_found())?;
62+ let actor = current_actor(cx).await?;
63+
64+ Ok(view_repo(
65+ &profile.handle,
66+ &name,
67+ &actor,
68+ &orgs(cx),
69+ &memberships(cx),
70+ &repos(cx),
71+ )
72+ .await
73+ .map_err(server_error)?
74+ .ok_or_not_found()?)
75+}
76+
77+#[page("/{handle}/repos/new")]
78+async fn new_repo_page(cx: &Cx) -> Result {
79+ let profile = profile_for(cx).await?;
80+
81+ // The use case decides this too; checking here as well keeps the form from
82+ // rendering for someone whose submission would only be rejected.
83+ if !profile.viewer_is_owner {
84+ return Err(forbidden().into());
85+ }
86+
87+ view! {
88+ new_repo_form(
89+ handle: profile.handle.as_str(),
90+ name: "",
91+ description: "",
92+ visibility: Visibility::Public,
93+ error: "",
94+ )
95+ }
96+}
97+
98+/// Creates the repository.
99+///
100+/// Success redirects to the new repository, using the **normalised** name from the
101+/// created record — someone who typed `MyRepo` belongs at `/{handle}/repos/myrepo`.
102+/// Failure re-renders with the reason and what was typed.
103+///
104+/// The success reply is a 303 — see [`location`] for why it is spelled this way and
105+/// not with `redirect()`.
106+#[page(POST "/{handle}/repos/new")]
107+async fn create(cx: &Cx, Form(submitted): Form<CreateForm>) -> Result {
108+ let profile = profile_for(cx).await?;
109+
110+ // An unparseable value is a tampered form, not something to default: defaulting
111+ // here could publish a repository the owner asked to keep private.
112+ let visibility = submitted
113+ .visibility
114+ .parse::<Visibility>()
115+ .map_err(|_| topcoat::router::error::bad_request("unknown visibility"))?;
116+
117+ let outcome = create_repo(
118+ &current_actor(cx).await?,
119+ &profile.handle,
120+ &NewRepo {
121+ name: submitted.name.clone(),
122+ description: optional(&submitted.description),
123+ visibility,
124+ },
125+ &orgs(cx),
126+ &memberships(cx),
127+ &repos(cx),
128+ &storage(cx),
129+ )
130+ .await;
131+
132+ let message = match outcome {
133+ Ok(repo) => {
134+ return view! {
135+ (StatusCode::SEE_OTHER)
136+ (location(&format!("/{}/repos/{}", profile.handle, repo.name))?)
137+ };
138+ }
139+ Err(Error::Domain(DomainError::Validation { field, reason })) => {
140+ format!("That {field} is no good: {reason}.")
141+ }
142+ Err(Error::Domain(DomainError::AlreadyExists { .. })) => {
143+ format!(
144+ "You already have a repository called {}.",
145+ submitted.name.trim()
146+ )
147+ }
148+ Err(Error::Domain(DomainError::Forbidden)) => return Err(forbidden().into()),
149+ Err(other) => return Err(server_error(std::io::Error::other(other.to_string()))),
150+ };
151+
152+ view! {
153+ new_repo_form(
154+ handle: profile.handle.as_str(),
155+ name: submitted.name.as_str(),
156+ description: submitted.description.as_str(),
157+ visibility: visibility,
158+ error: message.as_str(),
159+ )
160+ }
161+}
162+
163+#[page("/{handle}/repos/{name}")]
164+async fn repo_page(cx: &Cx) -> Result {
165+ let repo = repo_for(cx).await?;
166+
167+ view! {
168+ <header class="mb-8">
169+ <p class="font-mono text-sm text-muted-foreground">
170+ <a href=(format!("/{}", repo.handle)) class="hover:text-foreground">
171+ "@" (repo.handle.as_str())
172+ </a>
173+ " / "
174+ </p>
175+ <div class="mt-1 flex items-center gap-3">
176+ <h1 class="text-2xl font-semibold tracking-tight">(repo.name.as_str())</h1>
177+ if !repo.visibility.is_public() {
178+ badge(variant: BadgeVariant::Outline, "Private")
179+ }
180+ </div>
181+ ({
182+ match &repo.description {
183+ Some(description) => view! {
184+ <p class="mt-3 text-sm leading-relaxed">(description)</p>
185+ },
186+ None => view! {},
187+ }
188+ }?)
189+ </header>
190+
191+ // Deliberately no clone command: the git protocol arrives in Milestone 4, and
192+ // printing an instruction that fails is worse than printing nothing.
193+ <div class="rounded-lg border border-border px-4 py-10 text-center">
194+ <p class="text-sm text-muted-foreground">"This repository is empty."</p>
195+ </div>
196+ }
197+}
198+
199+/// The creation form.
200+///
201+/// Values arrive as parameters rather than being read back, so a rejected submission
202+/// re-renders exactly what was typed.
203+#[component]
204+async fn new_repo_form(
205+ handle: &str,
206+ name: &str,
207+ description: &str,
208+ visibility: Visibility,
209+ error: &str,
210+) -> Result {
211+ view! {
212+ <h1 class="text-xl font-semibold tracking-tight">"New repository"</h1>
213+ <p class="mt-1 font-mono text-sm text-muted-foreground">"@" (handle)</p>
214+
215+ if !error.is_empty() {
216+ <div class="mt-6">
217+ flash(kind: FlashKind::Error, (error))
218+ </div>
219+ }
220+
221+ <form method="post" action=(format!("/{handle}/repos/new")) class="mt-6 space-y-5">
222+ <div class="space-y-2">
223+ label(attrs: attributes! { for="name" }, "Name")
224+ input(attrs: attributes! {
225+ id="name"
226+ name="name"
227+ type="text"
228+ value=(name)
229+ placeholder="my-project"
230+ required=(true)
231+ maxlength=(RepoName::MAX_LEN.to_string())
232+ autofocus=(true)
233+ })
234+ <p class="text-xs text-muted-foreground">
235+ "Letters, digits, hyphens, underscores and dots. Lowercased."
236+ </p>
237+ </div>
238+
239+ <div class="space-y-2">
240+ label(attrs: attributes! { for="description" }, "Description")
241+ textarea(
242+ attrs: attributes! {
243+ id="description"
244+ name="description"
245+ rows="2"
246+ maxlength=(Repository::MAX_DESCRIPTION_LEN.to_string())
247+ placeholder="A sentence for your profile."
248+ },
249+ (description)
250+ )
251+ <p class="text-xs text-muted-foreground">
252+ "Optional. At most "
253+ (Repository::MAX_DESCRIPTION_LEN.to_string()) " characters."
254+ </p>
255+ </div>
256+
257+ <div class="space-y-2">
258+ label(attrs: attributes! { for="visibility" }, "Visibility")
259+ select(
260+ attrs: attributes! { id="visibility" name="visibility" },
261+ <option value="public" selected=(visibility.is_public())>"Public"</option>
262+ <option value="private" selected=(!visibility.is_public())>"Private"</option>
263+ )
264+ <p class="text-xs text-muted-foreground">
265+ "Public repositories appear on your profile to anyone."
266+ </p>
267+ </div>
268+
269+ <div class="flex items-center gap-3">
270+ button(attrs: attributes! { type="submit" }, "Create repository")
271+ <a
272+ href=(format!("/{handle}"))
273+ class="text-sm text-muted-foreground hover:text-foreground"
274+ >"Cancel"</a>
275+ </div>
276+ </form>
277+ }
278+}
src/infrastructure/web/settings.rs+13 −10View file
@@ -7,11 +7,7 @@ use serde::Deserialize;
77 use topcoat::{
88 Result,
99 context::Cx,
10 router::{
11 content::Form,
12 error::{forbidden, redirect},
13 page, query_params,
14 },
10+ router::{StatusCode, content::Form, error::forbidden, page, query_params},
1511 view::{attributes, component, view},
1612 };
1713
@@ -28,7 +24,7 @@ use crate::{
2824 };
2925
3026 use super::{
31 context::{current_actor, memberships, orgs, server_error},
27+ context::{current_actor, location, memberships, orgs, server_error},
3228 profile::profile_for,
3329 };
3430
@@ -76,9 +72,13 @@ async fn settings_page(cx: &Cx) -> Result {
7672
7773 /// Applies an edit.
7874 ///
79/// Success redirects, so a reload cannot resubmit. Failure re-renders with the message
80/// and **what was typed** — bouncing back to a blank form would throw away the work and
81/// leave the reason invisible, which is the whole problem `flash` exists to fix.
75+/// Success replies 303, so a reload cannot resubmit — see [`location`] for why it is
76+/// spelled this way and not with `redirect()`, which is a 307 and would re-POST this
77+/// form to itself.
78+///
79+/// Failure re-renders with the message and **what was typed** — bouncing back to a
80+/// blank form would throw away the work and leave the reason invisible, which is the
81+/// whole problem `flash` exists to fix.
8282 #[page(POST "/{handle}/settings")]
8383 async fn save(cx: &Cx, Form(submitted): Form<ProfileForm>) -> Result {
8484 let profile = profile_for(cx).await?;
@@ -95,7 +95,10 @@ async fn save(cx: &Cx, Form(submitted): Form<ProfileForm>) -> Result {
9595
9696 let message = match outcome {
9797 Ok(()) => {
98 return Err(redirect(&format!("/{}/settings?saved", profile.handle)).into());
98+ return view! {
99+ (StatusCode::SEE_OTHER)
100+ (location(&format!("/{}/settings?saved", profile.handle))?)
101+ };
99102 }
100103 // The visitor's to fix, so it is shown.
101104 Err(Error::Domain(DomainError::Validation { field, reason })) => {