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::{
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
21 application::{
22 Browsed, Error, FileView, NewRepo, RepoSummary, RepoView, create_repo, view_repo,
23 },
285f5fdfeat: create and view repositories through the browser24d
24 components::{
25 badge::{BadgeVariant, badge},
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
26 button::{ButtonSize, ButtonVariant, button, button_variants},
285f5fdfeat: create and view repositories through the browser24d
27 flash::{FlashKind, flash},
28 input::input,
29 label::label,
30 select::select,
31 textarea::textarea,
32 },
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
33 domain::{
34 DomainError, EntryKind, RefName, RepoName, RepoPath, Repository, TreeEntry, Visibility,
35 },
285f5fdfeat: create and view repositories through the browser24d
36};
37
38use super::{
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
39 browse::{blob, browsed_at, directory, empty_repo, tree_url},
acde6b5feat: show the clone URL on a repository page8d
40 context::{
41 current_actor, location, memberships, orgs, public_origin, repos, server_error, storage,
42 },
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
43 markdown,
285f5fdfeat: create and view repositories through the browser24d
44 profile::profile_for,
45};
46
47/// `{name}` from the path, raw — validation is [`RepoName`]'s job.
48#[path_param]
49struct Name(str);
50
51#[derive(Debug, Deserialize)]
52struct CreateForm {
53 name: String,
54 description: String,
55 visibility: String,
56}
57
58/// Blank input means unset, which is what the domain stores.
59fn optional(value: &str) -> Option<String> {
60 Some(value.trim().to_owned()).filter(|value| !value.is_empty())
61}
62
63/// Resolves `{handle}/repos/{name}` into a repository the viewer may see, or 404.
64///
65/// A repository the viewer may not see and one that does not exist are the same
66/// answer here, deliberately — see [`view_repo`].
dce0bf3feat: browse a repository's files and history8d
67pub(super) async fn repo_for(cx: &Cx) -> Result<RepoView> {
285f5fdfeat: create and view repositories through the browser24d
68 let profile = profile_for(cx).await?;
69 let name = RepoName::new(path_param::<Name>(cx)).map_err(|_| not_found())?;
70 let actor = current_actor(cx).await?;
71
72 Ok(view_repo(
73 &profile.handle,
74 &name,
75 &actor,
76 &orgs(cx),
77 &memberships(cx),
78 &repos(cx),
79 )
80 .await
81 .map_err(server_error)?
82 .ok_or_not_found()?)
83}
84
85#[page("/{handle}/repos/new")]
86async fn new_repo_page(cx: &Cx) -> Result {
87 let profile = profile_for(cx).await?;
88
89 // The use case decides this too; checking here as well keeps the form from
90 // rendering for someone whose submission would only be rejected.
91 if !profile.viewer_is_owner {
92 return Err(forbidden().into());
93 }
94
95 view! {
96 new_repo_form(
97 handle: profile.handle.as_str(),
98 name: "",
99 description: "",
100 visibility: Visibility::Public,
101 error: "",
102 )
103 }
104}
105
106/// Creates the repository.
107///
108/// Success redirects to the new repository, using the **normalised** name from the
109/// created record — someone who typed `MyRepo` belongs at `/{handle}/repos/myrepo`.
110/// Failure re-renders with the reason and what was typed.
111///
112/// The success reply is a 303 — see [`location`] for why it is spelled this way and
113/// not with `redirect()`.
114#[page(POST "/{handle}/repos/new")]
115async fn create(cx: &Cx, Form(submitted): Form<CreateForm>) -> Result {
116 let profile = profile_for(cx).await?;
117
118 // An unparseable value is a tampered form, not something to default: defaulting
119 // here could publish a repository the owner asked to keep private.
120 let visibility = submitted
121 .visibility
122 .parse::<Visibility>()
123 .map_err(|_| topcoat::router::error::bad_request("unknown visibility"))?;
124
125 let outcome = create_repo(
126 &current_actor(cx).await?,
127 &profile.handle,
128 &NewRepo {
129 name: submitted.name.clone(),
130 description: optional(&submitted.description),
131 visibility,
132 },
133 &orgs(cx),
134 &memberships(cx),
135 &repos(cx),
136 &storage(cx),
137 )
138 .await;
139
140 let message = match outcome {
141 Ok(repo) => {
142 return view! {
143 (StatusCode::SEE_OTHER)
144 (location(&format!("/{}/repos/{}", profile.handle, repo.name))?)
145 };
146 }
147 Err(Error::Domain(DomainError::Validation { field, reason })) => {
148 format!("That {field} is no good: {reason}.")
149 }
150 Err(Error::Domain(DomainError::AlreadyExists { .. })) => {
151 format!(
152 "You already have a repository called {}.",
153 submitted.name.trim()
154 )
155 }
156 Err(Error::Domain(DomainError::Forbidden)) => return Err(forbidden().into()),
157 Err(other) => return Err(server_error(std::io::Error::other(other.to_string()))),
158 };
159
160 view! {
161 new_repo_form(
162 handle: profile.handle.as_str(),
163 name: submitted.name.as_str(),
164 description: submitted.description.as_str(),
165 visibility: visibility,
166 error: message.as_str(),
167 )
168 }
169}
170
dce0bf3feat: browse a repository's files and history8d
171/// The repository's own page: its default branch, at the root.
172///
173/// The listing is the page rather than a link to one — the reason to open a repository
174/// is to see what is in it. An empty repository gets push instructions instead, which
175/// is the only useful thing to show someone who has just created one.
285f5fdfeat: create and view repositories through the browser24d
176#[page("/{handle}/repos/{name}")]
177async fn repo_page(cx: &Cx) -> Result {
178 let repo = repo_for(cx).await?;
acde6b5feat: show the clone URL on a repository page8d
179 let clone = clone_url_for(cx, &repo);
dce0bf3feat: browse a repository's files and history8d
180 let browsed = browsed_at(cx, &repo, None, &RepoPath::root()).await?;
285f5fdfeat: create and view repositories through the browser24d
181
182 view! {
183 <header class="mb-8">
184 <p class="font-mono text-sm text-muted-foreground">
185 <a href=(format!("/{}", repo.handle)) class="hover:text-foreground">
186 "@" (repo.handle.as_str())
187 </a>
188 " / "
189 </p>
190 <div class="mt-1 flex items-center gap-3">
191 <h1 class="text-2xl font-semibold tracking-tight">(repo.name.as_str())</h1>
192 if !repo.visibility.is_public() {
193 badge(variant: BadgeVariant::Outline, "Private")
194 }
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
195 // Only the owner can change a repository, so only the owner is offered
196 // the way in. The settings page enforces this again — this is the link
197 // not being a dead end, not the authorization.
198 if repo.viewer_is_owner {
199 <a
200 href=(format!("/{}/repos/{}/settings", repo.handle, repo.name))
201 class=(format!(
202 "ml-auto {}",
203 button_variants(ButtonVariant::Outline, ButtonSize::Sm)
204 ))
205 >"Settings"</a>
206 }
285f5fdfeat: create and view repositories through the browser24d
207 </div>
208 ({
209 match &repo.description {
210 Some(description) => view! {
211 <p class="mt-3 text-sm leading-relaxed">(description)</p>
212 },
213 None => view! {},
214 }
215 }?)
216 </header>
217
acde6b5feat: show the clone URL on a repository page8d
218 clone_url(url: clone.as_str())
219
dce0bf3feat: browse a repository's files and history8d
220 match &browsed {
221 Browsed::Empty => empty_repo(url: clone.as_str()),
222 Browsed::Directory { rev, path, entries } => {
223 <div class="mt-8 mb-3 flex items-center justify-between text-sm">
224 <span class="inline-flex items-center gap-1.5 font-mono text-xs text-muted-foreground">
225 (rev.as_str())
226 </span>
227 <a
228 href=(format!("/{}/repos/{}/log", repo.handle, repo.name))
229 class="text-muted-foreground hover:text-foreground"
230 >"Commits"</a>
231 </div>
232 directory(
233 handle: repo.handle.as_str(),
234 name: repo.name.as_str(),
235 rev: rev,
236 path: path,
237 entries: entries,
238 )
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
239
240 // No README means nothing here at all — an empty panel saying a
241 // repository has no README is worse than the silence.
242 match readme_of(entries) {
243 Some(entry) => readme_card(repo: &repo, rev: rev, entry: entry),
244 None => "",
245 }
dce0bf3feat: browse a repository's files and history8d
246 },
247 // The root of a revision is always a directory, so this is unreachable in
248 // practice — rendered rather than errored so it can never be a 500.
249 Browsed::File { rev, path, file } => <div class="mt-8">
250 blob(
251 handle: repo.handle.as_str(),
252 name: repo.name.as_str(),
253 rev: rev,
254 path: path,
255 file: file,
256 )
257 </div>,
258 }
285f5fdfeat: create and view repositories through the browser24d
259 }
260}
261
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
262// --- README -----------------------------------------------------------------------
263
264/// Extensions a README may carry, in the order they are preferred.
265///
266/// Empty last: a plain `README` is a README, but a repository holding both `README.md`
267/// and `README` means the markdown one. Nothing that is not markdown is here — a
268/// `README.rst` rendered as markdown would be worse than the file listing.
269const README_EXTENSIONS: [&str; 5] = ["md", "markdown", "mdown", "mkd", ""];
270
271/// How strongly a name says "this is the README", or `None` if it does not.
272///
273/// Case-insensitive, because the file is spelled `README`, `readme` and `Readme` in the
274/// wild and all three mean the same thing.
275fn readme_rank(name: &str) -> Option<usize> {
276 let lowered = name.to_ascii_lowercase();
277 let extension = match lowered.strip_prefix("readme")? {
278 "" => "",
279 rest => rest.strip_prefix('.')?,
280 };
281
282 README_EXTENSIONS
283 .iter()
284 .position(|candidate| *candidate == extension)
285}
286
287/// The README in a listing, if there is one.
288///
289/// Chosen from the entries the page already has rather than by asking git for a file
290/// that may not exist: every `git` call is a fork of about 12ms, and a speculative one
291/// would be spent on every repository without a README. See
292/// [0006](../../../plans/decisions/0006-git-binary-behind-narrow-ports.md).
293fn readme_of(entries: &[TreeEntry]) -> Option<&TreeEntry> {
294 entries
295 .iter()
296 .filter(|entry| entry.kind == EntryKind::Blob)
297 .filter_map(|entry| Some((readme_rank(&entry.name)?, entry)))
298 .min_by_key(|(rank, _)| *rank)
299 .map(|(_, entry)| entry)
300}
301
302/// Where a relative link in a README should point.
303///
304/// A README's links are written against the repository's own files, so `./CONTRIBUTING.md`
305/// means a file in the tree and not a Steid route — left alone it would 404. Anything
306/// this cannot make sense of returns `None` and is left exactly as written, which is the
307/// same thing a plain markdown renderer would do.
308///
309/// Only links. A relative *image* is left alone deliberately: an image needs the file's
310/// bytes, and a tree URL serves a page, so rewriting one would trade a 404 for a broken
311/// image. [`markdown::render_with_links`] never offers this an image.
312fn readme_link(handle: &str, name: &str, rev: &RefName, destination: &str) -> Option<String> {
313 // A query or fragment addresses something inside a rendered document; a file in a
314 // tree has neither.
315 let target = destination.split(['?', '#']).next()?;
316 let target = target.strip_prefix("./").unwrap_or(target);
317
318 // `RepoPath` refuses `..` and `.` components, so a link cannot walk out of the
319 // repository — it simply stays as it was written.
320 let path = RepoPath::new(target).ok()?;
321
322 if path.is_root() {
323 return None;
324 }
325
326 Some(tree_url(handle, name, rev, &path))
327}
328
329/// The rendered README, under the file listing.
330///
331/// Reading it costs the page one more `git` call, which is why it is the only file the
332/// page fetches beyond the listing itself.
333///
334/// A component rather than a function because `view!` needs the request context in
335/// scope — see [`browsing`](super::browse).
336#[component]
337async fn readme_card(cx: &Cx, repo: &RepoView, rev: &RefName, entry: &TreeEntry) -> Result {
338 let path = RepoPath::new(&entry.name).map_err(|_| not_found())?;
339
340 // The entry came out of a listing read moments ago, so anything but a file means
341 // the tree changed underneath this request. A vanished README is not a reason to
342 // fail the page it was going to decorate.
343 let Browsed::File { file, .. } = browsed_at(cx, repo, Some(rev), &path).await? else {
344 return view! {};
345 };
346
347 let handle = repo.handle.as_str();
348 let name = repo.name.as_str();
349
350 view! {
351 <section class="mt-6 overflow-hidden rounded-lg border border-border">
352 <div class="border-b border-border px-4 py-2.5">
353 <a
354 href=(tree_url(handle, name, rev, &path))
355 class="font-mono text-sm hover:underline"
356 >(&entry.name)</a>
357 </div>
358 readme_body(handle: handle, name: name, rev: rev, file: &file)
359 </section>
360 }
361}
362
363/// A README's contents, in the three states a file can be in.
364///
365/// Split out so the `view!` holding the rendered markdown is the only place the
366/// escape hatch is used, and it is one line long.
367#[component]
368async fn readme_body(handle: &str, name: &str, rev: &RefName, file: &FileView) -> Result {
369 view! {
370 match &file.text {
371 // The only unescaped content on any Steid page. It is safe because
372 // `markdown` writes every tag itself and never passes source HTML
373 // through — see that module's header.
374 Some(text) => <div class="px-5 py-4 text-sm [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
375 (markdown::render_with_links(text, |destination| {
376 readme_link(handle, name, rev, destination)
377 }))
378 </div>,
379 None if file.too_large => <p class="px-4 py-6 text-center text-sm text-muted-foreground">
380 "This README is too large to render here. Open it in the file listing above."
381 </p>,
382 None => <p class="px-4 py-6 text-center text-sm text-muted-foreground">
383 "This README is not valid UTF-8, so it cannot be rendered."
384 </p>,
385 }
386 }
387}
388
285f5fdfeat: create and view repositories through the browser24d
389/// The creation form.
390///
391/// Values arrive as parameters rather than being read back, so a rejected submission
392/// re-renders exactly what was typed.
393#[component]
394async fn new_repo_form(
395 handle: &str,
396 name: &str,
397 description: &str,
398 visibility: Visibility,
399 error: &str,
400) -> Result {
401 view! {
402 <h1 class="text-xl font-semibold tracking-tight">"New repository"</h1>
403 <p class="mt-1 font-mono text-sm text-muted-foreground">"@" (handle)</p>
404
405 if !error.is_empty() {
406 <div class="mt-6">
407 flash(kind: FlashKind::Error, (error))
408 </div>
409 }
410
411 <form method="post" action=(format!("/{handle}/repos/new")) class="mt-6 space-y-5">
412 <div class="space-y-2">
413 label(attrs: attributes! { for="name" }, "Name")
414 input(attrs: attributes! {
415 id="name"
416 name="name"
417 type="text"
418 value=(name)
419 placeholder="my-project"
420 required=(true)
421 maxlength=(RepoName::MAX_LEN.to_string())
422 autofocus=(true)
423 })
424 <p class="text-xs text-muted-foreground">
425 "Letters, digits, hyphens, underscores and dots. Lowercased."
426 </p>
427 </div>
428
429 <div class="space-y-2">
430 label(attrs: attributes! { for="description" }, "Description")
431 textarea(
432 attrs: attributes! {
433 id="description"
434 name="description"
435 rows="2"
436 maxlength=(Repository::MAX_DESCRIPTION_LEN.to_string())
437 placeholder="A sentence for your profile."
438 },
439 (description)
440 )
441 <p class="text-xs text-muted-foreground">
442 "Optional. At most "
443 (Repository::MAX_DESCRIPTION_LEN.to_string()) " characters."
444 </p>
445 </div>
446
447 <div class="space-y-2">
448 label(attrs: attributes! { for="visibility" }, "Visibility")
449 select(
450 attrs: attributes! { id="visibility" name="visibility" },
451 <option value="public" selected=(visibility.is_public())>"Public"</option>
452 <option value="private" selected=(!visibility.is_public())>"Private"</option>
453 )
454 <p class="text-xs text-muted-foreground">
455 "Public repositories appear on your profile to anyone."
456 </p>
457 </div>
458
459 <div class="flex items-center gap-3">
460 button(attrs: attributes! { type="submit" }, "Create repository")
461 <a
462 href=(format!("/{handle}"))
463 class="text-sm text-muted-foreground hover:text-foreground"
464 >"Cancel"</a>
465 </div>
466 </form>
467 }
468}
0c5ca49feat: list repositories on the profile8d
469
470/// The Repositories section of a profile.
471///
472/// Takes the already-filtered summaries rather than fetching: which repositories a
473/// viewer may see is [`list_repos`](crate::application::list_repos)'s decision, and a
474/// component that queried for itself would be a second place that rule could live.
475///
476/// One empty state serves both "no repositories" and "none you may see" — a distinct
477/// message for the second would leak that private repositories exist.
acde6b5feat: show the clone URL on a repository page8d
478/// The URL to clone this repository from.
479///
480/// Built from the origin the page is being served on, so it is correct wherever the
481/// instance is deployed without anything having to be configured. A private repository
482/// gets the same URL: cloning it needs a token, not a different address.
dce0bf3feat: browse a repository's files and history8d
483pub(super) fn clone_url_for(cx: &Cx, repo: &RepoView) -> String {
acde6b5feat: show the clone URL on a repository page8d
484 format!(
485 "{}/{}/repos/{}.git",
486 public_origin(cx),
487 repo.handle,
488 repo.name
489 )
490}
491
492/// The clone address, ready to copy.
493///
494/// Shown for every repository a viewer can see, including an empty one — an empty
495/// repository is exactly when someone needs this, because it is what they push to.
496#[component]
dce0bf3feat: browse a repository's files and history8d
497pub(super) async fn clone_url(url: &str) -> Result {
acde6b5feat: show the clone URL on a repository page8d
498 view! {
499 <div class="mt-6">
500 <p class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
501 "Clone"
502 </p>
503 <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>
504 </div>
505 }
506}
507
0c5ca49feat: list repositories on the profile8d
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}
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
542
543#[cfg(test)]
544mod tests {
545 use crate::domain::ObjectId;
546
547 use super::*;
548
549 fn entry(name: &str, kind: EntryKind) -> TreeEntry {
550 TreeEntry {
551 name: name.to_owned(),
552 kind,
553 id: ObjectId::from_trusted("0".repeat(40)),
554 size: Some(0),
555 }
556 }
557
558 fn rev() -> RefName {
559 RefName::new("main").expect("valid revision")
560 }
561
562 #[test]
563 fn the_usual_readme_spellings_are_all_readmes() {
564 for name in [
565 "README.md",
566 "readme.md",
567 "Readme.md",
568 "README",
569 "readme",
570 "README.markdown",
571 "README.mkd",
572 ] {
573 assert!(readme_rank(name).is_some(), "{name} should be a README");
574 }
575 }
576
577 #[test]
578 fn things_that_merely_start_with_readme_are_not_readmes() {
579 for name in [
580 "READMEISH.md",
581 "readme-first.md",
582 "README.rst",
583 "README.txt",
584 "docs.md",
585 "",
586 ] {
587 assert!(readme_rank(name).is_none(), "{name} should not be a README");
588 }
589 }
590
591 #[test]
592 fn the_markdown_readme_wins_over_the_plain_one() {
593 let entries = [
594 entry("readme", EntryKind::Blob),
595 entry("README.md", EntryKind::Blob),
596 ];
597
598 assert_eq!(
599 readme_of(&entries).map(|found| found.name.as_str()),
600 Some("README.md")
601 );
602 }
603
604 #[test]
605 fn a_directory_called_readme_is_not_a_readme() {
606 // Reading it would ask git for a blob at a tree's path and get nothing.
607 let entries = [entry("readme", EntryKind::Tree)];
608
609 assert!(readme_of(&entries).is_none());
610 }
611
612 #[test]
613 fn a_listing_without_one_has_no_readme() {
614 let entries = [
615 entry("src", EntryKind::Tree),
616 entry("Cargo.toml", EntryKind::Blob),
617 ];
618
619 assert!(readme_of(&entries).is_none());
620 }
621
622 #[test]
623 fn a_relative_link_becomes_a_link_into_the_tree() {
624 assert_eq!(
625 readme_link("ada", "steid", &rev(), "./CONTRIBUTING.md").as_deref(),
626 Some("/ada/repos/steid/tree/main/-/CONTRIBUTING.md")
627 );
628 assert_eq!(
629 readme_link("ada", "steid", &rev(), "docs/design.md#why").as_deref(),
630 Some("/ada/repos/steid/tree/main/-/docs/design.md")
631 );
632 }
633
634 #[test]
635 fn a_link_that_would_walk_out_of_the_repository_is_left_alone() {
636 for destination in ["../elsewhere.md", "./", "", "a/../b.md"] {
637 assert!(
638 readme_link("ada", "steid", &rev(), destination).is_none(),
639 "{destination} should not resolve"
640 );
641 }
642 }
643}