steid

@jamesgill /

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