steid

@jamesgill /

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