detached dfd6f0b

fix(web): commit signature row and compact home layout (#74)

commit
dfd6f0b357179d346e33720bb464397fcba5b3ba
author
brice · · 2026-10-18 19:01 UTC
committer
bnema · · 2026-10-18 19:01 UTC
parent
df1db36
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
refs
tag: v0.26.1
8 files · +46 -25
M internal/adapters/web/components.templ +1 -1
@@ -96,7 +96,7 @@ templ HomeRowView(r HomeRow) {
9696 if r.CI != nil {
9797 <a class="repo-ci" href={ templ.SafeURL(r.CI.Href) } title={ r.CI.Title }>
9898 <span class={ "ck", "ck-" + r.CI.Kind } aria-hidden="true"></span>
99- <span class="sr">CI: </span>{ r.CI.Label }
99+ <span class="sr">CI: </span><span class="ci-label">{ r.CI.Label }</span>
100100 </a>
101101 }
102102 </div>
@@ -96,7 +96,7 @@ templ HomeRowView(r HomeRow) {
96 if r.CI != nil {96 if r.CI != nil {
97 <a class="repo-ci" href={ templ.SafeURL(r.CI.Href) } title={ r.CI.Title }>97 <a class="repo-ci" href={ templ.SafeURL(r.CI.Href) } title={ r.CI.Title }>
98 <span class={ "ck", "ck-" + r.CI.Kind } aria-hidden="true"></span>98 <span class={ "ck", "ck-" + r.CI.Kind } aria-hidden="true"></span>
99- <span class="sr">CI: </span>{ r.CI.Label }99+ <span class="sr">CI: </span><span class="ci-label">{ r.CI.Label }</span>
100 </a>100 </a>
101 }101 }
102 </div>102 </div>
M internal/adapters/web/components_templ.go +3 -3 generated file: collapsed
M internal/adapters/web/diff.templ +2 -2
@@ -50,8 +50,8 @@ templ Commit(l Layout, p CommitPage) {
5050 if p.Verification.KeyTitle != "" {
5151 <span class="dim">{ "· " + visibleText(p.Verification.KeyTitle) }</span>
5252 }
53- if p.Sig != nil && p.Sig.Class != "sig-none" {
54- <span class="sig-why dim">{ p.Sig.Title }</span>
53+ if p.Sig.Class != "sig-none" {
54+ <span class="sig-why dim">{ p.Sig.Reason }</span>
5555 }
5656 </dd>
5757 }
@@ -50,8 +50,8 @@ templ Commit(l Layout, p CommitPage) {
50 if p.Verification.KeyTitle != "" {50 if p.Verification.KeyTitle != "" {
51 <span class="dim">{ "· " + visibleText(p.Verification.KeyTitle) }</span>51 <span class="dim">{ "· " + visibleText(p.Verification.KeyTitle) }</span>
52 }52 }
53- if p.Sig != nil && p.Sig.Class != "sig-none" {53+ if p.Sig.Class != "sig-none" {
54- <span class="sig-why dim">{ p.Sig.Title }</span>54+ <span class="sig-why dim">{ p.Sig.Reason }</span>
55 }55 }
56 </dd>56 </dd>
57 }57 }
M internal/adapters/web/diff_templ.go +3 -3 generated file: collapsed
M internal/adapters/web/home_test.go +1 -1
@@ -212,7 +212,7 @@ func TestHomeRowCI(t *testing.T) {
212212 }
213213 require.NotNil(t, row.CI)
214214 assert.Contains(t, out, `class="repo-ci" href="`+tt.href+`"`)
215- assert.Contains(t, out, tt.label, "the state has a word, not only a mark")
215+ assert.Contains(t, out, `<span class="ci-label">`+tt.label+`</span>`, "the state has a word, not only a mark; compact hides it")
216216 assert.Contains(t, out, `<span class="sr">CI: </span>`)
217217 })
218218 }
@@ -212,7 +212,7 @@ func TestHomeRowCI(t *testing.T) {
212 }212 }
213 require.NotNil(t, row.CI)213 require.NotNil(t, row.CI)
214 assert.Contains(t, out, `class="repo-ci" href="`+tt.href+`"`)214 assert.Contains(t, out, `class="repo-ci" href="`+tt.href+`"`)
215- assert.Contains(t, out, tt.label, "the state has a word, not only a mark")215+ assert.Contains(t, out, `<span class="ci-label">`+tt.label+`</span>`, "the state has a word, not only a mark; compact hides it")
216 assert.Contains(t, out, `<span class="sr">CI: </span>`)216 assert.Contains(t, out, `<span class="sr">CI: </span>`)
217 })217 })
218 }218 }
M internal/adapters/web/signature.go +15 -5
@@ -9,8 +9,10 @@ import (
99 // SignatureBadge is the pill of a signed commit. Label is the verdict, Proto
1010 // the protocol ("SSH", "GPG", "X.509", empty when unknown), Class the style
1111 // (sig-ok, sig-warn, sig-bad, sig-none) and Title the tooltip that says why.
12+// Reason says why without naming the key, for a page that shows the key next
13+// to it.
1214 type SignatureBadge struct {
13- Label, Proto, Class, Title string
15+ Label, Proto, Class, Title, Reason string
1416 }
1517
1618 // signatureProto is the display name of a signature format.
@@ -37,7 +39,7 @@ func signatureBadge(v domain.CommitVerification, long bool) *SignatureBadge {
3739 if !long {
3840 return nil
3941 }
40- return &SignatureBadge{Label: "unsigned", Class: "sig-none", Title: "This commit is not signed"}
42+ return &SignatureBadge{Label: "unsigned", Class: "sig-none", Title: "This commit is not signed", Reason: "Not signed"}
4143 }
4244 b := &SignatureBadge{Proto: signatureProto(v.Format)}
4345 key := keyPhrase(v)
@@ -45,31 +47,39 @@ func signatureBadge(v domain.CommitVerification, long bool) *SignatureBadge {
4547 case domain.StatusVerified:
4648 b.Label, b.Class = "Verified", "sig-ok"
4749 b.Title = "Signed with " + key + " — verified for the committer email"
50+ b.Reason = "Verified for the committer email"
4851 case domain.StatusUnknownKey:
4952 b.Label, b.Class = "Unverified", "sig-warn"
5053 b.Title = unknownKeyTitle(v)
54+ b.Reason = "The key is unknown"
5155 case domain.StatusEmailMismatch:
5256 b.Label, b.Class = "Unverified", "sig-warn"
5357 b.Title = "Signed with " + key + " — this key is not trusted for the committer email"
58+ b.Reason = "This key is not trusted for the committer email"
5459 case domain.StatusExpired:
5560 b.Label, b.Class = "Unverified", "sig-warn"
5661 b.Title = "Signed with " + key + " — the key or the signature expired"
62+ b.Reason = "The key or the signature expired"
5763 case domain.StatusRevoked:
5864 b.Label, b.Class = "Unverified", "sig-warn"
5965 b.Title = "Signed with " + key + " — the key is revoked"
66+ b.Reason = "The key is revoked"
6067 case domain.StatusBadSignature:
6168 b.Label, b.Class = "Bad signature", "sig-bad"
62- b.Title = "The signature does not match the commit"
69+ b.Reason = "The signature does not match the commit"
6370 case domain.StatusUnsupported:
6471 b.Label, b.Class = "Unverified", "sig-warn"
6572 if proto := signatureProto(v.Format); proto != "" {
66- b.Title = proto + " signatures are not checked"
73+ b.Reason = proto + " signatures are not checked"
6774 } else {
68- b.Title = "This kind of signature is not checked"
75+ b.Reason = "This kind of signature is not checked"
6976 }
7077 default:
7178 return nil
7279 }
80+ if b.Title == "" {
81+ b.Title = b.Reason
82+ }
7383 return b
7484 }
7585
@@ -9,8 +9,10 @@ import (
9 // SignatureBadge is the pill of a signed commit. Label is the verdict, Proto9 // SignatureBadge is the pill of a signed commit. Label is the verdict, Proto
10 // the protocol ("SSH", "GPG", "X.509", empty when unknown), Class the style10 // the protocol ("SSH", "GPG", "X.509", empty when unknown), Class the style
11 // (sig-ok, sig-warn, sig-bad, sig-none) and Title the tooltip that says why.11 // (sig-ok, sig-warn, sig-bad, sig-none) and Title the tooltip that says why.
12+// Reason says why without naming the key, for a page that shows the key next
13+// to it.
12 type SignatureBadge struct {14 type SignatureBadge struct {
13- Label, Proto, Class, Title string15+ Label, Proto, Class, Title, Reason string
14 }16 }
15 17
16 // signatureProto is the display name of a signature format.18 // signatureProto is the display name of a signature format.
@@ -37,7 +39,7 @@ func signatureBadge(v domain.CommitVerification, long bool) *SignatureBadge {
37 if !long {39 if !long {
38 return nil40 return nil
39 }41 }
40- return &SignatureBadge{Label: "unsigned", Class: "sig-none", Title: "This commit is not signed"}42+ return &SignatureBadge{Label: "unsigned", Class: "sig-none", Title: "This commit is not signed", Reason: "Not signed"}
41 }43 }
42 b := &SignatureBadge{Proto: signatureProto(v.Format)}44 b := &SignatureBadge{Proto: signatureProto(v.Format)}
43 key := keyPhrase(v)45 key := keyPhrase(v)
@@ -45,31 +47,39 @@ func signatureBadge(v domain.CommitVerification, long bool) *SignatureBadge {
45 case domain.StatusVerified:47 case domain.StatusVerified:
46 b.Label, b.Class = "Verified", "sig-ok"48 b.Label, b.Class = "Verified", "sig-ok"
47 b.Title = "Signed with " + key + " — verified for the committer email"49 b.Title = "Signed with " + key + " — verified for the committer email"
50+ b.Reason = "Verified for the committer email"
48 case domain.StatusUnknownKey:51 case domain.StatusUnknownKey:
49 b.Label, b.Class = "Unverified", "sig-warn"52 b.Label, b.Class = "Unverified", "sig-warn"
50 b.Title = unknownKeyTitle(v)53 b.Title = unknownKeyTitle(v)
54+ b.Reason = "The key is unknown"
51 case domain.StatusEmailMismatch:55 case domain.StatusEmailMismatch:
52 b.Label, b.Class = "Unverified", "sig-warn"56 b.Label, b.Class = "Unverified", "sig-warn"
53 b.Title = "Signed with " + key + " — this key is not trusted for the committer email"57 b.Title = "Signed with " + key + " — this key is not trusted for the committer email"
58+ b.Reason = "This key is not trusted for the committer email"
54 case domain.StatusExpired:59 case domain.StatusExpired:
55 b.Label, b.Class = "Unverified", "sig-warn"60 b.Label, b.Class = "Unverified", "sig-warn"
56 b.Title = "Signed with " + key + " — the key or the signature expired"61 b.Title = "Signed with " + key + " — the key or the signature expired"
62+ b.Reason = "The key or the signature expired"
57 case domain.StatusRevoked:63 case domain.StatusRevoked:
58 b.Label, b.Class = "Unverified", "sig-warn"64 b.Label, b.Class = "Unverified", "sig-warn"
59 b.Title = "Signed with " + key + " — the key is revoked"65 b.Title = "Signed with " + key + " — the key is revoked"
66+ b.Reason = "The key is revoked"
60 case domain.StatusBadSignature:67 case domain.StatusBadSignature:
61 b.Label, b.Class = "Bad signature", "sig-bad"68 b.Label, b.Class = "Bad signature", "sig-bad"
62- b.Title = "The signature does not match the commit"69+ b.Reason = "The signature does not match the commit"
63 case domain.StatusUnsupported:70 case domain.StatusUnsupported:
64 b.Label, b.Class = "Unverified", "sig-warn"71 b.Label, b.Class = "Unverified", "sig-warn"
65 if proto := signatureProto(v.Format); proto != "" {72 if proto := signatureProto(v.Format); proto != "" {
66- b.Title = proto + " signatures are not checked"73+ b.Reason = proto + " signatures are not checked"
67 } else {74 } else {
68- b.Title = "This kind of signature is not checked"75+ b.Reason = "This kind of signature is not checked"
69 }76 }
70 default:77 default:
71 return nil78 return nil
72 }79 }
80+ if b.Title == "" {
81+ b.Title = b.Reason
82+ }
73 return b83 return b
74 }84 }
75 85
M internal/adapters/web/signature_test.go +2 -1
@@ -128,7 +128,8 @@ func TestCommitPageShowsTheSignature(t *testing.T) {
128128 got = page(verification(domain.FormatGPG, domain.StatusUnknownKey, gpgFP, ""))
129129 assert.Contains(t, got, `sig-warn`)
130130 assert.Contains(t, got, "<code>"+gpgFP+"</code>")
131- assert.Contains(t, got, `<span class="sig-why dim">Signed with an unknown GPG key `+gpgFP+`</span>`, "the reason is text, not only a tooltip")
131+ assert.Contains(t, got, `<span class="sig-why dim">The key is unknown</span>`, "the reason is text, not only a tooltip")
132+ assert.Equal(t, 1, strings.Count(got, ">"+gpgFP), "the fingerprint is shown once; the pill tooltip may repeat it")
132133
133134 got = page(verification("", domain.StatusUnsigned, "", ""))
134135 assert.Contains(t, got, "<dt>signature</dt>")
@@ -128,7 +128,8 @@ func TestCommitPageShowsTheSignature(t *testing.T) {
128 got = page(verification(domain.FormatGPG, domain.StatusUnknownKey, gpgFP, ""))128 got = page(verification(domain.FormatGPG, domain.StatusUnknownKey, gpgFP, ""))
129 assert.Contains(t, got, `sig-warn`)129 assert.Contains(t, got, `sig-warn`)
130 assert.Contains(t, got, "<code>"+gpgFP+"</code>")130 assert.Contains(t, got, "<code>"+gpgFP+"</code>")
131- assert.Contains(t, got, `<span class="sig-why dim">Signed with an unknown GPG key `+gpgFP+`</span>`, "the reason is text, not only a tooltip")131+ assert.Contains(t, got, `<span class="sig-why dim">The key is unknown</span>`, "the reason is text, not only a tooltip")
132+ assert.Equal(t, 1, strings.Count(got, ">"+gpgFP), "the fingerprint is shown once; the pill tooltip may repeat it")
132 133
133 got = page(verification("", domain.StatusUnsigned, "", ""))134 got = page(verification("", domain.StatusUnsigned, "", ""))
134 assert.Contains(t, got, "<dt>signature</dt>")135 assert.Contains(t, got, "<dt>signature</dt>")
M internal/adapters/web/static/css/30-list.css +19 -9
@@ -179,19 +179,27 @@
179179 .home[data-layout="split"] .repos { grid-template-columns: minmax(0, 1fr); }
180180 }
181181
182- /* compact: a line per repository. Name, description, figures, CI state and
183- a small pulse sit in one grid row; the commits and topics are left out. */
182+ /* compact: a line per repository. Name, description, figures, CI mark and
183+ a small pulse sit in one grid row; the commits and topics are left out.
184+ The rows share the columns of the list (subgrid): the name column is as
185+ wide as the longest name, the figures line up, and the description takes
186+ what is left. */
184187 .home[data-layout="compact"] {
185- & .repos { gap: var(--s2); }
188+ & .repos {
189+ display: grid; grid-template-columns: fit-content(18rem) minmax(0, 1fr) auto auto 140px;
190+ gap: var(--s2) var(--s4);
191+ }
186192 & .repo {
187- grid-template-columns: minmax(0, 2fr) minmax(0, 3fr) minmax(0, 20rem) 5.5rem 140px;
193+ grid-column: 1 / -1; grid-template-columns: subgrid;
188194 grid-template-areas: "name desc stats ci pulse";
189- grid-template-rows: none; column-gap: var(--s4); row-gap: 0; align-items: center;
195+ /* normal: the row takes the gaps of the list */
196+ grid-template-rows: none; column-gap: normal; row-gap: 0; align-items: center;
190197 padding: var(--s2) var(--s4);
191198 }
192199 & .repo-main, & .repo-head { display: contents; }
193200 & .repo-topics, & .repo-commits { display: none; }
194- & .repo-name { grid-area: name; flex-wrap: nowrap; gap: var(--s2); font-size: var(--fs); line-height: 24px; overflow-wrap: normal; }
201+ /* min-width 0: a long name is cut, it does not widen the column past its cap */
202+ & .repo-name { grid-area: name; min-width: 0; flex-wrap: nowrap; gap: var(--s2); font-size: var(--fs); line-height: 24px; overflow-wrap: normal; }
195203 & .repo-name a { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
196204 & .repo-name .pill { flex: none; }
197205 & .repo-desc {
@@ -212,7 +220,9 @@
212220 content: ""; display: inline-block; width: 12px; height: 12px; margin-right: 3px; vertical-align: -2px;
213221 background: currentColor; mask: url(icons/tag.svg) center / contain no-repeat;
214222 }
215- & .repo-ci { grid-area: ci; justify-self: start; padding-top: 0; font-size: var(--fs-xs); }
223+ /* the CI state is its mark; the word is in the tooltip and for screen readers */
224+ & .repo-ci { grid-area: ci; justify-self: center; padding-top: 0; }
225+ & .repo-ci .ci-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
216226 /* the pulse: the same curve, small, with a bigger dot to stay seen */
217227 & .repo-pulse { grid-area: pulse; width: 100%; height: 100%; min-height: 18px; margin: 0; padding: 0; }
218228 & div.repo-pulse { aspect-ratio: auto; box-sizing: border-box; }
@@ -221,14 +231,14 @@
221231 & .pulse-dot { r: 9px; stroke: none; }
222232 }
223233 @media (max-width: 1100px) {
224- .home[data-layout="compact"] .repo { grid-template-columns: minmax(0, 2fr) minmax(0, 2fr) minmax(0, 14rem) 5.5rem 96px; column-gap: var(--s3); }
234+ .home[data-layout="compact"] .repos { grid-template-columns: fit-content(14rem) minmax(0, 1fr) auto auto 96px; column-gap: var(--s3); }
225235 .home[data-layout="compact"] .repo-stats .rs-tag { display: none; }
226236 }
227237 /* on a phone: name and CI state on a line, the description and the figures
228238 under it; the pulse is dropped */
229239 @media (max-width: 720px) {
230240 .home[data-layout="compact"] .repo {
231- grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "name ci" "desc desc" "stats stats"; row-gap: 2px;
241+ grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "name ci" "desc desc" "stats stats"; column-gap: var(--s3); row-gap: 2px;
232242 }
233243 .home[data-layout="compact"] .repo-pulse { display: none; }
234244 .home[data-layout="compact"] .repo-stats .rs-tag { display: flex; }
@@ -179,19 +179,27 @@
179 .home[data-layout="split"] .repos { grid-template-columns: minmax(0, 1fr); }179 .home[data-layout="split"] .repos { grid-template-columns: minmax(0, 1fr); }
180 }180 }
181 181
182- /* compact: a line per repository. Name, description, figures, CI state and182+ /* compact: a line per repository. Name, description, figures, CI mark and
183- a small pulse sit in one grid row; the commits and topics are left out. */183+ a small pulse sit in one grid row; the commits and topics are left out.
184+ The rows share the columns of the list (subgrid): the name column is as
185+ wide as the longest name, the figures line up, and the description takes
186+ what is left. */
184 .home[data-layout="compact"] {187 .home[data-layout="compact"] {
185- & .repos { gap: var(--s2); }188+ & .repos {
189+ display: grid; grid-template-columns: fit-content(18rem) minmax(0, 1fr) auto auto 140px;
190+ gap: var(--s2) var(--s4);
191+ }
186 & .repo {192 & .repo {
187- grid-template-columns: minmax(0, 2fr) minmax(0, 3fr) minmax(0, 20rem) 5.5rem 140px;193+ grid-column: 1 / -1; grid-template-columns: subgrid;
188 grid-template-areas: "name desc stats ci pulse";194 grid-template-areas: "name desc stats ci pulse";
189- grid-template-rows: none; column-gap: var(--s4); row-gap: 0; align-items: center;195+ /* normal: the row takes the gaps of the list */
196+ grid-template-rows: none; column-gap: normal; row-gap: 0; align-items: center;
190 padding: var(--s2) var(--s4);197 padding: var(--s2) var(--s4);
191 }198 }
192 & .repo-main, & .repo-head { display: contents; }199 & .repo-main, & .repo-head { display: contents; }
193 & .repo-topics, & .repo-commits { display: none; }200 & .repo-topics, & .repo-commits { display: none; }
194- & .repo-name { grid-area: name; flex-wrap: nowrap; gap: var(--s2); font-size: var(--fs); line-height: 24px; overflow-wrap: normal; }201+ /* min-width 0: a long name is cut, it does not widen the column past its cap */
202+ & .repo-name { grid-area: name; min-width: 0; flex-wrap: nowrap; gap: var(--s2); font-size: var(--fs); line-height: 24px; overflow-wrap: normal; }
195 & .repo-name a { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }203 & .repo-name a { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
196 & .repo-name .pill { flex: none; }204 & .repo-name .pill { flex: none; }
197 & .repo-desc {205 & .repo-desc {
@@ -212,7 +220,9 @@
212 content: ""; display: inline-block; width: 12px; height: 12px; margin-right: 3px; vertical-align: -2px;220 content: ""; display: inline-block; width: 12px; height: 12px; margin-right: 3px; vertical-align: -2px;
213 background: currentColor; mask: url(icons/tag.svg) center / contain no-repeat;221 background: currentColor; mask: url(icons/tag.svg) center / contain no-repeat;
214 }222 }
215- & .repo-ci { grid-area: ci; justify-self: start; padding-top: 0; font-size: var(--fs-xs); }223+ /* the CI state is its mark; the word is in the tooltip and for screen readers */
224+ & .repo-ci { grid-area: ci; justify-self: center; padding-top: 0; }
225+ & .repo-ci .ci-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
216 /* the pulse: the same curve, small, with a bigger dot to stay seen */226 /* the pulse: the same curve, small, with a bigger dot to stay seen */
217 & .repo-pulse { grid-area: pulse; width: 100%; height: 100%; min-height: 18px; margin: 0; padding: 0; }227 & .repo-pulse { grid-area: pulse; width: 100%; height: 100%; min-height: 18px; margin: 0; padding: 0; }
218 & div.repo-pulse { aspect-ratio: auto; box-sizing: border-box; }228 & div.repo-pulse { aspect-ratio: auto; box-sizing: border-box; }
@@ -221,14 +231,14 @@
221 & .pulse-dot { r: 9px; stroke: none; }231 & .pulse-dot { r: 9px; stroke: none; }
222 }232 }
223 @media (max-width: 1100px) {233 @media (max-width: 1100px) {
224- .home[data-layout="compact"] .repo { grid-template-columns: minmax(0, 2fr) minmax(0, 2fr) minmax(0, 14rem) 5.5rem 96px; column-gap: var(--s3); }234+ .home[data-layout="compact"] .repos { grid-template-columns: fit-content(14rem) minmax(0, 1fr) auto auto 96px; column-gap: var(--s3); }
225 .home[data-layout="compact"] .repo-stats .rs-tag { display: none; }235 .home[data-layout="compact"] .repo-stats .rs-tag { display: none; }
226 }236 }
227 /* on a phone: name and CI state on a line, the description and the figures237 /* on a phone: name and CI state on a line, the description and the figures
228 under it; the pulse is dropped */238 under it; the pulse is dropped */
229 @media (max-width: 720px) {239 @media (max-width: 720px) {
230 .home[data-layout="compact"] .repo {240 .home[data-layout="compact"] .repo {
231- grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "name ci" "desc desc" "stats stats"; row-gap: 2px;241+ grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "name ci" "desc desc" "stats stats"; column-gap: var(--s3); row-gap: 2px;
232 }242 }
233 .home[data-layout="compact"] .repo-pulse { display: none; }243 .home[data-layout="compact"] .repo-pulse { display: none; }
234 .home[data-layout="compact"] .repo-stats .rs-tag { display: flex; }244 .home[data-layout="compact"] .repo-stats .rs-tag { display: flex; }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 42

↑↓ moveenter openesc close