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 {
124 align-items: center;124 align-items: center;
125 gap: 0.25rem;125 gap: 0.25rem;
126 flex: 1;126 flex: 1;
127 max-width: 34rem;127 max-width: 28rem;
128 margin: 0 auto;128 margin-left: auto;
129}129}
130.topbar-search input[type="search"] {130.topbar-search input[type="search"] {
131 width: 100%;131 width: 100%;
@@ -676,6 +676,42 @@ select:focus {
676 min-width: 15rem;676 min-width: 15rem;
677}677}
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
679/* ---- Tables ---- */715/* ---- Tables ---- */
680table.list {716table.list {
681 width: 100%;717 width: 100%;
@@ -838,7 +874,8 @@ pre.code {
838 color: var(--fb-fg-muted);874 color: var(--fb-fg-muted);
839}875}
840.repo-slug .badge {876.repo-slug .badge {
841 font-size: 0.6em;877 font-size: 0.7rem;
878 padding: 0.12rem 0.55rem;
842 align-self: center;879 align-self: center;
843}880}
844/* Tab strips (repo sub-nav, explore sub-nav): active-pill style. */881/* Tab strips (repo sub-nav, explore sub-nav): active-pill style. */
@@ -873,29 +910,64 @@ pre.code {
873.subnav {910.subnav {
874 justify-content: center;911 justify-content: center;
875}912}
876.repo-toolbar {913/* Tabs share their line with the branch/commits/clone actions. */
914.repo-nav {
877 display: flex;915 display: flex;
878 gap: 0.5rem;
879 align-items: center;916 align-items: center;
917 justify-content: space-between;
918 gap: 1rem;
880 flex-wrap: wrap;919 flex-wrap: wrap;
881 margin-top: 0.5rem;
882}920}
883.repo-codesearch {921.repo-nav .repo-tabs {
884 margin: 0 0 1.25rem;922 margin: 0.75rem 0 0;
923 flex: 1;
885}924}
886.clone-row {925.repo-actions {
887 display: flex;926 display: flex;
888 gap: 0.5rem;
889 align-items: center;927 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);
891}957}
892.clone-label {958.clone-field-label {
959 display: block;
960 margin-bottom: 0.3rem;
961 font-size: 0.78em;
893 font-weight: 600;962 font-weight: 600;
894}963}
895.clone-row input {964.clone-input {
896 max-width: 26rem;965 display: flex;
966 gap: 0.4rem;
967}
968.clone-input input {
897 font-family: var(--fb-font-mono);969 font-family: var(--fb-font-mono);
898 font-size: 0.85em;970 font-size: 0.8em;
899}971}
900.breadcrumb {972.breadcrumb {
901 margin-bottom: 0.75rem;973 margin-bottom: 0.75rem;
@@ -994,6 +1066,93 @@ table.blob tr:target {
994 background: var(--fb-diff-add-bg);1066 background: var(--fb-diff-add-bg);
995}1067}
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
997/* ---- Commit view & diffs ---- */1156/* ---- Commit view & diffs ---- */
998.commit-meta {1157.commit-meta {
999 margin-bottom: 1rem;1158 margin-bottom: 1rem;
@@ -1015,6 +1174,10 @@ table.blob tr:target {
1015 gap: 0.5rem;1174 gap: 0.5rem;
1016 margin-bottom: 0.75rem;1175 margin-bottom: 0.75rem;
1017}1176}
1177.diff-toggle {
1178 display: inline-flex;
1179 gap: 0.5rem;
1180}
1018.diff-toggle .btn {1181.diff-toggle .btn {
1019 padding: 0.2rem 0.6rem;1182 padding: 0.2rem 0.6rem;
1020}1183}
@@ -1143,14 +1306,10 @@ td.diff-empty {
1143 justify-content: center;1306 justify-content: center;
1144 min-width: 44px;1307 min-width: 44px;
1145 }1308 }
1146 .repo-tabs,1309 .repo-tabs {
1147 .clone-row {
1148 overflow-x: auto;1310 overflow-x: auto;
1149 -webkit-overflow-scrolling: touch;1311 -webkit-overflow-scrolling: touch;
1150 }1312 }
1151 .clone-row input {
1152 min-width: 0;
1153 }
1154 /* Blob and diff tables scroll within their own box. */1313 /* Blob and diff tables scroll within their own box. */
1155 table.blob,1314 table.blob,
1156 table.diff {1315 table.diff {
crates/web/src/activity.rs +1 −1
@@ -252,7 +252,7 @@ impl Activity {
252252
253/// Format an epoch-millisecond timestamp relative to `now_ms`, e.g. `3 hours253/// Format an epoch-millisecond timestamp relative to `now_ms`, e.g. `3 hours
254/// ago`, falling back to an absolute date past a month.254/// ago`, falling back to an absolute date past a month.
255fn fmt_relative(ms: i64, now_ms: i64) -> String {255pub(crate) fn fmt_relative(ms: i64, now_ms: i64) -> String {
256 let secs = (now_ms - ms).div_euclid(1000).max(0);256 let secs = (now_ms - ms).div_euclid(1000).max(0);
257 let mins = secs / 60;257 let mins = secs / 60;
258 let hours = mins / 60;258 let hours = mins / 60;
crates/web/src/icons.rs +4 −4
@@ -14,7 +14,6 @@ use maud::{Markup, PreEscaped, html};
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum Icon {15pub enum Icon {
16 PanelLeft,16 PanelLeft,
17 House,
18 Folder,17 Folder,
19 File,18 File,
20 GitBranch,19 GitBranch,
@@ -30,6 +29,7 @@ pub enum Icon {
30 Compass,29 Compass,
31 MapPin,30 MapPin,
32 Globe,31 Globe,
32 Download,
33 Github,33 Github,
34 Mastodon,34 Mastodon,
35}35}
@@ -46,9 +46,6 @@ impl Icon {
46 Icon::PanelLeft => {46 Icon::PanelLeft => {
47 r#"<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/>"#47 r#"<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/>"#
48 }48 }
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 }
52 Icon::Folder => {49 Icon::Folder => {
53 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"/>"#50 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"/>"#
54 }51 }
@@ -92,6 +89,9 @@ impl Icon {
92 Icon::Globe => {89 Icon::Globe => {
93 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"/>"#90 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"/>"#
94 }91 }
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 }
95 Icon::Github => {95 Icon::Github => {
96 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"/>"#96 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"/>"#
97 }97 }
crates/web/src/layout.rs +0 −1
@@ -76,7 +76,6 @@ fn topbar(chrome: &Chrome) -> Markup {
76 input type="search" name="q" placeholder="Search…" aria-label="Search";76 input type="search" name="q" placeholder="Search…" aria-label="Search";
77 button class="icon-btn" type="submit" aria-label="Search" { (icon(Icon::Search)) }77 button class="icon-btn" type="submit" aria-label="Search" { (icon(Icon::Search)) }
78 }78 }
79 a class="icon-btn" href="/" aria-label="Home" { (icon(Icon::House)) }
80 }79 }
81 }80 }
82}81}
crates/web/src/lib.rs +8 −0
@@ -107,6 +107,14 @@ impl AppState {
107 }107 }
108}108}
109109
110/// The current time in Unix milliseconds UTC.
111pub(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
110/// Build the application router with all middleware wired.118/// Build the application router with all middleware wired.
111pub fn build_router(state: AppState) -> Router {119pub fn build_router(state: AppState) -> Router {
112 let x_request_id = HeaderName::from_static("x-request-id");120 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(
267 "branches" => branches_view(state, viewer, jar, uri, ctx).await,267 "branches" => branches_view(state, viewer, jar, uri, ctx).await,
268 "tags" => tags_view(state, viewer, jar, uri, ctx).await,268 "tags" => tags_view(state, viewer, jar, uri, ctx).await,
269 "search" => {269 "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);
271 let raw_query = dq.q.clone().unwrap_or_default();271 let raw_query = dq.q.clone().unwrap_or_default();
272 crate::search::in_repo(state, viewer, jar, uri, &ctx, &raw_query, header).await272 crate::search::in_repo(state, viewer, jar, uri, &ctx, &raw_query, header).await
273 }273 }
@@ -324,8 +324,7 @@ async fn repo_home(
324 let readme = load_readme(state, &ctx.repo.id, &rev, &entries).await;324 let readme = load_readme(state, &ctx.repo.id, &rev, &entries).await;
325 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);325 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
326 html! {326 html! {
327 (repo_header(&ctx, Tab::Code, &rev_name))327 (repo_header(state, &ctx, Tab::Code, &rev_name))
328 (clone_widget(state, &ctx))
329 form class="search-row repo-codesearch" method="get" action=(format!("{base}/-/search")) {328 form class="search-row repo-codesearch" method="get" action=(format!("{base}/-/search")) {
330 input type="search" name="q" placeholder="Search this repository…"329 input type="search" name="q" placeholder="Search this repository…"
331 aria-label="Search this repository";330 aria-label="Search this repository";
@@ -374,7 +373,7 @@ async fn load_readme(
374/// The "empty repository" landing with clone instructions.373/// The "empty repository" landing with clone instructions.
375fn empty_repo_body(state: &AppState, ctx: &RepoCtx) -> Markup {374fn empty_repo_body(state: &AppState, ctx: &RepoCtx) -> Markup {
376 html! {375 html! {
377 (repo_header(ctx, Tab::Code, &ctx.repo.default_branch))376 (repo_header(state, ctx, Tab::Code, &ctx.repo.default_branch))
378 div class="card" {377 div class="card" {
379 h2 { "This repository is empty" }378 h2 { "This repository is empty" }
380 p class="muted" { "Push a commit to get started:" }379 p class="muted" { "Push a commit to get started:" }
@@ -410,7 +409,7 @@ async fn tree_view(
410 .await?;409 .await?;
411410
412 let body = html! {411 let body = html! {
413 (repo_header(&ctx, Tab::Code, &rev_name))412 (repo_header(state, &ctx, Tab::Code, &rev_name))
414 (breadcrumb(&ctx.owner.username, &ctx.repo.path, &rev_name, &path))413 (breadcrumb(&ctx.owner.username, &ctx.repo.path, &rev_name, &path))
415 (tree_table(&ctx.owner.username, &ctx.repo.path, &rev_name, &path, &entries))414 (tree_table(&ctx.owner.username, &ctx.repo.path, &rev_name, &path, &entries))
416 };415 };
@@ -464,7 +463,7 @@ async fn blob_view(
464 source_view,463 source_view,
465 );464 );
466 let body = html! {465 let body = html! {
467 (repo_header(&ctx, Tab::Code, &rev_name))466 (repo_header(state, &ctx, Tab::Code, &rev_name))
468 (breadcrumb(&ctx.owner.username, &ctx.repo.path, &rev_name, &path))467 (breadcrumb(&ctx.owner.username, &ctx.repo.path, &rev_name, &path))
469 (content_body)468 (content_body)
470 };469 };
@@ -612,7 +611,7 @@ async fn branches_view(
612) -> AppResult<Response> {611) -> AppResult<Response> {
613 let branches = git_read(state, &ctx.repo.id, git::Repo::branches).await?;612 let branches = git_read(state, &ctx.repo.id, git::Repo::branches).await?;
614 let body = html! {613 let body = html! {
615 (repo_header(&ctx, Tab::Branches, &ctx.repo.default_branch))614 (repo_header(state, &ctx, Tab::Branches, &ctx.repo.default_branch))
616 h2 { "Branches" }615 h2 { "Branches" }
617 table class="list" {616 table class="list" {
618 thead { tr { th { "Branch" } th { "Ahead" } th { "Behind" } th { "Updated" } } }617 thead { tr { th { "Branch" } th { "Ahead" } th { "Behind" } th { "Updated" } } }
@@ -647,7 +646,7 @@ async fn tags_view(
647) -> AppResult<Response> {646) -> AppResult<Response> {
648 let tags = git_read(state, &ctx.repo.id, git::Repo::tags).await?;647 let tags = git_read(state, &ctx.repo.id, git::Repo::tags).await?;
649 let body = html! {648 let body = html! {
650 (repo_header(&ctx, Tab::Tags, &ctx.repo.default_branch))649 (repo_header(state, &ctx, Tab::Tags, &ctx.repo.default_branch))
651 h2 { "Tags" }650 h2 { "Tags" }
652 table class="list" {651 table class="list" {
653 thead { tr { th { "Tag" } th { "Commit" } th { "Kind" } } }652 thead { tr { th { "Tag" } th { "Commit" } th { "Kind" } } }
@@ -704,21 +703,30 @@ async fn commits_view(
704 })703 })
705 .await?;704 .await?;
706705
706 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
707 let now = crate::now_ms();
707 let body = html! {708 let body = html! {
708 (repo_header(&ctx, Tab::Code, &rev_name))709 (repo_header(state, &ctx, Tab::Code, &rev_name))
709 h2 { "Commits" }710 h2 { "Commits" }
710 ul class="repo-list" {711 ul class="commit-list card" {
711 @for (commit, sig) in commits.iter().zip(states.iter()) {712 @for (commit, sig) in commits.iter().zip(states.iter()) {
712 li {713 @let commit_url = format!("{base}/-/commit/{}", commit.oid);
713 div class="name" {714 li class="commit-row" {
714 a href={ "/" (ctx.owner.username) "/" (ctx.repo.path) "/-/commit/" (commit.oid) } {715 div class="commit-row-body" {
715 (commit.summary)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)) }
716 }721 }
717 " " (signature_badge(sig))
718 }722 }
719 p class="muted" {723 div class="commit-row-actions" {
720 span class="mono" { (commit.oid.short()) }724 (signature_badge(sig))
721 " · " (commit.author.name) " · " (fmt_date(commit.author.time_ms))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 }
722 }730 }
723 }731 }
724 }732 }
@@ -743,6 +751,18 @@ async fn commit_view(
743 let keys = signing_keys(state.store.all_keys().await?);751 let keys = signing_keys(state.store.all_keys().await?);
744 let (detail, files, sig) = load_commit(state, &ctx.repo.id, sha, keys).await?;752 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
746 // View mode: an explicit `?view=` wins and is remembered; else the cookie;766 // View mode: an explicit `?view=` wins and is remembered; else the cookie;
747 // else unified.767 // else unified.
748 let chosen = dq.view.clone().filter(|v| v == "split" || v == "unified");768 let chosen = dq.view.clone().filter(|v| v == "split" || v == "unified");
@@ -756,8 +776,9 @@ async fn commit_view(
756 let repo_base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);776 let repo_base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
757777
758 let body = html! {778 let body = html! {
759 (repo_header(&ctx, Tab::Code, &ctx.repo.default_branch))779 (repo_header(state, &ctx, Tab::Code, &ctx.repo.default_branch))
760 (commit_meta(&detail, &sig))780 (commit_meta(&detail, &sig))
781 (signature_detail(&sig, signer.as_deref()))
761 div class="diff-toolbar" {782 div class="diff-toolbar" {
762 span {783 span {
763 (files.len()) " files changed · "784 (files.len()) " files changed · "
@@ -1218,8 +1239,9 @@ fn mastodon_link(handle: &str) -> Markup {
1218 html! { span { (icon(Icon::Mastodon)) span { (handle) } } }1239 html! { span { (icon(Icon::Mastodon)) span { (handle) } } }
1219}1240}
12201241
1221/// The repo sub-header: slug, tabs, and clone URLs.1242/// The repo sub-header: slug, description, and a tab strip sharing its line with
1222fn repo_header(ctx: &RepoCtx, active: Tab, rev: &str) -> Markup {1243/// the branch / commits / clone actions.
1244fn repo_header(state: &AppState, ctx: &RepoCtx, active: Tab, rev: &str) -> Markup {
1223 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);1245 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
1224 let tab = |t: Tab, label: &str, glyph: Icon, href: String| {1246 let tab = |t: Tab, label: &str, glyph: Icon, href: String| {
1225 html! {1247 html! {
@@ -1237,30 +1259,46 @@ fn repo_header(ctx: &RepoCtx, active: Tab, rev: &str) -> Markup {
1237 @if ctx.repo.is_private { span class="badge badge-private" { "private" } }1259 @if ctx.repo.is_private { span class="badge badge-private" { "private" } }
1238 }1260 }
1239 @if let Some(desc) = &ctx.repo.description { p class="muted" { (desc) } }1261 @if let Some(desc) = &ctx.repo.description { p class="muted" { (desc) } }
1240 nav class="tabs repo-tabs" {1262 div class="repo-nav" {
1241 (tab(Tab::Code, "Code", Icon::File, base.clone()))1263 nav class="tabs repo-tabs" {
1242 (tab(Tab::Branches, "Branches", Icon::GitBranch, format!("{base}/-/branches")))1264 (tab(Tab::Code, "Code", Icon::File, base.clone()))
1243 (tab(Tab::Tags, "Tags", Icon::Box, format!("{base}/-/tags")))1265 (tab(Tab::Branches, "Branches", Icon::GitBranch, format!("{base}/-/branches")))
1244 }1266 (tab(Tab::Tags, "Tags", Icon::Box, format!("{base}/-/tags")))
1245 div class="repo-toolbar" {1267 }
1246 a class="btn" href=(format!("{base}/-/branches")) {1268 div class="repo-actions" {
1247 (icon(Icon::GitBranch)) span { (rev) }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))
1248 }1274 }
1249 a class="btn" href=(format!("{base}/-/commits/{rev}")) { "Commits" }
1250 }1275 }
1251 }1276 }
1252 }1277 }
1253}1278}
12541279
1255/// The HTTPS clone widget, shown only on the repo overview.1280/// The Clone dropdown: a no-JS `details` menu offering the HTTPS and SSH URLs.
1256fn clone_widget(state: &AppState, ctx: &RepoCtx) -> Markup {1281fn 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 };
1257 html! {1296 html! {
1258 div class="clone-row" {1297 details class="clone-menu" {
1259 span class="muted clone-label" { "Clone" }1298 summary class="btn btn-primary" { (icon(Icon::Download)) span { "Clone" } }
1260 input type="text" readonly value=(clone_https(state, ctx))1299 div class="clone-pop card" {
1261 aria-label="HTTPS clone URL";1300 (field("HTTPS", &https))
1262 button class="btn" type="button" data-clipboard=(clone_https(state, ctx)) {1301 (field("SSH", &ssh))
1263 (icon(Icon::Copy)) span { "Copy" }
1264 }1302 }
1265 }1303 }
1266 }1304 }
@@ -1276,6 +1314,18 @@ fn clone_https(state: &AppState, ctx: &RepoCtx) -> String {
1276 )1314 )
1277}1315}
12781316
1317/// The SSH clone URL — scp-style on the default port, `ssh://` otherwise.
1318fn 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
1279/// A path breadcrumb for tree/blob views.1329/// A path breadcrumb for tree/blob views.
1280fn breadcrumb(owner: &str, repo_path: &str, rev: &str, path: &str) -> Markup {1330fn breadcrumb(owner: &str, repo_path: &str, rev: &str, path: &str) -> Markup {
1281 let root = format!("/{owner}/{repo_path}/-/tree/{rev}");1331 let root = format!("/{owner}/{repo_path}/-/tree/{rev}");
@@ -1376,6 +1426,44 @@ fn signature_badge(state: &git::SignatureState) -> Markup {
1376 }1426 }
1377}1427}
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.
1431fn 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
1379/// Map registered keys to the verification input type.1467/// Map registered keys to the verification input type.
1380fn signing_keys(keys: Vec<model::Key>) -> Vec<git::SigningKey> {1468fn signing_keys(keys: Vec<model::Key>) -> Vec<git::SigningKey> {
1381 keys.into_iter()1469 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]) -
445/// One file's matches. `blob_base` links each line to the blob (with `#L{n}`).445/// One file's matches. `blob_base` links each line to the blob (with `#L{n}`).
446fn file_result(blob_base: &str, in_repo: Option<&str>, file: &FileResult) -> Markup {446fn file_result(blob_base: &str, in_repo: Option<&str>, file: &FileResult) -> Markup {
447 html! {447 html! {
448 div class="card" style="margin-bottom:0.5rem" {448 div class="search-hit card" {
449 div class="mono" {449 div class="search-hit-path mono" {
450 @if in_repo.is_some() {450 @if in_repo.is_some() {
451 a href=(format!("{blob_base}/{}", file.path)) { (file.path) }451 a href=(format!("{blob_base}/{}", file.path)) { (file.path) }
452 } @else {452 } @else {
453 (file.path)453 (file.path)
454 }454 }
455 }455 }
456 @for m in &file.lines {456 @if !file.lines.is_empty() {
457 div class="mono" style="font-size:0.85em" {457 div class="search-hit-lines" {
458 @if in_repo.is_some() {458 @for m in &file.lines {
459 a href=(format!("{blob_base}/{}#L{}", file.path, m.line))459 div class="search-hit-line" {
460 class="muted" { (m.line) }460 @if in_repo.is_some() {
461 } @else {461 a href=(format!("{blob_base}/{}#L{}", file.path, m.line))
462 span class="muted" { (m.line) }462 class="lineno" { (m.line) }
463 } @else {
464 span class="lineno" { (m.line) }
465 }
466 code { (m.text) }
467 }
463 }468 }
464 " " (m.text)
465 }469 }
466 }470 }
467 }471 }