steid

@jamesgill /

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
7use serde::Deserialize;
8use 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
20use 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
34use 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]
41struct Name(str);
42
43#[derive(Debug, Deserialize)]
44struct CreateForm {
45 name: String,
46 description: String,
47 visibility: String,
48}
49
50/// Blank input means unset, which is what the domain stores.
51fn 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`].
59async 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")]
78async 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")]
107async 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}")]
164async 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]
204async 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}