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 std::time::SystemTime;
8
9use serde::Deserialize;
10use topcoat::{
11 Result,
12 context::Cx,
13 icon::{icon, iconify::iconify_icon},
14 router::{
15 StatusCode,
16 content::Form,
17 error::{RouterErrorExt, forbidden, not_found},
18 page, path_param,
19 },
20 view::{View, attributes, component, view},
21};
22
23use crate::{
24 application::{
25 Browsed, Error, FileView, NewRepo, RepoFacts, RepoView, create_repo, repo_summary,
26 view_repo,
27 },
28 components::{
29 badge::{BadgeVariant, badge},
30 button::{ButtonSize, ButtonVariant, button, button_variants},
31 flash::{FlashKind, flash},
32 input::input,
33 label::label,
34 select::select,
35 textarea::textarea,
36 },
37 domain::{
38 CommitSummary, DomainError, EntryKind, RefName, RepoName, RepoPath, Repository, TreeEntry,
39 Visibility,
40 },
41};
42
43use super::{
44 browse::{
45 ago, blob, browsed_at, browsed_rev, directory, empty_repo, log_url, repo_toolbar, tree_url,
46 },
47 context::{
48 current_actor, location, memberships, orgs, public_origin, queries, repos, server_error,
49 storage,
50 },
51 layout::{narrow, wide},
52 markdown,
53 profile::profile_for,
54};
55
56/// `{name}` from the path, raw — validation is [`RepoName`]'s job.
57#[path_param]
58struct Name(str);
59
60#[derive(Debug, Deserialize)]
61struct CreateForm {
62 name: String,
63 description: String,
64 visibility: String,
65}
66
67/// Blank input means unset, which is what the domain stores.
68fn optional(value: &str) -> Option<String> {
69 Some(value.trim().to_owned()).filter(|value| !value.is_empty())
70}
71
72/// Resolves `{handle}/repos/{name}` into a repository the viewer may see, or 404.
73///
74/// A repository the viewer may not see and one that does not exist are the same
75/// answer here, deliberately — see [`view_repo`].
76pub(super) async fn repo_for(cx: &Cx) -> Result<RepoView> {
77 let profile = profile_for(cx).await?;
78 let name = RepoName::new(path_param::<Name>(cx)).map_err(|_| not_found())?;
79 let actor = current_actor(cx).await?;
80
81 Ok(view_repo(
82 &profile.handle,
83 &name,
84 &actor,
85 &orgs(cx),
86 &memberships(cx),
87 &repos(cx),
88 )
89 .await
90 .map_err(server_error)?
91 .ok_or_not_found()?)
92}
93
94#[page("/{handle}/repos/new")]
95async fn new_repo_page(cx: &Cx) -> Result {
96 let profile = profile_for(cx).await?;
97
98 // The use case decides this too; checking here as well keeps the form from
99 // rendering for someone whose submission would only be rejected.
100 if !profile.viewer_is_owner {
101 return Err(forbidden().into());
102 }
103
104 view! {
105 new_repo_form(
106 handle: profile.handle.as_str(),
107 name: "",
108 description: "",
109 visibility: Visibility::Public,
110 error: "",
111 )
112 }
113}
114
115/// Creates the repository.
116///
117/// Success redirects to the new repository, using the **normalised** name from the
118/// created record — someone who typed `MyRepo` belongs at `/{handle}/repos/myrepo`.
119/// Failure re-renders with the reason and what was typed.
120///
121/// The success reply is a 303 — see [`location`] for why it is spelled this way and
122/// not with `redirect()`.
123#[page(POST "/{handle}/repos/new")]
124async fn create(cx: &Cx, Form(submitted): Form<CreateForm>) -> Result {
125 let profile = profile_for(cx).await?;
126
127 // An unparseable value is a tampered form, not something to default: defaulting
128 // here could publish a repository the owner asked to keep private.
129 let visibility = submitted
130 .visibility
131 .parse::<Visibility>()
132 .map_err(|_| topcoat::router::error::bad_request("unknown visibility"))?;
133
134 let outcome = create_repo(
135 &current_actor(cx).await?,
136 &profile.handle,
137 &NewRepo {
138 name: submitted.name.clone(),
139 description: optional(&submitted.description),
140 visibility,
141 },
142 &orgs(cx),
143 &memberships(cx),
144 &repos(cx),
145 &storage(cx),
146 )
147 .await;
148
149 let message = match outcome {
150 Ok(repo) => {
151 return view! {
152 (StatusCode::SEE_OTHER)
153 (location(&format!("/{}/repos/{}", profile.handle, repo.name))?)
154 };
155 }
156 Err(Error::Domain(DomainError::Validation { field, reason })) => {
157 format!("That {field} is no good: {reason}.")
158 }
159 Err(Error::Domain(DomainError::AlreadyExists { .. })) => {
160 format!(
161 "You already have a repository called {}.",
162 submitted.name.trim()
163 )
164 }
165 Err(Error::Domain(DomainError::Forbidden)) => return Err(forbidden().into()),
166 Err(other) => return Err(server_error(std::io::Error::other(other.to_string()))),
167 };
168
169 view! {
170 new_repo_form(
171 handle: profile.handle.as_str(),
172 name: submitted.name.as_str(),
173 description: submitted.description.as_str(),
174 visibility: visibility,
175 error: message.as_str(),
176 )
177 }
178}
179
180/// The repository's own page: its default branch, at the root, beside what the
181/// repository is.
182///
183/// The only two-column page in a repository. Code on the left because the reason to
184/// open a repository is to see what is in it; the About sidebar on the right because
185/// this is the page a visitor arrives at from a profile, and "what is this and may I
186/// use it" is the question they came with. Every page below this one — a tree subpath,
187/// a file, the log — stays a single wide column, the same way GitHub narrows once you
188/// are inside the tree.
189#[page("/{handle}/repos/{name}")]
190async fn repo_page(cx: &Cx) -> Result {
191 let repo = repo_for(cx).await?;
192 let clone = clone_url_for(cx, &repo);
193 let browsed = browsed_at(cx, &repo, None, &RepoPath::root()).await?;
194
195 // Only a browsed root has a revision and a listing to summarise. An empty
196 // repository has neither, so it is not asked — five more `git` processes to learn
197 // that nothing is there is exactly the cost 0006 warns about.
198 let facts = match &browsed {
199 Browsed::Directory { rev, entries, .. } => Some(facts_of(cx, &repo, rev, entries).await?),
200 _ => None,
201 };
202
203 let at = browsed_rev(&browsed);
204 let refs = facts
205 .as_ref()
206 .map(|facts| facts.refs.clone())
207 .unwrap_or_default();
208
209 view! {
210 wide(
211 repo_header(repo: &repo, rev: at, active: Tab::Code)
212
213 <div class="flex flex-col lg:flex-row lg:items-start lg:gap-6">
214 <div class="min-w-0 lg:flex-1">
215 match &browsed {
216 Browsed::Empty => empty_repo(url: clone.as_str()),
217 Browsed::Directory { rev, path, entries } => {
218 repo_toolbar(
219 handle: repo.handle.as_str(),
220 name: repo.name.as_str(),
221 rev: rev.as_str(),
222 path: path,
223 refs: &refs,
224 )
225 match facts.as_ref().and_then(|facts| facts.latest_commit.as_ref()) {
226 Some(commit) => latest_commit(
227 handle: repo.handle.as_str(),
228 name: repo.name.as_str(),
229 rev: rev.as_str(),
230 commit: commit,
231 ),
232 None => "",
233 }
234 directory(
235 handle: repo.handle.as_str(),
236 name: repo.name.as_str(),
237 rev: rev,
238 path: path,
239 entries: entries,
240 )
241
242 // No README means nothing here at all — an empty panel saying a
243 // repository has no README is worse than the silence.
244 match readme_of(entries) {
245 Some(entry) => readme_card(repo: &repo, rev: rev, entry: entry),
246 None => "",
247 }
248 },
249 // The root of a revision is always a directory, so this is unreachable in
250 // practice — rendered rather than errored so it can never be a 500.
251 Browsed::File { rev, path, file } => blob(
252 handle: repo.handle.as_str(),
253 name: repo.name.as_str(),
254 rev: rev,
255 path: path,
256 file: file,
257 ),
258 }
259 </div>
260
261 // Below `lg` the sidebar is not a sidebar: it comes *first*, above the
262 // file list, with a rule under it. Ordering it after the code would bury
263 // "what is this and may I use it" below a long README — which is the one
264 // question the visitor arrived with, on the viewport with the least room
265 // to go looking for it. `order-first` is why the container is a flex
266 // column below `lg` rather than a plain block.
267 <aside class="order-first mb-6 w-full shrink-0 border-b border-border pb-6 lg:order-none lg:sticky lg:top-6 lg:mb-0 lg:w-72 lg:border-b-0 lg:pb-0">
268 repo_about(
269 repo: &repo,
270 rev: at,
271 facts: facts.as_ref(),
272 clone: clone.as_str(),
273 )
274 </aside>
275 </div>
276 )
277 }
278}
279
280/// The facts about a repository the viewer can already see, or 404.
281///
282/// **Five `git` processes**, run concurrently — see
283/// [`repo_summary`](crate::application::repo_summary) and this step's note in
284/// `plans/progress.md`. Only the landing page calls it.
285async fn facts_of(
286 cx: &Cx,
287 repo: &RepoView,
288 rev: &RefName,
289 entries: &[TreeEntry],
290) -> Result<RepoFacts> {
291 Ok(repo_summary(
292 &repo.handle,
293 &repo.name,
294 rev,
295 entries,
296 &current_actor(cx).await?,
297 &orgs(cx),
298 &memberships(cx),
299 &repos(cx),
300 &queries(cx),
301 )
302 .await
303 .map_err(server_error)?
304 .ok_or_not_found()?)
305}
306
307// --- The repository frame ---------------------------------------------------------
308
309/// Which repository page is being looked at.
310///
311/// One variant per tab. Issues and pull requests each become a variant and a line in
312/// [`repo_header`] when they exist, which is the point of the strip being a component.
313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
314pub(super) enum Tab {
315 Code,
316 Commits,
317}
318
319/// The header every repository page carries: whose it is, what it is, and what else
320/// there is.
321///
322/// Shared so a tree, a file and a log read as one place rather than as three unrelated
323/// pages. Deliberately one line of title: the description lives in the About sidebar
324/// and nowhere else, because two homes for it means the header grows on exactly the
325/// repositories that have the most to say.
326#[component]
327pub(super) async fn repo_header(
328 repo: &RepoView,
329 rev: &str,
330 active: Tab,
331 /// The revision control, on the pages that carry it here. The landing page does
332 /// not: its switcher belongs in the toolbar above the file list.
333 #[default]
334 child: View,
335) -> Result {
336 let handle = repo.handle.as_str();
337 let name = repo.name.as_str();
338
339 // The primary colour marks the tab you are on. Nothing else on the strip is
340 // coloured, so it reads as position rather than as decoration.
341 let tab = |current| {
342 if current {
343 "border-primary text-foreground"
344 } else {
345 "border-transparent text-muted-foreground hover:text-foreground"
346 }
347 };
348
349 view! {
350 <header class="mb-4">
351 <p class="font-mono text-xs text-muted-foreground">
352 <a href=(format!("/{handle}")) class="hover:text-foreground">"@" (handle)</a>
353 " /"
354 </p>
355
356 <div class="mt-0.5 flex items-center gap-2.5">
357 <h1 class="text-2xl font-semibold tracking-tight">
358 <a href=(format!("/{handle}/repos/{name}"))>(name)</a>
359 </h1>
360 if !repo.visibility.is_public() {
361 badge(variant: BadgeVariant::Outline, "Private")
362 }
363 // Only the owner can change a repository, so only the owner is offered
364 // the way in. The settings page enforces this again — this is the link
365 // not being a dead end, not the authorization.
366 if repo.viewer_is_owner {
367 <a
368 href=(format!("/{handle}/repos/{name}/settings"))
369 class=(format!(
370 "ml-auto {}",
371 button_variants(ButtonVariant::Outline, ButtonSize::Sm)
372 ))
373 >"Settings"</a>
374 }
375 </div>
376
377 <nav class="mt-3 flex items-center gap-5 border-b border-border text-sm">
378 <a
379 href=(format!("/{handle}/repos/{name}"))
380 class=(format!("-mb-px border-b-2 pb-2 {}", tab(active == Tab::Code)))
381 >"Code"</a>
382 <a
383 href=(log_url(handle, name, rev))
384 class=(format!("-mb-px border-b-2 pb-2 {}", tab(active == Tab::Commits)))
385 >"Commits"</a>
386
387 <span class="ml-auto pb-2">(child)</span>
388 </nav>
389 </header>
390 }
391}
392
393/// The most recent commit, on one line above the listing.
394///
395/// This is what a per-file last-commit column would say if there were one, said once —
396/// the column itself is deferred until a kept-alive `cat-file --batch` exists, per the
397/// Milestone 5 amendment to
398/// [0006](../../../plans/decisions/0006-git-binary-behind-narrow-ports.md). The dot is
399/// the primary colour, which is the only mark on the row: a commit is the thing that
400/// changed most recently, and the eye should land on it.
401#[component]
402async fn latest_commit(handle: &str, name: &str, rev: &str, commit: &CommitSummary) -> Result {
403 view! {
404 <div class="mb-2 flex items-center gap-2.5 rounded-lg border border-border px-4 py-2 text-sm">
405 <span class="size-1.5 shrink-0 rounded-full bg-primary"></span>
406 <span class="truncate">(&commit.summary)</span>
407 <span class="ml-auto flex shrink-0 items-center gap-3 text-xs text-muted-foreground">
408 // The log is where the rest of the history is, and the only place this
409 // commit can currently be seen in context — there is no commit page yet.
410 <a href=(log_url(handle, name, rev)) class="font-mono hover:text-foreground">
411 (commit.id.short())
412 </a>
413 <span>(ago(commit.committed_at))</span>
414 </span>
415 </div>
416 }
417}
418
419// --- The About sidebar ------------------------------------------------------------
420
421/// What the repository is, in the column beside what is in it.
422///
423/// The portfolio pitch: a visitor arriving from a profile reads this before they read
424/// any code. Sections are separated by hairlines rather than boxed as cards — the same
425/// rule the profile page settled on, and the reason the page reads as one surface.
426#[component]
427async fn repo_about(repo: &RepoView, rev: &str, facts: Option<&RepoFacts>, clone: &str) -> Result {
428 let handle = repo.handle.as_str();
429 let name = repo.name.as_str();
430
431 view! {
432 <section class="text-sm">
433 <h2 class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
434 "About"
435 </h2>
436 match &repo.description {
437 Some(description) => <p class="mt-2 leading-relaxed">(description)</p>,
438 // Shown rather than omitted, because an owner looking at their own
439 // portfolio should see the gap they can fill in.
440 None => <p class="mt-2 text-muted-foreground">"No description."</p>,
441 }
442 match facts.and_then(|facts| facts.licence.as_ref()) {
443 Some(licence) => <p class="mt-2">
444 <a
445 href=(tree_url(handle, name, &RefName::from_trusted(rev), &licence.path))
446 class="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
447 >
448 icon(data: iconify_icon!("feather:book"), attrs: attributes! {
449 class="size-3.5"
450 })
451 // An unrecognised licence file is linked to and not named —
452 // see `Licence`. "Licence" is then the honest label.
453 (licence.name.unwrap_or("Licence"))
454 </a>
455 </p>,
456 None => "",
457 }
458 </section>
459
460 match facts {
461 Some(facts) => repo_stats(facts: facts, pushed: repo.updated_at),
462 // With no commits there is nothing true to count, so the row that is still
463 // true is shown on its own.
464 None => <dl class="mt-4 space-y-1.5 border-t border-border pt-4 text-xs">
465 fact(term: "Pushed", (ago(repo.updated_at)))
466 </dl>,
467 }
468
469 clone_block(url: clone)
470 }
471}
472
473/// The numbers, as a definition list.
474///
475/// A list rather than a row of badges: every value is a different kind of thing, and
476/// the label is what makes each one readable at a glance.
477#[component]
478async fn repo_stats(facts: &RepoFacts, pushed: SystemTime) -> Result {
479 view! {
480 <dl class="mt-4 space-y-1.5 border-t border-border pt-4 text-xs">
481 fact(term: "Commits", <span class="font-mono">(facts.commits.to_string())</span>)
482 fact(
483 term: "Branches",
484 <span class="font-mono">(facts.refs.branches.len().to_string())</span>
485 )
486 fact(term: "Tags", <span class="font-mono">(facts.refs.tags.len().to_string())</span>)
487 match &facts.latest_tag {
488 Some(tag) => fact(
489 term: "Latest tag",
490 <span class="inline-flex items-center gap-1.5 font-mono">
491 icon(data: iconify_icon!("feather:tag"), attrs: attributes! {
492 class="size-3 text-muted-foreground"
493 })
494 (tag.name.as_str())
495 </span>
496 ),
497 // A repository with no releases says nothing rather than "none": an
498 // empty value reads as a thing that is missing.
499 None => "",
500 }
501 fact(term: "Pushed", (ago(pushed)))
502 </dl>
503 }
504}
505
506/// One label-and-value row of [`repo_stats`].
507///
508/// The parameter is `term`, not `label`: the copied-in `label` component is a unit
509/// struct in this module's scope and would shadow a binding of that name.
510#[component]
511async fn fact(term: &str, #[default] child: View) -> Result {
512 view! {
513 <div class="flex items-baseline justify-between gap-3">
514 <dt class="text-muted-foreground">(term)</dt>
515 <dd class="min-w-0 truncate">(child)</dd>
516 </div>
517 }
518}
519
520/// The clone address, ready to copy.
521///
522/// Shown for every repository a viewer can see, including an empty one — an empty
523/// repository is exactly when someone needs this, because it is what they push to.
524///
525/// The URL alone rather than `git clone <url>`: at sidebar width the command wraps or
526/// scrolls, and the address is the part being copied. It wraps rather than scrolls —
527/// a horizontally scrolled URL looks like a truncated one, and the part cut off is the
528/// repository's own name. The two small download links — `.tar.gz` and `.zip` — belong
529/// under it once an archive endpoint exists.
530#[component]
531async fn clone_block(url: &str) -> Result {
532 view! {
533 <div class="mt-4 border-t border-border pt-4">
534 <p class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
535 "Clone"
536 </p>
537 <pre class="mt-2 rounded-lg border border-border bg-surface px-3 py-2 font-mono text-xs break-all whitespace-pre-wrap">(url)</pre>
538 </div>
539 }
540}
541
542// --- README -----------------------------------------------------------------------
543
544/// Extensions a README may carry, in the order they are preferred.
545///
546/// Empty last: a plain `README` is a README, but a repository holding both `README.md`
547/// and `README` means the markdown one. Nothing that is not markdown is here — a
548/// `README.rst` rendered as markdown would be worse than the file listing.
549const README_EXTENSIONS: [&str; 5] = ["md", "markdown", "mdown", "mkd", ""];
550
551/// How strongly a name says "this is the README", or `None` if it does not.
552///
553/// Case-insensitive, because the file is spelled `README`, `readme` and `Readme` in the
554/// wild and all three mean the same thing.
555fn readme_rank(name: &str) -> Option<usize> {
556 let lowered = name.to_ascii_lowercase();
557 let extension = match lowered.strip_prefix("readme")? {
558 "" => "",
559 rest => rest.strip_prefix('.')?,
560 };
561
562 README_EXTENSIONS
563 .iter()
564 .position(|candidate| *candidate == extension)
565}
566
567/// The README in a listing, if there is one.
568///
569/// Chosen from the entries the page already has rather than by asking git for a file
570/// that may not exist: every `git` call is a fork of about 12ms, and a speculative one
571/// would be spent on every repository without a README. See
572/// [0006](../../../plans/decisions/0006-git-binary-behind-narrow-ports.md).
573fn readme_of(entries: &[TreeEntry]) -> Option<&TreeEntry> {
574 entries
575 .iter()
576 .filter(|entry| entry.kind == EntryKind::Blob)
577 .filter_map(|entry| Some((readme_rank(&entry.name)?, entry)))
578 .min_by_key(|(rank, _)| *rank)
579 .map(|(_, entry)| entry)
580}
581
582/// Where a relative link in a README should point.
583///
584/// A README's links are written against the repository's own files, so `./CONTRIBUTING.md`
585/// means a file in the tree and not a Steid route — left alone it would 404. Anything
586/// this cannot make sense of returns `None` and is left exactly as written, which is the
587/// same thing a plain markdown renderer would do.
588///
589/// Only links. A relative *image* is left alone deliberately: an image needs the file's
590/// bytes, and a tree URL serves a page, so rewriting one would trade a 404 for a broken
591/// image. [`markdown::render_with_links`] never offers this an image.
592fn readme_link(handle: &str, name: &str, rev: &RefName, destination: &str) -> Option<String> {
593 // A query or fragment addresses something inside a rendered document; a file in a
594 // tree has neither.
595 let target = destination.split(['?', '#']).next()?;
596 let target = target.strip_prefix("./").unwrap_or(target);
597
598 // `RepoPath` refuses `..` and `.` components, so a link cannot walk out of the
599 // repository — it simply stays as it was written.
600 let path = RepoPath::new(target).ok()?;
601
602 if path.is_root() {
603 return None;
604 }
605
606 Some(tree_url(handle, name, rev, &path))
607}
608
609/// The rendered README, under the file listing.
610///
611/// Reading it costs the page one more `git` call, which is why it is the only file the
612/// page fetches beyond the listing itself.
613///
614/// A component rather than a function because `view!` needs the request context in
615/// scope — see [`browsing`](super::browse).
616#[component]
617async fn readme_card(cx: &Cx, repo: &RepoView, rev: &RefName, entry: &TreeEntry) -> Result {
618 let path = RepoPath::new(&entry.name).map_err(|_| not_found())?;
619
620 // The entry came out of a listing read moments ago, so anything but a file means
621 // the tree changed underneath this request. A vanished README is not a reason to
622 // fail the page it was going to decorate.
623 let Browsed::File { file, .. } = browsed_at(cx, repo, Some(rev), &path).await? else {
624 return view! {};
625 };
626
627 let handle = repo.handle.as_str();
628 let name = repo.name.as_str();
629
630 view! {
631 <section class="mt-6 overflow-hidden rounded-lg border border-border">
632 <div class="border-b border-border px-4 py-2.5">
633 <a
634 href=(tree_url(handle, name, rev, &path))
635 class="font-mono text-sm hover:underline"
636 >(&entry.name)</a>
637 </div>
638 readme_body(handle: handle, name: name, rev: rev, file: &file)
639 </section>
640 }
641}
642
643/// A README's contents, in the three states a file can be in.
644///
645/// Split out so the `view!` holding the rendered markdown is the only place the
646/// escape hatch is used, and it is one line long.
647#[component]
648async fn readme_body(handle: &str, name: &str, rev: &RefName, file: &FileView) -> Result {
649 view! {
650 match &file.text {
651 // The only unescaped content on any Steid page. It is safe because
652 // `markdown` writes every tag itself and never passes source HTML
653 // through — see that module's header.
654 Some(text) => <div class="px-5 py-4 text-sm [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
655 (markdown::render_with_links(text, |destination| {
656 readme_link(handle, name, rev, destination)
657 }))
658 </div>,
659 None if file.too_large => <p class="px-4 py-6 text-center text-sm text-muted-foreground">
660 "This README is too large to render here. Open it in the file listing above."
661 </p>,
662 None => <p class="px-4 py-6 text-center text-sm text-muted-foreground">
663 "This README is not valid UTF-8, so it cannot be rendered."
664 </p>,
665 }
666 }
667}
668
669/// The creation form.
670///
671/// Values arrive as parameters rather than being read back, so a rejected submission
672/// re-renders exactly what was typed.
673#[component]
674async fn new_repo_form(
675 handle: &str,
676 name: &str,
677 description: &str,
678 visibility: Visibility,
679 error: &str,
680) -> Result {
681 view! {
682 narrow(
683 <h1 class="text-xl font-semibold tracking-tight">"New repository"</h1>
684 <p class="mt-1 font-mono text-sm text-muted-foreground">"@" (handle)</p>
685
686 if !error.is_empty() {
687 <div class="mt-6">
688 flash(kind: FlashKind::Error, (error))
689 </div>
690 }
691
692 <form method="post" action=(format!("/{handle}/repos/new")) class="mt-6 space-y-5">
693 <div class="space-y-2">
694 label(attrs: attributes! { for="name" }, "Name")
695 input(attrs: attributes! {
696 id="name"
697 name="name"
698 type="text"
699 value=(name)
700 placeholder="my-project"
701 required=(true)
702 maxlength=(RepoName::MAX_LEN.to_string())
703 autofocus=(true)
704 })
705 <p class="text-xs text-muted-foreground">
706 "Letters, digits, hyphens, underscores and dots. Lowercased."
707 </p>
708 </div>
709
710 <div class="space-y-2">
711 label(attrs: attributes! { for="description" }, "Description")
712 textarea(
713 attrs: attributes! {
714 id="description"
715 name="description"
716 rows="2"
717 maxlength=(Repository::MAX_DESCRIPTION_LEN.to_string())
718 placeholder="A sentence for your profile."
719 },
720 (description)
721 )
722 <p class="text-xs text-muted-foreground">
723 "Optional. At most "
724 (Repository::MAX_DESCRIPTION_LEN.to_string()) " characters."
725 </p>
726 </div>
727
728 <div class="space-y-2">
729 label(attrs: attributes! { for="visibility" }, "Visibility")
730 select(
731 attrs: attributes! { id="visibility" name="visibility" },
732 <option value="public" selected=(visibility.is_public())>"Public"</option>
733 <option value="private" selected=(!visibility.is_public())>"Private"</option>
734 )
735 <p class="text-xs text-muted-foreground">
736 "Public repositories appear on your profile to anyone."
737 </p>
738 </div>
739
740 <div class="flex items-center gap-3">
741 button(attrs: attributes! { type="submit" }, "Create repository")
742 <a
743 href=(format!("/{handle}"))
744 class="text-sm text-muted-foreground hover:text-foreground"
745 >"Cancel"</a>
746 </div>
747 </form>
748 )
749 }
750}
751
752/// The Repositories section of a profile.
753///
754/// Takes the already-filtered summaries rather than fetching: which repositories a
755/// viewer may see is [`list_repos`](crate::application::list_repos)'s decision, and a
756/// component that queried for itself would be a second place that rule could live.
757///
758/// One empty state serves both "no repositories" and "none you may see" — a distinct
759/// message for the second would leak that private repositories exist.
760/// The URL to clone this repository from.
761///
762/// Built from the origin the page is being served on, so it is correct wherever the
763/// instance is deployed without anything having to be configured. A private repository
764/// gets the same URL: cloning it needs a token, not a different address.
765pub(super) fn clone_url_for(cx: &Cx, repo: &RepoView) -> String {
766 format!(
767 "{}/{}/repos/{}.git",
768 public_origin(cx),
769 repo.handle,
770 repo.name
771 )
772}
773
774#[cfg(test)]
775mod tests {
776 use crate::domain::ObjectId;
777
778 use super::*;
779
780 fn entry(name: &str, kind: EntryKind) -> TreeEntry {
781 TreeEntry {
782 name: name.to_owned(),
783 kind,
784 id: ObjectId::from_trusted("0".repeat(40)),
785 size: Some(0),
786 }
787 }
788
789 fn rev() -> RefName {
790 RefName::new("main").expect("valid revision")
791 }
792
793 #[test]
794 fn the_usual_readme_spellings_are_all_readmes() {
795 for name in [
796 "README.md",
797 "readme.md",
798 "Readme.md",
799 "README",
800 "readme",
801 "README.markdown",
802 "README.mkd",
803 ] {
804 assert!(readme_rank(name).is_some(), "{name} should be a README");
805 }
806 }
807
808 #[test]
809 fn things_that_merely_start_with_readme_are_not_readmes() {
810 for name in [
811 "READMEISH.md",
812 "readme-first.md",
813 "README.rst",
814 "README.txt",
815 "docs.md",
816 "",
817 ] {
818 assert!(readme_rank(name).is_none(), "{name} should not be a README");
819 }
820 }
821
822 #[test]
823 fn the_markdown_readme_wins_over_the_plain_one() {
824 let entries = [
825 entry("readme", EntryKind::Blob),
826 entry("README.md", EntryKind::Blob),
827 ];
828
829 assert_eq!(
830 readme_of(&entries).map(|found| found.name.as_str()),
831 Some("README.md")
832 );
833 }
834
835 #[test]
836 fn a_directory_called_readme_is_not_a_readme() {
837 // Reading it would ask git for a blob at a tree's path and get nothing.
838 let entries = [entry("readme", EntryKind::Tree)];
839
840 assert!(readme_of(&entries).is_none());
841 }
842
843 #[test]
844 fn a_listing_without_one_has_no_readme() {
845 let entries = [
846 entry("src", EntryKind::Tree),
847 entry("Cargo.toml", EntryKind::Blob),
848 ];
849
850 assert!(readme_of(&entries).is_none());
851 }
852
853 #[test]
854 fn a_relative_link_becomes_a_link_into_the_tree() {
855 assert_eq!(
856 readme_link("ada", "steid", &rev(), "./CONTRIBUTING.md").as_deref(),
857 Some("/ada/repos/steid/tree/main/-/CONTRIBUTING.md")
858 );
859 assert_eq!(
860 readme_link("ada", "steid", &rev(), "docs/design.md#why").as_deref(),
861 Some("/ada/repos/steid/tree/main/-/docs/design.md")
862 );
863 }
864
865 #[test]
866 fn a_link_that_would_walk_out_of_the_repository_is_left_alone() {
867 for destination in ["../elsewhere.md", "./", "", "a/../b.md"] {
868 assert!(
869 readme_link("ada", "steid", &rev(), destination).is_none(),
870 "{destination} should not resolve"
871 );
872 }
873 }
874}