steid

@jamesgill /

adac3a7feat: branches and tags get pages, so the counts stop being dead ends21h
1//! The branches and tags pages — `/{handle}/repos/{name}/branches` and `/tags`.
2//!
3//! The destinations `plans/ui.md` reserved for the counts beside the revision switcher,
4//! which were plain text until these existed because a dead link is worse than a
5//! number.
6//!
7//! **One `git` process each**, and one more only when the list comes back empty, to
8//! tell "nothing pushed yet" from "no tags yet". Everything a row shows — the tip
9//! commit's subject, its sha, its date, which branch is the default — arrives in that
10//! one `for-each-ref`; see [`list_branches`](crate::application::list_branches).
11//!
12//! Both pages sit under the Code tab. They are ways of getting *into* the code rather
13//! than a place of their own, and a tab each for two lists would make the strip advertise
14//! plumbing.
15
16use topcoat::{
17 Result,
18 context::Cx,
19 icon::{icon, iconify::iconify_icon},
20 router::{error::RouterErrorExt, page},
21 view::{View, attributes, component, view},
22};
23
24use crate::{
25 application::{Error, RefPage, RepoView, list_branches, list_tags},
26 components::badge::{BadgeVariant, badge},
27 domain::{BranchRow, RefName, TagRow},
28};
29
30use super::{
31 browse::{ago, empty_repo, encode, log_url, timestamp, tree_url},
32 context::{current_actor, memberships, orgs, queries, repos, server_error},
33 layout::wide,
34 repo::{Tab, clone_url_for, repo_for, repo_header},
35};
36
37/// Where the branches page lives. Linked from the toolbar count and the About sidebar.
38pub(super) fn branches_url(handle: &str, name: &str) -> String {
39 format!("/{handle}/repos/{name}/branches")
40}
41
42/// Where the tags page lives.
43pub(super) fn tags_url(handle: &str, name: &str) -> String {
44 format!("/{handle}/repos/{name}/tags")
45}
46
47/// The comparison between the default branch and another one.
48///
49/// **This page does not exist on this branch.** The URL is the contract the compare
50/// feature is being built to, and rendering the link now is what makes the two land as
51/// one working feature rather than as a page nobody can reach.
52///
53/// The refs are encoded whole, slashes included, so `feature/login` stays one segment —
54/// the `...` between them is then the only literal separator, exactly as `/-/` is on a
55/// tree URL.
56fn compare_url(handle: &str, name: &str, base: &RefName, head: &RefName) -> String {
57 format!(
58 "/{handle}/repos/{name}/compare/{}...{}",
59 encode(base.as_str(), false),
60 encode(head.as_str(), false)
61 )
62}
63
64/// Whether a failure was git being too slow rather than git being broken.
65///
66/// A timeout is the one read failure that is not a fault: the repository is fine and the
67/// request asked more of it than one page's budget allows. It gets a page state so the
68/// visitor is told what happened, rather than a 500 that says the instance is broken.
69fn timed_out(error: &Error) -> bool {
70 matches!(error, Error::GitQuery(query) if query.is_timeout())
71}
72
73#[page("/{handle}/repos/{name}/branches")]
74async fn branches_page(cx: &Cx) -> Result {
75 let repo = repo_for(cx).await?;
76 let clone = clone_url_for(cx, &repo);
77 let other = tags_url(repo.handle.as_str(), repo.name.as_str());
78
79 let listed = list_branches(
80 &repo.handle,
81 &repo.name,
82 &current_actor(cx).await?,
83 &orgs(cx),
84 &memberships(cx),
85 &repos(cx),
86 &queries(cx),
87 )
88 .await;
89
90 let listed: RefPage<BranchRow> = match listed {
91 Ok(page) => page.ok_or_not_found()?,
92 Err(error) if timed_out(&error) => {
93 return view! {
94 wide(
95 repo_header(repo: &repo, rev: "", active: Tab::Code)
96 heading(
97 title: "Branches",
98 count: None,
99 other: "Tags",
100 href: other.as_str(),
101 )
102 took_too_long(what: "branches")
103 )
104 };
105 }
106 Err(error) => return Err(server_error(error)),
107 };
108
109 // The default branch is first, because the use case pinned it there. Its name is
110 // also what a comparison is made *against*, so the whole page reads it from the row
111 // rather than spending a process on `symbolic-ref`.
112 let default = listed
113 .rows
114 .iter()
115 .find(|row| row.is_default)
116 .map(|row| row.name.clone());
117
118 let at = default.as_ref().map(RefName::as_str).unwrap_or_default();
119
120 view! {
121 wide(
122 repo_header(repo: &repo, rev: at, active: Tab::Code)
123 heading(
124 title: "Branches",
125 count: Some(listed.rows.len()),
126 other: "Tags",
127 href: other.as_str(),
128 )
129
130 if listed.repo_is_empty {
131 empty_repo(url: clone.as_str())
132 } else if listed.rows.is_empty() {
133 nothing_here("No branches. Every commit in this repository is reachable only by tag or by sha.")
134 } else {
135 <ul class="divide-y divide-border overflow-hidden rounded-lg border border-border">
136 for row in &listed.rows {
137 <li>
138 branch_row(repo: &repo, row: row, default: default.as_ref())
139 </li>
140 }
141 </ul>
142 }
143 )
144 }
145}
146
147/// One branch, on one line.
148///
149/// The name is the thing being scanned for, so it leads and is the only thing at full
150/// contrast. Everything after it — subject, sha, date — is the answer to "and what is on
151/// it", and is muted so a column of thirty branches reads as a list of names.
152///
153/// **No ahead/behind count.** It is a `rev-list` per branch, so a repository with twenty
154/// branches would fork twenty extra processes to decorate one page — the fork-per-fact
155/// cost [0006](../../../plans/decisions/0006-git-binary-behind-narrow-ports.md) exists to
156/// bound. It stays out until something keeps git alive between questions.
157#[component]
158async fn branch_row(repo: &RepoView, row: &BranchRow, default: Option<&RefName>) -> Result {
159 let handle = repo.handle.as_str();
160 let name = repo.name.as_str();
161 let tree = tree_url(handle, name, &row.name, &crate::domain::RepoPath::root());
162 let log = log_url(handle, name, row.name.as_str());
163
164 view! {
165 <div class="flex items-center gap-3 px-4 py-2 text-sm">
166 <a href=(tree.as_str()) class="max-w-[45%] shrink-0 truncate font-mono hover:underline">
167 (row.name.as_str())
168 </a>
169 if row.is_default {
170 badge(variant: BadgeVariant::Outline, "default")
171 }
172
173 // Hidden on a phone rather than wrapped: the row is a scanning surface, and
174 // a two-line row breaks the column of names that makes it scannable.
175 <a
176 href=(log.as_str())
177 class="hidden min-w-0 flex-1 truncate text-muted-foreground hover:text-foreground sm:block"
178 >(&row.summary)</a>
179
180 <span class="ml-auto flex shrink-0 items-center gap-3 text-xs text-muted-foreground sm:ml-0">
181 <code class="font-mono">(row.commit.short())</code>
265e887fix: the rows line up, and a phone keeps the names rather than the extras21h
182 // A fixed, right-aligned column: relative dates vary in width, and
183 // without one the sha beside them zig-zags down a list of thirty rows.
184 // Only from `sm` up — on a phone the reserved width is what pushes the
185 // row off the side of the screen.
186 <span class="sm:w-24 sm:text-right" title=(timestamp(row.committed_at))>
187 (ago(row.committed_at))
188 </span>
189 // A fixed slot, kept even on the default branch's row: comparing a
190 // branch with itself is an empty diff so that one row has no link, and
191 // without the slot its sha and date would sit out of line with the rest.
192 // Gone on a phone, with the commit subject: the row keeps what identifies
193 // a branch and drops the secondary action rather than overflowing.
194 <span class="hidden w-16 text-right sm:block">
195 match default {
196 Some(base) if base != &row.name => <a
197 href=(compare_url(handle, name, base, &row.name))
198 class="hover:text-foreground"
199 >"Compare"</a>,
200 _ => "",
201 }
202 </span>
adac3a7feat: branches and tags get pages, so the counts stop being dead ends21h
203 </span>
204 </div>
205 }
206}
207
208#[page("/{handle}/repos/{name}/tags")]
209async fn tags_page(cx: &Cx) -> Result {
210 let repo = repo_for(cx).await?;
211 let clone = clone_url_for(cx, &repo);
212 let other = branches_url(repo.handle.as_str(), repo.name.as_str());
213
214 let listed = list_tags(
215 &repo.handle,
216 &repo.name,
217 &current_actor(cx).await?,
218 &orgs(cx),
219 &memberships(cx),
220 &repos(cx),
221 &queries(cx),
222 )
223 .await;
224
225 let listed: RefPage<TagRow> = match listed {
226 Ok(page) => page.ok_or_not_found()?,
227 Err(error) if timed_out(&error) => {
228 return view! {
229 wide(
230 repo_header(repo: &repo, rev: "", active: Tab::Code)
231 heading(
232 title: "Tags",
233 count: None,
234 other: "Branches",
235 href: other.as_str(),
236 )
237 took_too_long(what: "tags")
238 )
239 };
240 }
241 Err(error) => return Err(server_error(error)),
242 };
243
244 view! {
245 wide(
246 // The tags page names no revision: it is a list of them.
247 repo_header(repo: &repo, rev: "", active: Tab::Code)
248 heading(
249 title: "Tags",
250 count: Some(listed.rows.len()),
251 other: "Branches",
252 href: other.as_str(),
253 )
254
255 if listed.repo_is_empty {
256 empty_repo(url: clone.as_str())
257 } else if listed.rows.is_empty() {
265e887fix: the rows line up, and a phone keeps the names rather than the extras21h
258 nothing_here(
259 "No tags yet. Push one with "
260 <code class="font-mono text-foreground">"git push --tags"</code>
261 " to see it here."
262 )
adac3a7feat: branches and tags get pages, so the counts stop being dead ends21h
263 } else {
264 <ul class="divide-y divide-border overflow-hidden rounded-lg border border-border">
265 for row in &listed.rows {
266 <li>tag_row(repo: &repo, row: row)</li>
267 }
268 </ul>
269 }
270 )
271 }
272}
273
274/// One tag, on one line.
275///
276/// The right of the row is deliberately short: the two archive links — `.tar.gz` and
277/// `.zip` — land there once an archive endpoint exists, which is the slot `plans/ui.md`
278/// reserves for them.
279#[component]
280async fn tag_row(repo: &RepoView, row: &TagRow) -> Result {
281 let handle = repo.handle.as_str();
282 let name = repo.name.as_str();
283 let tree = tree_url(handle, name, &row.name, &crate::domain::RepoPath::root());
284 let log = log_url(handle, name, row.name.as_str());
285
286 view! {
287 <div class="flex items-center gap-3 px-4 py-2 text-sm">
288 <span class="shrink-0 text-muted-foreground">
289 icon(data: iconify_icon!("feather:tag"), attrs: attributes! { class="size-3.5" })
290 </span>
291 <a href=(tree.as_str()) class="max-w-[45%] shrink-0 truncate font-mono hover:underline">
292 (row.name.as_str())
293 </a>
294 // Only an annotated tag carries one. A lightweight tag has no message of its
295 // own, and showing the commit's subject here would put words in its mouth.
296 if row.annotated {
297 badge(variant: BadgeVariant::Outline, "annotated")
298 }
299
300 match &row.message {
301 Some(message) => <span
302 class="hidden min-w-0 flex-1 truncate text-muted-foreground sm:block"
303 >(message)</span>,
304 None => <span class="hidden flex-1 sm:block"></span>,
305 }
306
307 <span class="ml-auto flex shrink-0 items-center gap-3 text-xs text-muted-foreground sm:ml-0">
308 // The commit the tag names, peeled through the tag object — the log at
309 // the tag is where it can be seen in context.
310 <a href=(log.as_str()) class="font-mono hover:text-foreground">
311 (row.commit.short())
312 </a>
265e887fix: the rows line up, and a phone keeps the names rather than the extras21h
313 <span class="sm:w-24 sm:text-right" title=(timestamp(row.created_at))>
314 (ago(row.created_at))
315 </span>
adac3a7feat: branches and tags get pages, so the counts stop being dead ends21h
316 </span>
317 </div>
318 }
319}
320
321/// The line above the list: what this page is, how many, and the other one.
322///
323/// The cross-link is the whole navigation between the two pages. Both live under the
324/// Code tab, so the strip cannot say which of them you are on, and having arrived from
325/// one count the visitor usually wants the other.
326#[component]
327async fn heading(title: &str, count: Option<usize>, other: &str, href: &str) -> Result {
328 view! {
329 <div class="mb-2 flex items-baseline gap-2.5">
330 <h2 class="text-sm font-medium">(title)</h2>
331 match count {
332 Some(count) => <span class="font-mono text-xs text-muted-foreground">
333 (count.to_string())
334 </span>,
335 None => "",
336 }
337 <a href=(href) class="ml-auto inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground">
338 (other)
339 icon(data: iconify_icon!("feather:arrow-right"), attrs: attributes! {
340 class="size-3.5"
341 })
342 </a>
343 </div>
344 }
345}
346
347/// A repository that has history but nothing of this kind in it.
348///
349/// Distinct from an empty repository, which gets the push snippet: this one is not
350/// missing a setup step, so telling it how to push would be answering a question it did
351/// not ask.
352#[component]
353async fn nothing_here(#[default] child: View) -> Result {
354 view! {
355 <p class="rounded-lg border border-border px-4 py-10 text-center text-sm text-muted-foreground">
356 (child)
357 </p>
358 }
359}
360
361/// What a read that ran out of time looks like.
362///
363/// A page state rather than a 500: the repository is fine, and the honest thing to say
364/// is that this one question took too long — with reloading offered, because on a
365/// repository this large it may well not the second time.
366#[component]
367async fn took_too_long(what: &str) -> Result {
368 view! {
369 <div class="rounded-lg border border-border px-4 py-10 text-center">
370 <p class="text-sm text-muted-foreground">
371 "Listing this repository's " (what) " took too long."
372 </p>
373 <p class="mt-1 text-xs text-muted-foreground">
374 "Nothing is wrong with the repository — reading it simply ran out of time. Try again."
375 </p>
376 </div>
377 }
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383
384 fn rev(value: &str) -> RefName {
385 RefName::new(value).expect("valid revision")
386 }
387
388 #[test]
389 fn the_pages_live_under_the_repository() {
390 assert_eq!(branches_url("ada", "steid"), "/ada/repos/steid/branches");
391 assert_eq!(tags_url("ada", "steid"), "/ada/repos/steid/tags");
392 }
393
394 #[test]
395 fn a_comparison_names_the_default_branch_first() {
396 assert_eq!(
397 compare_url("ada", "steid", &rev("main"), &rev("spike")),
398 "/ada/repos/steid/compare/main...spike"
399 );
400 }
401
402 #[test]
403 fn a_refs_slashes_are_encoded_so_the_separator_stays_the_only_one() {
404 // Otherwise `feature/login` would look like extra path segments, which is the
405 // same ambiguity `/-/` removes on a tree URL.
406 assert_eq!(
407 compare_url("ada", "steid", &rev("main"), &rev("feature/login")),
408 "/ada/repos/steid/compare/main...feature%2Flogin"
409 );
410 }
411}