steid

@jamesgill /

285f5fdfeat: create and view repositories through the browser24d
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::{
0c5ca49feat: list repositories on the profile8d
21 application::{Error, NewRepo, RepoSummary, RepoView, create_repo, view_repo},
285f5fdfeat: create and view repositories through the browser24d
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::{
acde6b5feat: show the clone URL on a repository page8d
35 context::{
36 current_actor, location, memberships, orgs, public_origin, repos, server_error, storage,
37 },
285f5fdfeat: create and view repositories through the browser24d
38 profile::profile_for,
39};
40
41/// `{name}` from the path, raw — validation is [`RepoName`]'s job.
42#[path_param]
43struct Name(str);
44
45#[derive(Debug, Deserialize)]
46struct CreateForm {
47 name: String,
48 description: String,
49 visibility: String,
50}
51
52/// Blank input means unset, which is what the domain stores.
53fn optional(value: &str) -> Option<String> {
54 Some(value.trim().to_owned()).filter(|value| !value.is_empty())
55}
56
57/// Resolves `{handle}/repos/{name}` into a repository the viewer may see, or 404.
58///
59/// A repository the viewer may not see and one that does not exist are the same
60/// answer here, deliberately — see [`view_repo`].
61async fn repo_for(cx: &Cx) -> Result<RepoView> {
62 let profile = profile_for(cx).await?;
63 let name = RepoName::new(path_param::<Name>(cx)).map_err(|_| not_found())?;
64 let actor = current_actor(cx).await?;
65
66 Ok(view_repo(
67 &profile.handle,
68 &name,
69 &actor,
70 &orgs(cx),
71 &memberships(cx),
72 &repos(cx),
73 )
74 .await
75 .map_err(server_error)?
76 .ok_or_not_found()?)
77}
78
79#[page("/{handle}/repos/new")]
80async fn new_repo_page(cx: &Cx) -> Result {
81 let profile = profile_for(cx).await?;
82
83 // The use case decides this too; checking here as well keeps the form from
84 // rendering for someone whose submission would only be rejected.
85 if !profile.viewer_is_owner {
86 return Err(forbidden().into());
87 }
88
89 view! {
90 new_repo_form(
91 handle: profile.handle.as_str(),
92 name: "",
93 description: "",
94 visibility: Visibility::Public,
95 error: "",
96 )
97 }
98}
99
100/// Creates the repository.
101///
102/// Success redirects to the new repository, using the **normalised** name from the
103/// created record — someone who typed `MyRepo` belongs at `/{handle}/repos/myrepo`.
104/// Failure re-renders with the reason and what was typed.
105///
106/// The success reply is a 303 — see [`location`] for why it is spelled this way and
107/// not with `redirect()`.
108#[page(POST "/{handle}/repos/new")]
109async fn create(cx: &Cx, Form(submitted): Form<CreateForm>) -> Result {
110 let profile = profile_for(cx).await?;
111
112 // An unparseable value is a tampered form, not something to default: defaulting
113 // here could publish a repository the owner asked to keep private.
114 let visibility = submitted
115 .visibility
116 .parse::<Visibility>()
117 .map_err(|_| topcoat::router::error::bad_request("unknown visibility"))?;
118
119 let outcome = create_repo(
120 &current_actor(cx).await?,
121 &profile.handle,
122 &NewRepo {
123 name: submitted.name.clone(),
124 description: optional(&submitted.description),
125 visibility,
126 },
127 &orgs(cx),
128 &memberships(cx),
129 &repos(cx),
130 &storage(cx),
131 )
132 .await;
133
134 let message = match outcome {
135 Ok(repo) => {
136 return view! {
137 (StatusCode::SEE_OTHER)
138 (location(&format!("/{}/repos/{}", profile.handle, repo.name))?)
139 };
140 }
141 Err(Error::Domain(DomainError::Validation { field, reason })) => {
142 format!("That {field} is no good: {reason}.")
143 }
144 Err(Error::Domain(DomainError::AlreadyExists { .. })) => {
145 format!(
146 "You already have a repository called {}.",
147 submitted.name.trim()
148 )
149 }
150 Err(Error::Domain(DomainError::Forbidden)) => return Err(forbidden().into()),
151 Err(other) => return Err(server_error(std::io::Error::other(other.to_string()))),
152 };
153
154 view! {
155 new_repo_form(
156 handle: profile.handle.as_str(),
157 name: submitted.name.as_str(),
158 description: submitted.description.as_str(),
159 visibility: visibility,
160 error: message.as_str(),
161 )
162 }
163}
164
165#[page("/{handle}/repos/{name}")]
166async fn repo_page(cx: &Cx) -> Result {
167 let repo = repo_for(cx).await?;
acde6b5feat: show the clone URL on a repository page8d
168 let clone = clone_url_for(cx, &repo);
285f5fdfeat: create and view repositories through the browser24d
169
170 view! {
171 <header class="mb-8">
172 <p class="font-mono text-sm text-muted-foreground">
173 <a href=(format!("/{}", repo.handle)) class="hover:text-foreground">
174 "@" (repo.handle.as_str())
175 </a>
176 " / "
177 </p>
178 <div class="mt-1 flex items-center gap-3">
179 <h1 class="text-2xl font-semibold tracking-tight">(repo.name.as_str())</h1>
180 if !repo.visibility.is_public() {
181 badge(variant: BadgeVariant::Outline, "Private")
182 }
183 </div>
184 ({
185 match &repo.description {
186 Some(description) => view! {
187 <p class="mt-3 text-sm leading-relaxed">(description)</p>
188 },
189 None => view! {},
190 }
191 }?)
192 </header>
193
acde6b5feat: show the clone URL on a repository page8d
194 clone_url(url: clone.as_str())
195
196 <div class="mt-6 rounded-lg border border-border px-4 py-10 text-center">
285f5fdfeat: create and view repositories through the browser24d
197 <p class="text-sm text-muted-foreground">"This repository is empty."</p>
198 </div>
199 }
200}
201
202/// The creation form.
203///
204/// Values arrive as parameters rather than being read back, so a rejected submission
205/// re-renders exactly what was typed.
206#[component]
207async fn new_repo_form(
208 handle: &str,
209 name: &str,
210 description: &str,
211 visibility: Visibility,
212 error: &str,
213) -> Result {
214 view! {
215 <h1 class="text-xl font-semibold tracking-tight">"New repository"</h1>
216 <p class="mt-1 font-mono text-sm text-muted-foreground">"@" (handle)</p>
217
218 if !error.is_empty() {
219 <div class="mt-6">
220 flash(kind: FlashKind::Error, (error))
221 </div>
222 }
223
224 <form method="post" action=(format!("/{handle}/repos/new")) class="mt-6 space-y-5">
225 <div class="space-y-2">
226 label(attrs: attributes! { for="name" }, "Name")
227 input(attrs: attributes! {
228 id="name"
229 name="name"
230 type="text"
231 value=(name)
232 placeholder="my-project"
233 required=(true)
234 maxlength=(RepoName::MAX_LEN.to_string())
235 autofocus=(true)
236 })
237 <p class="text-xs text-muted-foreground">
238 "Letters, digits, hyphens, underscores and dots. Lowercased."
239 </p>
240 </div>
241
242 <div class="space-y-2">
243 label(attrs: attributes! { for="description" }, "Description")
244 textarea(
245 attrs: attributes! {
246 id="description"
247 name="description"
248 rows="2"
249 maxlength=(Repository::MAX_DESCRIPTION_LEN.to_string())
250 placeholder="A sentence for your profile."
251 },
252 (description)
253 )
254 <p class="text-xs text-muted-foreground">
255 "Optional. At most "
256 (Repository::MAX_DESCRIPTION_LEN.to_string()) " characters."
257 </p>
258 </div>
259
260 <div class="space-y-2">
261 label(attrs: attributes! { for="visibility" }, "Visibility")
262 select(
263 attrs: attributes! { id="visibility" name="visibility" },
264 <option value="public" selected=(visibility.is_public())>"Public"</option>
265 <option value="private" selected=(!visibility.is_public())>"Private"</option>
266 )
267 <p class="text-xs text-muted-foreground">
268 "Public repositories appear on your profile to anyone."
269 </p>
270 </div>
271
272 <div class="flex items-center gap-3">
273 button(attrs: attributes! { type="submit" }, "Create repository")
274 <a
275 href=(format!("/{handle}"))
276 class="text-sm text-muted-foreground hover:text-foreground"
277 >"Cancel"</a>
278 </div>
279 </form>
280 }
281}
0c5ca49feat: list repositories on the profile8d
282
283/// The Repositories section of a profile.
284///
285/// Takes the already-filtered summaries rather than fetching: which repositories a
286/// viewer may see is [`list_repos`](crate::application::list_repos)'s decision, and a
287/// component that queried for itself would be a second place that rule could live.
288///
289/// One empty state serves both "no repositories" and "none you may see" — a distinct
290/// message for the second would leak that private repositories exist.
acde6b5feat: show the clone URL on a repository page8d
291/// The URL to clone this repository from.
292///
293/// Built from the origin the page is being served on, so it is correct wherever the
294/// instance is deployed without anything having to be configured. A private repository
295/// gets the same URL: cloning it needs a token, not a different address.
296fn clone_url_for(cx: &Cx, repo: &RepoView) -> String {
297 format!(
298 "{}/{}/repos/{}.git",
299 public_origin(cx),
300 repo.handle,
301 repo.name
302 )
303}
304
305/// The clone address, ready to copy.
306///
307/// Shown for every repository a viewer can see, including an empty one — an empty
308/// repository is exactly when someone needs this, because it is what they push to.
309#[component]
310async fn clone_url(url: &str) -> Result {
311 view! {
312 <div class="mt-6">
313 <p class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
314 "Clone"
315 </p>
316 <pre class="mt-2 overflow-x-auto rounded-lg border border-border bg-muted px-4 py-3 font-mono text-sm">"git clone " (url)</pre>
317 </div>
318 }
319}
320
0c5ca49feat: list repositories on the profile8d
321#[component]
322pub(super) async fn repo_list(handle: &str, repos: &[RepoSummary]) -> Result {
323 view! {
324 if repos.is_empty() {
325 <p class="mt-2 rounded-lg border border-border px-4 py-6 text-center text-sm text-muted-foreground">
326 "Nothing here yet."
327 </p>
328 } else {
329 <ul class="mt-2 divide-y divide-border rounded-lg border border-border">
330 for repo in repos {
331 <li class="px-4 py-3">
332 <div class="flex items-baseline gap-2">
333 <a
334 href=(format!("/{handle}/repos/{}", repo.name))
335 class="font-medium hover:underline"
336 >(repo.name.as_str())</a>
337 if !repo.visibility.is_public() {
338 badge(variant: BadgeVariant::Outline, "Private")
339 }
340 </div>
341 ({
342 match &repo.description {
343 Some(description) => view! {
344 <p class="mt-0.5 text-sm text-muted-foreground">(description)</p>
345 },
346 None => view! {},
347 }
348 }?)
349 </li>
350 }
351 </ul>
352 }
353 }
354}