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