steid

@jamesgill /

adac3a7feat: branches and tags get pages, so the counts stop being dead ends17h
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>
182 <span title=(timestamp(row.committed_at))>(ago(row.committed_at))</span>
183 match default {
184 // Comparing the default branch with itself is an empty diff, so the
185 // one row that cannot want this does not offer it.
186 Some(base) if base != &row.name => <a
187 href=(compare_url(handle, name, base, &row.name))
188 class="hover:text-foreground"
189 >"Compare"</a>,
190 _ => "",
191 }
192 </span>
193 </div>
194 }
195}
196
197#[page("/{handle}/repos/{name}/tags")]
198async fn tags_page(cx: &Cx) -> Result {
199 let repo = repo_for(cx).await?;
200 let clone = clone_url_for(cx, &repo);
201 let other = branches_url(repo.handle.as_str(), repo.name.as_str());
202
203 let listed = list_tags(
204 &repo.handle,
205 &repo.name,
206 &current_actor(cx).await?,
207 &orgs(cx),
208 &memberships(cx),
209 &repos(cx),
210 &queries(cx),
211 )
212 .await;
213
214 let listed: RefPage<TagRow> = match listed {
215 Ok(page) => page.ok_or_not_found()?,
216 Err(error) if timed_out(&error) => {
217 return view! {
218 wide(
219 repo_header(repo: &repo, rev: "", active: Tab::Code)
220 heading(
221 title: "Tags",
222 count: None,
223 other: "Branches",
224 href: other.as_str(),
225 )
226 took_too_long(what: "tags")
227 )
228 };
229 }
230 Err(error) => return Err(server_error(error)),
231 };
232
233 view! {
234 wide(
235 // The tags page names no revision: it is a list of them.
236 repo_header(repo: &repo, rev: "", active: Tab::Code)
237 heading(
238 title: "Tags",
239 count: Some(listed.rows.len()),
240 other: "Branches",
241 href: other.as_str(),
242 )
243
244 if listed.repo_is_empty {
245 empty_repo(url: clone.as_str())
246 } else if listed.rows.is_empty() {
247 nothing_here("No tags yet. Push one with `git push --tags` to see it here.")
248 } else {
249 <ul class="divide-y divide-border overflow-hidden rounded-lg border border-border">
250 for row in &listed.rows {
251 <li>tag_row(repo: &repo, row: row)</li>
252 }
253 </ul>
254 }
255 )
256 }
257}
258
259/// One tag, on one line.
260///
261/// The right of the row is deliberately short: the two archive links — `.tar.gz` and
262/// `.zip` — land there once an archive endpoint exists, which is the slot `plans/ui.md`
263/// reserves for them.
264#[component]
265async fn tag_row(repo: &RepoView, row: &TagRow) -> Result {
266 let handle = repo.handle.as_str();
267 let name = repo.name.as_str();
268 let tree = tree_url(handle, name, &row.name, &crate::domain::RepoPath::root());
269 let log = log_url(handle, name, row.name.as_str());
270
271 view! {
272 <div class="flex items-center gap-3 px-4 py-2 text-sm">
273 <span class="shrink-0 text-muted-foreground">
274 icon(data: iconify_icon!("feather:tag"), attrs: attributes! { class="size-3.5" })
275 </span>
276 <a href=(tree.as_str()) class="max-w-[45%] shrink-0 truncate font-mono hover:underline">
277 (row.name.as_str())
278 </a>
279 // Only an annotated tag carries one. A lightweight tag has no message of its
280 // own, and showing the commit's subject here would put words in its mouth.
281 if row.annotated {
282 badge(variant: BadgeVariant::Outline, "annotated")
283 }
284
285 match &row.message {
286 Some(message) => <span
287 class="hidden min-w-0 flex-1 truncate text-muted-foreground sm:block"
288 >(message)</span>,
289 None => <span class="hidden flex-1 sm:block"></span>,
290 }
291
292 <span class="ml-auto flex shrink-0 items-center gap-3 text-xs text-muted-foreground sm:ml-0">
293 // The commit the tag names, peeled through the tag object — the log at
294 // the tag is where it can be seen in context.
295 <a href=(log.as_str()) class="font-mono hover:text-foreground">
296 (row.commit.short())
297 </a>
298 <span title=(timestamp(row.created_at))>(ago(row.created_at))</span>
299 </span>
300 </div>
301 }
302}
303
304/// The line above the list: what this page is, how many, and the other one.
305///
306/// The cross-link is the whole navigation between the two pages. Both live under the
307/// Code tab, so the strip cannot say which of them you are on, and having arrived from
308/// one count the visitor usually wants the other.
309#[component]
310async fn heading(title: &str, count: Option<usize>, other: &str, href: &str) -> Result {
311 view! {
312 <div class="mb-2 flex items-baseline gap-2.5">
313 <h2 class="text-sm font-medium">(title)</h2>
314 match count {
315 Some(count) => <span class="font-mono text-xs text-muted-foreground">
316 (count.to_string())
317 </span>,
318 None => "",
319 }
320 <a href=(href) class="ml-auto inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground">
321 (other)
322 icon(data: iconify_icon!("feather:arrow-right"), attrs: attributes! {
323 class="size-3.5"
324 })
325 </a>
326 </div>
327 }
328}
329
330/// A repository that has history but nothing of this kind in it.
331///
332/// Distinct from an empty repository, which gets the push snippet: this one is not
333/// missing a setup step, so telling it how to push would be answering a question it did
334/// not ask.
335#[component]
336async fn nothing_here(#[default] child: View) -> Result {
337 view! {
338 <p class="rounded-lg border border-border px-4 py-10 text-center text-sm text-muted-foreground">
339 (child)
340 </p>
341 }
342}
343
344/// What a read that ran out of time looks like.
345///
346/// A page state rather than a 500: the repository is fine, and the honest thing to say
347/// is that this one question took too long — with reloading offered, because on a
348/// repository this large it may well not the second time.
349#[component]
350async fn took_too_long(what: &str) -> Result {
351 view! {
352 <div class="rounded-lg border border-border px-4 py-10 text-center">
353 <p class="text-sm text-muted-foreground">
354 "Listing this repository's " (what) " took too long."
355 </p>
356 <p class="mt-1 text-xs text-muted-foreground">
357 "Nothing is wrong with the repository — reading it simply ran out of time. Try again."
358 </p>
359 </div>
360 }
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366
367 fn rev(value: &str) -> RefName {
368 RefName::new(value).expect("valid revision")
369 }
370
371 #[test]
372 fn the_pages_live_under_the_repository() {
373 assert_eq!(branches_url("ada", "steid"), "/ada/repos/steid/branches");
374 assert_eq!(tags_url("ada", "steid"), "/ada/repos/steid/tags");
375 }
376
377 #[test]
378 fn a_comparison_names_the_default_branch_first() {
379 assert_eq!(
380 compare_url("ada", "steid", &rev("main"), &rev("spike")),
381 "/ada/repos/steid/compare/main...spike"
382 );
383 }
384
385 #[test]
386 fn a_refs_slashes_are_encoded_so_the_separator_stays_the_only_one() {
387 // Otherwise `feature/login` would look like extra path segments, which is the
388 // same ambiguity `/-/` removes on a tree URL.
389 assert_eq!(
390 compare_url("ada", "steid", &rev("main"), &rev("feature/login")),
391 "/ada/repos/steid/compare/main...feature%2Flogin"
392 );
393 }
394}