steid

@jamesgill /

2268309feat: a commit is a page, and two revisions can be compared19h
1//! One commit, and the comparison of two revisions.
2//!
3//! Three routes sharing one renderer: `/commits/{sha}` shows what a commit changed,
4//! `/compare` asks which two revisions, and `/compare/{base}...{head}` answers.
5//!
6//! The diff renderer is the reason they live together. A commit's diff and a
7//! comparison's diff are the same thing — [`Diff`] — and drawing them twice would be
8//! two places for the line numbering to be subtly wrong in.
9//!
10//! # The compare URL
11//!
12//! `{base}...{head}` in one path segment, each half percent-encoded exactly as the tree
13//! pages encode a revision, so `feature/login` stays inside its half. The separator is
14//! unambiguous because [`RefName`] refuses `..` outright — a revision cannot contain
15//! two consecutive dots, so three of them can only be the separator. It is also the
16//! spelling git itself uses for merge-base semantics, which is what the page does.
17//!
18//! The form cannot build that URL: a GET form submits query parameters, not path
19//! segments, and Steid does not require JavaScript. So the form posts nowhere — it GETs
20//! `/compare?base=…&head=…`, and that page redirects to the path form. The shareable
21//! URL is therefore always the path one, which is what the branches page links to.
22
23use topcoat::{
24 Result,
25 context::Cx,
26 icon::{icon, iconify::iconify_icon},
27 router::{
28 error::{RouterErrorExt, not_found, redirect},
29 page, path_param, query_params,
30 },
31 view::{attributes, component, view},
32};
33
34use crate::{
35 application::{
00d9c06refactor: one place decides that git ran out of time18h
36 COMPARE_LOG_LIMIT, Compared, Comparison, Diff, DiffLine, FileChange, FileDiff, FileStat,
37 LineKind, MAX_FILE_DIFF_LINES, RefList, RepoView, compare_revisions, list_refs,
2268309feat: a commit is a page, and two revisions can be compared19h
38 view_commit,
39 },
40 components::{
41 button::{ButtonSize, ButtonVariant, button_variants},
42 input::input,
43 label::label,
44 },
45 domain::{CommitDetail, RefName, RepoPath},
46};
47
48use super::{
49 browse::{ago, commit_log, encode, timestamp, tree_url},
00d9c06refactor: one place decides that git ran out of time18h
50 context::{current_actor, memberships, orgs, queries, repos, server_error, timed_out},
2268309feat: a commit is a page, and two revisions can be compared19h
51 layout::wide,
52 repo::{Tab, repo_for, repo_header},
53};
54
55/// `{sha}` from the path: a full or abbreviated object id, or any other revision.
56#[path_param]
57struct Sha(str);
58
59/// `{spec}` from the path: `base...head`, each half already percent-decoded.
60#[path_param]
61struct Spec(str);
62
63/// What the compare form submits, before it is turned into a path.
64#[query_params(error = bad_request)]
65struct CompareQuery {
66 base: Option<String>,
67 head: Option<String>,
68}
69
70// --- Routes -----------------------------------------------------------------------
71
72#[page("/{handle}/repos/{name}/commits/{sha}")]
73async fn commit_page(cx: &Cx) -> Result {
74 // A malformed revision is a page that does not exist, the same reasoning the tree
75 // pages apply to theirs.
76 let rev = RefName::new(path_param::<Sha>(cx)).map_err(|_| not_found())?;
77
78 view! { commit_view(rev: rev) }
79}
80
81#[page("/{handle}/repos/{name}/compare")]
82async fn compare_form_page(cx: &Cx) -> Result {
83 let repo = repo_for(cx).await?;
84 let submitted = query_params::<CompareQuery>(cx)?;
85
86 // The form's own submission, on its way to the URL it should have been able to
87 // target directly. A 307 is right here: this is a GET, so preserving the method is
88 // exactly what is wanted.
89 if let (Some(base), Some(head)) = (&submitted.base, &submitted.head) {
90 let (base, head) = (base.trim(), head.trim());
91
92 if !base.is_empty() && !head.is_empty() {
93 return Err(redirect(&compare_url(
94 repo.handle.as_str(),
95 repo.name.as_str(),
96 base,
97 head,
98 ))
99 .into());
100 }
101 }
102
103 let refs = refs_for(cx, &repo).await?;
104 let default = default_base(cx, &repo).await?;
105
106 view! {
107 wide(
108 repo_header(repo: &repo, rev: "", active: Tab::Code)
109 compare_form(repo: &repo, base: default.as_str(), head: "", refs: &refs, error: "")
110 )
111 }
112}
113
114#[page("/{handle}/repos/{name}/compare/{spec}")]
115async fn compare_page(cx: &Cx) -> Result {
116 let spec = path_param::<Spec>(cx);
117
118 // Three dots, not two: `RefName` refuses `..` in a revision, so the only way this
119 // separator can appear is as the separator.
120 let Some((base, head)) = spec.split_once("...") else {
121 return Err(not_found().into());
122 };
123
124 let (Ok(base), Ok(head)) = (RefName::new(base), RefName::new(head)) else {
125 return Err(not_found().into());
126 };
127
128 view! { comparison_view(base: base, head: head) }
129}
130
131// --- The commit page --------------------------------------------------------------
132
133/// A component rather than a plain function because `view!` needs the request context
134/// in scope — the same reason the browse pages are components.
135#[component]
136async fn commit_view(cx: &Cx, rev: RefName) -> Result {
137 let repo = repo_for(cx).await?;
138
139 let loaded = view_commit(
140 &repo.handle,
141 &repo.name,
142 &rev,
143 &current_actor(cx).await?,
144 &orgs(cx),
145 &memberships(cx),
146 &repos(cx),
147 &queries(cx),
148 )
149 .await;
150
151 let page = match unwrap_page(loaded)? {
152 Some(page) => page,
153 None => {
154 return view! {
155 wide(
156 repo_header(repo: &repo, rev: rev.as_str(), active: Tab::Commits)
157 took_too_long()
158 )
159 };
160 }
161 };
162
163 let handle = repo.handle.as_str();
164 let name = repo.name.as_str();
165 // The commit's own id, not the revision the URL used: a branch name in the header's
166 // links would send someone to a different commit tomorrow.
167 let at = page.commit.id.as_str();
168
169 view! {
170 wide(
171 repo_header(repo: &repo, rev: at, active: Tab::Commits)
172 commit_summary(handle: handle, name: name, commit: &page.commit)
173 diff_view(handle: handle, name: name, rev: at, diff: &page.diff)
174 )
175 }
176}
177
178/// The commit itself: what it says, who made it, and where it sits in the history.
179#[component]
180async fn commit_summary(handle: &str, name: &str, commit: &CommitDetail) -> Result {
181 let sha = commit.id.as_str();
182
183 view! {
184 <article class="mb-4 rounded-lg border border-border px-4 py-3">
185 <h2 class="text-base font-medium">(&commit.summary)</h2>
186
187 if !commit.body.is_empty() {
188 // Preformatted, because a commit body is written for a fixed-width
189 // reader — lists, wrapped prose, pasted output — and reflowing it
190 // rewrites what somebody wrote. It wraps rather than scrolls so a long
191 // line does not widen the page.
192 <pre class="mt-2 whitespace-pre-wrap font-mono text-xs leading-relaxed text-muted-foreground">(&commit.body)</pre>
193 }
194
195 <div class="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1.5 border-t border-border pt-2.5 text-xs text-muted-foreground">
196 <span>
197 <span class="text-foreground">(&commit.author_name)</span>
198 " authored "
199 <span title=(timestamp(commit.authored_at))>(ago(commit.authored_at))</span>
200 </span>
201
202 // Named only when it differs. On an ordinary commit the two are the
203 // same person and saying it twice is noise.
204 if commit.has_distinct_committer() {
205 <span>
206 <span class="text-foreground">(&commit.committer_name)</span>
207 " committed "
208 <span title=(timestamp(commit.committed_at))>(ago(commit.committed_at))</span>
209 </span>
210 }
211
212 <span class="ml-auto flex flex-wrap items-center gap-x-3 gap-y-1.5">
213 match commit.parents.len() {
214 0 => <span>"root commit"</span>,
215 _ => <span class="flex items-center gap-1.5">
216 (if commit.parents.len() == 1 { "parent" } else { "parents" })
217 for parent in &commit.parents {
218 <a
219 href=(commit_url(handle, name, parent.as_str()))
220 class="font-mono hover:text-foreground"
221 >(parent.short())</a>
222 }
223 </span>,
224 }
225
226 <a
227 href=(tree_url(handle, name, &RefName::from_trusted(sha), &RepoPath::root()))
228 class="inline-flex items-center gap-1 hover:text-foreground"
229 >
230 icon(data: iconify_icon!("feather:folder"), attrs: attributes! {
231 class="size-3.5"
232 })
233 "Browse files"
234 </a>
235
236 // The full id, never the abbreviation: this is the page you copy a
237 // sha from, and seven characters is not a thing you can paste into
238 // `git show` with confidence on a large repository.
239 <code class="font-mono text-foreground">(sha)</code>
240 </span>
241 </div>
242 </article>
243 }
244}
245
246// --- The compare pages ------------------------------------------------------------
247
248#[component]
249async fn comparison_view(cx: &Cx, base: RefName, head: RefName) -> Result {
250 let repo = repo_for(cx).await?;
251
252 let loaded = compare_revisions(
253 &repo.handle,
254 &repo.name,
255 &base,
256 &head,
257 &current_actor(cx).await?,
258 &orgs(cx),
259 &memberships(cx),
260 &repos(cx),
261 &queries(cx),
262 )
263 .await;
264
265 let handle = repo.handle.as_str();
266 let name = repo.name.as_str();
267
268 let Some(compared) = unwrap_page(loaded)? else {
269 return view! {
270 wide(
271 repo_header(repo: &repo, rev: "", active: Tab::Code)
272 took_too_long()
273 )
274 };
275 };
276
277 // Only the states that re-render the form need the ref list, and it costs a whole
278 // `git` process — so it is fetched for those and not for a successful comparison,
279 // which has a switcher of neither kind.
280 let refs = match &compared {
281 Compared::UnknownRef { .. } => refs_for(cx, &repo).await?,
282 _ => RefList::default(),
283 };
284
285 // Built before the view, not inside it: a `format!` in an argument position is a
286 // temporary that the borrow it produces outlives.
287 let unknown = match &compared {
288 Compared::UnknownRef { rev } => {
289 format!("There is no branch, tag or commit called {rev} in this repository.")
290 }
291 _ => String::new(),
292 };
293
294 view! {
295 wide(
296 repo_header(repo: &repo, rev: "", active: Tab::Code)
297
298 match &compared {
299 Compared::UnknownRef { .. } => compare_form(
300 repo: &repo,
301 base: base.as_str(),
302 head: head.as_str(),
303 refs: &refs,
304 error: unknown.as_str(),
305 ),
306 Compared::Identical => {
307 compare_bar(handle: handle, name: name, base: base.as_str(), head: head.as_str())
308 <p class="rounded-lg border border-border px-4 py-10 text-center text-sm text-muted-foreground">
309 "Nothing to compare. These two revisions are the same commit."
310 </p>
311 }
312 Compared::Unrelated => {
313 compare_bar(handle: handle, name: name, base: base.as_str(), head: head.as_str())
314 <p class="rounded-lg border border-border px-4 py-10 text-center text-sm text-muted-foreground">
315 "These two revisions share no history, so there is nothing to compare them against."
316 </p>
317 }
318 Compared::Ready(comparison) => comparison_body(
319 handle: handle,
320 name: name,
321 comparison: comparison.as_ref(),
322 ),
323 }
324 )
325 }
326}
327
328/// A successful comparison: what it is, what it adds, and what that changes.
329#[component]
330async fn comparison_body(handle: &str, name: &str, comparison: &Comparison) -> Result {
331 let behind = comparison.head_is_behind();
332
333 view! {
334 compare_bar(
335 handle: handle,
336 name: name,
337 base: comparison.base.as_str(),
338 head: comparison.head.as_str(),
339 )
340
341 if behind {
342 // Not an error — the comparison is simply empty, and the one they meant is
343 // one click away.
344 <p class="mb-4 rounded-lg border border-border px-4 py-4 text-sm text-muted-foreground">
345 <span class="font-mono text-foreground">(comparison.head.as_str())</span>
346 " is already contained in "
347 <span class="font-mono text-foreground">(comparison.base.as_str())</span>
348 ", so it adds nothing. "
349 <a
350 href=(compare_url(handle, name, comparison.head.as_str(), comparison.base.as_str()))
351 class="text-primary hover:underline"
352 >"Compare them the other way round"</a>
353 "?"
354 </p>
355 } else {
356 <div class="mb-2 flex flex-wrap items-baseline gap-x-2 text-xs text-muted-foreground">
357 <span class="font-mono text-foreground">
358 (counted(comparison.total_commits, "commit", "commits"))
359 </span>
360 " to bring across, from merge base "
361 <a
362 href=(commit_url(handle, name, comparison.merge_base.as_str()))
363 class="font-mono hover:text-foreground"
364 >(comparison.merge_base.short())</a>
365 </div>
366
367 commit_log(handle: handle, name: name, commits: &comparison.commits)
368
369 if comparison.truncated() {
370 <p class="mt-2 text-xs text-muted-foreground">
371 "Showing the newest " (COMPARE_LOG_LIMIT.to_string()) " commits of "
372 (comparison.total_commits.to_string()) "."
373 </p>
374 }
375
376 <div class="mt-4">
377 diff_view(
378 handle: handle,
379 name: name,
380 rev: comparison.head_id.as_str(),
381 diff: &comparison.diff,
382 )
383 </div>
384 }
385 }
386}
387
388/// The two revisions, restated, with the way to swap them.
389#[component]
390async fn compare_bar(handle: &str, name: &str, base: &str, head: &str) -> Result {
391 view! {
392 <div class="mb-3 flex flex-wrap items-center gap-2 text-sm">
393 <span class="rounded-md border border-border px-2 py-0.5 font-mono text-xs">(base)</span>
394 <span class="text-muted-foreground">""</span>
395 <span class="rounded-md border border-border px-2 py-0.5 font-mono text-xs">(head)</span>
396 <a
397 href=(compare_url(handle, name, head, base))
398 class="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
399 >
400 icon(data: iconify_icon!("feather:repeat"), attrs: attributes! { class="size-3.5" })
401 "Swap"
402 </a>
403 <a
404 href=(format!("/{handle}/repos/{name}/compare"))
405 class="ml-auto text-xs text-muted-foreground hover:text-foreground"
406 >"Compare something else"</a>
407 </div>
408 }
409}
410
411/// Which two revisions to compare.
412///
413/// Two plain text inputs with a `<datalist>` rather than two `<select>`s: a revision
414/// need not be a branch or a tag — a sha is a perfectly good answer, and a select would
415/// make it unsayable. The datalist gives the common case autocompletion and leaves the
416/// rest typeable.
417#[component]
418async fn compare_form(
419 repo: &RepoView,
420 base: &str,
421 head: &str,
422 refs: &RefList,
423 error: &str,
424) -> Result {
425 let handle = repo.handle.as_str();
426 let name = repo.name.as_str();
427 let names: Vec<&str> = refs
428 .branches
429 .iter()
430 .chain(&refs.tags)
431 .map(RefName::as_str)
432 .collect();
433
434 view! {
435 <form method="get" action=(format!("/{handle}/repos/{name}/compare")) class="max-w-2xl">
436 <h2 class="text-sm font-medium">"Compare revisions"</h2>
437 <p class="mt-1 text-xs text-muted-foreground">
438 "What does the second revision add to the first? A branch, a tag or a commit id."
439 </p>
440
441 if !error.is_empty() {
442 <p class="mt-3 rounded-lg border border-destructive px-3 py-2 text-xs text-destructive">
443 (error)
444 </p>
445 }
446
447 <div class="mt-4 flex flex-wrap items-end gap-3">
448 <div class="flex min-w-48 flex-1 flex-col gap-1.5">
449 label(attrs: attributes! { for="base" class="text-xs" }, "Base")
450 input(attrs: attributes! {
451 id="base" name="base" list="compare-refs" required=(true)
452 autocomplete="off" spellcheck="false" class="font-mono"
453 value=(base) placeholder="main"
454 })
455 </div>
456
457 <span class="pb-2.5 font-mono text-sm text-muted-foreground">"..."</span>
458
459 <div class="flex min-w-48 flex-1 flex-col gap-1.5">
460 label(attrs: attributes! { for="head" class="text-xs" }, "Compare")
461 input(attrs: attributes! {
462 id="head" name="head" list="compare-refs" required=(true)
463 autocomplete="off" spellcheck="false" class="font-mono"
464 value=(head) placeholder="a branch, tag or sha"
465 })
466 </div>
467
468 <button
469 type="submit"
470 class=(button_variants(ButtonVariant::Primary, ButtonSize::Md))
471 >"Compare"</button>
472 </div>
473
474 <datalist id="compare-refs">
475 for git_ref in &names {
476 <option value=(*git_ref)></option>
477 }
478 </datalist>
479 </form>
480 }
481}
482
483// --- The diff ---------------------------------------------------------------------
484
485/// A whole diff: the summary line, then one panel per file.
486#[component]
487async fn diff_view(handle: &str, name: &str, rev: &str, diff: &Diff) -> Result {
488 view! {
489 if diff.is_empty() {
490 <p class="rounded-lg border border-border px-4 py-10 text-center text-sm text-muted-foreground">
491 "This commit changed no files."
492 </p>
493 } else {
494 diff_stats(diff: diff)
495
496 if diff.truncated {
497 // The counts are still real — they come from git's own numstat, which
498 // is written before the patch and therefore survives the cap. Only the
499 // lines are missing.
500 <p class="mb-2 rounded-lg border border-border px-4 py-3 text-xs text-muted-foreground">
501 "This diff is too large to display. Every changed file is listed below with its counts; "
502 "clone the repository or run "
503 <code class="font-mono text-foreground">"git show"</code>
504 " to read the patch."
505 </p>
506
507 <ul class="divide-y divide-border rounded-lg border border-border">
508 for file in &diff.stats {
509 <li class="flex flex-wrap items-center gap-x-3 gap-y-1 px-4 py-2">
510 stat_row(file: file)
511 <span class="text-xs text-muted-foreground">"Diff too large to display"</span>
512 </li>
513 }
514 </ul>
515 } else {
516 for file in &diff.files {
517 file_diff(handle: handle, name: name, rev: rev, file: file)
518 }
519 }
520 }
521 }
522}
523
524/// `N files changed · +a −b`.
525///
526/// The two numbers are the only place on the page the success and destructive tokens
527/// appear as text. That is deliberate: they are the summary of the whole commit, and
528/// colouring anything else the same way would dilute them.
529#[component]
530async fn diff_stats(diff: &Diff) -> Result {
531 view! {
532 <p class="mb-2 flex flex-wrap items-center gap-x-3 text-xs text-muted-foreground">
533 <span>(counted(diff.files_changed(), "file changed", "files changed"))</span>
534 <span class="font-mono">
535 <span class="text-success">"+" (diff.added().to_string())</span>
536 " "
537 <span class="text-destructive">"" (diff.removed().to_string())</span>
538 </span>
539 </p>
540 }
541}
542
543/// One file's path and counts, as the truncated listing shows it.
544#[component]
545async fn stat_row(file: &FileStat) -> Result {
546 view! {
547 <span class="min-w-0 truncate font-mono text-xs">
548 match &file.old_path {
549 Some(old) => <span class="text-muted-foreground">(old) ""</span>,
550 None => "",
551 }
552 (&file.path)
553 </span>
554 <span class="ml-auto shrink-0 font-mono text-xs">
555 match (file.added, file.removed) {
556 (Some(added), Some(removed)) => {
557 <span class="text-success">"+" (added.to_string())</span>
558 " "
559 <span class="text-destructive">"" (removed.to_string())</span>
560 }
561 _ => <span class="text-muted-foreground">"binary"</span>,
562 }
563 </span>
564 }
565}
566
567/// One file: a header that stays put, then the lines.
568///
569/// The header is `sticky`, which is why this panel is not `overflow-hidden` — a hidden
570/// overflow makes an ancestor a scroll container and sticky then sticks to a box that
571/// never scrolls, which looks exactly like sticky being broken. The corners are rounded
572/// on the children instead.
573#[component]
574async fn file_diff(handle: &str, name: &str, rev: &str, file: &FileDiff) -> Result {
575 let blob = tree_url(
576 handle,
577 name,
578 &RefName::from_trusted(rev),
579 &RepoPath::from_trusted(file.path.as_str()),
580 );
581
582 view! {
583 <section class="mb-4 rounded-lg border border-border">
584 <div class="sticky top-0 z-10 flex flex-wrap items-center gap-x-3 gap-y-1 rounded-t-lg border-b border-border bg-surface px-4 py-2">
585 <span class="min-w-0 truncate font-mono text-xs">
586 match &file.old_path {
587 Some(old) => <span class="text-muted-foreground">(old) ""</span>,
588 None => "",
589 }
590 (&file.path)
591 </span>
592
593 <span class="ml-auto flex shrink-0 items-center gap-3 text-xs">
594 if file.binary {
595 <span class="font-mono text-muted-foreground">"binary"</span>
596 } else {
597 <span class="font-mono">
598 <span class="text-success">"+" (file.added.to_string())</span>
599 " "
600 <span class="text-destructive">"" (file.removed.to_string())</span>
601 </span>
602 }
603 if file.change != FileChange::Deleted {
604 <a href=(&blob) class="text-muted-foreground hover:text-foreground">"View file"</a>
605 }
606 </span>
607 </div>
608
609 if file.binary {
610 <p class="px-4 py-5 text-center text-sm text-muted-foreground">
611 "Binary file changed"
612 </p>
613 } else if file.rows.is_empty() {
614 // A mode change, or a rename with no edit: git wrote a header and no
615 // hunks, and saying so is better than an empty panel.
616 <p class="px-4 py-5 text-center text-sm text-muted-foreground">
617 "No line changes."
618 </p>
619 } else {
620 <div class="overflow-x-auto rounded-b-lg">
621 <table class="w-full border-collapse font-mono text-xs leading-relaxed">
622 <tbody>
623 for row in &file.rows {
624 diff_row(row: row)
625 }
626 </tbody>
627 </table>
628 </div>
629
630 if file.truncated() {
631 <p class="border-t border-border px-4 py-2.5 text-xs text-muted-foreground">
632 "Showing the first " (MAX_FILE_DIFF_LINES.to_string()) " lines of "
633 (file.total_rows.to_string()) ". "
634 <a href=(&blob) class="text-primary hover:underline">"View the whole file"</a>
635 " at this revision."
636 </p>
637 }
638 }
639 </section>
640 }
641}
642
643/// One line of a diff, as one table row.
644///
645/// A row per line, rather than a `<pre>` with a gutter, so the two line numbers stay
646/// aligned with the line they belong to when the content scrolls sideways — the same
647/// reasoning the blob view uses for its single gutter.
648///
649/// The tints are classes defined in `styles.css`, not Tailwind utilities: they are a
650/// token at low alpha, which is a colour Tailwind has no utility for and which must not
651/// be written out as a literal.
652#[component]
653async fn diff_row(row: &DiffLine) -> Result {
654 let (line_class, gutter_class, marker) = match row.kind {
655 LineKind::Added => ("diff-line-add", "diff-gutter-add", "+"),
656 LineKind::Removed => ("diff-line-del", "diff-gutter-del", ""),
657 LineKind::Hunk => ("diff-line-hunk", "", ""),
658 LineKind::Note => ("", "", ""),
659 LineKind::Context => ("", "", " "),
660 };
661 let number = |value: Option<u32>| value.map(|value| value.to_string()).unwrap_or_default();
662
663 view! {
664 <tr class=(line_class)>
665 <td class=(format!(
666 "w-px select-none px-2 text-right align-top text-muted-foreground {gutter_class}"
667 ))>(number(row.old))</td>
668 <td class=(format!(
669 "w-px select-none border-r border-border px-2 text-right align-top text-muted-foreground {gutter_class}"
670 ))>(number(row.new))</td>
671 <td class="whitespace-pre px-3 align-top">
672 match row.kind {
673 // The hunk header and the "no newline" note are git's words about
674 // the file, not lines of it, so they are muted and unmarked.
675 LineKind::Hunk | LineKind::Note => <span class="text-muted-foreground">(&row.text)</span>,
676 _ => {
677 <span class="select-none text-muted-foreground">(marker)</span>
678 (if row.text.is_empty() { " " } else { row.text.as_str() })
679 }
680 }
681 </td>
682 </tr>
683 }
684}
685
686// --- Shared bits ------------------------------------------------------------------
687
688/// What the page says when git ran out of time.
689///
690/// A designed state rather than a 500: a timeout is this instance declining to spend
691/// more of itself on one request, which is a thing to say plainly and to offer a retry
692/// for — not a fault the visitor caused or can report.
693#[component]
694async fn took_too_long() -> Result {
695 view! {
696 <div class="rounded-lg border border-border px-4 py-10 text-center">
697 <p class="text-sm">"This took too long to read."</p>
698 <p class="mt-1.5 text-xs text-muted-foreground">
699 "The repository is large enough that building this diff ran past the time one request is given. Reloading may work; cloning the repository certainly will."
700 </p>
701 </div>
702 }
703}
704
705/// Turns a use case's answer into either a page or a rendered state.
706///
707/// `Ok(None)` from the use case is a 404 — invisible and absent are one answer, as
708/// everywhere else. A **timeout** is the one failure that is not a 500: it comes back
709/// as `Ok(None)` here so the caller renders [`took_too_long`] instead.
710fn unwrap_page<T>(loaded: crate::application::Result<Option<T>>) -> Result<Option<T>> {
711 match loaded {
712 Ok(Some(page)) => Ok(Some(page)),
713 Ok(None) => Err(not_found().into()),
00d9c06refactor: one place decides that git ran out of time18h
714 Err(error) if timed_out(&error) => {
2268309feat: a commit is a page, and two revisions can be compared19h
715 eprintln!("steid: {error}");
716 Ok(None)
717 }
718 Err(other) => Err(server_error(std::io::Error::other(other.to_string()))),
719 }
720}
721
722/// The branches and tags of a repository the viewer can already see, or 404.
723///
724/// One `git` process. Called only by the states that draw the form's datalist.
725async fn refs_for(cx: &Cx, repo: &RepoView) -> Result<RefList> {
726 Ok(list_refs(
727 &repo.handle,
728 &repo.name,
729 &current_actor(cx).await?,
730 &orgs(cx),
731 &memberships(cx),
732 &repos(cx),
733 &queries(cx),
734 )
735 .await
736 .map_err(server_error)?
737 .ok_or_not_found()?)
738}
739
740/// What the form's base field opens with: the repository's default branch.
741///
742/// Comparing against anything else is the unusual case, and an empty field would make
743/// the common one typing. One `git` process; empty for a repository with no commits,
744/// where there is nothing to prefill with.
745async fn default_base(cx: &Cx, repo: &RepoView) -> Result<String> {
746 use crate::application::port::GitQuery;
747
748 Ok(queries(cx)
749 .default_branch(&repo.handle, &repo.name)
750 .await
751 .map_err(server_error)?
752 .map(|branch| branch.as_str().to_owned())
753 .unwrap_or_default())
754}
755
756/// A count and the thing it counts, pluralised.
757fn counted(count: usize, one: &str, many: &str) -> String {
758 format!("{count} {}", if count == 1 { one } else { many })
759}
760
761// --- URLs -------------------------------------------------------------------------
762
763/// The page for one commit.
764///
765/// Takes a `&str` rather than an [`ObjectId`] because the sha in a URL may equally be a
766/// branch name someone typed — the page resolves whatever it is given.
767pub(super) fn commit_url(handle: &str, name: &str, sha: &str) -> String {
768 format!("/{handle}/repos/{name}/commits/{}", encode(sha, false))
769}
770
771/// The comparison of two revisions.
772///
773/// Each half is encoded on its own, so a slash inside a branch name cannot be mistaken
774/// for a path separator and the `...` between them is unambiguous.
775pub(super) fn compare_url(handle: &str, name: &str, base: &str, head: &str) -> String {
776 format!(
777 "/{handle}/repos/{name}/compare/{}...{}",
778 encode(base, false),
779 encode(head, false)
780 )
781}
782
783#[cfg(test)]
784mod tests {
785 use super::*;
786
787 #[test]
788 fn a_commit_url_carries_the_whole_sha() {
789 assert_eq!(
790 commit_url("ada", "steid", "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"),
791 "/ada/repos/steid/commits/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"
792 );
793 }
794
795 #[test]
796 fn a_compare_url_encodes_each_half_on_its_own() {
797 // A slash inside a branch name must not become a path separator, and the `...`
798 // between the halves must survive as the separator it is.
799 assert_eq!(
800 compare_url("ada", "steid", "main", "feature/login"),
801 "/ada/repos/steid/compare/main...feature%2Flogin"
802 );
803 }
804
805 #[test]
806 fn a_compare_url_round_trips_through_its_separator() {
807 // What `compare_page` does with the segment it is handed, in reverse.
808 let url = compare_url("ada", "steid", "v1.0", "v2.0");
809 let spec = url.rsplit('/').next().expect("a last segment");
810
811 assert_eq!(spec.split_once("..."), Some(("v1.0", "v2.0")));
812 }
813
814 #[test]
815 fn counts_read_as_english() {
816 assert_eq!(
817 counted(1, "file changed", "files changed"),
818 "1 file changed"
819 );
820 assert_eq!(
821 counted(3, "file changed", "files changed"),
822 "3 files changed"
823 );
824 }
825}