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