fabrica

hanna/fabrica

feat(web): repo toolbar, clone menu, commit list & signature, search hits

2980823 · hanna committed on 2026-07-25

Move the branch/Commits actions onto the tab line with a no-JS Clone
dropdown offering HTTPS and SSH URLs. Redesign the commits page as clean
rows (message, author, relative time, sha pill with copy) and add a
signature detail bar to the commit view (signer + key fingerprint). Style
search hits as a path header over a code block of matched lines. Move the
top-bar search to the right (drop the Home button), space the Unified/Split
toggle, and enlarge the repo-slug private pill.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
7 files changed · +332 −74UnifiedSplit
assets/base.css +179 −20
@@ -124,8 +124,8 @@ code, pre, .mono {
124124 align-items: center;
125125 gap: 0.25rem;
126126 flex: 1;
127- max-width: 34rem;
128- margin: 0 auto;
127+ max-width: 28rem;
128+ margin-left: auto;
129129 }
130130 .topbar-search input[type="search"] {
131131 width: 100%;
@@ -676,6 +676,42 @@ select:focus {
676676 min-width: 15rem;
677677 }
678678
679+/* ---- Search hits ---- */
680+.search-hit {
681+ padding: 0;
682+ overflow: hidden;
683+ margin-bottom: 0.75rem;
684+}
685+.search-hit-path {
686+ padding: 0.55rem 0.85rem;
687+ font-weight: 600;
688+ border-bottom: 1px solid var(--fb-border);
689+}
690+.search-hit-lines {
691+ margin: 0;
692+ padding: 0.4rem 0;
693+ background: var(--fb-bg-inset);
694+ font-family: var(--fb-font-mono);
695+ font-size: 0.85rem;
696+ overflow-x: auto;
697+}
698+.search-hit-line {
699+ display: flex;
700+ gap: 1rem;
701+ padding: 0 0.85rem;
702+ white-space: pre;
703+}
704+.search-hit-line .lineno {
705+ flex: none;
706+ min-width: 2.5rem;
707+ text-align: right;
708+ color: var(--fb-fg-muted);
709+ user-select: none;
710+}
711+.search-hit-line code {
712+ white-space: pre;
713+}
714+
679715 /* ---- Tables ---- */
680716 table.list {
681717 width: 100%;
@@ -838,7 +874,8 @@ pre.code {
838874 color: var(--fb-fg-muted);
839875 }
840876 .repo-slug .badge {
841- font-size: 0.6em;
877+ font-size: 0.7rem;
878+ padding: 0.12rem 0.55rem;
842879 align-self: center;
843880 }
844881 /* Tab strips (repo sub-nav, explore sub-nav): active-pill style. */
@@ -873,29 +910,64 @@ pre.code {
873910 .subnav {
874911 justify-content: center;
875912 }
876-.repo-toolbar {
913+/* Tabs share their line with the branch/commits/clone actions. */
914+.repo-nav {
877915 display: flex;
878- gap: 0.5rem;
879916 align-items: center;
917+ justify-content: space-between;
918+ gap: 1rem;
880919 flex-wrap: wrap;
881- margin-top: 0.5rem;
882920 }
883-.repo-codesearch {
884- margin: 0 0 1.25rem;
921+.repo-nav .repo-tabs {
922+ margin: 0.75rem 0 0;
923+ flex: 1;
885924 }
886-.clone-row {
925+.repo-actions {
887926 display: flex;
888- gap: 0.5rem;
889927 align-items: center;
890- margin: 0 0 1rem;
928+ gap: 0.5rem;
929+ flex-wrap: wrap;
930+}
931+.repo-codesearch {
932+ margin: 1rem 0 1.25rem;
933+}
934+
935+/* Clone dropdown: a no-JS details/summary popover. */
936+.clone-menu {
937+ position: relative;
938+}
939+.clone-menu > summary {
940+ list-style: none;
941+ cursor: pointer;
942+}
943+.clone-menu > summary::-webkit-details-marker {
944+ display: none;
945+}
946+.clone-pop {
947+ position: absolute;
948+ right: 0;
949+ top: calc(100% + 0.35rem);
950+ z-index: 30;
951+ width: 24rem;
952+ max-width: 88vw;
953+ display: flex;
954+ flex-direction: column;
955+ gap: 0.75rem;
956+ box-shadow: 0 10px 28px rgba(0, 0, 0, 0.35);
891957 }
892-.clone-label {
958+.clone-field-label {
959+ display: block;
960+ margin-bottom: 0.3rem;
961+ font-size: 0.78em;
893962 font-weight: 600;
894963 }
895-.clone-row input {
896- max-width: 26rem;
964+.clone-input {
965+ display: flex;
966+ gap: 0.4rem;
967+}
968+.clone-input input {
897969 font-family: var(--fb-font-mono);
898- font-size: 0.85em;
970+ font-size: 0.8em;
899971 }
900972 .breadcrumb {
901973 margin-bottom: 0.75rem;
@@ -994,6 +1066,93 @@ table.blob tr:target {
9941066 background: var(--fb-diff-add-bg);
9951067 }
9961068
1069+/* ---- Commit list ---- */
1070+.commit-list {
1071+ list-style: none;
1072+ margin: 0;
1073+ padding: 0;
1074+}
1075+.commit-row {
1076+ display: flex;
1077+ align-items: center;
1078+ justify-content: space-between;
1079+ gap: 1rem;
1080+ padding: 0.7rem 1rem;
1081+ border-bottom: 1px solid var(--fb-border);
1082+}
1083+.commit-row:last-child {
1084+ border-bottom: none;
1085+}
1086+.commit-row-body {
1087+ min-width: 0;
1088+}
1089+.commit-row-msg {
1090+ display: block;
1091+ font-weight: 600;
1092+ color: var(--fb-fg);
1093+ overflow: hidden;
1094+ text-overflow: ellipsis;
1095+ white-space: nowrap;
1096+}
1097+.commit-row-msg:hover {
1098+ color: var(--fb-accent);
1099+}
1100+.commit-row-meta {
1101+ display: flex;
1102+ gap: 0.4rem;
1103+ margin-top: 0.15rem;
1104+ font-size: 0.85em;
1105+}
1106+.commit-row-actions {
1107+ display: flex;
1108+ align-items: center;
1109+ gap: 0.5rem;
1110+ flex: none;
1111+}
1112+.commit-sha-pill {
1113+ padding: 0.1rem 0.45rem;
1114+ background: var(--fb-bg-inset);
1115+ border: 1px solid var(--fb-border);
1116+ border-radius: 5px;
1117+ font-size: 0.82em;
1118+ color: var(--fb-fg);
1119+}
1120+.commit-sha-pill:hover {
1121+ border-color: var(--fb-accent);
1122+ text-decoration: none;
1123+}
1124+.commit-copy {
1125+ width: 1.75rem;
1126+ height: 1.75rem;
1127+}
1128+
1129+/* Commit signature detail bar. */
1130+.sig-detail {
1131+ display: flex;
1132+ justify-content: space-between;
1133+ align-items: center;
1134+ gap: 1rem;
1135+ flex-wrap: wrap;
1136+ padding: 0.5rem 0.85rem;
1137+ margin-bottom: 1rem;
1138+ border: 1px solid var(--fb-border);
1139+ border-radius: var(--fb-radius);
1140+ font-size: 0.9em;
1141+}
1142+.sig-verified {
1143+ border-color: var(--fb-success);
1144+}
1145+.sig-verified .sig-signer {
1146+ color: var(--fb-success);
1147+}
1148+.sig-unverified {
1149+ border-color: var(--fb-warning);
1150+}
1151+.sig-fp {
1152+ color: var(--fb-fg-muted);
1153+ word-break: break-all;
1154+}
1155+
9971156 /* ---- Commit view & diffs ---- */
9981157 .commit-meta {
9991158 margin-bottom: 1rem;
@@ -1015,6 +1174,10 @@ table.blob tr:target {
10151174 gap: 0.5rem;
10161175 margin-bottom: 0.75rem;
10171176 }
1177+.diff-toggle {
1178+ display: inline-flex;
1179+ gap: 0.5rem;
1180+}
10181181 .diff-toggle .btn {
10191182 padding: 0.2rem 0.6rem;
10201183 }
@@ -1143,14 +1306,10 @@ td.diff-empty {
11431306 justify-content: center;
11441307 min-width: 44px;
11451308 }
1146- .repo-tabs,
1147- .clone-row {
1309+ .repo-tabs {
11481310 overflow-x: auto;
11491311 -webkit-overflow-scrolling: touch;
11501312 }
1151- .clone-row input {
1152- min-width: 0;
1153- }
11541313 /* Blob and diff tables scroll within their own box. */
11551314 table.blob,
11561315 table.diff {
crates/web/src/activity.rs +1 −1
@@ -252,7 +252,7 @@ impl Activity {
252252
253253 /// Format an epoch-millisecond timestamp relative to `now_ms`, e.g. `3 hours
254254 /// ago`, falling back to an absolute date past a month.
255-fn fmt_relative(ms: i64, now_ms: i64) -> String {
255+pub(crate) fn fmt_relative(ms: i64, now_ms: i64) -> String {
256256 let secs = (now_ms - ms).div_euclid(1000).max(0);
257257 let mins = secs / 60;
258258 let hours = mins / 60;
crates/web/src/icons.rs +4 −4
@@ -14,7 +14,6 @@ use maud::{Markup, PreEscaped, html};
1414 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1515 pub enum Icon {
1616 PanelLeft,
17- House,
1817 Folder,
1918 File,
2019 GitBranch,
@@ -30,6 +29,7 @@ pub enum Icon {
3029 Compass,
3130 MapPin,
3231 Globe,
32+ Download,
3333 Github,
3434 Mastodon,
3535 }
@@ -46,9 +46,6 @@ impl Icon {
4646 Icon::PanelLeft => {
4747 r#"<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/>"#
4848 }
49- Icon::House => {
50- r#"<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>"#
51- }
5249 Icon::Folder => {
5350 r#"<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>"#
5451 }
@@ -92,6 +89,9 @@ impl Icon {
9289 Icon::Globe => {
9390 r#"<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>"#
9491 }
92+ Icon::Download => {
93+ r#"<path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/>"#
94+ }
9595 Icon::Github => {
9696 r#"<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/>"#
9797 }
crates/web/src/layout.rs +0 −1
@@ -76,7 +76,6 @@ fn topbar(chrome: &Chrome) -> Markup {
7676 input type="search" name="q" placeholder="Search…" aria-label="Search";
7777 button class="icon-btn" type="submit" aria-label="Search" { (icon(Icon::Search)) }
7878 }
79- a class="icon-btn" href="/" aria-label="Home" { (icon(Icon::House)) }
8079 }
8180 }
8281 }
crates/web/src/lib.rs +8 −0
@@ -107,6 +107,14 @@ impl AppState {
107107 }
108108 }
109109
110+/// The current time in Unix milliseconds UTC.
111+pub(crate) fn now_ms() -> i64 {
112+ use std::time::{SystemTime, UNIX_EPOCH};
113+ SystemTime::now()
114+ .duration_since(UNIX_EPOCH)
115+ .map_or(0, |d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX))
116+}
117+
110118 /// Build the application router with all middleware wired.
111119 pub fn build_router(state: AppState) -> Router {
112120 let x_request_id = HeaderName::from_static("x-request-id");
crates/web/src/repo.rs +126 −38
@@ -267,7 +267,7 @@ async fn dispatch_view(
267267 "branches" => branches_view(state, viewer, jar, uri, ctx).await,
268268 "tags" => tags_view(state, viewer, jar, uri, ctx).await,
269269 "search" => {
270- let header = repo_header(&ctx, Tab::Search, &ctx.repo.default_branch);
270+ let header = repo_header(state, &ctx, Tab::Search, &ctx.repo.default_branch);
271271 let raw_query = dq.q.clone().unwrap_or_default();
272272 crate::search::in_repo(state, viewer, jar, uri, &ctx, &raw_query, header).await
273273 }
@@ -324,8 +324,7 @@ async fn repo_home(
324324 let readme = load_readme(state, &ctx.repo.id, &rev, &entries).await;
325325 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
326326 html! {
327- (repo_header(&ctx, Tab::Code, &rev_name))
328- (clone_widget(state, &ctx))
327+ (repo_header(state, &ctx, Tab::Code, &rev_name))
329328 form class="search-row repo-codesearch" method="get" action=(format!("{base}/-/search")) {
330329 input type="search" name="q" placeholder="Search this repository…"
331330 aria-label="Search this repository";
@@ -374,7 +373,7 @@ async fn load_readme(
374373 /// The "empty repository" landing with clone instructions.
375374 fn empty_repo_body(state: &AppState, ctx: &RepoCtx) -> Markup {
376375 html! {
377- (repo_header(ctx, Tab::Code, &ctx.repo.default_branch))
376+ (repo_header(state, ctx, Tab::Code, &ctx.repo.default_branch))
378377 div class="card" {
379378 h2 { "This repository is empty" }
380379 p class="muted" { "Push a commit to get started:" }
@@ -410,7 +409,7 @@ async fn tree_view(
410409 .await?;
411410
412411 let body = html! {
413- (repo_header(&ctx, Tab::Code, &rev_name))
412+ (repo_header(state, &ctx, Tab::Code, &rev_name))
414413 (breadcrumb(&ctx.owner.username, &ctx.repo.path, &rev_name, &path))
415414 (tree_table(&ctx.owner.username, &ctx.repo.path, &rev_name, &path, &entries))
416415 };
@@ -464,7 +463,7 @@ async fn blob_view(
464463 source_view,
465464 );
466465 let body = html! {
467- (repo_header(&ctx, Tab::Code, &rev_name))
466+ (repo_header(state, &ctx, Tab::Code, &rev_name))
468467 (breadcrumb(&ctx.owner.username, &ctx.repo.path, &rev_name, &path))
469468 (content_body)
470469 };
@@ -612,7 +611,7 @@ async fn branches_view(
612611 ) -> AppResult<Response> {
613612 let branches = git_read(state, &ctx.repo.id, git::Repo::branches).await?;
614613 let body = html! {
615- (repo_header(&ctx, Tab::Branches, &ctx.repo.default_branch))
614+ (repo_header(state, &ctx, Tab::Branches, &ctx.repo.default_branch))
616615 h2 { "Branches" }
617616 table class="list" {
618617 thead { tr { th { "Branch" } th { "Ahead" } th { "Behind" } th { "Updated" } } }
@@ -647,7 +646,7 @@ async fn tags_view(
647646 ) -> AppResult<Response> {
648647 let tags = git_read(state, &ctx.repo.id, git::Repo::tags).await?;
649648 let body = html! {
650- (repo_header(&ctx, Tab::Tags, &ctx.repo.default_branch))
649+ (repo_header(state, &ctx, Tab::Tags, &ctx.repo.default_branch))
651650 h2 { "Tags" }
652651 table class="list" {
653652 thead { tr { th { "Tag" } th { "Commit" } th { "Kind" } } }
@@ -704,21 +703,30 @@ async fn commits_view(
704703 })
705704 .await?;
706705
706+ let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
707+ let now = crate::now_ms();
707708 let body = html! {
708- (repo_header(&ctx, Tab::Code, &rev_name))
709+ (repo_header(state, &ctx, Tab::Code, &rev_name))
709710 h2 { "Commits" }
710- ul class="repo-list" {
711+ ul class="commit-list card" {
711712 @for (commit, sig) in commits.iter().zip(states.iter()) {
712- li {
713- div class="name" {
714- a href={ "/" (ctx.owner.username) "/" (ctx.repo.path) "/-/commit/" (commit.oid) } {
715- (commit.summary)
713+ @let commit_url = format!("{base}/-/commit/{}", commit.oid);
714+ li class="commit-row" {
715+ div class="commit-row-body" {
716+ a class="commit-row-msg" href=(commit_url) { (commit.summary) }
717+ div class="commit-row-meta muted" {
718+ span { (commit.author.name) }
719+ span { "·" }
720+ span { (crate::activity::fmt_relative(commit.author.time_ms, now)) }
716721 }
717- " " (signature_badge(sig))
718722 }
719- p class="muted" {
720- span class="mono" { (commit.oid.short()) }
721- " · " (commit.author.name) " · " (fmt_date(commit.author.time_ms))
723+ div class="commit-row-actions" {
724+ (signature_badge(sig))
725+ a class="mono commit-sha-pill" href=(commit_url) { (commit.oid.short()) }
726+ button class="icon-btn commit-copy" type="button"
727+ data-clipboard=(commit.oid.to_string()) aria-label="Copy commit id" {
728+ (icon(Icon::Copy))
729+ }
722730 }
723731 }
724732 }
@@ -743,6 +751,18 @@ async fn commit_view(
743751 let keys = signing_keys(state.store.all_keys().await?);
744752 let (detail, files, sig) = load_commit(state, &ctx.repo.id, sha, keys).await?;
745753
754+ // Resolve the signer's username for a verified signature's detail line.
755+ let signer = match &sig {
756+ git::SignatureState::Verified { user_id, .. } => state
757+ .store
758+ .user_by_id(user_id)
759+ .await
760+ .ok()
761+ .flatten()
762+ .map(|u| u.username),
763+ _ => None,
764+ };
765+
746766 // View mode: an explicit `?view=` wins and is remembered; else the cookie;
747767 // else unified.
748768 let chosen = dq.view.clone().filter(|v| v == "split" || v == "unified");
@@ -756,8 +776,9 @@ async fn commit_view(
756776 let repo_base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
757777
758778 let body = html! {
759- (repo_header(&ctx, Tab::Code, &ctx.repo.default_branch))
779+ (repo_header(state, &ctx, Tab::Code, &ctx.repo.default_branch))
760780 (commit_meta(&detail, &sig))
781+ (signature_detail(&sig, signer.as_deref()))
761782 div class="diff-toolbar" {
762783 span {
763784 (files.len()) " files changed · "
@@ -1218,8 +1239,9 @@ fn mastodon_link(handle: &str) -> Markup {
12181239 html! { span { (icon(Icon::Mastodon)) span { (handle) } } }
12191240 }
12201241
1221-/// The repo sub-header: slug, tabs, and clone URLs.
1222-fn repo_header(ctx: &RepoCtx, active: Tab, rev: &str) -> Markup {
1242+/// The repo sub-header: slug, description, and a tab strip sharing its line with
1243+/// the branch / commits / clone actions.
1244+fn repo_header(state: &AppState, ctx: &RepoCtx, active: Tab, rev: &str) -> Markup {
12231245 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
12241246 let tab = |t: Tab, label: &str, glyph: Icon, href: String| {
12251247 html! {
@@ -1237,30 +1259,46 @@ fn repo_header(ctx: &RepoCtx, active: Tab, rev: &str) -> Markup {
12371259 @if ctx.repo.is_private { span class="badge badge-private" { "private" } }
12381260 }
12391261 @if let Some(desc) = &ctx.repo.description { p class="muted" { (desc) } }
1240- nav class="tabs repo-tabs" {
1241- (tab(Tab::Code, "Code", Icon::File, base.clone()))
1242- (tab(Tab::Branches, "Branches", Icon::GitBranch, format!("{base}/-/branches")))
1243- (tab(Tab::Tags, "Tags", Icon::Box, format!("{base}/-/tags")))
1244- }
1245- div class="repo-toolbar" {
1246- a class="btn" href=(format!("{base}/-/branches")) {
1247- (icon(Icon::GitBranch)) span { (rev) }
1262+ div class="repo-nav" {
1263+ nav class="tabs repo-tabs" {
1264+ (tab(Tab::Code, "Code", Icon::File, base.clone()))
1265+ (tab(Tab::Branches, "Branches", Icon::GitBranch, format!("{base}/-/branches")))
1266+ (tab(Tab::Tags, "Tags", Icon::Box, format!("{base}/-/tags")))
1267+ }
1268+ div class="repo-actions" {
1269+ a class="btn" href=(format!("{base}/-/branches")) {
1270+ (icon(Icon::GitBranch)) span { (rev) }
1271+ }
1272+ a class="btn" href=(format!("{base}/-/commits/{rev}")) { "Commits" }
1273+ (clone_menu(state, ctx))
12481274 }
1249- a class="btn" href=(format!("{base}/-/commits/{rev}")) { "Commits" }
12501275 }
12511276 }
12521277 }
12531278 }
12541279
1255-/// The HTTPS clone widget, shown only on the repo overview.
1256-fn clone_widget(state: &AppState, ctx: &RepoCtx) -> Markup {
1280+/// The Clone dropdown: a no-JS `details` menu offering the HTTPS and SSH URLs.
1281+fn clone_menu(state: &AppState, ctx: &RepoCtx) -> Markup {
1282+ let https = clone_https(state, ctx);
1283+ let ssh = clone_ssh(state, ctx);
1284+ let field = |label: &str, url: &str| {
1285+ html! {
1286+ div class="clone-field" {
1287+ span class="clone-field-label muted" { (label) }
1288+ div class="clone-input" {
1289+ input type="text" readonly value=(url) aria-label=(format!("{label} clone URL"));
1290+ button class="btn" type="button" data-clipboard=(url)
1291+ aria-label=(format!("Copy {label} URL")) { (icon(Icon::Copy)) }
1292+ }
1293+ }
1294+ }
1295+ };
12571296 html! {
1258- div class="clone-row" {
1259- span class="muted clone-label" { "Clone" }
1260- input type="text" readonly value=(clone_https(state, ctx))
1261- aria-label="HTTPS clone URL";
1262- button class="btn" type="button" data-clipboard=(clone_https(state, ctx)) {
1263- (icon(Icon::Copy)) span { "Copy" }
1297+ details class="clone-menu" {
1298+ summary class="btn btn-primary" { (icon(Icon::Download)) span { "Clone" } }
1299+ div class="clone-pop card" {
1300+ (field("HTTPS", &https))
1301+ (field("SSH", &ssh))
12641302 }
12651303 }
12661304 }
@@ -1276,6 +1314,18 @@ fn clone_https(state: &AppState, ctx: &RepoCtx) -> String {
12761314 )
12771315 }
12781316
1317+/// The SSH clone URL — scp-style on the default port, `ssh://` otherwise.
1318+fn clone_ssh(state: &AppState, ctx: &RepoCtx) -> String {
1319+ let host = &state.config.ssh.clone_host;
1320+ let port = state.config.ssh.clone_port;
1321+ let (owner, repo) = (&ctx.owner.username, &ctx.repo.path);
1322+ if port == 22 {
1323+ format!("git@{host}:{owner}/{repo}.git")
1324+ } else {
1325+ format!("ssh://git@{host}:{port}/{owner}/{repo}.git")
1326+ }
1327+}
1328+
12791329 /// A path breadcrumb for tree/blob views.
12801330 fn breadcrumb(owner: &str, repo_path: &str, rev: &str, path: &str) -> Markup {
12811331 let root = format!("/{owner}/{repo_path}/-/tree/{rev}");
@@ -1376,6 +1426,44 @@ fn signature_badge(state: &git::SignatureState) -> Markup {
13761426 }
13771427 }
13781428
1429+/// 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.
1431+fn 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 {
1437+ git::SignatureState::Unsigned => html! {},
1438+ git::SignatureState::Verified {
1439+ kind,
1440+ fingerprint,
1441+ user_id,
1442+ ..
1443+ } => html! {
1444+ div class="sig-detail sig-verified" {
1445+ span class="sig-signer" {
1446+ "Signed by " strong { (signer.unwrap_or(user_id)) }
1447+ }
1448+ span class="sig-fp mono" {
1449+ (kind_label(*kind)) " key fingerprint: " (fingerprint)
1450+ }
1451+ }
1452+ },
1453+ git::SignatureState::UnknownKey { kind, fingerprint } => html! {
1454+ div class="sig-detail sig-unverified" {
1455+ span { "Signed by an unregistered " (kind_label(*kind)) " key" }
1456+ span class="sig-fp mono" { (fingerprint) }
1457+ }
1458+ },
1459+ git::SignatureState::Invalid { reason, .. } => html! {
1460+ div class="sig-detail sig-unverified" {
1461+ span { "Signature could not be verified: " (reason) }
1462+ }
1463+ },
1464+ }
1465+}
1466+
13791467 /// Map registered keys to the verification input type.
13801468 fn signing_keys(keys: Vec<model::Key>) -> Vec<git::SigningKey> {
13811469 keys.into_iter()
crates/web/src/search.rs +14 −10
@@ -445,23 +445,27 @@ fn repo_content(owner: &str, repo_path: &str, rev: &str, files: &[FileResult]) -
445445 /// One file's matches. `blob_base` links each line to the blob (with `#L{n}`).
446446 fn file_result(blob_base: &str, in_repo: Option<&str>, file: &FileResult) -> Markup {
447447 html! {
448- div class="card" style="margin-bottom:0.5rem" {
449- div class="mono" {
448+ div class="search-hit card" {
449+ div class="search-hit-path mono" {
450450 @if in_repo.is_some() {
451451 a href=(format!("{blob_base}/{}", file.path)) { (file.path) }
452452 } @else {
453453 (file.path)
454454 }
455455 }
456- @for m in &file.lines {
457- div class="mono" style="font-size:0.85em" {
458- @if in_repo.is_some() {
459- a href=(format!("{blob_base}/{}#L{}", file.path, m.line))
460- class="muted" { (m.line) }
461- } @else {
462- span class="muted" { (m.line) }
456+ @if !file.lines.is_empty() {
457+ div class="search-hit-lines" {
458+ @for m in &file.lines {
459+ div class="search-hit-line" {
460+ @if in_repo.is_some() {
461+ a href=(format!("{blob_base}/{}#L{}", file.path, m.line))
462+ class="lineno" { (m.line) }
463+ } @else {
464+ span class="lineno" { (m.line) }
465+ }
466+ code { (m.text) }
467+ }
463468 }
464- " " (m.text)
465469 }
466470 }
467471 }