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