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