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