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