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