fabrica

hanna/fabrica

feat(web): branch switcher, commits tab, signature popover, squarer pills

9755dfe · hanna committed on 2026-07-25

Make Commits a repo tab (Code · Commits · Branches · Tags) and turn the
branch button into a no-JS dropdown that switches branches (new cheap
git::Repo::branch_names). Add a GitHub-style hover/focus popover on verified
commit-list badges (signer, key fingerprint, verified date). On the commit
view, drop the redundant Verified pill and move the short id / author / date
to the right of the title. Square off badges and tab pills to match the
buttons, and align the profile social-link icons.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
4 files changed · +292 −34UnifiedSplit
assets/base.css +126 −4
@@ -346,7 +346,7 @@ select:focus {
346 gap: 0.25rem;346 gap: 0.25rem;
347 padding: 0.05rem 0.45rem;347 padding: 0.05rem 0.45rem;
348 font-size: 0.8em;348 font-size: 0.8em;
349 border-radius: 999px;349 border-radius: var(--fb-radius);
350 border: 1px solid var(--fb-border);350 border: 1px solid var(--fb-border);
351}351}
352.badge-verified {352.badge-verified {
@@ -619,7 +619,7 @@ select:focus {
619.profile-pronouns {619.profile-pronouns {
620 padding: 0.05rem 0.5rem;620 padding: 0.05rem 0.5rem;
621 border: 1px solid var(--fb-border);621 border: 1px solid var(--fb-border);
622 border-radius: 999px;622 border-radius: var(--fb-radius);
623 font-size: 0.82em;623 font-size: 0.82em;
624}624}
625.profile-bio {625.profile-bio {
@@ -653,8 +653,13 @@ select:focus {
653 color: var(--fb-fg-muted);653 color: var(--fb-fg-muted);
654}654}
655.profile-links a {655.profile-links a {
656 color: var(--fb-accent);656 display: inline-flex;
657 align-items: center;
658 gap: 0.5rem;
657 min-width: 0;659 min-width: 0;
660 color: var(--fb-accent);
661}
662.profile-links a span {
658 overflow: hidden;663 overflow: hidden;
659 text-overflow: ellipsis;664 text-overflow: ellipsis;
660 white-space: nowrap;665 white-space: nowrap;
@@ -892,7 +897,7 @@ pre.code {
892 align-items: center;897 align-items: center;
893 gap: 0.4rem;898 gap: 0.4rem;
894 padding: 0.4rem 0.9rem;899 padding: 0.4rem 0.9rem;
895 border-radius: 999px;900 border-radius: var(--fb-radius);
896 color: var(--fb-fg-muted);901 color: var(--fb-fg-muted);
897}902}
898.repo-tabs a:hover,903.repo-tabs a:hover,
@@ -969,6 +974,63 @@ pre.code {
969 font-family: var(--fb-font-mono);974 font-family: var(--fb-font-mono);
970 font-size: 0.8em;975 font-size: 0.8em;
971}976}
977
978/* Branch switcher dropdown. */
979.branch-menu {
980 position: relative;
981}
982.branch-menu > summary {
983 list-style: none;
984 cursor: pointer;
985}
986.branch-menu > summary::-webkit-details-marker {
987 display: none;
988}
989.branch-menu > summary .icon:last-child {
990 margin-left: 0.1rem;
991}
992.branch-pop {
993 position: absolute;
994 left: 0;
995 top: calc(100% + 0.35rem);
996 z-index: 30;
997 width: 17rem;
998 max-width: 88vw;
999 max-height: 20rem;
1000 overflow-y: auto;
1001 padding: 0.35rem;
1002 box-shadow: 0 10px 28px rgba(0, 0, 0, 0.35);
1003}
1004.branch-pop-head {
1005 padding: 0.3rem 0.5rem;
1006 font-size: 0.78em;
1007 font-weight: 600;
1008}
1009.branch-pop a {
1010 display: block;
1011 padding: 0.35rem 0.5rem;
1012 border-radius: var(--fb-radius);
1013 color: var(--fb-fg);
1014 font-family: var(--fb-font-mono);
1015 font-size: 0.88em;
1016 overflow: hidden;
1017 text-overflow: ellipsis;
1018 white-space: nowrap;
1019}
1020.branch-pop a:hover {
1021 background: var(--fb-bg-inset);
1022 text-decoration: none;
1023}
1024.branch-pop a[aria-current="page"] {
1025 color: var(--fb-accent);
1026 font-weight: 600;
1027}
1028.branch-pop-all {
1029 margin-top: 0.25rem;
1030 border-top: 1px solid var(--fb-border);
1031 color: var(--fb-accent) !important;
1032 font-family: var(--fb-font-sans) !important;
1033}
972.breadcrumb {1034.breadcrumb {
973 margin-bottom: 0.75rem;1035 margin-bottom: 0.75rem;
974 font-family: var(--fb-font-mono);1036 font-family: var(--fb-font-mono);
@@ -1126,6 +1188,50 @@ table.blob tr:target {
1126 height: 1.75rem;1188 height: 1.75rem;
1127}1189}
11281190
1191/* Verified-signature hover popover in the commits list. */
1192.sig-pop-wrap {
1193 position: relative;
1194 display: inline-flex;
1195}
1196.sig-pop {
1197 position: absolute;
1198 right: 0;
1199 top: calc(100% + 0.35rem);
1200 z-index: 40;
1201 width: 21rem;
1202 max-width: 88vw;
1203 display: none;
1204 text-align: left;
1205 white-space: normal;
1206 cursor: default;
1207 box-shadow: 0 10px 28px rgba(0, 0, 0, 0.35);
1208}
1209.sig-pop-wrap:hover .sig-pop,
1210.sig-pop-wrap:focus-within .sig-pop {
1211 display: block;
1212}
1213.sig-pop-title {
1214 font-weight: 600;
1215 margin-bottom: 0.6rem;
1216}
1217.sig-pop-signer {
1218 margin-bottom: 0.6rem;
1219}
1220.sig-pop-fp {
1221 font-size: 0.85em;
1222 color: var(--fb-fg-muted);
1223 margin-bottom: 0.4rem;
1224}
1225.sig-pop-fp .mono {
1226 display: block;
1227 margin-top: 0.15rem;
1228 color: var(--fb-fg);
1229 word-break: break-all;
1230}
1231.sig-pop-when {
1232 font-size: 0.85em;
1233}
1234
1129/* Commit signature detail bar. */1235/* Commit signature detail bar. */
1130.sig-detail {1236.sig-detail {
1131 display: flex;1237 display: flex;
@@ -1157,6 +1263,22 @@ table.blob tr:target {
1157.commit-meta {1263.commit-meta {
1158 margin-bottom: 1rem;1264 margin-bottom: 1rem;
1159}1265}
1266.commit-title-row {
1267 display: flex;
1268 align-items: baseline;
1269 justify-content: space-between;
1270 gap: 1rem;
1271 flex-wrap: wrap;
1272}
1273.commit-title {
1274 margin: 0;
1275 min-width: 0;
1276}
1277.commit-title-meta {
1278 margin: 0;
1279 font-size: 0.85em;
1280 text-align: right;
1281}
1160.commit-body {1282.commit-body {
1161 margin: 0.5rem 0;1283 margin: 0.5rem 0;
1162 padding: 0.5rem 0.75rem;1284 padding: 0.5rem 0.75rem;
crates/git/src/repo.rs +24 −0
@@ -211,6 +211,30 @@ impl Repo {
211 Ok(out)211 Ok(out)
212 }212 }
213213
214 /// The local branch names, default first then alphabetical. Cheaper than
215 /// [`Self::branches`] — it skips the ahead/behind graph walk, for the branch
216 /// switcher.
217 ///
218 /// # Errors
219 ///
220 /// Returns [`GitError::Libgit2`] on a libgit2 failure.
221 pub fn branch_names(&self) -> Result<Vec<String>, GitError> {
222 let default = self.head_branch()?;
223 let mut names: Vec<String> = Vec::new();
224 for branch in self.inner.branches(Some(BranchType::Local))? {
225 let (branch, _) = branch?;
226 if let Some(name) = branch.name()?.map(str::to_string) {
227 names.push(name);
228 }
229 }
230 names.sort_by(|a, b| {
231 let da = default.as_deref() == Some(a.as_str());
232 let db = default.as_deref() == Some(b.as_str());
233 db.cmp(&da).then_with(|| a.cmp(b))
234 });
235 Ok(names)
236 }
237
214 /// The tags, peeled to the commit each ultimately points at, annotated tags238 /// The tags, peeled to the commit each ultimately points at, annotated tags
215 /// carrying their message and tagger.239 /// carrying their message and tagger.
216 ///240 ///
crates/web/src/icons.rs +6 −0
@@ -17,6 +17,8 @@ pub enum Icon {
17 Folder,17 Folder,
18 File,18 File,
19 GitBranch,19 GitBranch,
20 History,
21 ChevronDown,
20 Search,22 Search,
21 Copy,23 Copy,
22 Box,24 Box,
@@ -55,6 +57,10 @@ impl Icon {
55 Icon::GitBranch => {57 Icon::GitBranch => {
56 r#"<path d="M15 6a9 9 0 0 0-9 9V3"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/>"#58 r#"<path d="M15 6a9 9 0 0 0-9 9V3"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/>"#
57 }59 }
60 Icon::History => {
61 r#"<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>"#
62 }
63 Icon::ChevronDown => r#"<path d="m6 9 6 6 6-6"/>"#,
58 Icon::Search => r#"<path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/>"#,64 Icon::Search => r#"<path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/>"#,
59 Icon::Copy => {65 Icon::Copy => {
60 r#"<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>"#66 r#"<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>"#
crates/web/src/repo.rs +136 −30
@@ -11,6 +11,7 @@
11//! resolved once through [`auth::access`]; no access to a private repo renders11//! resolved once through [`auth::access`]; no access to a private repo renders
12//! `404`, never `403`, so existence never leaks.12//! `404`, never `403`, so existence never leaks.
1313
14use std::collections::HashMap;
14use std::path::Path as FsPath;15use std::path::Path as FsPath;
1516
16use axum::extract::{Path, Query, State};17use axum::extract::{Path, Query, State};
@@ -58,6 +59,7 @@ const README_NAMES: &[&str] = &["README.md", "README", "readme.md", "README.txt"
58#[derive(Clone, Copy, PartialEq, Eq)]59#[derive(Clone, Copy, PartialEq, Eq)]
59enum Tab {60enum Tab {
60 Code,61 Code,
62 Commits,
61 Branches,63 Branches,
62 Tags,64 Tags,
63 Search,65 Search,
@@ -92,6 +94,14 @@ where
92 .map_err(AppError::from)94 .map_err(AppError::from)
93}95}
9496
97/// The local branch names for the switcher, empty on any error (an empty repo or
98/// a failed read simply yields no dropdown).
99async fn branch_names(state: &AppState, repo_id: &str) -> Vec<String> {
100 git_read(state, repo_id, git::Repo::branch_names)
101 .await
102 .unwrap_or_default()
103}
104
95/// Resolve `(owner, path)` to a repo and check access, or `Ok(None)` if the owner105/// Resolve `(owner, path)` to a repo and check access, or `Ok(None)` if the owner
96/// or repo does not exist (so the caller can try a group listing).106/// or repo does not exist (so the caller can try a group listing).
97///107///
@@ -267,7 +277,14 @@ async fn dispatch_view(
267 "branches" => branches_view(state, viewer, jar, uri, ctx).await,277 "branches" => branches_view(state, viewer, jar, uri, ctx).await,
268 "tags" => tags_view(state, viewer, jar, uri, ctx).await,278 "tags" => tags_view(state, viewer, jar, uri, ctx).await,
269 "search" => {279 "search" => {
270 let header = repo_header(state, &ctx, Tab::Search, &ctx.repo.default_branch);280 let branches = branch_names(state, &ctx.repo.id).await;
281 let header = repo_header(
282 state,
283 &ctx,
284 Tab::Search,
285 &ctx.repo.default_branch,
286 &branches,
287 );
271 let raw_query = dq.q.clone().unwrap_or_default();288 let raw_query = dq.q.clone().unwrap_or_default();
272 crate::search::in_repo(state, viewer, jar, uri, &ctx, &raw_query, header).await289 crate::search::in_repo(state, viewer, jar, uri, &ctx, &raw_query, header).await
273 }290 }
@@ -322,9 +339,10 @@ async fn repo_home(
322 })339 })
323 .await?;340 .await?;
324 let readme = load_readme(state, &ctx.repo.id, &rev, &entries).await;341 let readme = load_readme(state, &ctx.repo.id, &rev, &entries).await;
342 let branches = branch_names(state, &ctx.repo.id).await;
325 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);343 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
326 html! {344 html! {
327 (repo_header(state, &ctx, Tab::Code, &rev_name))345 (repo_header(state, &ctx, Tab::Code, &rev_name, &branches))
328 form class="search-row repo-codesearch" method="get" action=(format!("{base}/-/search")) {346 form class="search-row repo-codesearch" method="get" action=(format!("{base}/-/search")) {
329 input type="search" name="q" placeholder="Search this repository…"347 input type="search" name="q" placeholder="Search this repository…"
330 aria-label="Search this repository";348 aria-label="Search this repository";
@@ -373,7 +391,7 @@ async fn load_readme(
373/// The "empty repository" landing with clone instructions.391/// The "empty repository" landing with clone instructions.
374fn empty_repo_body(state: &AppState, ctx: &RepoCtx) -> Markup {392fn empty_repo_body(state: &AppState, ctx: &RepoCtx) -> Markup {
375 html! {393 html! {
376 (repo_header(state, ctx, Tab::Code, &ctx.repo.default_branch))394 (repo_header(state, ctx, Tab::Code, &ctx.repo.default_branch, &[]))
377 div class="card" {395 div class="card" {
378 h2 { "This repository is empty" }396 h2 { "This repository is empty" }
379 p class="muted" { "Push a commit to get started:" }397 p class="muted" { "Push a commit to get started:" }
@@ -408,8 +426,9 @@ async fn tree_view(
408 })426 })
409 .await?;427 .await?;
410428
429 let branches = branch_names(state, &ctx.repo.id).await;
411 let body = html! {430 let body = html! {
412 (repo_header(state, &ctx, Tab::Code, &rev_name))431 (repo_header(state, &ctx, Tab::Code, &rev_name, &branches))
413 (breadcrumb(&ctx.owner.username, &ctx.repo.path, &rev_name, &path))432 (breadcrumb(&ctx.owner.username, &ctx.repo.path, &rev_name, &path))
414 (tree_table(&ctx.owner.username, &ctx.repo.path, &rev_name, &path, &entries))433 (tree_table(&ctx.owner.username, &ctx.repo.path, &rev_name, &path, &entries))
415 };434 };
@@ -462,8 +481,9 @@ async fn blob_view(
462 attr_lang.as_deref(),481 attr_lang.as_deref(),
463 source_view,482 source_view,
464 );483 );
484 let branches = branch_names(state, &ctx.repo.id).await;
465 let body = html! {485 let body = html! {
466 (repo_header(state, &ctx, Tab::Code, &rev_name))486 (repo_header(state, &ctx, Tab::Code, &rev_name, &branches))
467 (breadcrumb(&ctx.owner.username, &ctx.repo.path, &rev_name, &path))487 (breadcrumb(&ctx.owner.username, &ctx.repo.path, &rev_name, &path))
468 (content_body)488 (content_body)
469 };489 };
@@ -610,8 +630,9 @@ async fn branches_view(
610 ctx: RepoCtx,630 ctx: RepoCtx,
611) -> AppResult<Response> {631) -> AppResult<Response> {
612 let branches = git_read(state, &ctx.repo.id, git::Repo::branches).await?;632 let branches = git_read(state, &ctx.repo.id, git::Repo::branches).await?;
633 let branch_list: Vec<String> = branches.iter().map(|b| b.name.clone()).collect();
613 let body = html! {634 let body = html! {
614 (repo_header(state, &ctx, Tab::Branches, &ctx.repo.default_branch))635 (repo_header(state, &ctx, Tab::Branches, &ctx.repo.default_branch, &branch_list))
615 h2 { "Branches" }636 h2 { "Branches" }
616 table class="list" {637 table class="list" {
617 thead { tr { th { "Branch" } th { "Ahead" } th { "Behind" } th { "Updated" } } }638 thead { tr { th { "Branch" } th { "Ahead" } th { "Behind" } th { "Updated" } } }
@@ -645,8 +666,9 @@ async fn tags_view(
645 ctx: RepoCtx,666 ctx: RepoCtx,
646) -> AppResult<Response> {667) -> AppResult<Response> {
647 let tags = git_read(state, &ctx.repo.id, git::Repo::tags).await?;668 let tags = git_read(state, &ctx.repo.id, git::Repo::tags).await?;
669 let branches = branch_names(state, &ctx.repo.id).await;
648 let body = html! {670 let body = html! {
649 (repo_header(state, &ctx, Tab::Tags, &ctx.repo.default_branch))671 (repo_header(state, &ctx, Tab::Tags, &ctx.repo.default_branch, &branches))
650 h2 { "Tags" }672 h2 { "Tags" }
651 table class="list" {673 table class="list" {
652 thead { tr { th { "Tag" } th { "Commit" } th { "Kind" } } }674 thead { tr { th { "Tag" } th { "Commit" } th { "Kind" } } }
@@ -703,14 +725,30 @@ async fn commits_view(
703 })725 })
704 .await?;726 .await?;
705727
728 // Resolve the usernames of verified signers for the hover popovers.
729 let mut signers: HashMap<String, String> = HashMap::new();
730 for s in &states {
731 if let git::SignatureState::Verified { user_id, .. } = s
732 && !signers.contains_key(user_id)
733 && let Ok(Some(u)) = state.store.user_by_id(user_id).await
734 {
735 signers.insert(user_id.clone(), u.username);
736 }
737 }
738
706 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);739 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
707 let now = crate::now_ms();740 let now = crate::now_ms();
741 let branches = branch_names(state, &ctx.repo.id).await;
708 let body = html! {742 let body = html! {
709 (repo_header(state, &ctx, Tab::Code, &rev_name))743 (repo_header(state, &ctx, Tab::Commits, &rev_name, &branches))
710 h2 { "Commits" }744 h2 { "Commits" }
711 ul class="commit-list card" {745 ul class="commit-list card" {
712 @for (commit, sig) in commits.iter().zip(states.iter()) {746 @for (commit, sig) in commits.iter().zip(states.iter()) {
713 @let commit_url = format!("{base}/-/commit/{}", commit.oid);747 @let commit_url = format!("{base}/-/commit/{}", commit.oid);
748 @let signer = match sig {
749 git::SignatureState::Verified { user_id, .. } => signers.get(user_id).map(String::as_str),
750 _ => None,
751 };
714 li class="commit-row" {752 li class="commit-row" {
715 div class="commit-row-body" {753 div class="commit-row-body" {
716 a class="commit-row-msg" href=(commit_url) { (commit.summary) }754 a class="commit-row-msg" href=(commit_url) { (commit.summary) }
@@ -721,7 +759,7 @@ async fn commits_view(
721 }759 }
722 }760 }
723 div class="commit-row-actions" {761 div class="commit-row-actions" {
724 (signature_badge(sig))762 (commit_sig_cell(sig, signer, commit.author.time_ms))
725 a class="mono commit-sha-pill" href=(commit_url) { (commit.oid.short()) }763 a class="mono commit-sha-pill" href=(commit_url) { (commit.oid.short()) }
726 button class="icon-btn commit-copy" type="button"764 button class="icon-btn commit-copy" type="button"
727 data-clipboard=(commit.oid.to_string()) aria-label="Copy commit id" {765 data-clipboard=(commit.oid.to_string()) aria-label="Copy commit id" {
@@ -774,10 +812,11 @@ async fn commit_view(
774 (a + fa, d + fd)812 (a + fa, d + fd)
775 });813 });
776 let repo_base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);814 let repo_base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
815 let branches = branch_names(state, &ctx.repo.id).await;
777816
778 let body = html! {817 let body = html! {
779 (repo_header(state, &ctx, Tab::Code, &ctx.repo.default_branch))818 (repo_header(state, &ctx, Tab::Code, &ctx.repo.default_branch, &branches))
780 (commit_meta(&detail, &sig))819 (commit_meta(&detail))
781 (signature_detail(&sig, signer.as_deref()))820 (signature_detail(&sig, signer.as_deref()))
782 div class="diff-toolbar" {821 div class="diff-toolbar" {
783 span {822 span {
@@ -910,19 +949,22 @@ fn render_one_file(
910 }949 }
911}950}
912951
913/// The commit metadata block: subject, signature badge, sha, author, and body.952/// The commit metadata block: the subject with the short id, author, and commit
914fn commit_meta(detail: &git::CommitDetail, sig: &git::SignatureState) -> Markup {953/// date to its right, and the body below.
954fn commit_meta(detail: &git::CommitDetail) -> Markup {
915 let (subject, body) = detail955 let (subject, body) = detail
916 .message956 .message
917 .split_once("\n\n")957 .split_once("\n\n")
918 .map_or((detail.message.as_str(), ""), |(s, b)| (s.trim(), b));958 .map_or((detail.message.as_str(), ""), |(s, b)| (s.trim(), b));
919 html! {959 html! {
920 div class="commit-meta" {960 div class="commit-meta" {
921 h2 { (subject.lines().next().unwrap_or("")) " " (signature_badge(sig)) }961 div class="commit-title-row" {
922 p class="muted" {962 h2 class="commit-title" { (subject.lines().next().unwrap_or("")) }
923 span class="mono" { (detail.oid.short()) }963 p class="commit-title-meta muted" {
924 " · " (detail.author.name)964 span class="mono" { (detail.oid.short()) }
925 " committed on " (fmt_date(detail.committer.time_ms))965 " · " (detail.author.name)
966 " committed on " (fmt_date(detail.committer.time_ms))
967 }
926 }968 }
927 @if !body.trim().is_empty() {969 @if !body.trim().is_empty() {
928 pre class="commit-body" { (body.trim_end()) }970 pre class="commit-body" { (body.trim_end()) }
@@ -1240,8 +1282,14 @@ fn mastodon_link(handle: &str) -> Markup {
1240}1282}
12411283
1242/// The repo sub-header: slug, description, and a tab strip sharing its line with1284/// The repo sub-header: slug, description, and a tab strip sharing its line with
1243/// the branch / commits / clone actions.1285/// the branch switcher and clone menu.
1244fn repo_header(state: &AppState, ctx: &RepoCtx, active: Tab, rev: &str) -> Markup {1286fn repo_header(
1287 state: &AppState,
1288 ctx: &RepoCtx,
1289 active: Tab,
1290 rev: &str,
1291 branches: &[String],
1292) -> Markup {
1245 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);1293 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
1246 let tab = |t: Tab, label: &str, glyph: Icon, href: String| {1294 let tab = |t: Tab, label: &str, glyph: Icon, href: String| {
1247 html! {1295 html! {
@@ -1262,14 +1310,12 @@ fn repo_header(state: &AppState, ctx: &RepoCtx, active: Tab, rev: &str) -> Marku
1262 div class="repo-nav" {1310 div class="repo-nav" {
1263 nav class="tabs repo-tabs" {1311 nav class="tabs repo-tabs" {
1264 (tab(Tab::Code, "Code", Icon::File, base.clone()))1312 (tab(Tab::Code, "Code", Icon::File, base.clone()))
1313 (tab(Tab::Commits, "Commits", Icon::History, format!("{base}/-/commits/{rev}")))
1265 (tab(Tab::Branches, "Branches", Icon::GitBranch, format!("{base}/-/branches")))1314 (tab(Tab::Branches, "Branches", Icon::GitBranch, format!("{base}/-/branches")))
1266 (tab(Tab::Tags, "Tags", Icon::Box, format!("{base}/-/tags")))1315 (tab(Tab::Tags, "Tags", Icon::Box, format!("{base}/-/tags")))
1267 }1316 }
1268 div class="repo-actions" {1317 div class="repo-actions" {
1269 a class="btn" href=(format!("{base}/-/branches")) {1318 (branch_menu(&base, rev, branches))
1270 (icon(Icon::GitBranch)) span { (rev) }
1271 }
1272 a class="btn" href=(format!("{base}/-/commits/{rev}")) { "Commits" }
1273 (clone_menu(state, ctx))1319 (clone_menu(state, ctx))
1274 }1320 }
1275 }1321 }
@@ -1277,6 +1323,30 @@ fn repo_header(state: &AppState, ctx: &RepoCtx, active: Tab, rev: &str) -> Marku
1277 }1323 }
1278}1324}
12791325
1326/// The branch switcher: a no-JS `details` menu linking to each branch's tree.
1327/// Falls back to a plain link to the branches page when the list is empty.
1328fn branch_menu(base: &str, rev: &str, branches: &[String]) -> Markup {
1329 html! {
1330 @if branches.is_empty() {
1331 a class="btn" href=(format!("{base}/-/branches")) {
1332 (icon(Icon::GitBranch)) span { (rev) }
1333 }
1334 } @else {
1335 details class="branch-menu" {
1336 summary class="btn" { (icon(Icon::GitBranch)) span { (rev) } (icon(Icon::ChevronDown)) }
1337 div class="branch-pop card" {
1338 div class="branch-pop-head muted" { "Switch branch" }
1339 @for name in branches {
1340 a href=(format!("{base}/-/tree/{name}"))
1341 aria-current=[(name == rev).then_some("page")] { (name) }
1342 }
1343 a class="branch-pop-all" href=(format!("{base}/-/branches")) { "View all branches" }
1344 }
1345 }
1346 }
1347 }
1348}
1349
1280/// The Clone dropdown: a no-JS `details` menu offering the HTTPS and SSH URLs.1350/// The Clone dropdown: a no-JS `details` menu offering the HTTPS and SSH URLs.
1281fn clone_menu(state: &AppState, ctx: &RepoCtx) -> Markup {1351fn clone_menu(state: &AppState, ctx: &RepoCtx) -> Markup {
1282 let https = clone_https(state, ctx);1352 let https = clone_https(state, ctx);
@@ -1406,6 +1476,46 @@ fn entry_icon(kind: git::EntryKind) -> Icon {
1406 }1476 }
1407}1477}
14081478
1479/// The uppercase label for a key kind.
1480fn key_kind_label(kind: model::KeyKind) -> &'static str {
1481 match kind {
1482 model::KeyKind::Ssh => "SSH",
1483 model::KeyKind::Gpg => "GPG",
1484 }
1485}
1486
1487/// A commit's signature cell for the commits list: a verified signature gets a
1488/// hover/focus popover with the signer, key fingerprint, and verified date; any
1489/// other state falls back to the plain badge.
1490fn commit_sig_cell(state: &git::SignatureState, signer: Option<&str>, time_ms: i64) -> Markup {
1491 match state {
1492 git::SignatureState::Verified {
1493 kind,
1494 fingerprint,
1495 user_id,
1496 ..
1497 } => html! {
1498 span class="sig-pop-wrap" tabindex="0" {
1499 (signature_badge(state))
1500 div class="sig-pop card" {
1501 div class="sig-pop-title" {
1502 "This commit was signed with a verified signature."
1503 }
1504 div class="sig-pop-signer" {
1505 "Signed by " strong { (signer.unwrap_or(user_id)) }
1506 }
1507 div class="sig-pop-fp" {
1508 (key_kind_label(*kind)) " key fingerprint:"
1509 span class="mono" { (fingerprint) }
1510 }
1511 div class="muted sig-pop-when" { "Verified on " (fmt_joined(time_ms)) }
1512 }
1513 }
1514 },
1515 _ => signature_badge(state),
1516 }
1517}
1518
1409/// A signature badge (green Verified / amber Unverified) with a descriptive title.1519/// A signature badge (green Verified / amber Unverified) with a descriptive title.
1410fn signature_badge(state: &git::SignatureState) -> Markup {1520fn signature_badge(state: &git::SignatureState) -> Markup {
1411 match state {1521 match state {
@@ -1429,10 +1539,6 @@ fn signature_badge(state: &git::SignatureState) -> Markup {
1429/// The detail bar under a commit's metadata describing its signature: who signed1539/// The detail bar under a commit's metadata describing its signature: who signed
1430/// it and with which key fingerprint. Renders nothing for an unsigned commit.1540/// it and with which key fingerprint. Renders nothing for an unsigned commit.
1431fn signature_detail(state: &git::SignatureState, signer: Option<&str>) -> Markup {1541fn signature_detail(state: &git::SignatureState, signer: Option<&str>) -> Markup {
1432 let kind_label = |k: model::KeyKind| match k {
1433 model::KeyKind::Ssh => "SSH",
1434 model::KeyKind::Gpg => "GPG",
1435 };
1436 match state {1542 match state {
1437 git::SignatureState::Unsigned => html! {},1543 git::SignatureState::Unsigned => html! {},
1438 git::SignatureState::Verified {1544 git::SignatureState::Verified {
@@ -1446,13 +1552,13 @@ fn signature_detail(state: &git::SignatureState, signer: Option<&str>) -> Markup
1446 "Signed by " strong { (signer.unwrap_or(user_id)) }1552 "Signed by " strong { (signer.unwrap_or(user_id)) }
1447 }1553 }
1448 span class="sig-fp mono" {1554 span class="sig-fp mono" {
1449 (kind_label(*kind)) " key fingerprint: " (fingerprint)1555 (key_kind_label(*kind)) " key fingerprint: " (fingerprint)
1450 }1556 }
1451 }1557 }
1452 },1558 },
1453 git::SignatureState::UnknownKey { kind, fingerprint } => html! {1559 git::SignatureState::UnknownKey { kind, fingerprint } => html! {
1454 div class="sig-detail sig-unverified" {1560 div class="sig-detail sig-unverified" {
1455 span { "Signed by an unregistered " (kind_label(*kind)) " key" }1561 span { "Signed by an unregistered " (key_kind_label(*kind)) " key" }
1456 span class="sig-fp mono" { (fingerprint) }1562 span class="sig-fp mono" { (fingerprint) }
1457 }1563 }
1458 },1564 },