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