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