@jpgilldev / steid

44.1 KBRaw
1//! Browsing a repository — the file tree, a single file, and the commit log.
2//!
3//! Four routes, all reading through [`browse_repo`] and [`repo_log`], so visibility is
4//! decided in one place: a repository invisible on its page is invisible here, and
5//! `Ok(None)` from either use case renders as the same 404 as a repository that was
6//! never created.
7//!
8//! The URL carries a `/-/` separator between the revision and the path
9//! (`/tree/{rev}/-/{path}`) because a ref may contain slashes, so a candidate split
10//! would cost a ref lookup — another `git` process — on every page. The separator is
11//! unambiguous by construction instead.
12//!
13//! One route serves both directories and files. Which one a path is, is git's answer,
14//! not the URL's, and a link that had to know would be wrong the moment a file became
15//! a directory.
16//!
17//! A fifth route, `/raw/{rev}/-/{*path}`, serves a file's bytes verbatim. It is not a
18//! page: it hands back arbitrary bytes an untrusted person put in a repository, so its
19//! response headers are a security policy rather than a convenience — see
20//! [`raw_response`].
21
22use std::time::{SystemTime, UNIX_EPOCH};
23
24use topcoat::{
25 Result,
26 context::Cx,
27 icon::{icon, iconify::iconify_icon},
28 router::{
29 Body, Response, StatusCode,
30 error::{RouterErrorExt, not_found},
31 header::{CONTENT_DISPOSITION, CONTENT_TYPE},
32 page, path_param, route,
33 },
34 view::{View, attributes, component, view},
35};
36
37use crate::{
38 application::{
39 Browsed, FileView, RepoView,
40 browse::{RawFile, RefList, list_refs, read_raw_file},
41 browse_repo, repo_log,
42 },
43 components::badge::{BadgeVariant, badge},
44 domain::{CommitSummary, EntryKind, RefName, RepoPath, TreeEntry},
45};
46
47use super::{
48 context::{current_actor, memberships, orgs, queries, repos, server_error},
49 repo::{clone_url, clone_url_for, repo_for},
50};
51
52/// `{rev}` from the path, raw — validation is [`RefName`]'s job.
53#[path_param]
54struct Rev(str);
55
56/// `{*path}` from the path: every remaining segment, as one string.
57#[path_param]
58struct Path(str);
59
60/// Which page of a repository is being looked at, for the nav.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub(super) enum Tab {
63 Files,
64 Log,
65}
66
67/// The revision from the URL, or 404.
68///
69/// A malformed revision is a page that does not exist rather than a bad request — the
70/// same reasoning that 404s a malformed handle.
71fn rev_param(cx: &Cx) -> Result<RefName> {
72 Ok(RefName::new(path_param::<Rev>(cx)).map_err(|_| not_found())?)
73}
74
75/// The path from the URL, or 404. Same reasoning as [`rev_param`].
76fn path_arg(cx: &Cx) -> Result<RepoPath> {
77 Ok(RepoPath::new(path_param::<Path>(cx)).map_err(|_| not_found())?)
78}
79
80#[page("/{handle}/repos/{name}/tree/{rev}")]
81async fn tree_root_page(cx: &Cx) -> Result {
82 let rev = rev_param(cx)?;
83
84 view! { browsing(rev: Some(rev), path: RepoPath::root()) }
85}
86
87#[page("/{handle}/repos/{name}/tree/{rev}/-/{*path}")]
88async fn tree_path_page(cx: &Cx) -> Result {
89 let rev = rev_param(cx)?;
90 let path = path_arg(cx)?;
91
92 view! { browsing(rev: Some(rev), path: path) }
93}
94
95#[page("/{handle}/repos/{name}/log")]
96async fn log_page(_cx: &Cx) -> Result {
97 view! { history(rev: None) }
98}
99
100#[page("/{handle}/repos/{name}/log/{rev}")]
101async fn log_rev_page(cx: &Cx) -> Result {
102 let rev = rev_param(cx)?;
103
104 view! { history(rev: Some(rev)) }
105}
106
107/// A file's bytes, exactly as they are stored.
108///
109/// A route rather than a page: the response is a file, not a document, so no layout
110/// wraps it. `/-/` separates the revision from the path for the same reason the tree
111/// route does — both can contain slashes and they sit adjacent.
112///
113/// Authorized through [`read_raw_file`], which goes through the same `view_repo` every
114/// browse page does, so a private repository answers 404 here exactly as it does there.
115#[route(GET "/{handle}/repos/{name}/raw/{rev}/-/{*path}")]
116async fn raw_page(cx: &Cx) -> Result<Response<Body>> {
117 let rev = rev_param(cx)?;
118 let path = path_arg(cx)?;
119 let repo = repo_for(cx).await?;
120
121 let raw = read_raw_file(
122 &repo.handle,
123 &repo.name,
124 Some(&rev),
125 &path,
126 &current_actor(cx).await?,
127 &orgs(cx),
128 &memberships(cx),
129 &repos(cx),
130 &queries(cx),
131 )
132 .await
133 .map_err(server_error)?
134 .ok_or_not_found()?;
135
136 match raw {
137 RawFile::Ready { name, content } => raw_response(&name, content),
138 // The only case that is neither a file nor a 404. Its body is Steid's own text,
139 // never the repository's, so it is the one raw response that may name a type.
140 RawFile::TooLarge { size } => Response::builder()
141 .status(StatusCode::PAYLOAD_TOO_LARGE)
142 .header(CONTENT_TYPE, "text/plain; charset=utf-8")
143 .header(NOSNIFF.0, NOSNIFF.1)
144 .body(Body::from(format!(
145 "This file is {}, which is larger than this instance serves raw. Clone the repository to read it.\n",
146 size_of(size)
147 )))
148 .map_err(server_error),
149 }
150}
151
152/// Reads a path in a repository, or 404.
153///
154/// Shared with the repository page, which browses the default branch at the root.
155pub(super) async fn browsed_at(
156 cx: &Cx,
157 repo: &RepoView,
158 rev: Option<&RefName>,
159 path: &RepoPath,
160) -> Result<Browsed> {
161 Ok(browse_repo(
162 &repo.handle,
163 &repo.name,
164 rev,
165 path,
166 &current_actor(cx).await?,
167 &orgs(cx),
168 &memberships(cx),
169 &repos(cx),
170 &queries(cx),
171 )
172 .await
173 .map_err(server_error)?
174 .ok_or_not_found()?)
175}
176
177/// The branches and tags of a repository the viewer can already see, or 404.
178///
179/// **One extra `git` process, ~14ms**, on top of the two or three a browse already
180/// spends — see the Milestone 5 amendment to
181/// [0006](../../plans/decisions/0006-git-binary-behind-narrow-ports.md). Called only by
182/// the pages that show the switcher, and skipped for a repository with no commits,
183/// where there is nothing to list.
184async fn refs_for(cx: &Cx, repo: &RepoView) -> Result<RefList> {
185 Ok(list_refs(
186 &repo.handle,
187 &repo.name,
188 &current_actor(cx).await?,
189 &orgs(cx),
190 &memberships(cx),
191 &repos(cx),
192 &queries(cx),
193 )
194 .await
195 .map_err(server_error)?
196 .ok_or_not_found()?)
197}
198
199/// The tree and blob pages, which differ only in what git found at the path.
200///
201/// A component rather than a plain function because `view!` needs the request context
202/// in scope, and a component is how a body of markup gets it — the same reason the
203/// layout is a layout.
204#[component]
205async fn browsing(cx: &Cx, rev: Option<RefName>, path: RepoPath) -> Result {
206 let repo = repo_for(cx).await?;
207 let browsed = browsed_at(cx, &repo, rev.as_ref(), &path).await?;
208 let clone = clone_url_for(cx, &repo);
209
210 // An empty repository has no refs, so the fork is not spent asking.
211 let refs = match browsed {
212 Browsed::Empty => RefList::default(),
213 _ => refs_for(cx, &repo).await?,
214 };
215
216 let at = browsed_rev(&browsed);
217 let switch = Switch::Tree(&path);
218 let handle = repo.handle.as_str();
219 let name = repo.name.as_str();
220 let known = RefName::new(at).is_ok_and(|at| refs.contains(&at));
221 let branches = ref_links(handle, name, &refs.branches, at, &switch);
222 let tags = ref_links(handle, name, &refs.tags, at, &switch);
223
224 view! {
225 repo_bar(
226 repo: &repo,
227 rev: at,
228 active: Tab::Files,
229 rev_switcher(current: at, known: known, branches: &branches, tags: &tags)
230 )
231
232 match &browsed {
233 Browsed::Empty => {
234 clone_url(url: clone.as_str())
235 empty_repo(url: clone.as_str())
236 },
237 Browsed::Directory { rev, path, entries } => directory(
238 handle: repo.handle.as_str(),
239 name: repo.name.as_str(),
240 rev: rev,
241 path: path,
242 entries: entries,
243 ),
244 Browsed::File { rev, path, file } => blob(
245 handle: repo.handle.as_str(),
246 name: repo.name.as_str(),
247 rev: rev,
248 path: path,
249 file: file,
250 ),
251 }
252 }
253}
254
255/// The commit log page. A component for the same reason as [`browsing`].
256#[component]
257async fn history(cx: &Cx, rev: Option<RefName>) -> Result {
258 let repo = repo_for(cx).await?;
259
260 let log = repo_log(
261 &repo.handle,
262 &repo.name,
263 rev.as_ref(),
264 &current_actor(cx).await?,
265 &orgs(cx),
266 &memberships(cx),
267 &repos(cx),
268 &queries(cx),
269 )
270 .await
271 .map_err(server_error)?
272 .ok_or_not_found()?;
273
274 let refs = refs_for(cx, &repo).await?;
275 let at = rev.as_ref().map(RefName::as_str).unwrap_or_default();
276 let handle = repo.handle.as_str();
277 let name = repo.name.as_str();
278 let known = RefName::new(at).is_ok_and(|at| refs.contains(&at));
279 let branches = ref_links(handle, name, &refs.branches, at, &Switch::Log);
280 let tags = ref_links(handle, name, &refs.tags, at, &Switch::Log);
281
282 view! {
283 repo_bar(
284 repo: &repo,
285 rev: at,
286 active: Tab::Log,
287 // At the default branch the URL names no revision and `repo_log` does not
288 // report the one it resolved, so the switcher opens with nothing marked
289 // current rather than guessing. Noted in `plans/current.md`.
290 rev_switcher(current: at, known: known, branches: &branches, tags: &tags)
291 )
292 commit_log(commits: &log)
293 }
294}
295
296/// The revision a browse landed on, for display. Empty when there is none.
297fn browsed_rev(browsed: &Browsed) -> &str {
298 match browsed {
299 Browsed::Empty => "",
300 Browsed::Directory { rev, .. } | Browsed::File { rev, .. } => rev.as_str(),
301 }
302}
303
304// --- Serving bytes ----------------------------------------------------------------
305
306/// The header that stops a browser second-guessing a `Content-Type`.
307///
308/// A pair rather than a constant string so the name is written once; `http` has no
309/// constant for it.
310const NOSNIFF: (&str, &str) = ("x-content-type-options", "nosniff");
311
312/// What a raw file is served as.
313///
314/// **Never the file's own type, and never guessed from its extension.** Steid serves
315/// repository contents from the same origin as the application, so a file the origin
316/// labels `text/html` runs *as this site*: it reads the session cookie, calls Steid's
317/// own endpoints as the viewer, and rewrites the page around it. Somebody pushing
318/// `evil.html` would then have stored XSS on every visitor who followed a link to it.
319/// The same holds for SVG (scriptable), XML, and anything a browser will render.
320///
321/// Today only the repository's owner can push, so the only person who could attack a
322/// viewer is the person whose site it is. Milestone 7 adds other users and this endpoint
323/// will outlive that assumption, so the policy is written for the world where the bytes
324/// are hostile.
325///
326/// Four headers, each closing a different door:
327///
328/// - `application/octet-stream` — a type no browser renders. Not `text/plain`, which
329/// *is* rendered, and which older browsers have been talked into sniffing as HTML.
330/// - `nosniff` — without it a browser is free to ignore the type above and decide from
331/// the content, which is exactly the guess this policy refuses to make.
332/// - `Content-Disposition: attachment` — the file is saved, not shown, so nothing it
333/// contains is ever parsed in this origin's context. It also stops a same-origin
334/// `<iframe>` from rendering it.
335/// - `Content-Security-Policy: default-src 'none'; sandbox` — belt and braces for the
336/// case where one of the above is wrong or unsupported. Nothing in the response may
337/// load, run, or navigate.
338///
339/// The cost is that a raw URL downloads rather than displays. That is the correct trade
340/// for a forge serving other people's bytes, and it is what `curl` wants anyway.
341fn raw_response(name: &str, content: Vec<u8>) -> Result<Response<Body>> {
342 Response::builder()
343 .header(CONTENT_TYPE, "application/octet-stream")
344 .header(NOSNIFF.0, NOSNIFF.1)
345 .header(CONTENT_DISPOSITION, disposition(name))
346 .header("content-security-policy", "default-src 'none'; sandbox")
347 .body(Body::from(content))
348 .map_err(server_error)
349}
350
351/// The `Content-Disposition` for a downloaded file.
352///
353/// Two filenames, per RFC 6266: a plain `filename` every client understands, and a
354/// `filename*` carrying the real name as percent-encoded UTF-8 for those that do. The
355/// plain one is reduced to characters that cannot end the quoted string or be read as a
356/// header of their own — a name is repository content, so a quote or a newline in it
357/// would otherwise be header injection.
358fn disposition(name: &str) -> String {
359 let mut safe = String::with_capacity(name.len());
360
361 for char in name.chars() {
362 match char {
363 'A'..='Z' | 'a'..='z' | '0'..='9' | '.' | '-' | '_' => safe.push(char),
364 _ => safe.push('_'),
365 }
366 }
367
368 // A name reduced to nothing recognisable — punctuation, or a name written entirely
369 // in a script the plain form cannot carry — downloads as `file` rather than as
370 // `___`. The real name is still in `filename*` for anything that reads it.
371 if !safe.chars().any(|char| char.is_ascii_alphanumeric()) {
372 safe = "file".to_owned();
373 }
374
375 format!(
376 "attachment; filename=\"{safe}\"; filename*=UTF-8''{}",
377 encode(name, false)
378 )
379}
380
381// --- URLs -------------------------------------------------------------------------
382
383/// The URL for a path at a revision.
384///
385/// The revision is encoded whole, slashes included, so `feature/login` stays one
386/// segment and the `/-/` separator keeps its meaning. The path keeps its slashes,
387/// because they *are* segments.
388pub(super) fn tree_url(handle: &str, name: &str, rev: &RefName, path: &RepoPath) -> String {
389 let encoded = encode(rev.as_str(), false);
390
391 if path.is_root() {
392 format!("/{handle}/repos/{name}/tree/{encoded}")
393 } else {
394 format!(
395 "/{handle}/repos/{name}/tree/{encoded}/-/{}",
396 encode(path.as_str(), true)
397 )
398 }
399}
400
401/// The URL a file's bytes are served from.
402///
403/// Always has a path: there are no raw bytes for a directory, so the root has no raw
404/// URL and the `/-/` separator is unconditional.
405pub(super) fn raw_url(handle: &str, name: &str, rev: &RefName, path: &RepoPath) -> String {
406 format!(
407 "/{handle}/repos/{name}/raw/{}/-/{}",
408 encode(rev.as_str(), false),
409 encode(path.as_str(), true)
410 )
411}
412
413/// The commit log's URL, at a revision or at the default branch.
414fn log_url(handle: &str, name: &str, rev: &str) -> String {
415 if rev.is_empty() {
416 format!("/{handle}/repos/{name}/log")
417 } else {
418 format!("/{handle}/repos/{name}/log/{}", encode(rev, false))
419 }
420}
421
422// --- The revision switcher --------------------------------------------------------
423
424/// Which page a switcher's links lead back to.
425///
426/// Switching branch keeps you where you are: the same path on the tree, the log on the
427/// log. A path that does not exist on the revision you picked lands on a 404, which is
428/// the honest answer — the alternative is silently sending you somewhere you did not
429/// ask for.
430enum Switch<'a> {
431 Tree(&'a RepoPath),
432 Log,
433}
434
435/// One row of the switcher.
436struct RefLink {
437 name: String,
438 href: String,
439 current: bool,
440}
441
442fn ref_links(
443 handle: &str,
444 name: &str,
445 refs: &[RefName],
446 current: &str,
447 switch: &Switch,
448) -> Vec<RefLink> {
449 refs.iter()
450 .map(|git_ref| RefLink {
451 name: git_ref.to_string(),
452 href: match switch {
453 Switch::Tree(path) => tree_url(handle, name, git_ref, path),
454 Switch::Log => log_url(handle, name, git_ref.as_str()),
455 },
456 current: git_ref.as_str() == current,
457 })
458 .collect()
459}
460
461/// Whether a revision is an object id rather than a name.
462///
463/// A heuristic, and only used for display: the worst it can do is abbreviate a branch
464/// somebody named `deadbeef`.
465fn is_object_id(rev: &str) -> bool {
466 rev.len() >= 7 && rev.len() <= 64 && rev.chars().all(|char| char.is_ascii_hexdigit())
467}
468
469/// How the switcher labels the revision it is on.
470///
471/// A ref by its name; a commit browsed directly by its abbreviation, because forty
472/// characters of hex in a control reads as noise and is not a branch.
473fn rev_label(rev: &str, known: bool) -> String {
474 if !known && is_object_id(rev) {
475 rev[..7].to_owned()
476 } else {
477 rev.to_owned()
478 }
479}
480
481/// Percent-encodes a URL segment.
482///
483/// Hand-rolled rather than pulled in as a dependency: it is the unreserved set from
484/// RFC 3986 and nothing else. `keep_slash` is the difference between a path, whose
485/// slashes are structure, and a revision, whose slashes are part of its name.
486fn encode(value: &str, keep_slash: bool) -> String {
487 let mut encoded = String::with_capacity(value.len());
488
489 for byte in value.bytes() {
490 match byte {
491 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
492 encoded.push(byte as char);
493 }
494 b'/' if keep_slash => encoded.push('/'),
495 other => encoded.push_str(&format!("%{other:02X}")),
496 }
497 }
498
499 encoded
500}
501
502// --- Formatting -------------------------------------------------------------------
503
504/// A file size, rounded for reading rather than for accounting.
505fn size_of(bytes: u64) -> String {
506 const UNITS: [&str; 4] = ["KB", "MB", "GB", "TB"];
507
508 if bytes < 1024 {
509 return format!("{bytes} B");
510 }
511
512 let mut value = bytes as f64 / 1024.0;
513 let mut unit = UNITS[0];
514
515 for next in &UNITS[1..] {
516 if value < 1024.0 {
517 break;
518 }
519
520 value /= 1024.0;
521 unit = next;
522 }
523
524 format!("{value:.1} {unit}")
525}
526
527/// How long ago something happened, in words.
528///
529/// A commit's timestamp comes from whoever made it, so it can sit in the future — a
530/// skewed clock, or a rewritten history. That reads as "just now" rather than as a
531/// negative duration.
532pub(super) fn ago(time: SystemTime) -> String {
533 let Ok(elapsed) = SystemTime::now().duration_since(time) else {
534 return "just now".to_owned();
535 };
536
537 let seconds = elapsed.as_secs();
538
539 let (count, unit) = match seconds {
540 0..=59 => return "just now".to_owned(),
541 60..=3599 => (seconds / 60, "minute"),
542 3600..=86_399 => (seconds / 3600, "hour"),
543 86_400..=2_591_999 => (seconds / 86_400, "day"),
544 2_592_000..=31_535_999 => (seconds / 2_592_000, "month"),
545 _ => (seconds / 31_536_000, "year"),
546 };
547
548 if count == 1 {
549 format!("1 {unit} ago")
550 } else {
551 format!("{count} {unit}s ago")
552 }
553}
554
555/// The exact time, for the tooltip behind [`ago`].
556fn timestamp(time: SystemTime) -> String {
557 let seconds = time
558 .duration_since(UNIX_EPOCH)
559 .map(|since| since.as_secs() as i64)
560 .unwrap_or(0);
561
562 let (year, month, day) = civil_from_days(seconds.div_euclid(86_400));
563 let rest = seconds.rem_euclid(86_400);
564
565 format!(
566 "{year:04}-{month:02}-{day:02} {:02}:{:02} UTC",
567 rest / 3600,
568 (rest % 3600) / 60
569 )
570}
571
572/// Days since the epoch to a calendar date, by Howard Hinnant's `civil_from_days`.
573///
574/// Written out rather than taken as a dependency: this is the whole of the date
575/// handling Steid needs, and a date library is a large thing to add for one function.
576fn civil_from_days(days: i64) -> (i64, u32, u32) {
577 // Shift the epoch to 0000-03-01, which puts the leap day at the end of the year.
578 let shifted = days + 719_468;
579 let era = shifted.div_euclid(146_097);
580 let day_of_era = shifted.rem_euclid(146_097);
581
582 let year_of_era =
583 (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
584 let year = year_of_era + era * 400;
585 let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
586
587 let shifted_month = (5 * day_of_year + 2) / 153;
588 let day = (day_of_year - (153 * shifted_month + 2) / 5 + 1) as u32;
589 let month = if shifted_month < 10 {
590 shifted_month + 3
591 } else {
592 shifted_month - 9
593 } as u32;
594
595 (if month <= 2 { year + 1 } else { year }, month, day)
596}
597
598// --- Views ------------------------------------------------------------------------
599
600/// The bar every repository page carries: where you are, and what else there is.
601///
602/// Kept out of the repository page's own header so that a tree, a file and a log all
603/// read as the same repository rather than as three unrelated pages.
604#[component]
605pub(super) async fn repo_bar(
606 repo: &RepoView,
607 rev: &str,
608 active: Tab,
609 /// The revision control, when the page has one. Empty on a page that does not.
610 #[default]
611 child: View,
612) -> Result {
613 let handle = repo.handle.as_str();
614 let name = repo.name.as_str();
615 let tab = |current| {
616 if current {
617 "text-foreground border-foreground"
618 } else {
619 "text-muted-foreground border-transparent hover:text-foreground"
620 }
621 };
622
623 view! {
624 <header class="mb-6 border-b border-border pb-3">
625 <p class="font-mono text-sm text-muted-foreground">
626 <a href=(format!("/{handle}")) class="hover:text-foreground">"@" (handle)</a>
627 " / "
628 <a href=(format!("/{handle}/repos/{name}")) class="text-foreground hover:underline">
629 (name)
630 </a>
631 if !repo.visibility.is_public() {
632 " "
633 badge(variant: BadgeVariant::Outline, "Private")
634 }
635 </p>
636
637 <nav class="mt-3 flex items-center gap-5 text-sm">
638 <a
639 href=(format!("/{handle}/repos/{name}"))
640 class=(format!("-mb-3 border-b-2 pb-2 {}", tab(active == Tab::Files)))
641 >"Files"</a>
642 <a
643 href=(log_url(handle, name, rev))
644 class=(format!("-mb-3 border-b-2 pb-2 {}", tab(active == Tab::Log)))
645 >"Commits"</a>
646
647 <span class="ml-auto">(child)</span>
648 </nav>
649 </header>
650 }
651}
652
653/// The branch and tag picker.
654///
655/// A `<details>` disclosure, so it opens and closes with no scripting — the rest of
656/// Steid works without JavaScript and a navigation control is the last place to start
657/// requiring it. Every entry is a plain link, so it is also the whole keyboard and
658/// screen-reader story for free.
659///
660/// Renders nothing at all when there is neither a revision nor a ref to offer, which is
661/// an empty repository.
662#[component]
663async fn rev_switcher(
664 current: &str,
665 known: bool,
666 branches: &[RefLink],
667 tags: &[RefLink],
668) -> Result {
669 let label = rev_label(current, known);
670 let empty = branches.is_empty() && tags.is_empty();
671
672 view! {
673 if empty {
674 // Nothing to switch to, so the control degrades to what it replaced: a
675 // statement of where you are.
676 if !current.is_empty() {
677 <span class="inline-flex items-center gap-1.5 font-mono text-xs text-muted-foreground">
678 icon(data: iconify_icon!("feather:git-branch"), attrs: attributes! {
679 class="size-3.5"
680 })
681 (label)
682 </span>
683 }
684 } else {
685 <details class="group relative inline-block">
686 <summary class="inline-flex cursor-pointer list-none items-center gap-1.5 rounded-lg border border-border px-2.5 py-1 font-mono text-xs text-muted-foreground hover:text-foreground [&::-webkit-details-marker]:hidden">
687 icon(
688 data: if known {
689 iconify_icon!("feather:git-branch")
690 } else {
691 iconify_icon!("feather:git-commit")
692 },
693 attrs: attributes! { class="size-3.5" },
694 )
695 (if label.is_empty() { "Revision" } else { label.as_str() })
696 icon(
697 data: iconify_icon!("feather:chevron-down"),
698 attrs: attributes! {
699 class="size-3.5 transition-transform group-open:rotate-180"
700 },
701 )
702 </summary>
703
704 <div class="absolute right-0 z-20 mt-1 max-h-80 w-64 overflow-y-auto rounded-lg border border-border bg-background p-1 shadow-lg">
705 if !known && !current.is_empty() {
706 <p class="px-2 py-1.5 font-mono text-xs text-muted-foreground">
707 "At commit " (label)
708 </p>
709 }
710
711 ref_group(title: "Branches", links: branches)
712 ref_group(title: "Tags", links: tags)
713 </div>
714 </details>
715 }
716 }
717}
718
719/// One labelled section of the switcher, omitted entirely when it is empty.
720///
721/// The heading is what tells a branch from a tag; nothing else in the list does, and
722/// picking a tag when you meant a branch of the same name is a confusing way to end up
723/// on the wrong tree.
724#[component]
725async fn ref_group(title: &str, links: &[RefLink]) -> Result {
726 view! {
727 if !links.is_empty() {
728 <p class="px-2 pt-1.5 pb-1 text-xs font-medium uppercase tracking-wider text-muted-foreground">
729 (title)
730 </p>
731 <ul>
732 for link in links {
733 <li>
734 <a
735 href=(&link.href)
736 class=(format!(
737 "flex items-center gap-2 rounded-md px-2 py-1.5 font-mono text-sm hover:bg-foreground/5 {}",
738 if link.current { "font-medium" } else { "" },
739 ))
740 >
741 <span class="truncate">(&link.name)</span>
742 if link.current {
743 <span class="ml-auto text-muted-foreground">
744 icon(
745 data: iconify_icon!("feather:check"),
746 label: "Current",
747 attrs: attributes! { class="size-3.5" },
748 )
749 </span>
750 }
751 </a>
752 </li>
753 }
754 </ul>
755 }
756 }
757}
758
759/// What a repository with no commits offers instead of a listing.
760///
761/// This is the state every freshly-created repository is in, so it is the first thing
762/// its owner sees — the snippet is the point of the page, not decoration.
763#[component]
764pub(super) async fn empty_repo(url: &str) -> Result {
765 let push = format!("git remote add origin {url}\ngit branch -M main\ngit push -u origin main");
766
767 view! {
768 <div class="mt-6 rounded-lg border border-border px-4 py-5">
769 <p class="text-sm text-muted-foreground">
770 "This repository has no commits yet. Push one to see it here."
771 </p>
772 <p class="mt-4 text-xs font-medium uppercase tracking-wider text-muted-foreground">
773 "Push an existing repository"
774 </p>
775 <pre class="mt-2 overflow-x-auto rounded-lg border border-border bg-muted px-4 py-3 font-mono text-sm">(push)</pre>
776 </div>
777 }
778}
779
780/// The path you are at, with every ancestor linked.
781///
782/// The main way anyone moves around a tree: the last component is the current page and
783/// is deliberately not a link, so the trail reads as a position rather than a menu.
784#[component]
785async fn crumbs(handle: &str, name: &str, rev: &RefName, path: &RepoPath) -> Result {
786 let parts: Vec<&str> = path.components().collect();
787 let mut walked = RepoPath::root();
788 let mut trail: Vec<(String, String)> = Vec::new();
789
790 for (index, part) in parts.iter().enumerate() {
791 walked = walked.join(part);
792
793 let href = if index + 1 == parts.len() {
794 String::new()
795 } else {
796 tree_url(handle, name, rev, &walked)
797 };
798
799 trail.push(((*part).to_owned(), href));
800 }
801
802 view! {
803 <div class="flex flex-wrap items-center gap-1 font-mono text-sm">
804 <a
805 href=(tree_url(handle, name, rev, &RepoPath::root()))
806 class="text-muted-foreground hover:text-foreground"
807 >(name)</a>
808
809 for (part, href) in &trail {
810 <span class="text-muted-foreground">"/"</span>
811 match href.is_empty() {
812 true => <span class="font-medium">(part)</span>,
813 false => <a href=(href) class="text-muted-foreground hover:text-foreground">(part)</a>,
814 }
815 }
816 </div>
817 }
818}
819
820/// A directory listing.
821///
822/// Entries arrive ordered by the use case — directories first, then case-insensitively
823/// by name — so nothing here re-sorts them.
824#[component]
825pub(super) async fn directory(
826 handle: &str,
827 name: &str,
828 rev: &RefName,
829 path: &RepoPath,
830 entries: &[TreeEntry],
831) -> Result {
832 view! {
833 <div class="overflow-hidden rounded-lg border border-border">
834 <div class="border-b border-border px-4 py-2.5">
835 crumbs(handle: handle, name: name, rev: rev, path: path)
836 </div>
837
838 if entries.is_empty() {
839 <p class="px-4 py-6 text-center text-sm text-muted-foreground">
840 "This directory is empty."
841 </p>
842 } else {
843 <ul class="divide-y divide-border text-sm">
844 match path.parent() {
845 Some(parent) => <li class="px-4 py-2">
846 <a
847 href=(tree_url(handle, name, rev, &parent))
848 class="inline-flex items-center gap-2 font-mono text-muted-foreground hover:text-foreground"
849 >
850 icon(data: iconify_icon!("feather:corner-left-up"), attrs: attributes! {
851 class="size-4"
852 })
853 ".."
854 </a>
855 </li>,
856 None => "",
857 }
858
859 for entry in entries {
860 <li class="flex items-center gap-3 px-4 py-2">
861 entry_row(
862 handle: handle,
863 name: name,
864 rev: rev,
865 path: path,
866 entry: entry,
867 )
868 </li>
869 }
870 </ul>
871 }
872 </div>
873 }
874}
875
876/// One entry in a listing.
877///
878/// A symlink and a submodule are their own kinds, not files: a submodule is another
879/// repository Steid cannot look inside, so it is labelled and left unlinked rather
880/// than offered as a click that would 404.
881#[component]
882async fn entry_row(
883 handle: &str,
884 name: &str,
885 rev: &RefName,
886 path: &RepoPath,
887 entry: &TreeEntry,
888) -> Result {
889 let href = tree_url(handle, name, rev, &path.join(&entry.name));
890 let linkable = entry.kind != EntryKind::Submodule;
891
892 view! {
893 <span class="text-muted-foreground">
894 match entry.kind {
895 EntryKind::Tree => icon(
896 data: iconify_icon!("feather:folder"),
897 label: "Directory",
898 attrs: attributes! { class="size-4" },
899 ),
900 EntryKind::Blob => icon(
901 data: iconify_icon!("feather:file"),
902 label: "File",
903 attrs: attributes! { class="size-4" },
904 ),
905 EntryKind::Symlink => icon(
906 data: iconify_icon!("feather:link-2"),
907 label: "Symlink",
908 attrs: attributes! { class="size-4" },
909 ),
910 EntryKind::Submodule => icon(
911 data: iconify_icon!("feather:package"),
912 label: "Submodule",
913 attrs: attributes! { class="size-4" },
914 ),
915 }
916 </span>
917
918 match linkable {
919 true => <a
920 href=(href)
921 class=(if entry.kind.is_tree() {
922 "font-mono font-medium hover:underline"
923 } else {
924 "font-mono hover:underline"
925 })
926 >(&entry.name)</a>,
927 false => <span class="font-mono">(&entry.name)</span>,
928 }
929
930 match entry.kind {
931 EntryKind::Symlink => badge(variant: BadgeVariant::Outline, "symlink"),
932 EntryKind::Submodule => badge(variant: BadgeVariant::Outline, "submodule"),
933 _ => "",
934 }
935
936 <span class="ml-auto font-mono text-xs text-muted-foreground">
937 match entry.size {
938 Some(size) => (size_of(size)),
939 None if entry.kind == EntryKind::Submodule => (entry.id.short()),
940 None => "",
941 }
942 </span>
943 }
944}
945
946/// A single file.
947///
948/// Three outcomes, all of them a page rather than an error: text, something that is not
949/// text, and something too big to be worth rendering. The last says how big, because
950/// that is the only useful thing left to say about it.
951#[component]
952pub(super) async fn blob(
953 handle: &str,
954 name: &str,
955 rev: &RefName,
956 path: &RepoPath,
957 file: &FileView,
958) -> Result {
959 view! {
960 <div class="overflow-hidden rounded-lg border border-border">
961 <div class="flex flex-wrap items-center justify-between gap-3 border-b border-border px-4 py-2.5">
962 crumbs(handle: handle, name: name, rev: rev, path: path)
963 <span class="flex items-center gap-3 font-mono text-xs text-muted-foreground">
964 (size_of(file.size))
965 // The way out for anything the page cannot show — a binary, an
966 // oversized file — and the URL to hand to `curl`.
967 <a
968 href=(raw_url(handle, name, rev, path))
969 class="inline-flex items-center gap-1 hover:text-foreground"
970 >
971 icon(data: iconify_icon!("feather:download"), attrs: attributes! {
972 class="size-3.5"
973 })
974 "Raw"
975 </a>
976 </span>
977 </div>
978
979 match &file.text {
980 Some(text) => source(text: text.as_str()),
981 None if file.too_large => <p class="px-4 py-6 text-center text-sm text-muted-foreground">
982 "This file is " (size_of(file.size)) ", which is too large to display. Clone the repository to read it."
983 </p>,
984 None => <p class="px-4 py-6 text-center text-sm text-muted-foreground">
985 "This file cannot be displayed as text."
986 </p>,
987 }
988 </div>
989 }
990}
991
992/// A file's contents, with line numbers.
993///
994/// A table rather than a `<pre>` with a gutter: the numbers stay put when the code
995/// scrolls sideways, and selecting the code does not drag the numbers along with it.
996#[component]
997async fn source(text: &str) -> Result {
998 view! {
999 <div class="overflow-x-auto">
1000 <table class="w-full border-collapse font-mono text-xs leading-relaxed">
1001 <tbody>
1002 for (index, line) in text.lines().enumerate() {
1003 <tr>
1004 <td class="w-px select-none border-r border-border px-3 text-right align-top text-muted-foreground">
1005 ((index + 1).to_string())
1006 </td>
1007 <td class="whitespace-pre px-4 align-top">
1008 (if line.is_empty() { " " } else { line })
1009 </td>
1010 </tr>
1011 }
1012 </tbody>
1013 </table>
1014 </div>
1015 }
1016}
1017
1018/// The commit log — the most recent commits, newest first, and no paging in v1.
1019#[component]
1020async fn commit_log(commits: &[CommitSummary]) -> Result {
1021 view! {
1022 if commits.is_empty() {
1023 <p class="rounded-lg border border-border px-4 py-10 text-center text-sm text-muted-foreground">
1024 "No commits yet."
1025 </p>
1026 } else {
1027 <ul class="divide-y divide-border rounded-lg border border-border">
1028 for commit in commits {
1029 <li class="px-4 py-3">
1030 <div class="flex items-baseline justify-between gap-4">
1031 <p class="text-sm font-medium">(&commit.summary)</p>
1032 <code class="shrink-0 font-mono text-xs text-muted-foreground">
1033 (commit.id.short())
1034 </code>
1035 </div>
1036 <p class="mt-1 text-xs text-muted-foreground">
1037 (&commit.author_name)
1038 " committed "
1039 <span title=(timestamp(commit.committed_at))>(ago(commit.committed_at))</span>
1040 </p>
1041 </li>
1042 }
1043 </ul>
1044 }
1045 }
1046}
1047
1048#[cfg(test)]
1049mod tests {
1050 use std::time::Duration;
1051
1052 use super::*;
1053
1054 fn rev(value: &str) -> RefName {
1055 RefName::new(value).expect("valid revision")
1056 }
1057
1058 #[test]
1059 fn a_root_tree_url_has_no_separator() {
1060 assert_eq!(
1061 tree_url("ada", "steid", &rev("main"), &RepoPath::root()),
1062 "/ada/repos/steid/tree/main"
1063 );
1064 }
1065
1066 #[test]
1067 fn a_path_follows_the_separator_with_its_slashes_intact() {
1068 let path = RepoPath::new("src/domain/repo.rs").expect("valid");
1069
1070 assert_eq!(
1071 tree_url("ada", "steid", &rev("main"), &path),
1072 "/ada/repos/steid/tree/main/-/src/domain/repo.rs"
1073 );
1074 }
1075
1076 #[test]
1077 fn a_revisions_slashes_are_encoded_so_it_stays_one_segment() {
1078 // Otherwise `feature/login` would look like a revision plus a path, which is
1079 // the ambiguity the separator exists to remove.
1080 assert_eq!(
1081 tree_url("ada", "steid", &rev("feature/login"), &RepoPath::root()),
1082 "/ada/repos/steid/tree/feature%2Flogin"
1083 );
1084 }
1085
1086 #[test]
1087 fn names_needing_escaping_are_encoded() {
1088 let path = RepoPath::new("docs/a b#c.md").expect("valid");
1089
1090 assert_eq!(
1091 tree_url("ada", "steid", &rev("main"), &path),
1092 "/ada/repos/steid/tree/main/-/docs/a%20b%23c.md"
1093 );
1094 }
1095
1096 #[test]
1097 fn the_log_url_is_the_default_branch_when_no_revision_is_named() {
1098 assert_eq!(log_url("ada", "steid", ""), "/ada/repos/steid/log");
1099 assert_eq!(
1100 log_url("ada", "steid", "feature/login"),
1101 "/ada/repos/steid/log/feature%2Flogin"
1102 );
1103 }
1104
1105 #[test]
1106 fn a_raw_url_always_carries_a_path() {
1107 let path = RepoPath::new("src/main.rs").expect("valid");
1108
1109 assert_eq!(
1110 raw_url("ada", "steid", &rev("main"), &path),
1111 "/ada/repos/steid/raw/main/-/src/main.rs"
1112 );
1113 assert_eq!(
1114 raw_url("ada", "steid", &rev("feature/login"), &path),
1115 "/ada/repos/steid/raw/feature%2Flogin/-/src/main.rs"
1116 );
1117 }
1118
1119 #[test]
1120 fn a_raw_response_carries_the_whole_policy() {
1121 let response = raw_response("notes.txt", b"hello".to_vec()).expect("should build");
1122 let header = |name: &str| {
1123 response
1124 .headers()
1125 .get(name)
1126 .and_then(|value| value.to_str().ok())
1127 .unwrap_or_default()
1128 .to_owned()
1129 };
1130
1131 // Each of these is load-bearing on its own; see `raw_response`.
1132 assert_eq!(header("content-type"), "application/octet-stream");
1133 assert_eq!(header("x-content-type-options"), "nosniff");
1134 assert!(header("content-disposition").starts_with("attachment;"));
1135 assert_eq!(
1136 header("content-security-policy"),
1137 "default-src \'none\'; sandbox"
1138 );
1139 }
1140
1141 #[test]
1142 fn a_disposition_carries_both_spellings_of_the_name() {
1143 assert_eq!(
1144 disposition("notes.txt"),
1145 "attachment; filename=\"notes.txt\"; filename*=UTF-8\'\'notes.txt"
1146 );
1147 }
1148
1149 #[test]
1150 fn a_disposition_cannot_be_escaped_by_a_filename() {
1151 // A file name is repository content, so it is somebody else\'s input arriving
1152 // in a header. A quote would end the quoted string and a newline would start a
1153 // header of its own.
1154 let hostile = disposition("a\"; x=1\r\nSet-Cookie: nope=1");
1155
1156 assert!(!hostile.contains('\r'));
1157 assert!(!hostile.contains('\n'));
1158 assert_eq!(hostile.matches('"').count(), 2);
1159 }
1160
1161 #[test]
1162 fn a_nameless_file_still_downloads_as_something() {
1163 assert!(disposition("...").starts_with("attachment; filename=\"file\""));
1164 }
1165
1166 #[test]
1167 fn a_non_ascii_name_survives_in_the_extended_form() {
1168 let value = disposition("日本語.txt");
1169
1170 // The plain form is reduced to what a header can carry safely; the real name
1171 // rides along percent-encoded, which is what a modern client uses.
1172 assert!(value.contains("filename=\"___.txt\""));
1173 assert!(value.contains("filename*=UTF-8\'\'%E6%97%A5%E6%9C%AC%E8%AA%9E.txt"));
1174 }
1175
1176 #[test]
1177 fn the_switcher_marks_the_revision_it_is_on() {
1178 let refs = [RefName::from_trusted("main"), RefName::from_trusted("next")];
1179 let path = RepoPath::new("src").expect("valid");
1180 let links = ref_links("ada", "steid", &refs, "next", &Switch::Tree(&path));
1181
1182 assert_eq!(links[0].href, "/ada/repos/steid/tree/main/-/src");
1183 assert!(!links[0].current);
1184 assert!(links[1].current);
1185 }
1186
1187 #[test]
1188 fn switching_from_the_log_stays_on_the_log() {
1189 let refs = [RefName::from_trusted("v1.0")];
1190 let links = ref_links("ada", "steid", &refs, "main", &Switch::Log);
1191
1192 assert_eq!(links[0].href, "/ada/repos/steid/log/v1.0");
1193 }
1194
1195 #[test]
1196 fn an_object_id_is_labelled_as_a_commit_rather_than_a_branch() {
1197 let id = "0123456789abcdef0123456789abcdef01234567";
1198
1199 assert!(is_object_id(id));
1200 assert_eq!(rev_label(id, false), "0123456");
1201 // A branch that happens to look like hex is still shown by its name.
1202 assert_eq!(rev_label("deadbeef", true), "deadbeef");
1203 assert_eq!(rev_label("main", false), "main");
1204 }
1205
1206 #[test]
1207 fn sizes_read_as_sizes() {
1208 assert_eq!(size_of(0), "0 B");
1209 assert_eq!(size_of(999), "999 B");
1210 assert_eq!(size_of(1024), "1.0 KB");
1211 assert_eq!(size_of(1_048_576), "1.0 MB");
1212 assert_eq!(size_of(1_572_864), "1.5 MB");
1213 }
1214
1215 #[test]
1216 fn elapsed_time_reads_as_words() {
1217 let now = SystemTime::now();
1218 let since = |seconds| ago(now - Duration::from_secs(seconds));
1219
1220 assert_eq!(since(5), "just now");
1221 assert_eq!(since(60), "1 minute ago");
1222 assert_eq!(since(7200), "2 hours ago");
1223 assert_eq!(since(86_400 * 3), "3 days ago");
1224 assert_eq!(since(86_400 * 400), "1 year ago");
1225 }
1226
1227 #[test]
1228 fn a_commit_from_the_future_reads_as_now_rather_than_as_a_negative() {
1229 // A commit carries whoever made it's clock, so this happens.
1230 assert_eq!(
1231 ago(SystemTime::now() + Duration::from_secs(3600)),
1232 "just now"
1233 );
1234 }
1235
1236 #[test]
1237 fn timestamps_are_utc_calendar_dates() {
1238 assert_eq!(
1239 timestamp(UNIX_EPOCH + Duration::from_secs(0)),
1240 "1970-01-01 00:00 UTC"
1241 );
1242 // 2026-08-29T12:34:00Z
1243 assert_eq!(
1244 timestamp(UNIX_EPOCH + Duration::from_secs(1_788_006_840)),
1245 "2026-08-29 12:34 UTC"
1246 );
1247 // A leap day, which is what the calendar arithmetic exists to get right.
1248 assert_eq!(
1249 timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)),
1250 "2024-02-29 00:00 UTC"
1251 );
1252 }
1253}