Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
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 | } |
| 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 | /* ---- Tables ---- */ | 715 | /* ---- Tables ---- */ |
| 680 | table.list { | 716 | table.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 | } |
| 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 | /* ---- 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 { | |||
| 252 | 252 | ||
| 253 | /// Format an epoch-millisecond timestamp relative to `now_ms`, e.g. `3 hours | 253 | /// 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. |
| 255 | fn fmt_relative(ms: i64, now_ms: i64) -> String { | 255 | pub(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)] |
| 15 | pub enum Icon { | 15 | pub 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 | } |
| 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 | /// Build the application router with all middleware wired. | 118 | /// Build the application router with all middleware wired. |
| 111 | pub fn build_router(state: AppState) -> Router { | 119 | pub 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).await | 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 | 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. |
| 375 | fn empty_repo_body(state: &AppState, ctx: &RepoCtx) -> Markup { | 374 | fn 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?; |
| 411 | 410 | ||
| 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?; |
| 706 | 705 | ||
| 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?; |
| 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 | // 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); |
| 757 | 777 | ||
| 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 | } |
| 1220 | 1241 | ||
| 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 |
| 1222 | fn repo_header(ctx: &RepoCtx, active: Tab, rev: &str) -> Markup { | 1243 | /// the branch / commits / clone actions. |
| 1244 | fn 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 | } |
| 1254 | 1279 | ||
| 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. |
| 1256 | fn clone_widget(state: &AppState, ctx: &RepoCtx) -> Markup { | 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 | 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 | } |
| 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 | /// A path breadcrumb for tree/blob views. | 1329 | /// A path breadcrumb for tree/blob views. |
| 1280 | fn breadcrumb(owner: &str, repo_path: &str, rev: &str, path: &str) -> Markup { | 1330 | fn 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 | } |
| 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 | /// Map registered keys to the verification input type. | 1467 | /// Map registered keys to the verification input type. |
| 1380 | fn signing_keys(keys: Vec<model::Key>) -> Vec<git::SigningKey> { | 1468 | fn 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}`). |
| 446 | fn file_result(blob_base: &str, in_repo: Option<&str>, file: &FileResult) -> Markup { | 446 | fn 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 | } |