Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
assets/base.css +179 −20
| @@ -124,8 +124,8 @@ code, pre, .mono { | |||
| 124 | 124 | align-items: center; | |
| 125 | 125 | gap: 0.25rem; | |
| 126 | 126 | flex: 1; | |
| 127 | - | max-width: 34rem; | |
| 128 | - | margin: 0 auto; | |
| 127 | + | max-width: 28rem; | |
| 128 | + | margin-left: auto; | |
| 129 | 129 | } | |
| 130 | 130 | .topbar-search input[type="search"] { | |
| 131 | 131 | width: 100%; | |
| @@ -676,6 +676,42 @@ select:focus { | |||
| 676 | 676 | min-width: 15rem; | |
| 677 | 677 | } | |
| 678 | 678 | ||
| 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 | 715 | /* ---- Tables ---- */ | |
| 680 | 716 | table.list { | |
| 681 | 717 | width: 100%; | |
| @@ -838,7 +874,8 @@ pre.code { | |||
| 838 | 874 | color: var(--fb-fg-muted); | |
| 839 | 875 | } | |
| 840 | 876 | .repo-slug .badge { | |
| 841 | - | font-size: 0.6em; | |
| 877 | + | font-size: 0.7rem; | |
| 878 | + | padding: 0.12rem 0.55rem; | |
| 842 | 879 | align-self: center; | |
| 843 | 880 | } | |
| 844 | 881 | /* Tab strips (repo sub-nav, explore sub-nav): active-pill style. */ | |
| @@ -873,29 +910,64 @@ pre.code { | |||
| 873 | 910 | .subnav { | |
| 874 | 911 | justify-content: center; | |
| 875 | 912 | } | |
| 876 | - | .repo-toolbar { | |
| 913 | + | /* Tabs share their line with the branch/commits/clone actions. */ | |
| 914 | + | .repo-nav { | |
| 877 | 915 | display: flex; | |
| 878 | - | gap: 0.5rem; | |
| 879 | 916 | align-items: center; | |
| 917 | + | justify-content: space-between; | |
| 918 | + | gap: 1rem; | |
| 880 | 919 | flex-wrap: wrap; | |
| 881 | - | margin-top: 0.5rem; | |
| 882 | 920 | } | |
| 883 | - | .repo-codesearch { | |
| 884 | - | margin: 0 0 1.25rem; | |
| 921 | + | .repo-nav .repo-tabs { | |
| 922 | + | margin: 0.75rem 0 0; | |
| 923 | + | flex: 1; | |
| 885 | 924 | } | |
| 886 | - | .clone-row { | |
| 925 | + | .repo-actions { | |
| 887 | 926 | display: flex; | |
| 888 | - | gap: 0.5rem; | |
| 889 | 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 | 962 | font-weight: 600; | |
| 894 | 963 | } | |
| 895 | - | .clone-row input { | |
| 896 | - | max-width: 26rem; | |
| 964 | + | .clone-input { | |
| 965 | + | display: flex; | |
| 966 | + | gap: 0.4rem; | |
| 967 | + | } | |
| 968 | + | .clone-input input { | |
| 897 | 969 | font-family: var(--fb-font-mono); | |
| 898 | - | font-size: 0.85em; | |
| 970 | + | font-size: 0.8em; | |
| 899 | 971 | } | |
| 900 | 972 | .breadcrumb { | |
| 901 | 973 | margin-bottom: 0.75rem; | |
| @@ -994,6 +1066,93 @@ table.blob tr:target { | |||
| 994 | 1066 | background: var(--fb-diff-add-bg); | |
| 995 | 1067 | } | |
| 996 | 1068 | ||
| 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 | 1156 | /* ---- Commit view & diffs ---- */ | |
| 998 | 1157 | .commit-meta { | |
| 999 | 1158 | margin-bottom: 1rem; | |
| @@ -1015,6 +1174,10 @@ table.blob tr:target { | |||
| 1015 | 1174 | gap: 0.5rem; | |
| 1016 | 1175 | margin-bottom: 0.75rem; | |
| 1017 | 1176 | } | |
| 1177 | + | .diff-toggle { | |
| 1178 | + | display: inline-flex; | |
| 1179 | + | gap: 0.5rem; | |
| 1180 | + | } | |
| 1018 | 1181 | .diff-toggle .btn { | |
| 1019 | 1182 | padding: 0.2rem 0.6rem; | |
| 1020 | 1183 | } | |
| @@ -1143,14 +1306,10 @@ td.diff-empty { | |||
| 1143 | 1306 | justify-content: center; | |
| 1144 | 1307 | min-width: 44px; | |
| 1145 | 1308 | } | |
| 1146 | - | .repo-tabs, | |
| 1147 | - | .clone-row { | |
| 1309 | + | .repo-tabs { | |
| 1148 | 1310 | overflow-x: auto; | |
| 1149 | 1311 | -webkit-overflow-scrolling: touch; | |
| 1150 | 1312 | } | |
| 1151 | - | .clone-row input { | |
| 1152 | - | min-width: 0; | |
| 1153 | - | } | |
| 1154 | 1313 | /* Blob and diff tables scroll within their own box. */ | |
| 1155 | 1314 | table.blob, | |
| 1156 | 1315 | table.diff { | |
crates/web/src/activity.rs +1 −1
| @@ -252,7 +252,7 @@ impl Activity { | |||
| 252 | 252 | ||
| 253 | 253 | /// Format an epoch-millisecond timestamp relative to `now_ms`, e.g. `3 hours | |
| 254 | 254 | /// 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 { | |
| 256 | 256 | let secs = (now_ms - ms).div_euclid(1000).max(0); | |
| 257 | 257 | let mins = secs / 60; | |
| 258 | 258 | let hours = mins / 60; | |
crates/web/src/icons.rs +4 −4
| @@ -14,7 +14,6 @@ use maud::{Markup, PreEscaped, html}; | |||
| 14 | 14 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 15 | 15 | pub enum Icon { | |
| 16 | 16 | PanelLeft, | |
| 17 | - | House, | |
| 18 | 17 | Folder, | |
| 19 | 18 | File, | |
| 20 | 19 | GitBranch, | |
| @@ -30,6 +29,7 @@ pub enum Icon { | |||
| 30 | 29 | Compass, | |
| 31 | 30 | MapPin, | |
| 32 | 31 | Globe, | |
| 32 | + | Download, | |
| 33 | 33 | Github, | |
| 34 | 34 | Mastodon, | |
| 35 | 35 | } | |
| @@ -46,9 +46,6 @@ impl Icon { | |||
| 46 | 46 | Icon::PanelLeft => { | |
| 47 | 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 | 49 | Icon::Folder => { | |
| 53 | 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 | 89 | Icon::Globe => { | |
| 93 | 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 | 95 | Icon::Github => { | |
| 96 | 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 | 76 | input type="search" name="q" placeholder="Search…" aria-label="Search"; | |
| 77 | 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 | } | |
| 109 | 109 | ||
| 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 | + | ||
| 110 | 118 | /// Build the application router with all middleware wired. | |
| 111 | 119 | pub fn build_router(state: AppState) -> Router { | |
| 112 | 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 | 267 | "branches" => branches_view(state, viewer, jar, uri, ctx).await, | |
| 268 | 268 | "tags" => tags_view(state, viewer, jar, uri, ctx).await, | |
| 269 | 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 | 271 | let raw_query = dq.q.clone().unwrap_or_default(); | |
| 272 | 272 | crate::search::in_repo(state, viewer, jar, uri, &ctx, &raw_query, header).await | |
| 273 | 273 | } | |
| @@ -324,8 +324,7 @@ async fn repo_home( | |||
| 324 | 324 | let readme = load_readme(state, &ctx.repo.id, &rev, &entries).await; | |
| 325 | 325 | let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path); | |
| 326 | 326 | html! { | |
| 327 | - | (repo_header(&ctx, Tab::Code, &rev_name)) | |
| 328 | - | (clone_widget(state, &ctx)) | |
| 327 | + | (repo_header(state, &ctx, Tab::Code, &rev_name)) | |
| 329 | 328 | form class="search-row repo-codesearch" method="get" action=(format!("{base}/-/search")) { | |
| 330 | 329 | input type="search" name="q" placeholder="Search this repository…" | |
| 331 | 330 | aria-label="Search this repository"; | |
| @@ -374,7 +373,7 @@ async fn load_readme( | |||
| 374 | 373 | /// The "empty repository" landing with clone instructions. | |
| 375 | 374 | fn empty_repo_body(state: &AppState, ctx: &RepoCtx) -> Markup { | |
| 376 | 375 | html! { | |
| 377 | - | (repo_header(ctx, Tab::Code, &ctx.repo.default_branch)) | |
| 376 | + | (repo_header(state, ctx, Tab::Code, &ctx.repo.default_branch)) | |
| 378 | 377 | div class="card" { | |
| 379 | 378 | h2 { "This repository is empty" } | |
| 380 | 379 | p class="muted" { "Push a commit to get started:" } | |
| @@ -410,7 +409,7 @@ async fn tree_view( | |||
| 410 | 409 | .await?; | |
| 411 | 410 | ||
| 412 | 411 | let body = html! { | |
| 413 | - | (repo_header(&ctx, Tab::Code, &rev_name)) | |
| 412 | + | (repo_header(state, &ctx, Tab::Code, &rev_name)) | |
| 414 | 413 | (breadcrumb(&ctx.owner.username, &ctx.repo.path, &rev_name, &path)) | |
| 415 | 414 | (tree_table(&ctx.owner.username, &ctx.repo.path, &rev_name, &path, &entries)) | |
| 416 | 415 | }; | |
| @@ -464,7 +463,7 @@ async fn blob_view( | |||
| 464 | 463 | source_view, | |
| 465 | 464 | ); | |
| 466 | 465 | let body = html! { | |
| 467 | - | (repo_header(&ctx, Tab::Code, &rev_name)) | |
| 466 | + | (repo_header(state, &ctx, Tab::Code, &rev_name)) | |
| 468 | 467 | (breadcrumb(&ctx.owner.username, &ctx.repo.path, &rev_name, &path)) | |
| 469 | 468 | (content_body) | |
| 470 | 469 | }; | |
| @@ -612,7 +611,7 @@ async fn branches_view( | |||
| 612 | 611 | ) -> AppResult<Response> { | |
| 613 | 612 | let branches = git_read(state, &ctx.repo.id, git::Repo::branches).await?; | |
| 614 | 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 | 615 | h2 { "Branches" } | |
| 617 | 616 | table class="list" { | |
| 618 | 617 | thead { tr { th { "Branch" } th { "Ahead" } th { "Behind" } th { "Updated" } } } | |
| @@ -647,7 +646,7 @@ async fn tags_view( | |||
| 647 | 646 | ) -> AppResult<Response> { | |
| 648 | 647 | let tags = git_read(state, &ctx.repo.id, git::Repo::tags).await?; | |
| 649 | 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 | 650 | h2 { "Tags" } | |
| 652 | 651 | table class="list" { | |
| 653 | 652 | thead { tr { th { "Tag" } th { "Commit" } th { "Kind" } } } | |
| @@ -704,21 +703,30 @@ async fn commits_view( | |||
| 704 | 703 | }) | |
| 705 | 704 | .await?; | |
| 706 | 705 | ||
| 706 | + | let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path); | |
| 707 | + | let now = crate::now_ms(); | |
| 707 | 708 | let body = html! { | |
| 708 | - | (repo_header(&ctx, Tab::Code, &rev_name)) | |
| 709 | + | (repo_header(state, &ctx, Tab::Code, &rev_name)) | |
| 709 | 710 | h2 { "Commits" } | |
| 710 | - | ul class="repo-list" { | |
| 711 | + | ul class="commit-list card" { | |
| 711 | 712 | @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)) } | |
| 716 | 721 | } | |
| 717 | - | " " (signature_badge(sig)) | |
| 718 | 722 | } | |
| 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 | + | } | |
| 722 | 730 | } | |
| 723 | 731 | } | |
| 724 | 732 | } | |
| @@ -743,6 +751,18 @@ async fn commit_view( | |||
| 743 | 751 | let keys = signing_keys(state.store.all_keys().await?); | |
| 744 | 752 | let (detail, files, sig) = load_commit(state, &ctx.repo.id, sha, keys).await?; | |
| 745 | 753 | ||
| 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 | 766 | // View mode: an explicit `?view=` wins and is remembered; else the cookie; | |
| 747 | 767 | // else unified. | |
| 748 | 768 | let chosen = dq.view.clone().filter(|v| v == "split" || v == "unified"); | |
| @@ -756,8 +776,9 @@ async fn commit_view( | |||
| 756 | 776 | let repo_base = format!("/{}/{}", ctx.owner.username, ctx.repo.path); | |
| 757 | 777 | ||
| 758 | 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 | 780 | (commit_meta(&detail, &sig)) | |
| 781 | + | (signature_detail(&sig, signer.as_deref())) | |
| 761 | 782 | div class="diff-toolbar" { | |
| 762 | 783 | span { | |
| 763 | 784 | (files.len()) " files changed · " | |
| @@ -1218,8 +1239,9 @@ fn mastodon_link(handle: &str) -> Markup { | |||
| 1218 | 1239 | html! { span { (icon(Icon::Mastodon)) span { (handle) } } } | |
| 1219 | 1240 | } | |
| 1220 | 1241 | ||
| 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 { | |
| 1223 | 1245 | let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path); | |
| 1224 | 1246 | let tab = |t: Tab, label: &str, glyph: Icon, href: String| { | |
| 1225 | 1247 | html! { | |
| @@ -1237,30 +1259,46 @@ fn repo_header(ctx: &RepoCtx, active: Tab, rev: &str) -> Markup { | |||
| 1237 | 1259 | @if ctx.repo.is_private { span class="badge badge-private" { "private" } } | |
| 1238 | 1260 | } | |
| 1239 | 1261 | @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)) | |
| 1248 | 1274 | } | |
| 1249 | - | a class="btn" href=(format!("{base}/-/commits/{rev}")) { "Commits" } | |
| 1250 | 1275 | } | |
| 1251 | 1276 | } | |
| 1252 | 1277 | } | |
| 1253 | 1278 | } | |
| 1254 | 1279 | ||
| 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 | + | }; | |
| 1257 | 1296 | 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)) | |
| 1264 | 1302 | } | |
| 1265 | 1303 | } | |
| 1266 | 1304 | } | |
| @@ -1276,6 +1314,18 @@ fn clone_https(state: &AppState, ctx: &RepoCtx) -> String { | |||
| 1276 | 1314 | ) | |
| 1277 | 1315 | } | |
| 1278 | 1316 | ||
| 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 | + | ||
| 1279 | 1329 | /// A path breadcrumb for tree/blob views. | |
| 1280 | 1330 | fn breadcrumb(owner: &str, repo_path: &str, rev: &str, path: &str) -> Markup { | |
| 1281 | 1331 | let root = format!("/{owner}/{repo_path}/-/tree/{rev}"); | |
| @@ -1376,6 +1426,44 @@ fn signature_badge(state: &git::SignatureState) -> Markup { | |||
| 1376 | 1426 | } | |
| 1377 | 1427 | } | |
| 1378 | 1428 | ||
| 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 | + | ||
| 1379 | 1467 | /// Map registered keys to the verification input type. | |
| 1380 | 1468 | fn signing_keys(keys: Vec<model::Key>) -> Vec<git::SigningKey> { | |
| 1381 | 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 | 445 | /// One file's matches. `blob_base` links each line to the blob (with `#L{n}`). | |
| 446 | 446 | fn file_result(blob_base: &str, in_repo: Option<&str>, file: &FileResult) -> Markup { | |
| 447 | 447 | 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" { | |
| 450 | 450 | @if in_repo.is_some() { | |
| 451 | 451 | a href=(format!("{blob_base}/{}", file.path)) { (file.path) } | |
| 452 | 452 | } @else { | |
| 453 | 453 | (file.path) | |
| 454 | 454 | } | |
| 455 | 455 | } | |
| 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 | + | } | |
| 463 | 468 | } | |
| 464 | - | " " (m.text) | |
| 465 | 469 | } | |
| 466 | 470 | } | |
| 467 | 471 | } | |