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