detached ca9dcac

Merge 5336d8b77ba1fc3916f4dc9c563dddd80bfe37ea into cf2636510abd18ed4875895a18b54026dbca22e1

commit
ca9dcac8286aba4349a1f1fb6b19d94ad8f2a0aa
author
ermite · · 2026-10-22 08:23 UTC
merge of
cf26365 + 5336d8b · diff against the first parent
signature
unsigned
73 files · +2058 -1284
M README.md +1 -0
@@ -139,6 +139,7 @@ Give the agent the [`skills/ermite`](skills/ermite/SKILL.md) skill so it knows t
139139 - [Issues](docs/issues.md)
140140 - [Commit signatures](docs/commit-signatures.md): SSH and OpenPGP badges, trusted keys
141141 - [Web sign-in](docs/web-sign-in.md): passkeys and one-time links
142+- [Keyboard shortcuts](docs/keyboard-shortcuts.md): web section navigation and page actions
142143 - [CI](docs/ci.md): runners, trust, concurrency, reusable workflows
143144 - [CI events](docs/ci-events.md)
144145 - [OIDC](docs/ci-oidc.md)
M docs/issues.md +1 -1
@@ -50,7 +50,7 @@ Limits: the 100 most recent commits of the merge request, 16 KiB of each text, 1
5050
5151 The signed-in owner has an Inbox at `/-/inbox`, reached from the **Inbox** button at the top left of the home page, which shows the number of open items. It lists the open merge requests and the open issues of every repository, most recently updated first. The counters at the top (`N merge requests · M issues`) always count everything; `?repo=<name>` restricts the two lists to one repository, and the repository links under the counters set it. Repositories with issues off contribute no issues.
5252
53-The **Running** section, after **Awaiting moderation**, lists queued and in-progress CI runs newest first, with links to each run, its repository, ref, and start time. The `N running` counter counts every repository; the repository filter also applies to this section. **Shift+I** (`I`) opens the inbox from any page for the signed-in owner, unless single-key shortcuts are off; lowercase `i` still opens the issues tab.
53+The **Running** section, after **Awaiting moderation**, lists queued and in-progress CI runs newest first, with links to each run, its repository, ref, and start time. The `N running` counter counts every repository; the repository filter also applies to this section. **Shift+I** (`I`) opens the inbox from any page for the signed-in owner, unless single-key shortcuts are off; `g i` opens the issues tab.
5454
5555 The page updates live: run status changes, merge request and issue events of any repository refresh the counters and the lists. Anyone who is not the signed-in owner, including a token, gets the same 404 as for an unknown page, and the same answer for the `owner` event stream it follows.
5656
A docs/keyboard-shortcuts.md +30 -0
@@ -0,0 +1,30 @@
1+# Web keyboard shortcuts
2+
3+Press `?` for the complete, page-specific list. Shortcuts do not run while typing
4+in a field. Section navigation uses two successive keys, not a chord:
5+
6+| Keys | Destination |
7+| --- | --- |
8+| `g s` | Summary |
9+| `g f` | Files |
10+| `g c` | Commits |
11+| `g b` | Branches & tags |
12+| `g m` | Merge requests |
13+| `g i` | Issues |
14+| `g r` | Releases |
15+| `g a` | Actions (when CI is enabled) |
16+| `g h` | Home |
17+| `g g` | Top / first row |
18+| `G` (Shift+g) | Bottom / last row |
19+
20+There is no `g t` alias, and `s`, `t`, `c`, `r`, `m`, and `i` alone do
21+not navigate sections. `b` still opens the branch/tag switcher; `f`, `:`,
22+and Ctrl+K (Cmd+K on macOS) open the command palette.
23+
24+On merge requests, `l` focuses the files side panel; `g f` always navigates
25+to the repository Files section. `h` returns to the content. Uppercase `M`
26+opens the merge menu, `R` marks the head read, `E` expands every diff file,
27+and `I` opens the signed-in owner's inbox.
28+
29+The help dialog can turn off character shortcuts, including `g` sequences
30+and shifted letters. Ctrl+K / Cmd+K and Escape remain available.
M docs/merge-requests.md +1 -1
@@ -142,7 +142,7 @@ the diff opens the Checks panel and continues to update live. Commit totals use
142142 `N+` when the branch list is capped: the reader's existing count operation counts all reachable
143143 ancestors, not the source-minus-target range.
144144
145-The rail keeps Files (including its `g f` shortcut) and Verdicts. Merge settings
145+The rail keeps Files (including its `l` shortcut) and Verdicts. Merge settings
146146 are read-only information in the More (⋯) menu, available to every viewer.
147147 Signing-key information is also there when a merge box offers signed modes;
148148 merge actions remain restricted to their existing permissions.
M internal/adapters/kvstore/issues_test.go +1 -1
@@ -364,7 +364,7 @@ func TestSchemaNewerThanThisBuildStopsIssuesData(t *testing.T) {
364364 putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: kvstore.SchemaVersion + 1}))
365365 _, err := kvstore.Open(ctx, dir)
366366 require.ErrorIs(t, err, kvstore.ErrSchemaTooNew)
367- assert.Equal(t, uint32(6), uint32(kvstore.SchemaVersion))
367+ assert.Equal(t, uint32(7), uint32(kvstore.SchemaVersion))
368368 }
369369
370370 // A reopen racing the delete of the repository either lands before the delete
M internal/adapters/kvstore/releases.go +28 -2
@@ -36,7 +36,26 @@ func relNamePrefix(repo domain.RepoName, id int64) string {
3636 func relNameKey(a domain.ReleaseAsset) []byte {
3737 return []byte(relNamePrefix(a.Repo, a.ReleaseID) + a.Name)
3838 }
39-func relUseKey(repo domain.RepoName) []byte { return []byte("reluse/" + repo.String()) }
39+func relLatestKey(repo domain.RepoName) []byte { return []byte("rellatest/" + repo.String()) }
40+func relUseKey(repo domain.RepoName) []byte { return []byte("reluse/" + repo.String()) }
41+
42+// GetLatestRelease resolves the index and release from the same snapshot.
43+func (c *Releases) GetLatestRelease(ctx context.Context, repo domain.RepoName) (domain.Release, error) {
44+ snap := c.db.Snapshot(ctx)
45+ item, ok := snap.Get(ctx, relLatestKey(repo))
46+ if !ok {
47+ return domain.Release{}, domain.ErrNotFound
48+ }
49+ id, err := strconv.ParseInt(string(item.Value), 10, 64)
50+ if err != nil {
51+ return domain.Release{}, err
52+ }
53+ item, ok = snap.Get(ctx, relKey(repo, id))
54+ if !ok {
55+ return domain.Release{}, domain.ErrNotFound
56+ }
57+ return decodeRelease(item)
58+}
4059 func encodeRelease(r domain.Release) []byte {
4160 return mustMarshal(&storagepb.Release{
4261 Id: r.ID,
@@ -285,6 +304,13 @@ func (c *Releases) ApplyReleases(ctx context.Context, w ports.ReleaseWrite) erro
285304 b.RequireVersion(relUseKey(w.Repo), w.UsageVersion)
286305 }
287306 b.Put(relUseKey(w.Repo), mustMarshal(&storagepb.ReleaseUsage{Used: w.Used}))
307+ if w.SetLatest {
308+ if w.LatestID > 0 {
309+ b.Put(relLatestKey(w.Repo), []byte(strconv.FormatInt(w.LatestID, 10)))
310+ } else {
311+ b.Delete(relLatestKey(w.Repo))
312+ }
313+ }
288314 snap := c.db.Snapshot(ctx)
289315 for _, r := range w.DeleteReleases {
290316 if r.Repo != w.Repo || r.Version == 0 {
@@ -388,7 +414,7 @@ func (c *Releases) ApplyReleases(ctx context.Context, w ports.ReleaseWrite) erro
388414
389415 // releaseKeys collects all repository-owned release keys, including global indexes.
390416 func releaseKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte {
391- keys := [][]byte{relUseKey(repo)}
417+ keys := [][]byte{relUseKey(repo), relLatestKey(repo)}
392418 for _, prefix := range []string{"rel/", "reltag/", "relassets/"} {
393419 for key, item := range snap.Prefix(ctx, []byte(prefix+repo.String()+"/")) {
394420 keys = append(keys, slices.Clone(key))
A internal/adapters/kvstore/releases_index.go +67 -0
@@ -0,0 +1,67 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package kvstore
3+
4+import (
5+ "context"
6+ "fmt"
7+ "strconv"
8+
9+ kv "bnema.dev/ermite-kv"
10+ "bnema.dev/ermite/internal/domain"
11+)
12+
13+const releaseLatestIndexMarker = "meta/release-latest-index"
14+
15+// buildReleaseLatestIndex runs before the store is exposed to writers. A crash
16+// before the marker is written safely repeats the pass on the next open.
17+// Selection remains owned by domain.LatestRelease.
18+func (s *Store) buildReleaseLatestIndex(ctx context.Context) error {
19+ if _, ok := s.db.Get(ctx, []byte(releaseLatestIndexMarker)); ok {
20+ return nil
21+ }
22+ var repo domain.RepoName
23+ var best domain.Release
24+ var eligible bool
25+ flush := func() error {
26+ var b kv.Batch
27+ if eligible {
28+ b.Put(relLatestKey(repo), []byte(strconv.FormatInt(best.ID, 10)))
29+ } else {
30+ b.Delete(relLatestKey(repo))
31+ }
32+ if _, err := s.db.Apply(ctx, &b); err != nil {
33+ return fmt.Errorf("build latest release index: %w", err)
34+ }
35+ return nil
36+ }
37+ haveRepo := false
38+ for _, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte("rel/")) {
39+ rel, err := decodeRelease(item)
40+ if err != nil {
41+ return fmt.Errorf("build latest release index: %w", err)
42+ }
43+ if haveRepo && rel.Repo != repo {
44+ if err := flush(); err != nil {
45+ return err
46+ }
47+ best, eligible = domain.Release{}, false
48+ }
49+ repo, haveRepo = rel.Repo, true
50+ if eligible {
51+ best, eligible = domain.LatestRelease([]domain.Release{best, rel})
52+ } else {
53+ best, eligible = domain.LatestRelease([]domain.Release{rel})
54+ }
55+ }
56+ if haveRepo {
57+ if err := flush(); err != nil {
58+ return err
59+ }
60+ }
61+ var b kv.Batch
62+ b.Put([]byte(releaseLatestIndexMarker), []byte{1})
63+ if _, err := s.db.Apply(ctx, &b); err != nil {
64+ return fmt.Errorf("mark latest release index: %w", err)
65+ }
66+ return nil
67+}
A internal/adapters/kvstore/releases_index_test.go +97 -0
@@ -0,0 +1,97 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package kvstore
3+
4+import (
5+ "context"
6+ "testing"
7+ "time"
8+
9+ kv "bnema.dev/ermite-kv"
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestReleaseLatestIndexBackfill(t *testing.T) {
16+ ctx := t.Context()
17+ dir := t.TempDir()
18+ s, err := Open(ctx, dir)
19+ require.NoError(t, err)
20+ var repos []domain.RepoName
21+ for _, raw := range []string{"backfill", "empty", "newest"} {
22+ repo, err := domain.ParseRepoName(raw)
23+ require.NoError(t, err)
24+ repos = append(repos, repo)
25+ }
26+ for _, repo := range repos {
27+ require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPublic}))
28+ }
29+ var b kv.Batch
30+ for i, repo := range repos {
31+ for j := 1; j <= 5; j++ {
32+ rel := domain.Release{ID: int64(i*10 + j), Repo: repo, Tag: string(rune('a' + j)), CreatedAt: time.Unix(int64(j), 0)}
33+ rel.Draft = j == 5 || i == 1
34+ rel.Prerelease = j == 4
35+ rel.MakeLatest = j == 1 && i == 0
36+ b.Put(relKey(repo, rel.ID), encodeRelease(rel))
37+ }
38+ // A stale key must also be removed for a repo with no eligible release.
39+ b.Put(relLatestKey(repo), []byte("999"))
40+ }
41+ b.Delete([]byte(releaseLatestIndexMarker))
42+ _, err = s.db.Apply(ctx, &b)
43+ require.NoError(t, err)
44+ require.NoError(t, s.Close(context.Background()))
45+ s, err = Open(ctx, dir)
46+ require.NoError(t, err)
47+ defer func() { require.NoError(t, s.Close(context.Background())) }()
48+ for i, want := range []int64{1, 0, 23} {
49+ got, err := s.Releases().GetLatestRelease(ctx, repos[i])
50+ if want == 0 {
51+ require.ErrorIs(t, err, domain.ErrNotFound)
52+ } else {
53+ require.NoError(t, err)
54+ require.Equal(t, want, got.ID)
55+ }
56+ }
57+ require.NoError(t, s.buildReleaseLatestIndex(ctx))
58+ got, err := s.Releases().GetLatestRelease(ctx, repos[0])
59+ require.NoError(t, err)
60+ require.EqualValues(t, 1, got.ID)
61+ require.NoError(t, s.Delete(ctx, repos[0]))
62+ _, err = s.Releases().GetLatestRelease(ctx, repos[0])
63+ require.ErrorIs(t, err, domain.ErrNotFound)
64+}
65+
66+func TestGetLatestReleaseAtomicUpdate(t *testing.T) {
67+ ctx := t.Context()
68+ s, err := Open(ctx, t.TempDir())
69+ require.NoError(t, err)
70+ defer func() { require.NoError(t, s.Close(context.Background())) }()
71+ repo, err := domain.ParseRepoName("atomic")
72+ require.NoError(t, err)
73+ require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPublic}))
74+ a := domain.Release{ID: 1, Repo: repo, Tag: "a"}
75+ b := domain.Release{ID: 2, Repo: repo, Tag: "b"}
76+ store := s.Releases()
77+ require.NoError(t, store.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, PutReleases: []domain.Release{a, b}, SetLatest: true, LatestID: a.ID}))
78+ a, err = store.GetLatestRelease(ctx, repo)
79+ require.NoError(t, err)
80+ require.EqualValues(t, 1, a.ID)
81+ _, version, err := store.ReleaseUsage(ctx, repo)
82+ require.NoError(t, err)
83+ a.Prerelease = true
84+ require.NoError(t, store.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version, PutReleases: []domain.Release{a}, SetLatest: true, LatestID: b.ID}))
85+ got, err := store.GetLatestRelease(ctx, repo)
86+ require.NoError(t, err)
87+ require.EqualValues(t, 2, got.ID)
88+ // An asset-only/usage write leaves the index untouched, including its version.
89+ before, ok := s.db.Get(ctx, relLatestKey(repo))
90+ require.True(t, ok)
91+ _, version, err = store.ReleaseUsage(ctx, repo)
92+ require.NoError(t, err)
93+ require.NoError(t, store.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version, Used: 10}))
94+ after, ok := s.db.Get(ctx, relLatestKey(repo))
95+ require.True(t, ok)
96+ require.Equal(t, before, after)
97+}
M internal/adapters/kvstore/store.go +6 -1
@@ -43,7 +43,8 @@ import (
4343 // holding all eleven previous scopes, preserving their owner rights. Version 6
4444 // adds releases and release:write; older builds cannot clean up their metadata
4545 // or preserve the owner rights of upgraded tokens (see upgradeReleaseTokens).
46-const SchemaVersion = 6
46+// Version 7 adds the latest-release index; older writers would leave it stale.
47+const SchemaVersion = 7
4748
4849 const schemaKey = "meta/schema"
4950
@@ -76,6 +77,10 @@ func Open(ctx context.Context, dir string) (*Store, error) {
7677 _ = db.Close(context.WithoutCancel(ctx))
7778 return nil, err
7879 }
80+ if err := s.buildReleaseLatestIndex(ctx); err != nil {
81+ _ = db.Close(context.WithoutCancel(ctx))
82+ return nil, err
83+ }
7984 return s, nil
8085 }
8186
M internal/adapters/web/fragments.go +1 -1
@@ -167,7 +167,7 @@ func (l *liveStream) gone(ctx context.Context, name string) ([]fragment, error)
167167 func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {
168168 switch ev := we.Event.(type) {
169169 case domain.ReleasePublished, domain.ReleaseUpdated, domain.ReleaseDeleted, domain.ReleaseUnpublished:
170- if l.view == "releases" || l.view == "release" {
170+ if l.view == "releases" || l.view == "release" || l.view == "summary" {
171171 return gap, nil // notes, assets, latest or visibility changed: reload
172172 }
173173 return nil, nil
M internal/adapters/web/fragments_test.go +3 -3
@@ -101,8 +101,8 @@ func TestMRPushedHeadSignal(t *testing.T) {
101101 }
102102 }
103103
104-// Release lifecycle events invalidate the release pages without putting notes
105-// or asset metadata in SSE payloads. Other views need no release fragments.
104+// Release lifecycle events invalidate release pages and the summary's release
105+// fact without putting notes or asset metadata in SSE payloads.
106106 func TestReleaseLiveUpdates(t *testing.T) {
107107 for _, ev := range []domain.Event{domain.ReleasePublished{}, domain.ReleaseUpdated{}, domain.ReleaseDeleted{}, domain.ReleaseUnpublished{}} {
108108 we := ports.WatchEvent{Event: ev}
@@ -111,7 +111,7 @@ func TestReleaseLiveUpdates(t *testing.T) {
111111 st := &liveStream{repo: "proj", view: view}
112112 got, err := st.render(t.Context(), we)
113113 require.NoError(t, err)
114- if view == "release" || view == "releases" {
114+ if view == "release" || view == "releases" || view == "summary" {
115115 assert.Equal(t, gap, got)
116116 } else {
117117 assert.Empty(t, got)
M internal/adapters/web/issues_test.go +3 -3
@@ -100,7 +100,7 @@ func TestIssueList(t *testing.T) {
100100 assert.Contains(t, body, "crash &lt;b&gt;on&lt;/b&gt; start")
101101 assert.Contains(t, body, `href="/proj/issues?page=tok2"`, "older page")
102102 assert.Contains(t, body, `href="/proj/issues?state=closed"`, "closed tab")
103- assert.Contains(t, body, `href="/proj/issues" data-key="i"`, "the Issues tab")
103+ assert.Contains(t, body, `href="/proj/issues" data-key="g i"`, "the Issues tab")
104104 assert.NotContains(t, body, "New issue", "no form for an anonymous visitor")
105105 assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control"))
106106
@@ -169,7 +169,7 @@ func TestIssueNotFound(t *testing.T) {
169169 }
170170 summary := off.do("GET", "/proj", false, nil)
171171 require.Equal(t, 200, summary.Code)
172- assert.NotContains(t, summary.Body.String(), `data-key="i"`)
172+ assert.NotContains(t, summary.Body.String(), `data-key="g i"`)
173173
174174 // The mode is unset in the settings: off as well.
175175 unset := newIssueRig(t, "", false)
@@ -257,7 +257,7 @@ func TestIssueRoutesAbsentWithoutService(t *testing.T) {
257257 }
258258 rec := httptest.NewRecorder()
259259 srv.ServeHTTP(rec, httptest.NewRequest("GET", "/proj", nil))
260- assert.NotContains(t, rec.Body.String(), `data-key="i"`)
260+ assert.NotContains(t, rec.Body.String(), `data-key="g i"`)
261261 }
262262
263263 func TestIssueNumberRedirects(t *testing.T) {
A internal/adapters/web/jstest/keyboard.test.mjs +76 -0
@@ -0,0 +1,76 @@
1+// Exercise the production key handler without a browser or dependencies.
2+import assert from 'node:assert/strict';
3+import { readFileSync } from 'node:fs';
4+import { test } from 'node:test';
5+import { runInNewContext } from 'node:vm';
6+const source = readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8');
7+function keyboard({ owner = true, off = false, typing = false, dialog = false } = {}) {
8+ let handler;
9+ const fired = [];
10+ const timers = new Map();
11+ let timerID = 0;
12+ runInNewContext(source.slice(source.indexOf(' const single ='), source.indexOf(' // 2. A [data-fire]')), {
13+ addEventListener: (_, fn) => { handler = fn; },
14+ window: { ermiteKeys: { blocks: () => off } },
15+ ermite: { fire: name => fired.push(name) },
16+ $: selector => (owner && selector === '[data-inbox]') || (dialog && selector === 'dialog[open]'),
17+ closeMenus: () => false,
18+ clearTimeout: id => timers.delete(id),
19+ setTimeout: fn => { timers.set(++timerID, fn); return timerID; },
20+ });
21+ return { fired, expireTimers() {
22+ const callbacks = [...timers.values()];
23+ timers.clear();
24+ for (const callback of callbacks) callback();
25+ }, press(key, shiftKey = false) {
26+ handler({ key, shiftKey, target: { closest: selector => typing && selector.startsWith('input'), blur() {} }, preventDefault() {} });
27+ } };
28+}
29+test('sections require g and have no old t alias', () => {
30+ const k = keyboard();
31+ for (const key of ['s', 't', 'c', 'r', 'm', 'i']) k.press(key);
32+ assert.deepEqual(k.fired, []);
33+ for (const key of ['s', 'f', 'c', 'b', 'm', 'i', 'r', 'a', 'h', 'g', 't']) { k.press('g'); k.press(key); }
34+ assert.deepEqual(k.fired, ['s', 'f', 'c', 'b', 'm', 'i', 'r', 'a', 'gh', 'top']);
35+});
36+test('uppercase actions work with either reported casing and interrupt prefixes', () => {
37+ for (const [key, action] of Object.entries({ G: 'bottom', M: 'merge', R: 'read', E: 'expandAll', I: 'inbox' })) {
38+ for (const reported of [key, key.toLowerCase()]) {
39+ const k = keyboard();
40+ k.press('g'); k.press(reported, true); k.press(reported, true);
41+ assert.deepEqual(k.fired, [action, action]);
42+ }
43+ }
44+});
45+test('uppercase actions respect preference, fields and dialogs', () => {
46+ for (const options of [{ off: true }, { typing: true }, { dialog: true }]) {
47+ const k = keyboard(options); k.press('g', true);
48+ assert.deepEqual(k.fired, []);
49+ }
50+});
51+
52+test('Shift+I opens inbox while issues need the g i sequence', () => {
53+ const k = keyboard();
54+ k.press('I', true); k.press('i');
55+ assert.deepEqual(k.fired, ['inbox']);
56+ k.press('g'); k.press('i');
57+ assert.deepEqual(k.fired, ['inbox', 'i']);
58+});
59+test('inbox shortcut respects preference, fields, dialogs, and owner target', () => {
60+ for (const options of [{ off: true }, { typing: true }, { dialog: true }, { owner: false }]) {
61+ const k = keyboard(options); k.press('I', true);
62+ assert.deepEqual(k.fired, [], JSON.stringify(options));
63+ }
64+});
65+test('shifted punctuation still opens help and the command palette', () => {
66+ const k = keyboard();
67+ k.press('?', true); k.press(':', true);
68+ assert.deepEqual(k.fired, ['help', 'cmd']);
69+});
70+test('g prefix expires without scheduling real timers', () => {
71+ const k = keyboard();
72+ k.press('g'); k.expireTimers(); k.press('s');
73+ assert.deepEqual(k.fired, []);
74+ k.press('g'); k.press('s');
75+ assert.deepEqual(k.fired, ['s']);
76+});
D internal/adapters/web/jstest/keys.test.mjs +0 -29
@@ -1,29 +0,0 @@
1-// Exercise the production keydown handler without loading the rest of the page.
2-import assert from 'node:assert/strict';
3-import { readFileSync } from 'node:fs';
4-import { test } from 'node:test';
5-const src = readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8');
6-const block = src.slice(src.indexOf(' // 1. Keys.'), src.indexOf(' // 2. A [data-fire]'));
7-
8-function page({ owner = true, off = false, typing = false, dialog = false } = {}) {
9- let handler;
10- const fired = [];
11- const window = { ermiteKeys: { blocks: () => off } };
12- const $ = (s) => s === 'dialog[open]' ? dialog : s === '[data-inbox]' ? owner : null;
13- new Function('window', '$', 'ermite', 'addEventListener', block)(window, $, { fire: (n) => fired.push(n) }, (_, f) => { handler = f; });
14- return (key) => {
15- const e = { key, shiftKey: key === 'I', preventDefault() {}, target: { closest: (s) => s.startsWith('input') && typing } };
16- handler(e);
17- return fired;
18- };
19-}
20-test('Shift+I opens inbox while lowercase i retains the issues tab', () => {
21- const press = page();
22- assert.deepEqual(press('I'), ['inbox']);
23- assert.deepEqual(press('i'), ['inbox', 'i']);
24-});
25-test('inbox shortcut respects preference, fields, dialogs, and owner target', () => {
26- for (const options of [{ off: true }, { typing: true }, { dialog: true }, { owner: false }]) {
27- assert.deepEqual(page(options)('I'), [], JSON.stringify(options));
28- }
29-});
M internal/adapters/web/keys.templ +3 -2
@@ -23,7 +23,9 @@ templ Keys() {
2323 k-filter from:body -> ermite.focusFilter();
2424 k-gh from:body -> ermite.go('.brand');
2525 k-s from:body -> ermite.tab('s');
26- k-t from:body -> ermite.tab('t');
26+ k-f from:body -> ermite.tab('f');
27+ k-b from:body -> ermite.tab('b');
28+ k-a from:body -> ermite.tab('a');
2729 k-c from:body -> ermite.tab('c');
2830 k-r from:body -> ermite.tab('r');
2931 k-m from:body -> ermite.tab('m');
@@ -39,7 +41,6 @@ templ Keys() {
3941 k-permalink from:body -> ermite.permalink();
4042 k-merge from:body -> window.ermiteMR?.openMerge();
4143 k-read from:body -> window.ermiteMR?.markRead();
42- k-gf from:body -> window.ermiteMR?.focusFiles();
4344 k-keys from:body -> ermite.toggleKeys()"
4445 ></div>
4546 }
M internal/adapters/web/keys_templ.go +1 -1 generated file: collapsed
A internal/adapters/web/keys_test.go +26 -0
@@ -0,0 +1,26 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+)
10+
11+func TestSectionShortcutSequences(t *testing.T) {
12+ sections := []Section{}
13+ for _, key := range []string{"s", "f", "c", "b", "m", "i", "r", "a"} {
14+ sections = append(sections, Section{Key: key, Label: key, Href: "/" + key})
15+ }
16+ header := renderToString(t, Header(Layout{Sections: sections}))
17+ bindings := renderToString(t, Keys())
18+ for _, section := range sections {
19+ assert.Contains(t, header, `data-key="g `+section.Key+`"`)
20+ assert.NotContains(t, header, `aria-keyshortcuts="g `+section.Key+`"`)
21+ assert.Contains(t, header, `<kbd aria-hidden="true">g `+section.Key+`</kbd>`)
22+ assert.Contains(t, bindings, "ermite.tab('"+section.Key+"')")
23+ }
24+ assert.NotContains(t, bindings, "k-gf")
25+ assert.NotContains(t, bindings, "k-t from:body")
26+}
M internal/adapters/web/mr.templ +1 -1
@@ -430,7 +430,7 @@ templ mrFooter(p MRPage) {
430430 templ mrRail(p MRPage) {
431431 <section aria-labelledby="r-files" class="r-files">
432432 <h2 id="r-files">
433- Files <span class="count">{ itoa(len(p.Files)) }</span><kbd aria-hidden="true">g f</kbd>
433+ Files <span class="count">{ itoa(len(p.Files)) }</span><kbd aria-hidden="true">l</kbd>
434434 <button type="button" class="btn icon fold" data-js aria-expanded="true" title="fold the list of files" hx-on:click="ermiteMR.foldFiles()">
435435 <span class="ico i-chevron" aria-hidden="true"></span><span class="sr">fold the list of files</span>
436436 </button>
M internal/adapters/web/mr_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/mr_test.go +1 -1
@@ -156,7 +156,7 @@ func TestMRPanelsAndMenus(t *testing.T) {
156156 assert.Contains(t, footer, "more commits")
157157 assert.Contains(t, footer, "1/2 failed")
158158 rail := renderToString(t, mrRail(p))
159- assert.Contains(t, rail, "g f")
159+ assert.Contains(t, rail, `<kbd aria-hidden="true">l</kbd>`)
160160 for _, text := range []string{"Checks", "Commits", "Settings", "mr-checks"} {
161161 assert.NotContains(t, rail, text)
162162 }
M internal/adapters/web/overlays.templ +2 -2
@@ -26,7 +26,7 @@ templ OverlayHelp(mr bool) {
2626 <div><dt><kbd>g g</kbd> <kbd>G</kbd></dt><dd>first / last row</dd></div>
2727 <div><dt><kbd>/</kbd></dt><dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd></div>
2828 <div><dt><kbd>b</kbd></dt><dd>{ "switch" } branch or tag</dd></div>
29- <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd> <kbd>i</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd></div>
29+ <div><dt><kbd>g s</kbd> <kbd>g f</kbd> <kbd>g c</kbd> <kbd>g b</kbd> <kbd>g m</kbd> <kbd>g i</kbd> <kbd>g r</kbd> <kbd>g a</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues, releases, actions (when available)</dd></div>
3030 if SignedIn(ctx) {
3131 <div><dt><kbd>I</kbd></dt><dd>inbox</dd></div>
3232 }
@@ -37,7 +37,7 @@ templ OverlayHelp(mr bool) {
3737 <div><dt><kbd>e</kbd> <kbd>E</kbd></dt><dd>diff: the whole file in view / every file, with the lines the hunks hide</dd></div>
3838 <div><dt><kbd>n</kbd></dt><dd>merge request: next note in the diff</dd></div>
3939 if mr {
40- <div><dt><kbd>g f</kbd></dt><dd>merge request: the list of files (j k move, enter opens, esc back)</dd></div>
40+ <div><dt><kbd>l</kbd></dt><dd>merge request: the list of files (j k move, enter opens, esc back)</dd></div>
4141 <div><dt><kbd>M</kbd></dt><dd>merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges</dd></div>
4242 <div><dt><kbd>R</kbd></dt><dd>merge request: mark the head read</dd></div>
4343 }
M internal/adapters/web/overlays_templ.go +2 -2 generated file: collapsed
M internal/adapters/web/pages.templ +8 -0
@@ -115,6 +115,14 @@ templ summaryRail(p SummaryPage) {
115115 if p.Latest != nil {
116116 @latestTagFact(*p.Latest)
117117 }
118+ if r := p.Release; r != nil {
119+ <dt>release</dt>
120+ <dd>
121+ <a href={ templ.SafeURL(r.Href) }>{ r.Tag }</a>{ " · " }
122+ <time datetime={ r.WhenAt } title={ r.WhenAt }>{ r.When }</time>{ " · " }
123+ <a href={ templ.SafeURL(r.AllHref) }>all releases</a>
124+ </dd>
125+ }
118126 </dl>
119127 </section>
120128 if len(p.Recent) > 0 {
M internal/adapters/web/pages_templ.go +371 -261
@@ -534,25 +534,135 @@ func summaryRail(p SummaryPage) templ.Component {
534534 return templ_7745c5c3_Err
535535 }
536536 }
537- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</dl></section>")
537+ if r := p.Release; r != nil {
538+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<dt>release</dt><dd><a href=\"")
539+ if templ_7745c5c3_Err != nil {
540+ return templ_7745c5c3_Err
541+ }
542+ var templ_7745c5c3_Var27 templ.SafeURL
543+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
544+ if templ_7745c5c3_Err != nil {
545+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 121, Col: 36}
546+ }
547+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
548+ if templ_7745c5c3_Err != nil {
549+ return templ_7745c5c3_Err
550+ }
551+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\">")
552+ if templ_7745c5c3_Err != nil {
553+ return templ_7745c5c3_Err
554+ }
555+ var templ_7745c5c3_Var28 string
556+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(r.Tag)
557+ if templ_7745c5c3_Err != nil {
558+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 121, Col: 46}
559+ }
560+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
561+ if templ_7745c5c3_Err != nil {
562+ return templ_7745c5c3_Err
563+ }
564+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</a>")
565+ if templ_7745c5c3_Err != nil {
566+ return templ_7745c5c3_Err
567+ }
568+ var templ_7745c5c3_Var29 string
569+ templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
570+ if templ_7745c5c3_Err != nil {
571+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 121, Col: 60}
572+ }
573+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
574+ if templ_7745c5c3_Err != nil {
575+ return templ_7745c5c3_Err
576+ }
577+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " <time datetime=\"")
578+ if templ_7745c5c3_Err != nil {
579+ return templ_7745c5c3_Err
580+ }
581+ var templ_7745c5c3_Var30 string
582+ templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
583+ if templ_7745c5c3_Err != nil {
584+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 122, Col: 30}
585+ }
586+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
587+ if templ_7745c5c3_Err != nil {
588+ return templ_7745c5c3_Err
589+ }
590+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\" title=\"")
591+ if templ_7745c5c3_Err != nil {
592+ return templ_7745c5c3_Err
593+ }
594+ var templ_7745c5c3_Var31 string
595+ templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
596+ if templ_7745c5c3_Err != nil {
597+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 122, Col: 49}
598+ }
599+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
600+ if templ_7745c5c3_Err != nil {
601+ return templ_7745c5c3_Err
602+ }
603+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\">")
604+ if templ_7745c5c3_Err != nil {
605+ return templ_7745c5c3_Err
606+ }
607+ var templ_7745c5c3_Var32 string
608+ templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(r.When)
609+ if templ_7745c5c3_Err != nil {
610+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 122, Col: 60}
611+ }
612+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
613+ if templ_7745c5c3_Err != nil {
614+ return templ_7745c5c3_Err
615+ }
616+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</time>")
617+ if templ_7745c5c3_Err != nil {
618+ return templ_7745c5c3_Err
619+ }
620+ var templ_7745c5c3_Var33 string
621+ templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
622+ if templ_7745c5c3_Err != nil {
623+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 122, Col: 77}
624+ }
625+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
626+ if templ_7745c5c3_Err != nil {
627+ return templ_7745c5c3_Err
628+ }
629+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " <a href=\"")
630+ if templ_7745c5c3_Err != nil {
631+ return templ_7745c5c3_Err
632+ }
633+ var templ_7745c5c3_Var34 templ.SafeURL
634+ templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.AllHref))
635+ if templ_7745c5c3_Err != nil {
636+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 123, Col: 39}
637+ }
638+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
639+ if templ_7745c5c3_Err != nil {
640+ return templ_7745c5c3_Err
641+ }
642+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\">all releases</a></dd>")
643+ if templ_7745c5c3_Err != nil {
644+ return templ_7745c5c3_Err
645+ }
646+ }
647+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</dl></section>")
538648 if templ_7745c5c3_Err != nil {
539649 return templ_7745c5c3_Err
540650 }
541651 if len(p.Recent) > 0 {
542- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<section aria-labelledby=\"r-recent\"><h2 id=\"r-recent\">Recent commits <a href=\"")
652+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<section aria-labelledby=\"r-recent\"><h2 id=\"r-recent\">Recent commits <a href=\"")
543653 if templ_7745c5c3_Err != nil {
544654 return templ_7745c5c3_Err
545655 }
546- var templ_7745c5c3_Var27 templ.SafeURL
547- templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.AllHref))
656+ var templ_7745c5c3_Var35 templ.SafeURL
657+ templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.AllHref))
548658 if templ_7745c5c3_Err != nil {
549- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 121, Col: 105}
659+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 129, Col: 105}
550660 }
551- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
661+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
552662 if templ_7745c5c3_Err != nil {
553663 return templ_7745c5c3_Err
554664 }
555- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\">View all</a></h2>")
665+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\">View all</a></h2>")
556666 if templ_7745c5c3_Err != nil {
557667 return templ_7745c5c3_Err
558668 }
@@ -560,7 +670,7 @@ func summaryRail(p SummaryPage) templ.Component {
560670 if templ_7745c5c3_Err != nil {
561671 return templ_7745c5c3_Err
562672 }
563- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</section>")
673+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</section>")
564674 if templ_7745c5c3_Err != nil {
565675 return templ_7745c5c3_Err
566676 }
@@ -572,7 +682,7 @@ func summaryRail(p SummaryPage) templ.Component {
572682 }
573683 }
574684 if len(p.Outline) > 0 && p.Readme != nil {
575- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<section aria-labelledby=\"r-outline\"><h2 id=\"r-outline\">On this page</h2>")
685+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<section aria-labelledby=\"r-outline\"><h2 id=\"r-outline\">On this page</h2>")
576686 if templ_7745c5c3_Err != nil {
577687 return templ_7745c5c3_Err
578688 }
@@ -580,7 +690,7 @@ func summaryRail(p SummaryPage) templ.Component {
580690 if templ_7745c5c3_Err != nil {
581691 return templ_7745c5c3_Err
582692 }
583- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</section>")
693+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</section>")
584694 if templ_7745c5c3_Err != nil {
585695 return templ_7745c5c3_Err
586696 }
@@ -606,12 +716,12 @@ func Refs(l Layout, p RefsPage) templ.Component {
606716 }()
607717 }
608718 ctx = templ.InitializeContext(ctx)
609- templ_7745c5c3_Var28 := templ.GetChildren(ctx)
610- if templ_7745c5c3_Var28 == nil {
611- templ_7745c5c3_Var28 = templ.NopComponent
719+ templ_7745c5c3_Var36 := templ.GetChildren(ctx)
720+ if templ_7745c5c3_Var36 == nil {
721+ templ_7745c5c3_Var36 = templ.NopComponent
612722 }
613723 ctx = templ.ClearChildren(ctx)
614- templ_7745c5c3_Var29 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
724+ templ_7745c5c3_Var37 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
615725 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
616726 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
617727 if !templ_7745c5c3_IsBuffer {
@@ -623,7 +733,7 @@ func Refs(l Layout, p RefsPage) templ.Component {
623733 }()
624734 }
625735 ctx = templ.InitializeContext(ctx)
626- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<div class=\"filter\"><label for=\"ref-filter\" class=\"sr\">filter</label> <input id=\"ref-filter\" type=\"search\" data-filter=\"#ref-rows > tr\" placeholder=\"Filter branches and tags…\" autocomplete=\"off\" hx-on:input=\"ermite.filter(this)\"></div><table class=\"list refs\" aria-label=\"Branches and tags\"><thead><tr><th class=\"c-kind\">kind</th><th class=\"c-name\">name</th><th class=\"c-sha\">commit</th><th class=\"c-sub\">subject</th><th class=\"c-when\">date</th></tr></thead>")
736+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<div class=\"filter\"><label for=\"ref-filter\" class=\"sr\">filter</label> <input id=\"ref-filter\" type=\"search\" data-filter=\"#ref-rows > tr\" placeholder=\"Filter branches and tags…\" autocomplete=\"off\" hx-on:input=\"ermite.filter(this)\"></div><table class=\"list refs\" aria-label=\"Branches and tags\"><thead><tr><th class=\"c-kind\">kind</th><th class=\"c-name\">name</th><th class=\"c-sha\">commit</th><th class=\"c-sub\">subject</th><th class=\"c-when\">date</th></tr></thead>")
627737 if templ_7745c5c3_Err != nil {
628738 return templ_7745c5c3_Err
629739 }
@@ -631,32 +741,32 @@ func Refs(l Layout, p RefsPage) templ.Component {
631741 if templ_7745c5c3_Err != nil {
632742 return templ_7745c5c3_Err
633743 }
634- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</table>")
744+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</table>")
635745 if templ_7745c5c3_Err != nil {
636746 return templ_7745c5c3_Err
637747 }
638748 if p.Note != "" {
639- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<p class=\"note\">")
749+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<p class=\"note\">")
640750 if templ_7745c5c3_Err != nil {
641751 return templ_7745c5c3_Err
642752 }
643- var templ_7745c5c3_Var30 string
644- templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)
753+ var templ_7745c5c3_Var38 string
754+ templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)
645755 if templ_7745c5c3_Err != nil {
646- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 154, Col: 27}
756+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 162, Col: 27}
647757 }
648- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
758+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
649759 if templ_7745c5c3_Err != nil {
650760 return templ_7745c5c3_Err
651761 }
652- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</p>")
762+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</p>")
653763 if templ_7745c5c3_Err != nil {
654764 return templ_7745c5c3_Err
655765 }
656766 }
657767 return nil
658768 })
659- templ_7745c5c3_Err = Shell(l, refsRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var29), templ_7745c5c3_Buffer)
769+ templ_7745c5c3_Err = Shell(l, refsRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var37), templ_7745c5c3_Buffer)
660770 if templ_7745c5c3_Err != nil {
661771 return templ_7745c5c3_Err
662772 }
@@ -680,112 +790,112 @@ func refRow(kind string, r RefRow) templ.Component {
680790 }()
681791 }
682792 ctx = templ.InitializeContext(ctx)
683- templ_7745c5c3_Var31 := templ.GetChildren(ctx)
684- if templ_7745c5c3_Var31 == nil {
685- templ_7745c5c3_Var31 = templ.NopComponent
793+ templ_7745c5c3_Var39 := templ.GetChildren(ctx)
794+ if templ_7745c5c3_Var39 == nil {
795+ templ_7745c5c3_Var39 = templ.NopComponent
686796 }
687797 ctx = templ.ClearChildren(ctx)
688- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<tr")
798+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<tr")
689799 if templ_7745c5c3_Err != nil {
690800 return templ_7745c5c3_Err
691801 }
692802 if r.Cursor {
693- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " data-cursor")
803+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, " data-cursor")
694804 if templ_7745c5c3_Err != nil {
695805 return templ_7745c5c3_Err
696806 }
697807 }
698- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "><td class=\"c-kind mute\">")
808+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "><td class=\"c-kind mute\">")
699809 if templ_7745c5c3_Err != nil {
700810 return templ_7745c5c3_Err
701811 }
702- var templ_7745c5c3_Var32 string
703- templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(kind)
812+ var templ_7745c5c3_Var40 string
813+ templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(kind)
704814 if templ_7745c5c3_Err != nil {
705- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 161, Col: 32}
815+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 169, Col: 32}
706816 }
707- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
817+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
708818 if templ_7745c5c3_Err != nil {
709819 return templ_7745c5c3_Err
710820 }
711- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</td><td class=\"c-name\"><a href=\"")
821+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</td><td class=\"c-name\"><a href=\"")
712822 if templ_7745c5c3_Err != nil {
713823 return templ_7745c5c3_Err
714824 }
715- var templ_7745c5c3_Var33 templ.SafeURL
716- templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
825+ var templ_7745c5c3_Var41 templ.SafeURL
826+ templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
717827 if templ_7745c5c3_Err != nil {
718- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 163, Col: 34}
828+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 171, Col: 34}
719829 }
720- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
830+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
721831 if templ_7745c5c3_Err != nil {
722832 return templ_7745c5c3_Err
723833 }
724- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\">")
834+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\">")
725835 if templ_7745c5c3_Err != nil {
726836 return templ_7745c5c3_Err
727837 }
728- var templ_7745c5c3_Var34 string
729- templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
838+ var templ_7745c5c3_Var42 string
839+ templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
730840 if templ_7745c5c3_Err != nil {
731- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 163, Col: 45}
841+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 171, Col: 45}
732842 }
733- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
843+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
734844 if templ_7745c5c3_Err != nil {
735845 return templ_7745c5c3_Err
736846 }
737- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</a> ")
847+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</a> ")
738848 if templ_7745c5c3_Err != nil {
739849 return templ_7745c5c3_Err
740850 }
741851 if r.Default {
742- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<span class=\"pill\">default</span>")
852+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<span class=\"pill\">default</span>")
743853 if templ_7745c5c3_Err != nil {
744854 return templ_7745c5c3_Err
745855 }
746856 }
747- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</td><td class=\"c-sha\">")
857+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</td><td class=\"c-sha\">")
748858 if templ_7745c5c3_Err != nil {
749859 return templ_7745c5c3_Err
750860 }
751861 if r.CHref != "" {
752- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<a href=\"")
862+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<a href=\"")
753863 if templ_7745c5c3_Err != nil {
754864 return templ_7745c5c3_Err
755865 }
756- var templ_7745c5c3_Var35 templ.SafeURL
757- templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CHref))
866+ var templ_7745c5c3_Var43 templ.SafeURL
867+ templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CHref))
758868 if templ_7745c5c3_Err != nil {
759- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 170, Col: 36}
869+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 178, Col: 36}
760870 }
761- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
871+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
762872 if templ_7745c5c3_Err != nil {
763873 return templ_7745c5c3_Err
764874 }
765- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\">")
875+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "\">")
766876 if templ_7745c5c3_Err != nil {
767877 return templ_7745c5c3_Err
768878 }
769- var templ_7745c5c3_Var36 string
770- templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short)
879+ var templ_7745c5c3_Var44 string
880+ templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short)
771881 if templ_7745c5c3_Err != nil {
772- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 170, Col: 48}
882+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 178, Col: 48}
773883 }
774- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
884+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
775885 if templ_7745c5c3_Err != nil {
776886 return templ_7745c5c3_Err
777887 }
778- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</a>")
888+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</a>")
779889 if templ_7745c5c3_Err != nil {
780890 return templ_7745c5c3_Err
781891 }
782892 } else {
783- var templ_7745c5c3_Var37 string
784- templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short)
893+ var templ_7745c5c3_Var45 string
894+ templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short)
785895 if templ_7745c5c3_Err != nil {
786- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 172, Col: 13}
896+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 180, Col: 13}
787897 }
788- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
898+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
789899 if templ_7745c5c3_Err != nil {
790900 return templ_7745c5c3_Err
791901 }
@@ -794,69 +904,69 @@ func refRow(kind string, r RefRow) templ.Component {
794904 if templ_7745c5c3_Err != nil {
795905 return templ_7745c5c3_Err
796906 }
797- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</td><td class=\"c-sub\">")
907+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</td><td class=\"c-sub\">")
798908 if templ_7745c5c3_Err != nil {
799909 return templ_7745c5c3_Err
800910 }
801- var templ_7745c5c3_Var38 string
802- templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(r.Subject)
911+ var templ_7745c5c3_Var46 string
912+ templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(r.Subject)
803913 if templ_7745c5c3_Err != nil {
804- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 176, Col: 31}
914+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 184, Col: 31}
805915 }
806- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
916+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
807917 if templ_7745c5c3_Err != nil {
808918 return templ_7745c5c3_Err
809919 }
810- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</td><td class=\"c-when dim\">")
920+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</td><td class=\"c-when dim\">")
811921 if templ_7745c5c3_Err != nil {
812922 return templ_7745c5c3_Err
813923 }
814924 if r.WhenAt != "" {
815- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<time datetime=\"")
925+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<time datetime=\"")
816926 if templ_7745c5c3_Err != nil {
817927 return templ_7745c5c3_Err
818928 }
819- var templ_7745c5c3_Var39 string
820- templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
929+ var templ_7745c5c3_Var47 string
930+ templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
821931 if templ_7745c5c3_Err != nil {
822- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 179, Col: 29}
932+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 187, Col: 29}
823933 }
824- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)
934+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var47)
825935 if templ_7745c5c3_Err != nil {
826936 return templ_7745c5c3_Err
827937 }
828- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\" title=\"")
938+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\" title=\"")
829939 if templ_7745c5c3_Err != nil {
830940 return templ_7745c5c3_Err
831941 }
832- var templ_7745c5c3_Var40 string
833- templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
942+ var templ_7745c5c3_Var48 string
943+ templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
834944 if templ_7745c5c3_Err != nil {
835- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 179, Col: 48}
945+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 187, Col: 48}
836946 }
837- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var40)
947+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var48)
838948 if templ_7745c5c3_Err != nil {
839949 return templ_7745c5c3_Err
840950 }
841- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\">")
951+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\">")
842952 if templ_7745c5c3_Err != nil {
843953 return templ_7745c5c3_Err
844954 }
845- var templ_7745c5c3_Var41 string
846- templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(r.When)
955+ var templ_7745c5c3_Var49 string
956+ templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(r.When)
847957 if templ_7745c5c3_Err != nil {
848- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 179, Col: 59}
958+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 187, Col: 59}
849959 }
850- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
960+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
851961 if templ_7745c5c3_Err != nil {
852962 return templ_7745c5c3_Err
853963 }
854- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</time>")
964+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</time>")
855965 if templ_7745c5c3_Err != nil {
856966 return templ_7745c5c3_Err
857967 }
858968 }
859- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</td></tr>")
969+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</td></tr>")
860970 if templ_7745c5c3_Err != nil {
861971 return templ_7745c5c3_Err
862972 }
@@ -880,12 +990,12 @@ func refsRail(p RefsPage) templ.Component {
880990 }()
881991 }
882992 ctx = templ.InitializeContext(ctx)
883- templ_7745c5c3_Var42 := templ.GetChildren(ctx)
884- if templ_7745c5c3_Var42 == nil {
885- templ_7745c5c3_Var42 = templ.NopComponent
993+ templ_7745c5c3_Var50 := templ.GetChildren(ctx)
994+ if templ_7745c5c3_Var50 == nil {
995+ templ_7745c5c3_Var50 = templ.NopComponent
886996 }
887997 ctx = templ.ClearChildren(ctx)
888- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<section aria-labelledby=\"r-refs\"><h2 id=\"r-refs\">Overview</h2>")
998+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "<section aria-labelledby=\"r-refs\"><h2 id=\"r-refs\">Overview</h2>")
889999 if templ_7745c5c3_Err != nil {
8901000 return templ_7745c5c3_Err
8911001 }
@@ -893,7 +1003,7 @@ func refsRail(p RefsPage) templ.Component {
8931003 if templ_7745c5c3_Err != nil {
8941004 return templ_7745c5c3_Err
8951005 }
896- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</section>")
1006+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</section>")
8971007 if templ_7745c5c3_Err != nil {
8981008 return templ_7745c5c3_Err
8991009 }
@@ -918,12 +1028,12 @@ func State(l Layout, p StatePage) templ.Component {
9181028 }()
9191029 }
9201030 ctx = templ.InitializeContext(ctx)
921- templ_7745c5c3_Var43 := templ.GetChildren(ctx)
922- if templ_7745c5c3_Var43 == nil {
923- templ_7745c5c3_Var43 = templ.NopComponent
1031+ templ_7745c5c3_Var51 := templ.GetChildren(ctx)
1032+ if templ_7745c5c3_Var51 == nil {
1033+ templ_7745c5c3_Var51 = templ.NopComponent
9241034 }
9251035 ctx = templ.ClearChildren(ctx)
926- templ_7745c5c3_Var44 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1036+ templ_7745c5c3_Var52 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
9271037 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
9281038 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
9291039 if !templ_7745c5c3_IsBuffer {
@@ -935,51 +1045,51 @@ func State(l Layout, p StatePage) templ.Component {
9351045 }()
9361046 }
9371047 ctx = templ.InitializeContext(ctx)
938- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<section class=\"state\" aria-labelledby=\"state-title\"><p class=\"eyebrow\">")
1048+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<section class=\"state\" aria-labelledby=\"state-title\"><p class=\"eyebrow\">")
9391049 if templ_7745c5c3_Err != nil {
9401050 return templ_7745c5c3_Err
9411051 }
942- var templ_7745c5c3_Var45 string
943- templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(p.Code)
1052+ var templ_7745c5c3_Var53 string
1053+ templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(p.Code)
9441054 if templ_7745c5c3_Err != nil {
945- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 196, Col: 30}
1055+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 204, Col: 30}
9461056 }
947- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
1057+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
9481058 if templ_7745c5c3_Err != nil {
9491059 return templ_7745c5c3_Err
9501060 }
951- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</p><h1 id=\"state-title\">")
1061+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</p><h1 id=\"state-title\">")
9521062 if templ_7745c5c3_Err != nil {
9531063 return templ_7745c5c3_Err
9541064 }
955- var templ_7745c5c3_Var46 string
956- templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
1065+ var templ_7745c5c3_Var54 string
1066+ templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
9571067 if templ_7745c5c3_Err != nil {
958- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 197, Col: 33}
1068+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 205, Col: 33}
9591069 }
960- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
1070+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
9611071 if templ_7745c5c3_Err != nil {
9621072 return templ_7745c5c3_Err
9631073 }
964- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</h1>")
1074+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</h1>")
9651075 if templ_7745c5c3_Err != nil {
9661076 return templ_7745c5c3_Err
9671077 }
9681078 for _, line := range p.Lines {
969- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "<p class=\"dim\">")
1079+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<p class=\"dim\">")
9701080 if templ_7745c5c3_Err != nil {
9711081 return templ_7745c5c3_Err
9721082 }
973- var templ_7745c5c3_Var47 string
974- templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(line)
1083+ var templ_7745c5c3_Var55 string
1084+ templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(line)
9751085 if templ_7745c5c3_Err != nil {
976- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 199, Col: 25}
1086+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 207, Col: 25}
9771087 }
978- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
1088+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
9791089 if templ_7745c5c3_Err != nil {
9801090 return templ_7745c5c3_Err
9811091 }
982- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</p>")
1092+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</p>")
9831093 if templ_7745c5c3_Err != nil {
9841094 return templ_7745c5c3_Err
9851095 }
@@ -990,49 +1100,49 @@ func State(l Layout, p StatePage) templ.Component {
9901100 return templ_7745c5c3_Err
9911101 }
9921102 }
993- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<p class=\"state-back\">")
1103+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<p class=\"state-back\">")
9941104 if templ_7745c5c3_Err != nil {
9951105 return templ_7745c5c3_Err
9961106 }
9971107 for _, b := range p.Back {
998- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<a href=\"")
1108+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "<a href=\"")
9991109 if templ_7745c5c3_Err != nil {
10001110 return templ_7745c5c3_Err
10011111 }
1002- var templ_7745c5c3_Var48 templ.SafeURL
1003- templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(b.Href))
1112+ var templ_7745c5c3_Var56 templ.SafeURL
1113+ templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(b.Href))
10041114 if templ_7745c5c3_Err != nil {
1005- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 206, Col: 36}
1115+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 214, Col: 36}
10061116 }
1007- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
1117+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
10081118 if templ_7745c5c3_Err != nil {
10091119 return templ_7745c5c3_Err
10101120 }
1011- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "\">")
1121+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "\">")
10121122 if templ_7745c5c3_Err != nil {
10131123 return templ_7745c5c3_Err
10141124 }
1015- var templ_7745c5c3_Var49 string
1016- templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(b.Label)
1125+ var templ_7745c5c3_Var57 string
1126+ templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(b.Label)
10171127 if templ_7745c5c3_Err != nil {
1018- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 206, Col: 48}
1128+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 214, Col: 48}
10191129 }
1020- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
1130+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))
10211131 if templ_7745c5c3_Err != nil {
10221132 return templ_7745c5c3_Err
10231133 }
1024- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</a>")
1134+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</a>")
10251135 if templ_7745c5c3_Err != nil {
10261136 return templ_7745c5c3_Err
10271137 }
10281138 }
1029- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</p></section>")
1139+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</p></section>")
10301140 if templ_7745c5c3_Err != nil {
10311141 return templ_7745c5c3_Err
10321142 }
10331143 return nil
10341144 })
1035- templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var44), templ_7745c5c3_Buffer)
1145+ templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var52), templ_7745c5c3_Buffer)
10361146 if templ_7745c5c3_Err != nil {
10371147 return templ_7745c5c3_Err
10381148 }
@@ -1061,12 +1171,12 @@ func Login(l Layout, p LoginPage) templ.Component {
10611171 }()
10621172 }
10631173 ctx = templ.InitializeContext(ctx)
1064- templ_7745c5c3_Var50 := templ.GetChildren(ctx)
1065- if templ_7745c5c3_Var50 == nil {
1066- templ_7745c5c3_Var50 = templ.NopComponent
1174+ templ_7745c5c3_Var58 := templ.GetChildren(ctx)
1175+ if templ_7745c5c3_Var58 == nil {
1176+ templ_7745c5c3_Var58 = templ.NopComponent
10671177 }
10681178 ctx = templ.ClearChildren(ctx)
1069- templ_7745c5c3_Var51 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1179+ templ_7745c5c3_Var59 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
10701180 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
10711181 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
10721182 if !templ_7745c5c3_IsBuffer {
@@ -1078,75 +1188,75 @@ func Login(l Layout, p LoginPage) templ.Component {
10781188 }()
10791189 }
10801190 ctx = templ.InitializeContext(ctx)
1081- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<section class=\"state\" aria-labelledby=\"state-title\"><h1 id=\"state-title\">")
1191+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<section class=\"state\" aria-labelledby=\"state-title\"><h1 id=\"state-title\">")
10821192 if templ_7745c5c3_Err != nil {
10831193 return templ_7745c5c3_Err
10841194 }
1085- var templ_7745c5c3_Var52 string
1086- templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
1195+ var templ_7745c5c3_Var60 string
1196+ templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
10871197 if templ_7745c5c3_Err != nil {
1088- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 221, Col: 33}
1198+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 229, Col: 33}
10891199 }
1090- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))
1200+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60))
10911201 if templ_7745c5c3_Err != nil {
10921202 return templ_7745c5c3_Err
10931203 }
1094- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</h1>")
1204+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "</h1>")
10951205 if templ_7745c5c3_Err != nil {
10961206 return templ_7745c5c3_Err
10971207 }
10981208 for _, line := range p.Lines {
1099- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<p class=\"dim\">")
1209+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "<p class=\"dim\">")
11001210 if templ_7745c5c3_Err != nil {
11011211 return templ_7745c5c3_Err
11021212 }
1103- var templ_7745c5c3_Var53 string
1104- templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(line)
1213+ var templ_7745c5c3_Var61 string
1214+ templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(line)
11051215 if templ_7745c5c3_Err != nil {
1106- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 223, Col: 25}
1216+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 231, Col: 25}
11071217 }
1108- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
1218+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61))
11091219 if templ_7745c5c3_Err != nil {
11101220 return templ_7745c5c3_Err
11111221 }
1112- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</p>")
1222+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "</p>")
11131223 if templ_7745c5c3_Err != nil {
11141224 return templ_7745c5c3_Err
11151225 }
11161226 }
1117- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<form class=\"state-act\" method=\"post\" action=\"")
1227+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "<form class=\"state-act\" method=\"post\" action=\"")
11181228 if templ_7745c5c3_Err != nil {
11191229 return templ_7745c5c3_Err
11201230 }
1121- var templ_7745c5c3_Var54 templ.SafeURL
1122- templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Action))
1231+ var templ_7745c5c3_Var62 templ.SafeURL
1232+ templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Action))
11231233 if templ_7745c5c3_Err != nil {
1124- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 225, Col: 73}
1234+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 233, Col: 73}
11251235 }
1126- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
1236+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62))
11271237 if templ_7745c5c3_Err != nil {
11281238 return templ_7745c5c3_Err
11291239 }
1130- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\" data-autosubmit><button type=\"submit\" class=\"btn primary\">")
1240+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "\" data-autosubmit><button type=\"submit\" class=\"btn primary\">")
11311241 if templ_7745c5c3_Err != nil {
11321242 return templ_7745c5c3_Err
11331243 }
1134- var templ_7745c5c3_Var55 string
1135- templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button)
1244+ var templ_7745c5c3_Var63 string
1245+ templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button)
11361246 if templ_7745c5c3_Err != nil {
1137- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 226, Col: 56}
1247+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 234, Col: 56}
11381248 }
1139- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
1249+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63))
11401250 if templ_7745c5c3_Err != nil {
11411251 return templ_7745c5c3_Err
11421252 }
1143- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</button></form><p class=\"state-back\"><a href=\"/\">Back to home</a></p></section>")
1253+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "</button></form><p class=\"state-back\"><a href=\"/\">Back to home</a></p></section>")
11441254 if templ_7745c5c3_Err != nil {
11451255 return templ_7745c5c3_Err
11461256 }
11471257 return nil
11481258 })
1149- templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var51), templ_7745c5c3_Buffer)
1259+ templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var59), templ_7745c5c3_Buffer)
11501260 if templ_7745c5c3_Err != nil {
11511261 return templ_7745c5c3_Err
11521262 }
@@ -1172,12 +1282,12 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component {
11721282 }()
11731283 }
11741284 ctx = templ.InitializeContext(ctx)
1175- templ_7745c5c3_Var56 := templ.GetChildren(ctx)
1176- if templ_7745c5c3_Var56 == nil {
1177- templ_7745c5c3_Var56 = templ.NopComponent
1285+ templ_7745c5c3_Var64 := templ.GetChildren(ctx)
1286+ if templ_7745c5c3_Var64 == nil {
1287+ templ_7745c5c3_Var64 = templ.NopComponent
11781288 }
11791289 ctx = templ.ClearChildren(ctx)
1180- templ_7745c5c3_Var57 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1290+ templ_7745c5c3_Var65 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
11811291 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
11821292 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
11831293 if !templ_7745c5c3_IsBuffer {
@@ -1189,88 +1299,88 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component {
11891299 }()
11901300 }
11911301 ctx = templ.InitializeContext(ctx)
1192- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<section class=\"state\" aria-labelledby=\"state-title\"><h1 id=\"state-title\">")
1302+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "<section class=\"state\" aria-labelledby=\"state-title\"><h1 id=\"state-title\">")
11931303 if templ_7745c5c3_Err != nil {
11941304 return templ_7745c5c3_Err
11951305 }
1196- var templ_7745c5c3_Var58 string
1197- templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
1306+ var templ_7745c5c3_Var66 string
1307+ templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
11981308 if templ_7745c5c3_Err != nil {
1199- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 238, Col: 33}
1309+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 246, Col: 33}
12001310 }
1201- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58))
1311+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66))
12021312 if templ_7745c5c3_Err != nil {
12031313 return templ_7745c5c3_Err
12041314 }
1205- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</h1><form class=\"state-act\" data-js data-passkey-form><button type=\"submit\" class=\"btn primary\" data-passkey=\"login\" data-begin=\"")
1315+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "</h1><form class=\"state-act\" data-js data-passkey-form><button type=\"submit\" class=\"btn primary\" data-passkey=\"login\" data-begin=\"")
12061316 if templ_7745c5c3_Err != nil {
12071317 return templ_7745c5c3_Err
12081318 }
1209- var templ_7745c5c3_Var59 string
1210- templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin)
1319+ var templ_7745c5c3_Var67 string
1320+ templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin)
12111321 if templ_7745c5c3_Err != nil {
1212- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 240, Col: 87}
1322+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 248, Col: 87}
12131323 }
1214- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var59)
1324+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var67)
12151325 if templ_7745c5c3_Err != nil {
12161326 return templ_7745c5c3_Err
12171327 }
1218- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "\" data-finish=\"")
1328+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "\" data-finish=\"")
12191329 if templ_7745c5c3_Err != nil {
12201330 return templ_7745c5c3_Err
12211331 }
1222- var templ_7745c5c3_Var60 string
1223- templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish)
1332+ var templ_7745c5c3_Var68 string
1333+ templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish)
12241334 if templ_7745c5c3_Err != nil {
1225- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 240, Col: 112}
1335+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 248, Col: 112}
12261336 }
1227- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var60)
1337+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var68)
12281338 if templ_7745c5c3_Err != nil {
12291339 return templ_7745c5c3_Err
12301340 }
1231- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "\">")
1341+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "\">")
12321342 if templ_7745c5c3_Err != nil {
12331343 return templ_7745c5c3_Err
12341344 }
1235- var templ_7745c5c3_Var61 string
1236- templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button)
1345+ var templ_7745c5c3_Var69 string
1346+ templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button)
12371347 if templ_7745c5c3_Err != nil {
1238- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 240, Col: 125}
1348+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 248, Col: 125}
12391349 }
1240- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61))
1350+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var69))
12411351 if templ_7745c5c3_Err != nil {
12421352 return templ_7745c5c3_Err
12431353 }
1244- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "</button></form><p class=\"dim\" role=\"status\" aria-live=\"polite\" data-passkey-status></p>")
1354+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "</button></form><p class=\"dim\" role=\"status\" aria-live=\"polite\" data-passkey-status></p>")
12451355 if templ_7745c5c3_Err != nil {
12461356 return templ_7745c5c3_Err
12471357 }
12481358 for _, line := range p.Lines {
1249- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "<p class=\"dim\">")
1359+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "<p class=\"dim\">")
12501360 if templ_7745c5c3_Err != nil {
12511361 return templ_7745c5c3_Err
12521362 }
1253- var templ_7745c5c3_Var62 string
1254- templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(line)
1363+ var templ_7745c5c3_Var70 string
1364+ templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinStringErrs(line)
12551365 if templ_7745c5c3_Err != nil {
1256- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 244, Col: 25}
1366+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 252, Col: 25}
12571367 }
1258- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62))
1368+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70))
12591369 if templ_7745c5c3_Err != nil {
12601370 return templ_7745c5c3_Err
12611371 }
1262- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "</p>")
1372+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "</p>")
12631373 if templ_7745c5c3_Err != nil {
12641374 return templ_7745c5c3_Err
12651375 }
12661376 }
1267- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "<p class=\"state-back\"><a href=\"/\">Back to home</a></p></section>")
1377+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "<p class=\"state-back\"><a href=\"/\">Back to home</a></p></section>")
12681378 if templ_7745c5c3_Err != nil {
12691379 return templ_7745c5c3_Err
12701380 }
12711381 return nil
12721382 })
1273- templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var57), templ_7745c5c3_Buffer)
1383+ templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var65), templ_7745c5c3_Buffer)
12741384 if templ_7745c5c3_Err != nil {
12751385 return templ_7745c5c3_Err
12761386 }
@@ -1295,12 +1405,12 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
12951405 }()
12961406 }
12971407 ctx = templ.InitializeContext(ctx)
1298- templ_7745c5c3_Var63 := templ.GetChildren(ctx)
1299- if templ_7745c5c3_Var63 == nil {
1300- templ_7745c5c3_Var63 = templ.NopComponent
1408+ templ_7745c5c3_Var71 := templ.GetChildren(ctx)
1409+ if templ_7745c5c3_Var71 == nil {
1410+ templ_7745c5c3_Var71 = templ.NopComponent
13011411 }
13021412 ctx = templ.ClearChildren(ctx)
1303- templ_7745c5c3_Var64 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1413+ templ_7745c5c3_Var72 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
13041414 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
13051415 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
13061416 if !templ_7745c5c3_IsBuffer {
@@ -1312,231 +1422,231 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
13121422 }()
13131423 }
13141424 ctx = templ.InitializeContext(ctx)
1315- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "<h2 class=\"rule-h\"><span>Passkeys</span><span class=\"count\">")
1425+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "<h2 class=\"rule-h\"><span>Passkeys</span><span class=\"count\">")
13161426 if templ_7745c5c3_Err != nil {
13171427 return templ_7745c5c3_Err
13181428 }
1319- var templ_7745c5c3_Var65 string
1320- templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Rows)))
1429+ var templ_7745c5c3_Var73 string
1430+ templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Rows)))
13211431 if templ_7745c5c3_Err != nil {
1322- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 254, Col: 81}
1432+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 262, Col: 81}
13231433 }
1324- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65))
1434+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var73))
13251435 if templ_7745c5c3_Err != nil {
13261436 return templ_7745c5c3_Err
13271437 }
1328- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "</span></h2><p class=\"dim\">A passkey signs you in from this device or your phone, without a terminal. Removing one signs every browser out.</p>")
1438+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "</span></h2><p class=\"dim\">A passkey signs you in from this device or your phone, without a terminal. Removing one signs every browser out.</p>")
13291439 if templ_7745c5c3_Err != nil {
13301440 return templ_7745c5c3_Err
13311441 }
13321442 if len(p.Rows) > 0 {
1333- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "<table class=\"list passkeys\" aria-label=\"Passkeys\"><thead><tr><th class=\"c-name\">Name</th><th class=\"c-when\">Created</th><th class=\"c-when\">Last used</th><th class=\"c-sync\">Synced</th><th class=\"c-act\"><span class=\"sr\">Remove</span></th></tr></thead> <tbody>")
1443+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "<table class=\"list passkeys\" aria-label=\"Passkeys\"><thead><tr><th class=\"c-name\">Name</th><th class=\"c-when\">Created</th><th class=\"c-when\">Last used</th><th class=\"c-sync\">Synced</th><th class=\"c-act\"><span class=\"sr\">Remove</span></th></tr></thead> <tbody>")
13341444 if templ_7745c5c3_Err != nil {
13351445 return templ_7745c5c3_Err
13361446 }
13371447 for _, r := range p.Rows {
1338- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "<tr><td class=\"c-name\">")
1448+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "<tr><td class=\"c-name\">")
13391449 if templ_7745c5c3_Err != nil {
13401450 return templ_7745c5c3_Err
13411451 }
1342- var templ_7745c5c3_Var66 string
1343- templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
1452+ var templ_7745c5c3_Var74 string
1453+ templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
13441454 if templ_7745c5c3_Err != nil {
1345- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 270, Col: 34}
1455+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 278, Col: 34}
13461456 }
1347- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66))
1457+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var74))
13481458 if templ_7745c5c3_Err != nil {
13491459 return templ_7745c5c3_Err
13501460 }
1351- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "</td><td class=\"c-when nowrap\"><time")
1461+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "</td><td class=\"c-when nowrap\"><time")
13521462 if templ_7745c5c3_Err != nil {
13531463 return templ_7745c5c3_Err
13541464 }
13551465 if r.CreatedAt != "" {
1356- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, " datetime=\"")
1466+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, " datetime=\"")
13571467 if templ_7745c5c3_Err != nil {
13581468 return templ_7745c5c3_Err
13591469 }
1360- var templ_7745c5c3_Var67 string
1361- templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.CreatedAt)
1470+ var templ_7745c5c3_Var75 string
1471+ templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.CreatedAt)
13621472 if templ_7745c5c3_Err != nil {
1363- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 274, Col: 32}
1473+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 282, Col: 32}
13641474 }
1365- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var67)
1475+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var75)
13661476 if templ_7745c5c3_Err != nil {
13671477 return templ_7745c5c3_Err
13681478 }
1369- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "\"")
1479+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "\"")
13701480 if templ_7745c5c3_Err != nil {
13711481 return templ_7745c5c3_Err
13721482 }
13731483 }
1374- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, ">")
1484+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, ">")
13751485 if templ_7745c5c3_Err != nil {
13761486 return templ_7745c5c3_Err
13771487 }
1378- var templ_7745c5c3_Var68 string
1379- templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(r.Created)
1488+ var templ_7745c5c3_Var76 string
1489+ templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinStringErrs(r.Created)
13801490 if templ_7745c5c3_Err != nil {
1381- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 276, Col: 20}
1491+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 284, Col: 20}
13821492 }
1383- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68))
1493+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76))
13841494 if templ_7745c5c3_Err != nil {
13851495 return templ_7745c5c3_Err
13861496 }
1387- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "</time></td><td class=\"c-when nowrap\"><time")
1497+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "</time></td><td class=\"c-when nowrap\"><time")
13881498 if templ_7745c5c3_Err != nil {
13891499 return templ_7745c5c3_Err
13901500 }
13911501 if r.LastUsedAt != "" {
1392- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, " datetime=\"")
1502+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, " datetime=\"")
13931503 if templ_7745c5c3_Err != nil {
13941504 return templ_7745c5c3_Err
13951505 }
1396- var templ_7745c5c3_Var69 string
1397- templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.LastUsedAt)
1506+ var templ_7745c5c3_Var77 string
1507+ templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.LastUsedAt)
13981508 if templ_7745c5c3_Err != nil {
1399- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 281, Col: 33}
1509+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 289, Col: 33}
14001510 }
1401- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var69)
1511+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var77)
14021512 if templ_7745c5c3_Err != nil {
14031513 return templ_7745c5c3_Err
14041514 }
1405- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "\"")
1515+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "\"")
14061516 if templ_7745c5c3_Err != nil {
14071517 return templ_7745c5c3_Err
14081518 }
14091519 }
1410- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, ">")
1520+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, ">")
14111521 if templ_7745c5c3_Err != nil {
14121522 return templ_7745c5c3_Err
14131523 }
1414- var templ_7745c5c3_Var70 string
1415- templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinStringErrs(r.LastUsed)
1524+ var templ_7745c5c3_Var78 string
1525+ templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinStringErrs(r.LastUsed)
14161526 if templ_7745c5c3_Err != nil {
1417- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 283, Col: 21}
1527+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 291, Col: 21}
14181528 }
1419- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70))
1529+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var78))
14201530 if templ_7745c5c3_Err != nil {
14211531 return templ_7745c5c3_Err
14221532 }
1423- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "</time></td><td class=\"c-sync\">")
1533+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "</time></td><td class=\"c-sync\">")
14241534 if templ_7745c5c3_Err != nil {
14251535 return templ_7745c5c3_Err
14261536 }
14271537 if r.Synced {
1428- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "yes")
1538+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "yes")
14291539 if templ_7745c5c3_Err != nil {
14301540 return templ_7745c5c3_Err
14311541 }
14321542 } else {
1433- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "no")
1543+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "no")
14341544 if templ_7745c5c3_Err != nil {
14351545 return templ_7745c5c3_Err
14361546 }
14371547 }
1438- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "</td><td class=\"c-act\"><form method=\"post\" action=\"")
1548+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "</td><td class=\"c-act\"><form method=\"post\" action=\"")
14391549 if templ_7745c5c3_Err != nil {
14401550 return templ_7745c5c3_Err
14411551 }
1442- var templ_7745c5c3_Var71 templ.SafeURL
1443- templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Remove))
1552+ var templ_7745c5c3_Var79 templ.SafeURL
1553+ templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Remove))
14441554 if templ_7745c5c3_Err != nil {
1445- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 293, Col: 60}
1555+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 301, Col: 60}
14461556 }
1447- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71))
1557+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var79))
14481558 if templ_7745c5c3_Err != nil {
14491559 return templ_7745c5c3_Err
14501560 }
1451- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "\"><button type=\"submit\" class=\"btn danger\" aria-label=\"")
1561+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "\"><button type=\"submit\" class=\"btn danger\" aria-label=\"")
14521562 if templ_7745c5c3_Err != nil {
14531563 return templ_7745c5c3_Err
14541564 }
1455- var templ_7745c5c3_Var72 string
1456- templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.ResolveAttributeValue("Remove passkey " + r.Name)
1565+ var templ_7745c5c3_Var80 string
1566+ templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.ResolveAttributeValue("Remove passkey " + r.Name)
14571567 if templ_7745c5c3_Err != nil {
1458- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 294, Col: 89}
1568+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 302, Col: 89}
14591569 }
1460- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var72)
1570+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var80)
14611571 if templ_7745c5c3_Err != nil {
14621572 return templ_7745c5c3_Err
14631573 }
1464- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "\">Remove</button></form></td></tr>")
1574+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "\">Remove</button></form></td></tr>")
14651575 if templ_7745c5c3_Err != nil {
14661576 return templ_7745c5c3_Err
14671577 }
14681578 }
1469- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "</tbody></table>")
1579+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "</tbody></table>")
14701580 if templ_7745c5c3_Err != nil {
14711581 return templ_7745c5c3_Err
14721582 }
14731583 } else {
1474- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "<p class=\"lede dim\">no passkeys yet.</p>")
1584+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "<p class=\"lede dim\">no passkeys yet.</p>")
14751585 if templ_7745c5c3_Err != nil {
14761586 return templ_7745c5c3_Err
14771587 }
14781588 }
1479- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, " ")
1589+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, " ")
14801590 if templ_7745c5c3_Err != nil {
14811591 return templ_7745c5c3_Err
14821592 }
14831593 if p.Full {
1484- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "<p class=\"dim\">")
1594+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "<p class=\"dim\">")
14851595 if templ_7745c5c3_Err != nil {
14861596 return templ_7745c5c3_Err
14871597 }
1488- var templ_7745c5c3_Var73 string
1489- templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Max))
1598+ var templ_7745c5c3_Var81 string
1599+ templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Max))
14901600 if templ_7745c5c3_Err != nil {
1491- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 305, Col: 31}
1601+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 313, Col: 31}
14921602 }
1493- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var73))
1603+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81))
14941604 if templ_7745c5c3_Err != nil {
14951605 return templ_7745c5c3_Err
14961606 }
1497- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, " passkeys are registered, the most there can be. Remove one to add another.</p>")
1607+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, " passkeys are registered, the most there can be. Remove one to add another.</p>")
14981608 if templ_7745c5c3_Err != nil {
14991609 return templ_7745c5c3_Err
15001610 }
15011611 } else {
1502- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "<form class=\"passkey-add\" data-js data-passkey-form><label for=\"passkey-name\" class=\"sr\">Name of the new passkey</label> <input id=\"passkey-name\" type=\"text\" placeholder=\"Name, for example Pixel 9\" autocomplete=\"off\" data-passkey-name> <button type=\"submit\" class=\"btn primary\" data-passkey=\"register\" data-begin=\"")
1612+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "<form class=\"passkey-add\" data-js data-passkey-form><label for=\"passkey-name\" class=\"sr\">Name of the new passkey</label> <input id=\"passkey-name\" type=\"text\" placeholder=\"Name, for example Pixel 9\" autocomplete=\"off\" data-passkey-name> <button type=\"submit\" class=\"btn primary\" data-passkey=\"register\" data-begin=\"")
15031613 if templ_7745c5c3_Err != nil {
15041614 return templ_7745c5c3_Err
15051615 }
1506- var templ_7745c5c3_Var74 string
1507- templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin)
1616+ var templ_7745c5c3_Var82 string
1617+ templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin)
15081618 if templ_7745c5c3_Err != nil {
1509- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 310, Col: 90}
1619+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 318, Col: 90}
15101620 }
1511- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var74)
1621+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var82)
15121622 if templ_7745c5c3_Err != nil {
15131623 return templ_7745c5c3_Err
15141624 }
1515- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "\" data-finish=\"")
1625+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "\" data-finish=\"")
15161626 if templ_7745c5c3_Err != nil {
15171627 return templ_7745c5c3_Err
15181628 }
1519- var templ_7745c5c3_Var75 string
1520- templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish)
1629+ var templ_7745c5c3_Var83 string
1630+ templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish)
15211631 if templ_7745c5c3_Err != nil {
1522- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 310, Col: 115}
1632+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 318, Col: 115}
15231633 }
1524- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var75)
1634+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var83)
15251635 if templ_7745c5c3_Err != nil {
15261636 return templ_7745c5c3_Err
15271637 }
1528- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "\">Add a passkey</button></form>")
1638+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "\">Add a passkey</button></form>")
15291639 if templ_7745c5c3_Err != nil {
15301640 return templ_7745c5c3_Err
15311641 }
15321642 }
1533- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, " <p class=\"dim\" role=\"status\" aria-live=\"polite\" data-passkey-status></p>")
1643+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, " <p class=\"dim\" role=\"status\" aria-live=\"polite\" data-passkey-status></p>")
15341644 if templ_7745c5c3_Err != nil {
15351645 return templ_7745c5c3_Err
15361646 }
15371647 return nil
15381648 })
1539- templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var64), templ_7745c5c3_Buffer)
1649+ templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var72), templ_7745c5c3_Buffer)
15401650 if templ_7745c5c3_Err != nil {
15411651 return templ_7745c5c3_Err
15421652 }
M internal/adapters/web/releases.templ +16 -9
@@ -40,15 +40,22 @@ templ Releases(l Layout, p ReleaseListPage) {
4040
4141 templ ReleaseDetail(l Layout, p ReleaseDetailPage) {
4242 @Shell(l, nil) {
43- <h1>{ p.Title }</h1>
44- <p>
45- <span class="pill">{ p.Tag }</span> @ReleaseBadges(p.ReleaseRow)
46- if p.SHA != "" {
47- <a href={ templ.SafeURL(p.CommitHref) }><code>{ p.SHA }</code></a>
48- }
49- <span class="dim">{ p.Published }</span>
50- </p>
51- <div class="md">@templ.Raw(string(p.Notes))</div>
43+ <section class="mr-card">
44+ <header class="show mr-h">
45+ <h1>{ p.Title }</h1>
46+ <dl class="kv show-kv">
47+ <dt>tag</dt>
48+ <dd><span class="pill">{ p.Tag }</span> @ReleaseBadges(p.ReleaseRow)</dd>
49+ if p.SHA != "" {
50+ <dt>commit</dt>
51+ <dd><a href={ templ.SafeURL(p.CommitHref) }><code>{ p.SHA }</code></a></dd>
52+ }
53+ <dt>published</dt>
54+ <dd>{ p.Published }</dd>
55+ </dl>
56+ </header>
57+ <article class="mr-desc prose">@templ.Raw(string(p.Notes))</article>
58+ </section>
5259 <h2>Assets</h2>
5360 if len(p.Assets) == 0 {
5461 <p class="dim">no assets.</p>
M internal/adapters/web/releases_templ.go +32 -28 generated file: collapsed
M internal/adapters/web/releases_test.go +12 -2
@@ -38,7 +38,7 @@ func newReleaseFixture(t *testing.T) *releaseFixture {
3838 t.Helper()
3939 repo := repoName(t, "proj")
4040 f := &releaseFixture{svc: mocks.NewReleaseService(t), data: "0123456789"}
41- f.published = domain.Release{ID: 1, Repo: repo, Tag: "stable/v1", Title: "First release", Notes: "# Notes\n\n**Ready** [bad](javascript:alert(1)) <script>alert(1)</script>\n\n[guide](guide.md)", TargetCommitish: strings.Repeat("a", 40), PublishedAt: fixedNow, Prerelease: true}
41+ f.published = domain.Release{ID: 1, Repo: repo, Tag: "stable/v1", Title: "First release", Notes: "# Notes\n\n**Ready** [bad](javascript:alert(1)) <script>alert(1)</script>\n\n[guide](guide.md)\n\n```sh\ncurl https://example.com\n```", TargetCommitish: strings.Repeat("a", 40), PublishedAt: fixedNow, Prerelease: true}
4242 f.draft = domain.Release{ID: 2, Repo: repo, Tag: "unpublished", Title: "Secret draft", Draft: true, MakeLatest: true}
4343 f.asset = domain.ReleaseAsset{ID: 7, ReleaseID: 1, Repo: repo, Name: "build.zip", Size: 10, ContentType: "application/zip", Digest: "sha256:" + strings.Repeat("b", 64)}
4444 f.svc.EXPECT().GetRelease(mock.Anything, mock.Anything, repo, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal, _ domain.RepoName, ref ports.ReleaseRef) (domain.Release, error) {
@@ -120,7 +120,8 @@ func TestReleasePages(t *testing.T) {
120120 assert.Contains(t, body, "2026-10-12")
121121 assert.Contains(t, body, `<td>1</td>`)
122122 assert.Contains(t, body, `?page=next%2B%2F%3D`)
123- assert.Contains(t, body, `aria-keyshortcuts="l"`)
123+ assert.Contains(t, body, `data-key="g r"`)
124+ assert.NotContains(t, body, `aria-keyshortcuts="g r"`)
124125 assert.Contains(t, body, "view=releases")
125126 }
126127 owner := f.request("GET", "/proj/releases?page=cursor%2B%2F%3D", "tok", "")
@@ -137,6 +138,15 @@ func TestReleasePages(t *testing.T) {
137138 for _, text := range []string{"<strong>Ready</strong>", "build.zip", humanSize(10), "application/zip", `href="/proj/releases/assets/7"`, `href="/proj/tree/stable/v1"`, `href="/proj/commit/` + strings.Repeat("a", 40) + `"`, "view=release"} {
138139 assert.Contains(t, body, text)
139140 }
141+ notes := strings.SplitN(body, `<article class="mr-desc prose">`, 2)
142+ require.Len(t, notes, 2)
143+ notesBody := strings.SplitN(notes[1], "</article>", 2)[0]
144+ assert.Contains(t, notesBody, "<pre")
145+ assert.Contains(t, notesBody, "<code")
146+ assert.Contains(t, notesBody, "curl")
147+ assert.Contains(t, notesBody, "</code></pre>")
148+ assert.Contains(t, notesBody, `<h1 id="md-notes">Notes</h1>`)
149+ assert.Contains(t, body, `<section class="mr-card">`)
140150 assert.NotContains(t, body, "<script>alert")
141151 assert.NotContains(t, body, `href="javascript:`)
142152 assert.Contains(t, body, `href="/proj/blob/stable/v1/guide.md"`)
M internal/adapters/web/repo.go +4 -4
@@ -144,9 +144,9 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La
144144 }
145145 l.Sections = []Section{
146146 mk("s", "Summary", repoHref(d.name()), "summary"),
147- mk("t", "Files", repoHref(d.name(), "tree", def), "tree"),
147+ mk("f", "Files", repoHref(d.name(), "tree", def), "tree"),
148148 mk("c", "Commits", repoHref(d.name(), "commits", def), "commits"),
149- mk("r", "Branches & tags", repoHref(d.name(), "refs"), "refs"),
149+ mk("b", "Branches & tags", repoHref(d.name(), "refs"), "refs"),
150150 }
151151 if s.cfg.MergeRequests != nil {
152152 mrs := mk("m", "Merge requests", mrBase(d.name()), "merge-requests")
@@ -157,14 +157,14 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La
157157 l.Sections = append(l.Sections, mk("i", "Issues", issuesBase(d.name()), "issues"))
158158 }
159159 if s.cfg.Releases != nil {
160- l.Sections = append(l.Sections, mk("l", "Releases", releasesBase(d.name()), "releases"))
160+ l.Sections = append(l.Sections, mk("r", "Releases", releasesBase(d.name()), "releases"))
161161 }
162162 l.Sections = s.withActions(l.Sections, d.name(), section)
163163 l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}
164164
165165 l.Cmd = append(l.Cmd, CmdItem{Kind: "go", Label: "home", Href: "/"})
166166 for _, sec := range l.Sections {
167- l.Cmd = append(l.Cmd, CmdItem{Kind: "section", Label: strings.ToLower(sec.Label), Href: sec.Href})
167+ l.Cmd = append(l.Cmd, CmdItem{Kind: "section", Label: strings.ToLower(sec.Label) + " (g " + sec.Key + ")", Href: sec.Href})
168168 }
169169 if l.Ref != "" {
170170 for _, b := range d.branches {
M internal/adapters/web/shell.templ +2 -3
@@ -172,8 +172,7 @@ templ Header(l Layout) {
172172 <li>
173173 <a
174174 href={ templ.SafeURL(s.Href) }
175- data-key={ s.Key }
176- aria-keyshortcuts={ s.Key }
175+ data-key={ "g " + s.Key }
177176 if s.Current {
178177 aria-current="page"
179178 }
@@ -182,7 +181,7 @@ templ Header(l Layout) {
182181 if s.Count != "" {
183182 <span class="count">{ s.Count }<span class="sr">{ " open" }</span></span>
184183 }
185- <kbd aria-hidden="true">{ s.Key }</kbd>
184+ <kbd aria-hidden="true">{ "g " + s.Key }</kbd>
186185 </a>
187186 </li>
188187 }
M internal/adapters/web/shell_templ.go +48 -61 generated file: collapsed
M internal/adapters/web/static/js/ermite.js +13 -4
@@ -112,7 +112,7 @@
112112 $(sel)?.click();
113113 },
114114 tab(key) {
115- $(`.tabs [data-key=${key}]`)?.click();
115+ $(`.tabs [data-key="g ${key}"]`)?.click();
116116 },
117117
118118 // The preference "single-key shortcuts" (ermiteKeys): the checkbox of the
@@ -612,11 +612,11 @@
612612
613613 // 1. Keys.
614614 const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink',
615- s: 's', t: 't', c: 'c', r: 'r', m: 'm', i: 'i', I: 'inbox', n: 'next', h: 'left', l: 'right', d: 'split', u: 'unified', x: 'collapse', w: 'wide', e: 'expand', E: 'expandAll', Enter: 'open', Escape: 'esc',
615+ I: 'inbox', n: 'next', h: 'left', l: 'right', d: 'split', u: 'unified', x: 'collapse', w: 'wide', e: 'expand', E: 'expandAll', Enter: 'open', Escape: 'esc',
616616 // the merge request page (mr.js): the merge menu, mark read
617617 M: 'merge', R: 'read' };
618- // "g t" is the key "t": the tabs have one event each.
619- const goto = { h: 'gh', t: 't', c: 'c', r: 'r', m: 'm', i: 'i', g: 'top', f: 'gf' };
618+ // Section keys are the second letter of the g sequence.
619+ const goto = { h: 'gh', s: 's', f: 'f', c: 'c', b: 'b', m: 'm', i: 'i', r: 'r', a: 'a', g: 'top' };
620620 let pending = 0;
621621 let pendingTimer = 0;
622622 addEventListener('keydown', (e) => {
@@ -642,6 +642,15 @@
642642 // behind it does not react (scripted clicks would go through inert).
643643 if ($('dialog[open]') && !['?', 'b', ':', 'f', 'Enter'].includes(e.key)) { pending = 0; return; }
644644 if (e.key === 'Enter' && e.target.closest('a, button, summary')) return;
645+ // Some layouts report lowercase e.key even with Shift. Uppercase actions
646+ // also interrupt a pending g rather than being swallowed by it.
647+ const actionKey = e.shiftKey ? e.key.toUpperCase() : e.key;
648+ if (['G', 'M', 'R', 'E', 'I'].includes(actionKey)) {
649+ pending = 0;
650+ if (actionKey === 'I' && !$('[data-inbox]')) return;
651+ e.preventDefault();
652+ return ermite.fire(single[actionKey]);
653+ }
645654 if (pending) {
646655 const name = goto[e.key];
647656 pending = 0;
M internal/adapters/web/static/js/mr.js +2 -2
@@ -8,7 +8,7 @@
88 // on its button, 1-9 pick a mode, D toggles the branch deletion, Enter
99 // submits (the browser does it: the focus is on the button), Esc closes.
1010 // R marks the head read. Nothing merges on one key.
11-// 3. g f puts the focus in the list of files (opening it when folded); j / k
11+// 3. l puts the focus in the list of files (opening it when folded); j / k
1212 // move in it, Enter opens the file, Esc goes back to the diff. The button
1313 // of its header folds the list, and the choice is remembered.
1414 //
@@ -186,7 +186,7 @@ window.ermiteMR = (() => {
186186 return true;
187187 }
188188
189- // The list of files by keyboard: g f focuses the current file.
189+ // The list of files by keyboard: l focuses the current file.
190190 const links = () => $$('.r-files .files > li:not([hidden]) > a');
191191 function focusFiles() {
192192 foldFiles(false);
M internal/adapters/web/summary.go +21 -0
@@ -68,6 +68,7 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component,
6868
6969 p.Facts = s.facts(d, now)
7070 p.Latest = s.latest(ctx, d)
71+ p.Release = s.summaryRelease(ctx, d.repo.Name, now)
7172 p.Actions = s.recentActions(ctx, d.repo.Name, now)
7273
7374 if rd, err := s.readme(ctx, d, ref); err != nil {
@@ -272,3 +273,23 @@ func (s *Server) listRecentActions(ctx context.Context, repo domain.RepoName, no
272273 }
273274 return a, nil
274275 }
276+
277+// summaryRelease uses the same Latest selection as the releases page. A
278+// missing service or an unavailable release must not prevent repository browsing.
279+func (s *Server) summaryRelease(ctx context.Context, repo domain.RepoName, now time.Time) *LatestRelease {
280+ if s.cfg.Releases == nil {
281+ return nil
282+ }
283+ rel, err := s.cfg.Releases.GetRelease(ctx, principal(ctx), repo, ports.ReleaseRef{Latest: true})
284+ if err != nil {
285+ if !errors.Is(err, domain.ErrNotFound) {
286+ log := zerowrap.FromCtx(ctx)
287+ log.Warn().Err(err).Str("repo", repo.String()).Msg("read the latest release")
288+ }
289+ return nil
290+ }
291+ if rel.Draft || rel.PublishedAt.IsZero() {
292+ return nil
293+ }
294+ return &LatestRelease{Tag: rel.Tag, Href: releaseHref(repo.String(), rel.Tag), When: ago(now, rel.PublishedAt), WhenAt: rfc3339(rel.PublishedAt), AllHref: releasesBase(repo.String())}
295+}
A internal/adapters/web/summary_releases_test.go +49 -0
@@ -0,0 +1,49 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
16+ "bnema.dev/ermite/internal/ports/mocks"
17+)
18+
19+func TestSummaryRelease(t *testing.T) {
20+ repo := repoName(t, "proj")
21+ for _, tc := range []struct {
22+ name string
23+ release domain.Release
24+ err error
25+ visible bool
26+ }{
27+ {name: "published", release: domain.Release{Tag: "v0.9.1", PublishedAt: fixedNow.Add(-6 * time.Minute)}, visible: true},
28+ {name: "none", err: domain.ErrNotFound},
29+ {name: "draft", release: domain.Release{Tag: "secret", Draft: true, PublishedAt: fixedNow}},
30+ } {
31+ t.Run(tc.name, func(t *testing.T) {
32+ svc := mocks.NewReleaseService(t)
33+ svc.EXPECT().GetRelease(mock.Anything, mock.Anything, repo, ports.ReleaseRef{Latest: true}).Return(tc.release, tc.err).Once()
34+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t), Releases: svc})
35+ rel := srv.summaryRelease(t.Context(), repo, fixedNow)
36+ out := strings.Join(strings.Fields(renderRail(t, SummaryPage{Latest: &LatestTag{Name: "v1", Count: 1}, Release: rel})), " ")
37+ if !tc.visible {
38+ assert.Nil(t, rel)
39+ assert.NotContains(t, out, "<dt>release</dt>")
40+ return
41+ }
42+ require.NotNil(t, rel)
43+ assert.Contains(t, out, `<a href="/proj/releases/tag/v0.9.1">v0.9.1</a> · <time`)
44+ assert.Contains(t, out, `datetime="`+rfc3339(tc.release.PublishedAt)+`"`)
45+ assert.Contains(t, out, `>`+ago(fixedNow, tc.release.PublishedAt)+`</time> · <a href="/proj/releases">all releases</a>`)
46+ assert.Regexp(t, `(?s)<dt>latest tag</dt>.*<dt>release</dt>`, out)
47+ })
48+ }
49+}
M internal/adapters/web/view.go +7 -1
@@ -211,12 +211,18 @@ type SummaryPage struct {
211211 Outline []Heading
212212 AllHref string
213213 // Latest is the newest tag, nil when the repository has none.
214- Latest *LatestTag
214+ Latest *LatestTag
215+ Release *LatestRelease
215216 // Actions is the recent runs of the rail, nil when the viewer cannot read
216217 // CI (or the server runs none).
217218 Actions *RecentActions
218219 }
219220
221+// LatestRelease is the published release selected as Latest by the release service.
222+type LatestRelease struct {
223+ Tag, Href, When, WhenAt, AllHref string
224+}
225+
220226 // RecentActions is the "Recent actions" block of the summary rail: the newest
221227 // runs. With no run the block is in the page but hidden, so that the first
222228 // run reaches it live.
M internal/app/testdata/web/blob-binary.html +33 -30
@@ -73,31 +73,31 @@
7373 <nav class="tabs" aria-label="Sections">
7474 <ul>
7575 <li>
76-<a href="/files" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
76+<a href="/files" data-key="g s">Summary <kbd aria-hidden="true">g s</kbd>
7777 </a>
7878 </li>
7979 <li>
80-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files <kbd aria-hidden="true">t</kbd>
80+<a href="/files/tree/main" data-key="g f" aria-current="page">Files <kbd aria-hidden="true">g f</kbd>
8181 </a>
8282 </li>
8383 <li>
84-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
84+<a href="/files/commits/main" data-key="g c">Commits <kbd aria-hidden="true">g c</kbd>
8585 </a>
8686 </li>
8787 <li>
88-<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
88+<a href="/files/refs" data-key="g b">Branches &amp; tags <kbd aria-hidden="true">g b</kbd>
8989 </a>
9090 </li>
9191 <li>
92-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
92+<a href="/files/merge-requests" data-key="g m">Merge requests <kbd aria-hidden="true">g m</kbd>
9393 </a>
9494 </li>
9595 <li>
96-<a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
96+<a href="/files/releases" data-key="g r">Releases <kbd aria-hidden="true">g r</kbd>
9797 </a>
9898 </li>
9999 <li>
100-<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
100+<a href="/files/actions" data-key="g a">Actions <kbd aria-hidden="true">g a</kbd>
101101 </a>
102102 </li>
103103 </ul>
@@ -226,14 +226,16 @@
226226 </div>
227227 <div>
228228 <dt>
229-<kbd>s</kbd>
230-<kbd>t</kbd>
231-<kbd>c</kbd>
232-<kbd>r</kbd>
233-<kbd>m</kbd>
234-<kbd>i</kbd>
229+<kbd>g s</kbd>
230+<kbd>g f</kbd>
231+<kbd>g c</kbd>
232+<kbd>g b</kbd>
233+<kbd>g m</kbd>
234+<kbd>g i</kbd>
235+<kbd>g r</kbd>
236+<kbd>g a</kbd>
235237 </dt>
236-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
238+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues, releases, actions (when available)</dd>
237239 </div>
238240 <div>
239241 <dt>
@@ -332,45 +334,45 @@
332334 <span class="k">go</span>
333335 </a>
334336 </li>
335-<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
337+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary (g s)">
336338 <a href="/files" tabindex="-1">
337-<span class="lbl">summary</span>
339+<span class="lbl">summary (g s)</span>
338340 <span class="k">section</span>
339341 </a>
340342 </li>
341-<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
343+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files (g f)">
342344 <a href="/files/tree/main" tabindex="-1">
343-<span class="lbl">files</span>
345+<span class="lbl">files (g f)</span>
344346 <span class="k">section</span>
345347 </a>
346348 </li>
347-<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
349+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits (g c)">
348350 <a href="/files/commits/main" tabindex="-1">
349-<span class="lbl">commits</span>
351+<span class="lbl">commits (g c)</span>
350352 <span class="k">section</span>
351353 </a>
352354 </li>
353-<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
355+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags (g b)">
354356 <a href="/files/refs" tabindex="-1">
355-<span class="lbl">branches &amp; tags</span>
357+<span class="lbl">branches &amp; tags (g b)</span>
356358 <span class="k">section</span>
357359 </a>
358360 </li>
359-<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
361+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests (g m)">
360362 <a href="/files/merge-requests" tabindex="-1">
361-<span class="lbl">merge requests</span>
363+<span class="lbl">merge requests (g m)</span>
362364 <span class="k">section</span>
363365 </a>
364366 </li>
365-<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
367+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases (g r)">
366368 <a href="/files/releases" tabindex="-1">
367-<span class="lbl">releases</span>
369+<span class="lbl">releases (g r)</span>
368370 <span class="k">section</span>
369371 </a>
370372 </li>
371-<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
373+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions (g a)">
372374 <a href="/files/actions" tabindex="-1">
373-<span class="lbl">actions</span>
375+<span class="lbl">actions (g a)</span>
374376 <span class="k">section</span>
375377 </a>
376378 </li>
@@ -423,7 +425,9 @@
423425 k-filter from:body -> ermite.focusFilter();
424426 k-gh from:body -> ermite.go('.brand');
425427 k-s from:body -> ermite.tab('s');
426- k-t from:body -> ermite.tab('t');
428+ k-f from:body -> ermite.tab('f');
429+ k-b from:body -> ermite.tab('b');
430+ k-a from:body -> ermite.tab('a');
427431 k-c from:body -> ermite.tab('c');
428432 k-r from:body -> ermite.tab('r');
429433 k-m from:body -> ermite.tab('m');
@@ -439,7 +443,6 @@
439443 k-permalink from:body -> ermite.permalink();
440444 k-merge from:body -> window.ermiteMR?.openMerge();
441445 k-read from:body -> window.ermiteMR?.markRead();
442- k-gf from:body -> window.ermiteMR?.focusFiles();
443446 k-keys from:body -> ermite.toggleKeys()">
444447 </div>
445448 </body>
M internal/app/testdata/web/blob-code.html +33 -30
@@ -73,31 +73,31 @@
7373 <nav class="tabs" aria-label="Sections">
7474 <ul>
7575 <li>
76-<a href="/files" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
76+<a href="/files" data-key="g s">Summary <kbd aria-hidden="true">g s</kbd>
7777 </a>
7878 </li>
7979 <li>
80-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files <kbd aria-hidden="true">t</kbd>
80+<a href="/files/tree/main" data-key="g f" aria-current="page">Files <kbd aria-hidden="true">g f</kbd>
8181 </a>
8282 </li>
8383 <li>
84-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
84+<a href="/files/commits/main" data-key="g c">Commits <kbd aria-hidden="true">g c</kbd>
8585 </a>
8686 </li>
8787 <li>
88-<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
88+<a href="/files/refs" data-key="g b">Branches &amp; tags <kbd aria-hidden="true">g b</kbd>
8989 </a>
9090 </li>
9191 <li>
92-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
92+<a href="/files/merge-requests" data-key="g m">Merge requests <kbd aria-hidden="true">g m</kbd>
9393 </a>
9494 </li>
9595 <li>
96-<a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
96+<a href="/files/releases" data-key="g r">Releases <kbd aria-hidden="true">g r</kbd>
9797 </a>
9898 </li>
9999 <li>
100-<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
100+<a href="/files/actions" data-key="g a">Actions <kbd aria-hidden="true">g a</kbd>
101101 </a>
102102 </li>
103103 </ul>
@@ -296,14 +296,16 @@
296296 </div>
297297 <div>
298298 <dt>
299-<kbd>s</kbd>
300-<kbd>t</kbd>
301-<kbd>c</kbd>
302-<kbd>r</kbd>
303-<kbd>m</kbd>
304-<kbd>i</kbd>
299+<kbd>g s</kbd>
300+<kbd>g f</kbd>
301+<kbd>g c</kbd>
302+<kbd>g b</kbd>
303+<kbd>g m</kbd>
304+<kbd>g i</kbd>
305+<kbd>g r</kbd>
306+<kbd>g a</kbd>
305307 </dt>
306-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
308+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues, releases, actions (when available)</dd>
307309 </div>
308310 <div>
309311 <dt>
@@ -402,45 +404,45 @@
402404 <span class="k">go</span>
403405 </a>
404406 </li>
405-<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
407+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary (g s)">
406408 <a href="/files" tabindex="-1">
407-<span class="lbl">summary</span>
409+<span class="lbl">summary (g s)</span>
408410 <span class="k">section</span>
409411 </a>
410412 </li>
411-<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
413+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files (g f)">
412414 <a href="/files/tree/main" tabindex="-1">
413-<span class="lbl">files</span>
415+<span class="lbl">files (g f)</span>
414416 <span class="k">section</span>
415417 </a>
416418 </li>
417-<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
419+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits (g c)">
418420 <a href="/files/commits/main" tabindex="-1">
419-<span class="lbl">commits</span>
421+<span class="lbl">commits (g c)</span>
420422 <span class="k">section</span>
421423 </a>
422424 </li>
423-<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
425+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags (g b)">
424426 <a href="/files/refs" tabindex="-1">
425-<span class="lbl">branches &amp; tags</span>
427+<span class="lbl">branches &amp; tags (g b)</span>
426428 <span class="k">section</span>
427429 </a>
428430 </li>
429-<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
431+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests (g m)">
430432 <a href="/files/merge-requests" tabindex="-1">
431-<span class="lbl">merge requests</span>
433+<span class="lbl">merge requests (g m)</span>
432434 <span class="k">section</span>
433435 </a>
434436 </li>
435-<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
437+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases (g r)">
436438 <a href="/files/releases" tabindex="-1">
437-<span class="lbl">releases</span>
439+<span class="lbl">releases (g r)</span>
438440 <span class="k">section</span>
439441 </a>
440442 </li>
441-<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
443+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions (g a)">
442444 <a href="/files/actions" tabindex="-1">
443-<span class="lbl">actions</span>
445+<span class="lbl">actions (g a)</span>
444446 <span class="k">section</span>
445447 </a>
446448 </li>
@@ -493,7 +495,9 @@
493495 k-filter from:body -> ermite.focusFilter();
494496 k-gh from:body -> ermite.go('.brand');
495497 k-s from:body -> ermite.tab('s');
496- k-t from:body -> ermite.tab('t');
498+ k-f from:body -> ermite.tab('f');
499+ k-b from:body -> ermite.tab('b');
500+ k-a from:body -> ermite.tab('a');
497501 k-c from:body -> ermite.tab('c');
498502 k-r from:body -> ermite.tab('r');
499503 k-m from:body -> ermite.tab('m');
@@ -509,7 +513,6 @@
509513 k-permalink from:body -> ermite.permalink();
510514 k-merge from:body -> window.ermiteMR?.openMerge();
511515 k-read from:body -> window.ermiteMR?.markRead();
512- k-gf from:body -> window.ermiteMR?.focusFiles();
513516 k-keys from:body -> ermite.toggleKeys()">
514517 </div>
515518 </body>
M internal/app/testdata/web/blob-empty.html +33 -30
@@ -73,31 +73,31 @@
7373 <nav class="tabs" aria-label="Sections">
7474 <ul>
7575 <li>
76-<a href="/files" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
76+<a href="/files" data-key="g s">Summary <kbd aria-hidden="true">g s</kbd>
7777 </a>
7878 </li>
7979 <li>
80-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files <kbd aria-hidden="true">t</kbd>
80+<a href="/files/tree/main" data-key="g f" aria-current="page">Files <kbd aria-hidden="true">g f</kbd>
8181 </a>
8282 </li>
8383 <li>
84-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
84+<a href="/files/commits/main" data-key="g c">Commits <kbd aria-hidden="true">g c</kbd>
8585 </a>
8686 </li>
8787 <li>
88-<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
88+<a href="/files/refs" data-key="g b">Branches &amp; tags <kbd aria-hidden="true">g b</kbd>
8989 </a>
9090 </li>
9191 <li>
92-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
92+<a href="/files/merge-requests" data-key="g m">Merge requests <kbd aria-hidden="true">g m</kbd>
9393 </a>
9494 </li>
9595 <li>
96-<a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
96+<a href="/files/releases" data-key="g r">Releases <kbd aria-hidden="true">g r</kbd>
9797 </a>
9898 </li>
9999 <li>
100-<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
100+<a href="/files/actions" data-key="g a">Actions <kbd aria-hidden="true">g a</kbd>
101101 </a>
102102 </li>
103103 </ul>
@@ -226,14 +226,16 @@
226226 </div>
227227 <div>
228228 <dt>
229-<kbd>s</kbd>
230-<kbd>t</kbd>
231-<kbd>c</kbd>
232-<kbd>r</kbd>
233-<kbd>m</kbd>
234-<kbd>i</kbd>
229+<kbd>g s</kbd>
230+<kbd>g f</kbd>
231+<kbd>g c</kbd>
232+<kbd>g b</kbd>
233+<kbd>g m</kbd>
234+<kbd>g i</kbd>
235+<kbd>g r</kbd>
236+<kbd>g a</kbd>
235237 </dt>
236-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
238+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues, releases, actions (when available)</dd>
237239 </div>
238240 <div>
239241 <dt>
@@ -332,45 +334,45 @@
332334 <span class="k">go</span>
333335 </a>
334336 </li>
335-<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
337+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary (g s)">
336338 <a href="/files" tabindex="-1">
337-<span class="lbl">summary</span>
339+<span class="lbl">summary (g s)</span>
338340 <span class="k">section</span>
339341 </a>
340342 </li>
341-<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
343+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files (g f)">
342344 <a href="/files/tree/main" tabindex="-1">
343-<span class="lbl">files</span>
345+<span class="lbl">files (g f)</span>
344346 <span class="k">section</span>
345347 </a>
346348 </li>
347-<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
349+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits (g c)">
348350 <a href="/files/commits/main" tabindex="-1">
349-<span class="lbl">commits</span>
351+<span class="lbl">commits (g c)</span>
350352 <span class="k">section</span>
351353 </a>
352354 </li>
353-<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
355+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags (g b)">
354356 <a href="/files/refs" tabindex="-1">
355-<span class="lbl">branches &amp; tags</span>
357+<span class="lbl">branches &amp; tags (g b)</span>
356358 <span class="k">section</span>
357359 </a>
358360 </li>
359-<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
361+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests (g m)">
360362 <a href="/files/merge-requests" tabindex="-1">
361-<span class="lbl">merge requests</span>
363+<span class="lbl">merge requests (g m)</span>
362364 <span class="k">section</span>
363365 </a>
364366 </li>
365-<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
367+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases (g r)">
366368 <a href="/files/releases" tabindex="-1">
367-<span class="lbl">releases</span>
369+<span class="lbl">releases (g r)</span>
368370 <span class="k">section</span>
369371 </a>
370372 </li>
371-<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
373+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions (g a)">
372374 <a href="/files/actions" tabindex="-1">
373-<span class="lbl">actions</span>
375+<span class="lbl">actions (g a)</span>
374376 <span class="k">section</span>
375377 </a>
376378 </li>
@@ -423,7 +425,9 @@
423425 k-filter from:body -> ermite.focusFilter();
424426 k-gh from:body -> ermite.go('.brand');
425427 k-s from:body -> ermite.tab('s');
426- k-t from:body -> ermite.tab('t');
428+ k-f from:body -> ermite.tab('f');
429+ k-b from:body -> ermite.tab('b');
430+ k-a from:body -> ermite.tab('a');
427431 k-c from:body -> ermite.tab('c');
428432 k-r from:body -> ermite.tab('r');
429433 k-m from:body -> ermite.tab('m');
@@ -439,7 +443,6 @@
439443 k-permalink from:body -> ermite.permalink();
440444 k-merge from:body -> window.ermiteMR?.openMerge();
441445 k-read from:body -> window.ermiteMR?.markRead();
442- k-gf from:body -> window.ermiteMR?.focusFiles();
443446 k-keys from:body -> ermite.toggleKeys()">
444447 </div>
445448 </body>
M internal/app/testdata/web/blob-image.html +33 -30
@@ -73,31 +73,31 @@
7373 <nav class="tabs" aria-label="Sections">
7474 <ul>
7575 <li>
76-<a href="/files" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
76+<a href="/files" data-key="g s">Summary <kbd aria-hidden="true">g s</kbd>
7777 </a>
7878 </li>
7979 <li>
80-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files <kbd aria-hidden="true">t</kbd>
80+<a href="/files/tree/main" data-key="g f" aria-current="page">Files <kbd aria-hidden="true">g f</kbd>
8181 </a>
8282 </li>
8383 <li>
84-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
84+<a href="/files/commits/main" data-key="g c">Commits <kbd aria-hidden="true">g c</kbd>
8585 </a>
8686 </li>
8787 <li>
88-<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
88+<a href="/files/refs" data-key="g b">Branches &amp; tags <kbd aria-hidden="true">g b</kbd>
8989 </a>
9090 </li>
9191 <li>
92-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
92+<a href="/files/merge-requests" data-key="g m">Merge requests <kbd aria-hidden="true">g m</kbd>
9393 </a>
9494 </li>
9595 <li>
96-<a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
96+<a href="/files/releases" data-key="g r">Releases <kbd aria-hidden="true">g r</kbd>
9797 </a>
9898 </li>
9999 <li>
100-<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
100+<a href="/files/actions" data-key="g a">Actions <kbd aria-hidden="true">g a</kbd>
101101 </a>
102102 </li>
103103 </ul>
@@ -228,14 +228,16 @@
228228 </div>
229229 <div>
230230 <dt>
231-<kbd>s</kbd>
232-<kbd>t</kbd>
233-<kbd>c</kbd>
234-<kbd>r</kbd>
235-<kbd>m</kbd>
236-<kbd>i</kbd>
231+<kbd>g s</kbd>
232+<kbd>g f</kbd>
233+<kbd>g c</kbd>
234+<kbd>g b</kbd>
235+<kbd>g m</kbd>
236+<kbd>g i</kbd>
237+<kbd>g r</kbd>
238+<kbd>g a</kbd>
237239 </dt>
238-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
240+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues, releases, actions (when available)</dd>
239241 </div>
240242 <div>
241243 <dt>
@@ -334,45 +336,45 @@
334336 <span class="k">go</span>
335337 </a>
336338 </li>
337-<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
339+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary (g s)">
338340 <a href="/files" tabindex="-1">
339-<span class="lbl">summary</span>
341+<span class="lbl">summary (g s)</span>
340342 <span class="k">section</span>
341343 </a>
342344 </li>
343-<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
345+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files (g f)">
344346 <a href="/files/tree/main" tabindex="-1">
345-<span class="lbl">files</span>
347+<span class="lbl">files (g f)</span>
346348 <span class="k">section</span>
347349 </a>
348350 </li>
349-<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
351+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits (g c)">
350352 <a href="/files/commits/main" tabindex="-1">
351-<span class="lbl">commits</span>
353+<span class="lbl">commits (g c)</span>
352354 <span class="k">section</span>
353355 </a>
354356 </li>
355-<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
357+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags (g b)">
356358 <a href="/files/refs" tabindex="-1">
357-<span class="lbl">branches &amp; tags</span>
359+<span class="lbl">branches &amp; tags (g b)</span>
358360 <span class="k">section</span>
359361 </a>
360362 </li>
361-<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
363+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests (g m)">
362364 <a href="/files/merge-requests" tabindex="-1">
363-<span class="lbl">merge requests</span>
365+<span class="lbl">merge requests (g m)</span>
364366 <span class="k">section</span>
365367 </a>
366368 </li>
367-<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
369+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases (g r)">
368370 <a href="/files/releases" tabindex="-1">
369-<span class="lbl">releases</span>
371+<span class="lbl">releases (g r)</span>
370372 <span class="k">section</span>
371373 </a>
372374 </li>
373-<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
375+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions (g a)">
374376 <a href="/files/actions" tabindex="-1">
375-<span class="lbl">actions</span>
377+<span class="lbl">actions (g a)</span>
376378 <span class="k">section</span>
377379 </a>
378380 </li>
@@ -425,7 +427,9 @@
425427 k-filter from:body -> ermite.focusFilter();
426428 k-gh from:body -> ermite.go('.brand');
427429 k-s from:body -> ermite.tab('s');
428- k-t from:body -> ermite.tab('t');
430+ k-f from:body -> ermite.tab('f');
431+ k-b from:body -> ermite.tab('b');
432+ k-a from:body -> ermite.tab('a');
429433 k-c from:body -> ermite.tab('c');
430434 k-r from:body -> ermite.tab('r');
431435 k-m from:body -> ermite.tab('m');
@@ -441,7 +445,6 @@
441445 k-permalink from:body -> ermite.permalink();
442446 k-merge from:body -> window.ermiteMR?.openMerge();
443447 k-read from:body -> window.ermiteMR?.markRead();
444- k-gf from:body -> window.ermiteMR?.focusFiles();
445448 k-keys from:body -> ermite.toggleKeys()">
446449 </div>
447450 </body>
M internal/app/testdata/web/blob-markdown.html +33 -30
@@ -76,31 +76,31 @@
7676 <nav class="tabs" aria-label="Sections">
7777 <ul>
7878 <li>
79-<a href="/files" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
79+<a href="/files" data-key="g s">Summary <kbd aria-hidden="true">g s</kbd>
8080 </a>
8181 </li>
8282 <li>
83-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files <kbd aria-hidden="true">t</kbd>
83+<a href="/files/tree/main" data-key="g f" aria-current="page">Files <kbd aria-hidden="true">g f</kbd>
8484 </a>
8585 </li>
8686 <li>
87-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
87+<a href="/files/commits/main" data-key="g c">Commits <kbd aria-hidden="true">g c</kbd>
8888 </a>
8989 </li>
9090 <li>
91-<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
91+<a href="/files/refs" data-key="g b">Branches &amp; tags <kbd aria-hidden="true">g b</kbd>
9292 </a>
9393 </li>
9494 <li>
95-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
95+<a href="/files/merge-requests" data-key="g m">Merge requests <kbd aria-hidden="true">g m</kbd>
9696 </a>
9797 </li>
9898 <li>
99-<a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
99+<a href="/files/releases" data-key="g r">Releases <kbd aria-hidden="true">g r</kbd>
100100 </a>
101101 </li>
102102 <li>
103-<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
103+<a href="/files/actions" data-key="g a">Actions <kbd aria-hidden="true">g a</kbd>
104104 </a>
105105 </li>
106106 </ul>
@@ -293,14 +293,16 @@
293293 </div>
294294 <div>
295295 <dt>
296-<kbd>s</kbd>
297-<kbd>t</kbd>
298-<kbd>c</kbd>
299-<kbd>r</kbd>
300-<kbd>m</kbd>
301-<kbd>i</kbd>
296+<kbd>g s</kbd>
297+<kbd>g f</kbd>
298+<kbd>g c</kbd>
299+<kbd>g b</kbd>
300+<kbd>g m</kbd>
301+<kbd>g i</kbd>
302+<kbd>g r</kbd>
303+<kbd>g a</kbd>
302304 </dt>
303-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
305+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues, releases, actions (when available)</dd>
304306 </div>
305307 <div>
306308 <dt>
@@ -399,45 +401,45 @@
399401 <span class="k">go</span>
400402 </a>
401403 </li>
402-<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
404+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary (g s)">
403405 <a href="/files" tabindex="-1">
404-<span class="lbl">summary</span>
406+<span class="lbl">summary (g s)</span>
405407 <span class="k">section</span>
406408 </a>
407409 </li>
408-<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
410+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files (g f)">
409411 <a href="/files/tree/main" tabindex="-1">
410-<span class="lbl">files</span>
412+<span class="lbl">files (g f)</span>
411413 <span class="k">section</span>
412414 </a>
413415 </li>
414-<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
416+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits (g c)">
415417 <a href="/files/commits/main" tabindex="-1">
416-<span class="lbl">commits</span>
418+<span class="lbl">commits (g c)</span>
417419 <span class="k">section</span>
418420 </a>
419421 </li>
420-<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
422+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags (g b)">
421423 <a href="/files/refs" tabindex="-1">
422-<span class="lbl">branches &amp; tags</span>
424+<span class="lbl">branches &amp; tags (g b)</span>
423425 <span class="k">section</span>
424426 </a>
425427 </li>
426-<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
428+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests (g m)">
427429 <a href="/files/merge-requests" tabindex="-1">
428-<span class="lbl">merge requests</span>
430+<span class="lbl">merge requests (g m)</span>
429431 <span class="k">section</span>
430432 </a>
431433 </li>
432-<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
434+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases (g r)">
433435 <a href="/files/releases" tabindex="-1">
434-<span class="lbl">releases</span>
436+<span class="lbl">releases (g r)</span>
435437 <span class="k">section</span>
436438 </a>
437439 </li>
438-<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
440+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions (g a)">
439441 <a href="/files/actions" tabindex="-1">
440-<span class="lbl">actions</span>
442+<span class="lbl">actions (g a)</span>
441443 <span class="k">section</span>
442444 </a>
443445 </li>
@@ -490,7 +492,9 @@
490492 k-filter from:body -> ermite.focusFilter();
491493 k-gh from:body -> ermite.go('.brand');
492494 k-s from:body -> ermite.tab('s');
493- k-t from:body -> ermite.tab('t');
495+ k-f from:body -> ermite.tab('f');
496+ k-b from:body -> ermite.tab('b');
497+ k-a from:body -> ermite.tab('a');
494498 k-c from:body -> ermite.tab('c');
495499 k-r from:body -> ermite.tab('r');
496500 k-m from:body -> ermite.tab('m');
@@ -506,7 +510,6 @@
506510 k-permalink from:body -> ermite.permalink();
507511 k-merge from:body -> window.ermiteMR?.openMerge();
508512 k-read from:body -> window.ermiteMR?.markRead();
509- k-gf from:body -> window.ermiteMR?.focusFiles();
510513 k-keys from:body -> ermite.toggleKeys()">
511514 </div>
512515 </body>
M internal/app/testdata/web/blob-svg.html +33 -30
@@ -73,31 +73,31 @@
7373 <nav class="tabs" aria-label="Sections">
7474 <ul>
7575 <li>
76-<a href="/files" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
76+<a href="/files" data-key="g s">Summary <kbd aria-hidden="true">g s</kbd>
7777 </a>
7878 </li>
7979 <li>
80-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files <kbd aria-hidden="true">t</kbd>
80+<a href="/files/tree/main" data-key="g f" aria-current="page">Files <kbd aria-hidden="true">g f</kbd>
8181 </a>
8282 </li>
8383 <li>
84-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
84+<a href="/files/commits/main" data-key="g c">Commits <kbd aria-hidden="true">g c</kbd>
8585 </a>
8686 </li>
8787 <li>
88-<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
88+<a href="/files/refs" data-key="g b">Branches &amp; tags <kbd aria-hidden="true">g b</kbd>
8989 </a>
9090 </li>
9191 <li>
92-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
92+<a href="/files/merge-requests" data-key="g m">Merge requests <kbd aria-hidden="true">g m</kbd>
9393 </a>
9494 </li>
9595 <li>
96-<a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
96+<a href="/files/releases" data-key="g r">Releases <kbd aria-hidden="true">g r</kbd>
9797 </a>
9898 </li>
9999 <li>
100-<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
100+<a href="/files/actions" data-key="g a">Actions <kbd aria-hidden="true">g a</kbd>
101101 </a>
102102 </li>
103103 </ul>
@@ -251,14 +251,16 @@
251251 </div>
252252 <div>
253253 <dt>
254-<kbd>s</kbd>
255-<kbd>t</kbd>
256-<kbd>c</kbd>
257-<kbd>r</kbd>
258-<kbd>m</kbd>
259-<kbd>i</kbd>
254+<kbd>g s</kbd>
255+<kbd>g f</kbd>
256+<kbd>g c</kbd>
257+<kbd>g b</kbd>
258+<kbd>g m</kbd>
259+<kbd>g i</kbd>
260+<kbd>g r</kbd>
261+<kbd>g a</kbd>
260262 </dt>
261-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
263+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues, releases, actions (when available)</dd>
262264 </div>
263265 <div>
264266 <dt>
@@ -357,45 +359,45 @@
357359 <span class="k">go</span>
358360 </a>
359361 </li>
360-<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
362+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary (g s)">
361363 <a href="/files" tabindex="-1">
362-<span class="lbl">summary</span>
364+<span class="lbl">summary (g s)</span>
363365 <span class="k">section</span>
364366 </a>
365367 </li>
366-<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
368+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files (g f)">
367369 <a href="/files/tree/main" tabindex="-1">
368-<span class="lbl">files</span>
370+<span class="lbl">files (g f)</span>
369371 <span class="k">section</span>
370372 </a>
371373 </li>
372-<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
374+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits (g c)">
373375 <a href="/files/commits/main" tabindex="-1">
374-<span class="lbl">commits</span>
376+<span class="lbl">commits (g c)</span>
375377 <span class="k">section</span>
376378 </a>
377379 </li>
378-<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
380+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags (g b)">
379381 <a href="/files/refs" tabindex="-1">
380-<span class="lbl">branches &amp; tags</span>
382+<span class="lbl">branches &amp; tags (g b)</span>
381383 <span class="k">section</span>
382384 </a>
383385 </li>
384-<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
386+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests (g m)">
385387 <a href="/files/merge-requests" tabindex="-1">
386-<span class="lbl">merge requests</span>
388+<span class="lbl">merge requests (g m)</span>
387389 <span class="k">section</span>
388390 </a>
389391 </li>
390-<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
392+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases (g r)">
391393 <a href="/files/releases" tabindex="-1">
392-<span class="lbl">releases</span>
394+<span class="lbl">releases (g r)</span>
393395 <span class="k">section</span>
394396 </a>
395397 </li>
396-<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
398+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions (g a)">
397399 <a href="/files/actions" tabindex="-1">
398-<span class="lbl">actions</span>
400+<span class="lbl">actions (g a)</span>
399401 <span class="k">section</span>
400402 </a>
401403 </li>
@@ -448,7 +450,9 @@
448450 k-filter from:body -> ermite.focusFilter();
449451 k-gh from:body -> ermite.go('.brand');
450452 k-s from:body -> ermite.tab('s');
451- k-t from:body -> ermite.tab('t');
453+ k-f from:body -> ermite.tab('f');
454+ k-b from:body -> ermite.tab('b');
455+ k-a from:body -> ermite.tab('a');
452456 k-c from:body -> ermite.tab('c');
453457 k-r from:body -> ermite.tab('r');
454458 k-m from:body -> ermite.tab('m');
@@ -464,7 +468,6 @@
464468 k-permalink from:body -> ermite.permalink();
465469 k-merge from:body -> window.ermiteMR?.openMerge();
466470 k-read from:body -> window.ermiteMR?.markRead();
467- k-gf from:body -> window.ermiteMR?.focusFiles();
468471 k-keys from:body -> ermite.toggleKeys()">
469472 </div>
470473 </body>
M internal/app/testdata/web/blob-text.html +33 -30
@@ -73,31 +73,31 @@
7373 <nav class="tabs" aria-label="Sections">
7474 <ul>
7575 <li>
76-<a href="/files" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
76+<a href="/files" data-key="g s">Summary <kbd aria-hidden="true">g s</kbd>
7777 </a>
7878 </li>
7979 <li>
80-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files <kbd aria-hidden="true">t</kbd>
80+<a href="/files/tree/main" data-key="g f" aria-current="page">Files <kbd aria-hidden="true">g f</kbd>
8181 </a>
8282 </li>
8383 <li>
84-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
84+<a href="/files/commits/main" data-key="g c">Commits <kbd aria-hidden="true">g c</kbd>
8585 </a>
8686 </li>
8787 <li>
88-<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
88+<a href="/files/refs" data-key="g b">Branches &amp; tags <kbd aria-hidden="true">g b</kbd>
8989 </a>
9090 </li>
9191 <li>
92-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
92+<a href="/files/merge-requests" data-key="g m">Merge requests <kbd aria-hidden="true">g m</kbd>
9393 </a>
9494 </li>
9595 <li>
96-<a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
96+<a href="/files/releases" data-key="g r">Releases <kbd aria-hidden="true">g r</kbd>
9797 </a>
9898 </li>
9999 <li>
100-<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
100+<a href="/files/actions" data-key="g a">Actions <kbd aria-hidden="true">g a</kbd>
101101 </a>
102102 </li>
103103 </ul>
@@ -246,14 +246,16 @@
246246 </div>
247247 <div>
248248 <dt>
249-<kbd>s</kbd>
250-<kbd>t</kbd>
251-<kbd>c</kbd>
252-<kbd>r</kbd>
253-<kbd>m</kbd>
254-<kbd>i</kbd>
249+<kbd>g s</kbd>
250+<kbd>g f</kbd>
251+<kbd>g c</kbd>
252+<kbd>g b</kbd>
253+<kbd>g m</kbd>
254+<kbd>g i</kbd>
255+<kbd>g r</kbd>
256+<kbd>g a</kbd>
255257 </dt>
256-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
258+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues, releases, actions (when available)</dd>
257259 </div>
258260 <div>
259261 <dt>
@@ -352,45 +354,45 @@
352354 <span class="k">go</span>
353355 </a>
354356 </li>
355-<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
357+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary (g s)">
356358 <a href="/files" tabindex="-1">
357-<span class="lbl">summary</span>
359+<span class="lbl">summary (g s)</span>
358360 <span class="k">section</span>
359361 </a>
360362 </li>
361-<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
363+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files (g f)">
362364 <a href="/files/tree/main" tabindex="-1">
363-<span class="lbl">files</span>
365+<span class="lbl">files (g f)</span>
364366 <span class="k">section</span>
365367 </a>
366368 </li>
367-<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
369+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits (g c)">
368370 <a href="/files/commits/main" tabindex="-1">
369-<span class="lbl">commits</span>
371+<span class="lbl">commits (g c)</span>
370372 <span class="k">section</span>
371373 </a>
372374 </li>
373-<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
375+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags (g b)">
374376 <a href="/files/refs" tabindex="-1">
375-<span class="lbl">branches &amp; tags</span>
377+<span class="lbl">branches &amp; tags (g b)</span>
376378 <span class="k">section</span>
377379 </a>
378380 </li>
379-<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
381+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests (g m)">
380382 <a href="/files/merge-requests" tabindex="-1">
381-<span class="lbl">merge requests</span>
383+<span class="lbl">merge requests (g m)</span>
382384 <span class="k">section</span>
383385 </a>
384386 </li>
385-<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
387+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases (g r)">
386388 <a href="/files/releases" tabindex="-1">
387-<span class="lbl">releases</span>
389+<span class="lbl">releases (g r)</span>
388390 <span class="k">section</span>
389391 </a>
390392 </li>
391-<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
393+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions (g a)">
392394 <a href="/files/actions" tabindex="-1">
393-<span class="lbl">actions</span>
395+<span class="lbl">actions (g a)</span>
394396 <span class="k">section</span>
395397 </a>
396398 </li>
@@ -443,7 +445,9 @@
443445 k-filter from:body -> ermite.focusFilter();
444446 k-gh from:body -> ermite.go('.brand');
445447 k-s from:body -> ermite.tab('s');
446- k-t from:body -> ermite.tab('t');
448+ k-f from:body -> ermite.tab('f');
449+ k-b from:body -> ermite.tab('b');
450+ k-a from:body -> ermite.tab('a');
447451 k-c from:body -> ermite.tab('c');
448452 k-r from:body -> ermite.tab('r');
449453 k-m from:body -> ermite.tab('m');
@@ -459,7 +463,6 @@
459463 k-permalink from:body -> ermite.permalink();
460464 k-merge from:body -> window.ermiteMR?.openMerge();
461465 k-read from:body -> window.ermiteMR?.markRead();
462- k-gf from:body -> window.ermiteMR?.focusFiles();
463466 k-keys from:body -> ermite.toggleKeys()">
464467 </div>
465468 </body>
M internal/app/testdata/web/blob-too-large.html +33 -30
@@ -73,31 +73,31 @@
7373 <nav class="tabs" aria-label="Sections">
7474 <ul>
7575 <li>
76-<a href="/files" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
76+<a href="/files" data-key="g s">Summary <kbd aria-hidden="true">g s</kbd>
7777 </a>
7878 </li>
7979 <li>
80-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files <kbd aria-hidden="true">t</kbd>
80+<a href="/files/tree/main" data-key="g f" aria-current="page">Files <kbd aria-hidden="true">g f</kbd>
8181 </a>
8282 </li>
8383 <li>
84-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
84+<a href="/files/commits/main" data-key="g c">Commits <kbd aria-hidden="true">g c</kbd>
8585 </a>
8686 </li>
8787 <li>
88-<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
88+<a href="/files/refs" data-key="g b">Branches &amp; tags <kbd aria-hidden="true">g b</kbd>
8989 </a>
9090 </li>
9191 <li>
92-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
92+<a href="/files/merge-requests" data-key="g m">Merge requests <kbd aria-hidden="true">g m</kbd>
9393 </a>
9494 </li>
9595 <li>
96-<a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
96+<a href="/files/releases" data-key="g r">Releases <kbd aria-hidden="true">g r</kbd>
9797 </a>
9898 </li>
9999 <li>
100-<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
100+<a href="/files/actions" data-key="g a">Actions <kbd aria-hidden="true">g a</kbd>
101101 </a>
102102 </li>
103103 </ul>
@@ -226,14 +226,16 @@
226226 </div>
227227 <div>
228228 <dt>
229-<kbd>s</kbd>
230-<kbd>t</kbd>
231-<kbd>c</kbd>
232-<kbd>r</kbd>
233-<kbd>m</kbd>
234-<kbd>i</kbd>
229+<kbd>g s</kbd>
230+<kbd>g f</kbd>
231+<kbd>g c</kbd>
232+<kbd>g b</kbd>
233+<kbd>g m</kbd>
234+<kbd>g i</kbd>
235+<kbd>g r</kbd>
236+<kbd>g a</kbd>
235237 </dt>
236-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
238+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues, releases, actions (when available)</dd>
237239 </div>
238240 <div>
239241 <dt>
@@ -332,45 +334,45 @@
332334 <span class="k">go</span>
333335 </a>
334336 </li>
335-<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
337+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary (g s)">
336338 <a href="/files" tabindex="-1">
337-<span class="lbl">summary</span>
339+<span class="lbl">summary (g s)</span>
338340 <span class="k">section</span>
339341 </a>
340342 </li>
341-<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
343+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files (g f)">
342344 <a href="/files/tree/main" tabindex="-1">
343-<span class="lbl">files</span>
345+<span class="lbl">files (g f)</span>
344346 <span class="k">section</span>
345347 </a>
346348 </li>
347-<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
349+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits (g c)">
348350 <a href="/files/commits/main" tabindex="-1">
349-<span class="lbl">commits</span>
351+<span class="lbl">commits (g c)</span>
350352 <span class="k">section</span>
351353 </a>
352354 </li>
353-<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
355+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags (g b)">
354356 <a href="/files/refs" tabindex="-1">
355-<span class="lbl">branches &amp; tags</span>
357+<span class="lbl">branches &amp; tags (g b)</span>
356358 <span class="k">section</span>
357359 </a>
358360 </li>
359-<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
361+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests (g m)">
360362 <a href="/files/merge-requests" tabindex="-1">
361-<span class="lbl">merge requests</span>
363+<span class="lbl">merge requests (g m)</span>
362364 <span class="k">section</span>
363365 </a>
364366 </li>
365-<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
367+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases (g r)">
366368 <a href="/files/releases" tabindex="-1">
367-<span class="lbl">releases</span>
369+<span class="lbl">releases (g r)</span>
368370 <span class="k">section</span>
369371 </a>
370372 </li>
371-<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
373+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions (g a)">
372374 <a href="/files/actions" tabindex="-1">
373-<span class="lbl">actions</span>
375+<span class="lbl">actions (g a)</span>
374376 <span class="k">section</span>
375377 </a>
376378 </li>
@@ -423,7 +425,9 @@
423425 k-filter from:body -> ermite.focusFilter();
424426 k-gh from:body -> ermite.go('.brand');
425427 k-s from:body -> ermite.tab('s');
426- k-t from:body -> ermite.tab('t');
428+ k-f from:body -> ermite.tab('f');
429+ k-b from:body -> ermite.tab('b');
430+ k-a from:body -> ermite.tab('a');
427431 k-c from:body -> ermite.tab('c');
428432 k-r from:body -> ermite.tab('r');
429433 k-m from:body -> ermite.tab('m');
@@ -439,7 +443,6 @@
439443 k-permalink from:body -> ermite.permalink();
440444 k-merge from:body -> window.ermiteMR?.openMerge();
441445 k-read from:body -> window.ermiteMR?.markRead();
442- k-gf from:body -> window.ermiteMR?.focusFiles();
443446 k-keys from:body -> ermite.toggleKeys()">
444447 </div>
445448 </body>
M internal/app/testdata/web/blob-trojan.html +33 -30
@@ -73,31 +73,31 @@
7373 <nav class="tabs" aria-label="Sections">
7474 <ul>
7575 <li>
76-<a href="/files" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
76+<a href="/files" data-key="g s">Summary <kbd aria-hidden="true">g s</kbd>
7777 </a>
7878 </li>
7979 <li>
80-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files <kbd aria-hidden="true">t</kbd>
80+<a href="/files/tree/main" data-key="g f" aria-current="page">Files <kbd aria-hidden="true">g f</kbd>
8181 </a>
8282 </li>
8383 <li>
84-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
84+<a href="/files/commits/main" data-key="g c">Commits <kbd aria-hidden="true">g c</kbd>
8585 </a>
8686 </li>
8787 <li>
88-<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
88+<a href="/files/refs" data-key="g b">Branches &amp; tags <kbd aria-hidden="true">g b</kbd>
8989 </a>
9090 </li>
9191 <li>
92-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
92+<a href="/files/merge-requests" data-key="g m">Merge requests <kbd aria-hidden="true">g m</kbd>
9393 </a>
9494 </li>
9595 <li>
96-<a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
96+<a href="/files/releases" data-key="g r">Releases <kbd aria-hidden="true">g r</kbd>
9797 </a>
9898 </li>
9999 <li>
100-<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
100+<a href="/files/actions" data-key="g a">Actions <kbd aria-hidden="true">g a</kbd>
101101 </a>
102102 </li>
103103 </ul>
@@ -293,14 +293,16 @@
293293 </div>
294294 <div>
295295 <dt>
296-<kbd>s</kbd>
297-<kbd>t</kbd>
298-<kbd>c</kbd>
299-<kbd>r</kbd>
300-<kbd>m</kbd>
301-<kbd>i</kbd>
296+<kbd>g s</kbd>
297+<kbd>g f</kbd>
298+<kbd>g c</kbd>
299+<kbd>g b</kbd>
300+<kbd>g m</kbd>
301+<kbd>g i</kbd>
302+<kbd>g r</kbd>
303+<kbd>g a</kbd>
302304 </dt>
303-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
305+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues, releases, actions (when available)</dd>
304306 </div>
305307 <div>
306308 <dt>
@@ -399,45 +401,45 @@
399401 <span class="k">go</span>
400402 </a>
401403 </li>
402-<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
404+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary (g s)">
403405 <a href="/files" tabindex="-1">
404-<span class="lbl">summary</span>
406+<span class="lbl">summary (g s)</span>
405407 <span class="k">section</span>
406408 </a>
407409 </li>
408-<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
410+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files (g f)">
409411 <a href="/files/tree/main" tabindex="-1">
410-<span class="lbl">files</span>
412+<span class="lbl">files (g f)</span>
411413 <span class="k">section</span>
412414 </a>
413415 </li>
414-<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
416+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits (g c)">
415417 <a href="/files/commits/main" tabindex="-1">
416-<span class="lbl">commits</span>
418+<span class="lbl">commits (g c)</span>
417419 <span class="k">section</span>
418420 </a>
419421 </li>
420-<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
422+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags (g b)">
421423 <a href="/files/refs" tabindex="-1">
422-<span class="lbl">branches &amp; tags</span>
424+<span class="lbl">branches &amp; tags (g b)</span>
423425 <span class="k">section</span>
424426 </a>
425427 </li>
426-<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
428+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests (g m)">
427429 <a href="/files/merge-requests" tabindex="-1">
428-<span class="lbl">merge requests</span>
430+<span class="lbl">merge requests (g m)</span>
429431 <span class="k">section</span>
430432 </a>
431433 </li>
432-<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
434+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases (g r)">
433435 <a href="/files/releases" tabindex="-1">
434-<span class="lbl">releases</span>
436+<span class="lbl">releases (g r)</span>
435437 <span class="k">section</span>
436438 </a>
437439 </li>
438-<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
440+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions (g a)">
439441 <a href="/files/actions" tabindex="-1">
440-<span class="lbl">actions</span>
442+<span class="lbl">actions (g a)</span>
441443 <span class="k">section</span>
442444 </a>
443445 </li>
@@ -490,7 +492,9 @@
490492 k-filter from:body -> ermite.focusFilter();
491493 k-gh from:body -> ermite.go('.brand');
492494 k-s from:body -> ermite.tab('s');
493- k-t from:body -> ermite.tab('t');
495+ k-f from:body -> ermite.tab('f');
496+ k-b from:body -> ermite.tab('b');
497+ k-a from:body -> ermite.tab('a');
494498 k-c from:body -> ermite.tab('c');
495499 k-r from:body -> ermite.tab('r');
496500 k-m from:body -> ermite.tab('m');
@@ -506,7 +510,6 @@
506510 k-permalink from:body -> ermite.permalink();
507511 k-merge from:body -> window.ermiteMR?.openMerge();
508512 k-read from:body -> window.ermiteMR?.markRead();
509- k-gf from:body -> window.ermiteMR?.focusFiles();
510513 k-keys from:body -> ermite.toggleKeys()">
511514 </div>
512515 </body>
M internal/app/testdata/web/commit-big.html +33 -30
@@ -72,31 +72,31 @@
7272 <nav class="tabs" aria-label="Sections">
7373 <ul>
7474 <li>
75-<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
75+<a href="/diffs" data-key="g s">Summary <kbd aria-hidden="true">g s</kbd>
7676 </a>
7777 </li>
7878 <li>
79-<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files <kbd aria-hidden="true">t</kbd>
79+<a href="/diffs/tree/main" data-key="g f">Files <kbd aria-hidden="true">g f</kbd>
8080 </a>
8181 </li>
8282 <li>
83-<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits <kbd aria-hidden="true">c</kbd>
83+<a href="/diffs/commits/main" data-key="g c" aria-current="page">Commits <kbd aria-hidden="true">g c</kbd>
8484 </a>
8585 </li>
8686 <li>
87-<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
87+<a href="/diffs/refs" data-key="g b">Branches &amp; tags <kbd aria-hidden="true">g b</kbd>
8888 </a>
8989 </li>
9090 <li>
91-<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
91+<a href="/diffs/merge-requests" data-key="g m">Merge requests <kbd aria-hidden="true">g m</kbd>
9292 </a>
9393 </li>
9494 <li>
95-<a href="/diffs/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
95+<a href="/diffs/releases" data-key="g r">Releases <kbd aria-hidden="true">g r</kbd>
9696 </a>
9797 </li>
9898 <li>
99-<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
99+<a href="/diffs/actions" data-key="g a">Actions <kbd aria-hidden="true">g a</kbd>
100100 </a>
101101 </li>
102102 </ul>
@@ -278,14 +278,16 @@
278278 </div>
279279 <div>
280280 <dt>
281-<kbd>s</kbd>
282-<kbd>t</kbd>
283-<kbd>c</kbd>
284-<kbd>r</kbd>
285-<kbd>m</kbd>
286-<kbd>i</kbd>
281+<kbd>g s</kbd>
282+<kbd>g f</kbd>
283+<kbd>g c</kbd>
284+<kbd>g b</kbd>
285+<kbd>g m</kbd>
286+<kbd>g i</kbd>
287+<kbd>g r</kbd>
288+<kbd>g a</kbd>
287289 </dt>
288-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
290+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues, releases, actions (when available)</dd>
289291 </div>
290292 <div>
291293 <dt>
@@ -384,45 +386,45 @@
384386 <span class="k">go</span>
385387 </a>
386388 </li>
387-<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
389+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary (g s)">
388390 <a href="/diffs" tabindex="-1">
389-<span class="lbl">summary</span>
391+<span class="lbl">summary (g s)</span>
390392 <span class="k">section</span>
391393 </a>
392394 </li>
393-<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
395+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files (g f)">
394396 <a href="/diffs/tree/main" tabindex="-1">
395-<span class="lbl">files</span>
397+<span class="lbl">files (g f)</span>
396398 <span class="k">section</span>
397399 </a>
398400 </li>
399-<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
401+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits (g c)">
400402 <a href="/diffs/commits/main" tabindex="-1">
401-<span class="lbl">commits</span>
403+<span class="lbl">commits (g c)</span>
402404 <span class="k">section</span>
403405 </a>
404406 </li>
405-<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
407+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags (g b)">
406408 <a href="/diffs/refs" tabindex="-1">
407-<span class="lbl">branches &amp; tags</span>
409+<span class="lbl">branches &amp; tags (g b)</span>
408410 <span class="k">section</span>
409411 </a>
410412 </li>
411-<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
413+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests (g m)">
412414 <a href="/diffs/merge-requests" tabindex="-1">
413-<span class="lbl">merge requests</span>
415+<span class="lbl">merge requests (g m)</span>
414416 <span class="k">section</span>
415417 </a>
416418 </li>
417-<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
419+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases (g r)">
418420 <a href="/diffs/releases" tabindex="-1">
419-<span class="lbl">releases</span>
421+<span class="lbl">releases (g r)</span>
420422 <span class="k">section</span>
421423 </a>
422424 </li>
423-<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
425+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions (g a)">
424426 <a href="/diffs/actions" tabindex="-1">
425-<span class="lbl">actions</span>
427+<span class="lbl">actions (g a)</span>
426428 <span class="k">section</span>
427429 </a>
428430 </li>
@@ -475,7 +477,9 @@
475477 k-filter from:body -> ermite.focusFilter();
476478 k-gh from:body -> ermite.go('.brand');
477479 k-s from:body -> ermite.tab('s');
478- k-t from:body -> ermite.tab('t');
480+ k-f from:body -> ermite.tab('f');
481+ k-b from:body -> ermite.tab('b');
482+ k-a from:body -> ermite.tab('a');
479483 k-c from:body -> ermite.tab('c');
480484 k-r from:body -> ermite.tab('r');
481485 k-m from:body -> ermite.tab('m');
@@ -491,7 +495,6 @@
491495 k-permalink from:body -> ermite.permalink();
492496 k-merge from:body -> window.ermiteMR?.openMerge();
493497 k-read from:body -> window.ermiteMR?.markRead();
494- k-gf from:body -> window.ermiteMR?.focusFiles();
495498 k-keys from:body -> ermite.toggleKeys()">
496499 </div>
497500 </body>
M internal/app/testdata/web/commit-binary.html +33 -30
@@ -72,31 +72,31 @@
7272 <nav class="tabs" aria-label="Sections">
7373 <ul>
7474 <li>
75-<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
75+<a href="/diffs" data-key="g s">Summary <kbd aria-hidden="true">g s</kbd>
7676 </a>
7777 </li>
7878 <li>
79-<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files <kbd aria-hidden="true">t</kbd>
79+<a href="/diffs/tree/main" data-key="g f">Files <kbd aria-hidden="true">g f</kbd>
8080 </a>
8181 </li>
8282 <li>
83-<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits <kbd aria-hidden="true">c</kbd>
83+<a href="/diffs/commits/main" data-key="g c" aria-current="page">Commits <kbd aria-hidden="true">g c</kbd>
8484 </a>
8585 </li>
8686 <li>
87-<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
87+<a href="/diffs/refs" data-key="g b">Branches &amp; tags <kbd aria-hidden="true">g b</kbd>
8888 </a>
8989 </li>
9090 <li>
91-<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
91+<a href="/diffs/merge-requests" data-key="g m">Merge requests <kbd aria-hidden="true">g m</kbd>
9292 </a>
9393 </li>
9494 <li>
95-<a href="/diffs/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
95+<a href="/diffs/releases" data-key="g r">Releases <kbd aria-hidden="true">g r</kbd>
9696 </a>
9797 </li>
9898 <li>
99-<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
99+<a href="/diffs/actions" data-key="g a">Actions <kbd aria-hidden="true">g a</kbd>
100100 </a>
101101 </li>
102102 </ul>
@@ -269,14 +269,16 @@
269269 </div>
270270 <div>
271271 <dt>
272-<kbd>s</kbd>
273-<kbd>t</kbd>
274-<kbd>c</kbd>
275-<kbd>r</kbd>
276-<kbd>m</kbd>
277-<kbd>i</kbd>
272+<kbd>g s</kbd>
273+<kbd>g f</kbd>
274+<kbd>g c</kbd>
275+<kbd>g b</kbd>
276+<kbd>g m</kbd>
277+<kbd>g i</kbd>
278+<kbd>g r</kbd>
279+<kbd>g a</kbd>
278280 </dt>
279-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
281+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues, releases, actions (when available)</dd>
280282 </div>
281283 <div>
282284 <dt>
@@ -375,45 +377,45 @@
375377 <span class="k">go</span>
376378 </a>
377379 </li>
378-<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
380+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary (g s)">
379381 <a href="/diffs" tabindex="-1">
380-<span class="lbl">summary</span>
382+<span class="lbl">summary (g s)</span>
381383 <span class="k">section</span>
382384 </a>
383385 </li>
384-<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
386+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files (g f)">
385387 <a href="/diffs/tree/main" tabindex="-1">
386-<span class="lbl">files</span>
388+<span class="lbl">files (g f)</span>
387389 <span class="k">section</span>
388390 </a>
389391 </li>
390-<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
392+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits (g c)">
391393 <a href="/diffs/commits/main" tabindex="-1">
392-<span class="lbl">commits</span>
394+<span class="lbl">commits (g c)</span>
393395 <span class="k">section</span>
394396 </a>
395397 </li>
396-<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
398+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags (g b)">
397399 <a href="/diffs/refs" tabindex="-1">
398-<span class="lbl">branches &amp; tags</span>
400+<span class="lbl">branches &amp; tags (g b)</span>
399401 <span class="k">section</span>
400402 </a>
401403 </li>
402-<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
404+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests (g m)">
403405 <a href="/diffs/merge-requests" tabindex="-1">
404-<span class="lbl">merge requests</span>
406+<span class="lbl">merge requests (g m)</span>
405407 <span class="k">section</span>
406408 </a>
407409 </li>
408-<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
410+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases (g r)">
409411 <a href="/diffs/releases" tabindex="-1">
410-<span class="lbl">releases</span>
412+<span class="lbl">releases (g r)</span>
411413 <span class="k">section</span>
412414 </a>
413415 </li>
414-<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
416+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions (g a)">
415417 <a href="/diffs/actions" tabindex="-1">
416-<span class="lbl">actions</span>
418+<span class="lbl">actions (g a)</span>
417419 <span class="k">section</span>
418420 </a>
419421 </li>
@@ -466,7 +468,9 @@
466468 k-filter from:body -> ermite.focusFilter();
467469 k-gh from:body -> ermite.go('.brand');
468470 k-s from:body -> ermite.tab('s');
469- k-t from:body -> ermite.tab('t');
471+ k-f from:body -> ermite.tab('f');
472+ k-b from:body -> ermite.tab('b');
473+ k-a from:body -> ermite.tab('a');
470474 k-c from:body -> ermite.tab('c');
471475 k-r from:body -> ermite.tab('r');
472476 k-m from:body -> ermite.tab('m');
@@ -482,7 +486,6 @@
482486 k-permalink from:body -> ermite.permalink();
483487 k-merge from:body -> window.ermiteMR?.openMerge();
484488 k-read from:body -> window.ermiteMR?.markRead();
485- k-gf from:body -> window.ermiteMR?.focusFiles();
486489 k-keys from:body -> ermite.toggleKeys()">
487490 </div>
488491 </body>
M internal/app/testdata/web/commit-merge.html +33 -30
@@ -72,31 +72,31 @@
7272 <nav class="tabs" aria-label="Sections">
7373 <ul>
7474 <li>
75-<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
75+<a href="/diffs" data-key="g s">Summary <kbd aria-hidden="true">g s</kbd>
7676 </a>
7777 </li>
7878 <li>
79-<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files <kbd aria-hidden="true">t</kbd>
79+<a href="/diffs/tree/main" data-key="g f">Files <kbd aria-hidden="true">g f</kbd>
8080 </a>
8181 </li>
8282 <li>
83-<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits <kbd aria-hidden="true">c</kbd>
83+<a href="/diffs/commits/main" data-key="g c" aria-current="page">Commits <kbd aria-hidden="true">g c</kbd>
8484 </a>
8585 </li>
8686 <li>
87-<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
87+<a href="/diffs/refs" data-key="g b">Branches &amp; tags <kbd aria-hidden="true">g b</kbd>
8888 </a>
8989 </li>
9090 <li>
91-<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
91+<a href="/diffs/merge-requests" data-key="g m">Merge requests <kbd aria-hidden="true">g m</kbd>
9292 </a>
9393 </li>
9494 <li>
95-<a href="/diffs/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
95+<a href="/diffs/releases" data-key="g r">Releases <kbd aria-hidden="true">g r</kbd>
9696 </a>
9797 </li>
9898 <li>
99-<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
99+<a href="/diffs/actions" data-key="g a">Actions <kbd aria-hidden="true">g a</kbd>
100100 </a>
101101 </li>
102102 </ul>
@@ -311,14 +311,16 @@
311311 </div>
312312 <div>
313313 <dt>
314-<kbd>s</kbd>
315-<kbd>t</kbd>
316-<kbd>c</kbd>
317-<kbd>r</kbd>
318-<kbd>m</kbd>
319-<kbd>i</kbd>
314+<kbd>g s</kbd>
315+<kbd>g f</kbd>
316+<kbd>g c</kbd>
317+<kbd>g b</kbd>
318+<kbd>g m</kbd>
319+<kbd>g i</kbd>
320+<kbd>g r</kbd>
321+<kbd>g a</kbd>
320322 </dt>
321-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
323+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues, releases, actions (when available)</dd>
322324 </div>
323325 <div>
324326 <dt>
@@ -417,45 +419,45 @@
417419 <span class="k">go</span>
418420 </a>
419421 </li>
420-<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
422+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary (g s)">
421423 <a href="/diffs" tabindex="-1">
422-<span class="lbl">summary</span>
424+<span class="lbl">summary (g s)</span>
423425 <span class="k">section</span>
424426 </a>
425427 </li>
426-<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
428+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files (g f)">
427429 <a href="/diffs/tree/main" tabindex="-1">
428-<span class="lbl">files</span>
430+<span class="lbl">files (g f)</span>
429431 <span class="k">section</span>
430432 </a>
431433 </li>
432-<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
434+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits (g c)">
433435 <a href="/diffs/commits/main" tabindex="-1">
434-<span class="lbl">commits</span>
436+<span class="lbl">commits (g c)</span>
435437 <span class="k">section</span>
436438 </a>
437439 </li>
438-<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
440+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags (g b)">
439441 <a href="/diffs/refs" tabindex="-1">
440-<span class="lbl">branches &amp; tags</span>
442+<span class="lbl">branches &amp; tags (g b)</span>
441443 <span class="k">section</span>
442444 </a>
443445 </li>
444-<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
446+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests (g m)">
445447 <a href="/diffs/merge-requests" tabindex="-1">
446-<span class="lbl">merge requests</span>
448+<span class="lbl">merge requests (g m)</span>
447449 <span class="k">section</span>
448450 </a>
449451 </li>
450-<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
452+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases (g r)">
451453 <a href="/diffs/releases" tabindex="-1">
452-<span class="lbl">releases</span>
454+<span class="lbl">releases (g r)</span>
453455 <span class="k">section</span>
454456 </a>
455457 </li>
456-<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
458+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions (g a)">
457459 <a href="/diffs/actions" tabindex="-1">
458-<span class="lbl">actions</span>
460+<span class="lbl">actions (g a)</span>
459461 <span class="k">section</span>
460462 </a>
461463 </li>
@@ -508,7 +510,9 @@
508510 k-filter from:body -> ermite.focusFilter();
509511 k-gh from:body -> ermite.go('.brand');
510512 k-s from:body -> ermite.tab('s');
511- k-t from:body -> ermite.tab('t');
513+ k-f from:body -> ermite.tab('f');
514+ k-b from:body -> ermite.tab('b');
515+ k-a from:body -> ermite.tab('a');
512516 k-c from:body -> ermite.tab('c');
513517 k-r from:body -> ermite.tab('r');
514518 k-m from:body -> ermite.tab('m');
@@ -524,7 +528,6 @@
524528 k-permalink from:body -> ermite.permalink();
525529 k-merge from:body -> window.ermiteMR?.openMerge();
526530 k-read from:body -> window.ermiteMR?.markRead();
527- k-gf from:body -> window.ermiteMR?.focusFiles();
528531 k-keys from:body -> ermite.toggleKeys()">
529532 </div>
530533 </body>
M internal/app/testdata/web/commit-modify-split.html +33 -30
@@ -72,31 +72,31 @@
7272 <nav class="tabs" aria-label="Sections">
7373 <ul>
7474 <li>
75-<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
75+<a href="/diffs" data-key="g s">Summary <kbd aria-hidden="true">g s</kbd>
7676 </a>
7777 </li>
7878 <li>
79-<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files <kbd aria-hidden="true">t</kbd>
79+<a href="/diffs/tree/main" data-key="g f">Files <kbd aria-hidden="true">g f</kbd>
8080 </a>
8181 </li>
8282 <li>
83-<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits <kbd aria-hidden="true">c</kbd>
83+<a href="/diffs/commits/main" data-key="g c" aria-current="page">Commits <kbd aria-hidden="true">g c</kbd>
8484 </a>
8585 </li>
8686 <li>
87-<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
87+<a href="/diffs/refs" data-key="g b">Branches &amp; tags <kbd aria-hidden="true">g b</kbd>
8888 </a>
8989 </li>
9090 <li>
91-<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
91+<a href="/diffs/merge-requests" data-key="g m">Merge requests <kbd aria-hidden="true">g m</kbd>
9292 </a>
9393 </li>
9494 <li>
95-<a href="/diffs/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
95+<a href="/diffs/releases" data-key="g r">Releases <kbd aria-hidden="true">g r</kbd>
9696 </a>
9797 </li>
9898 <li>
99-<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
99+<a href="/diffs/actions" data-key="g a">Actions <kbd aria-hidden="true">g a</kbd>
100100 </a>
101101 </li>
102102 </ul>
@@ -434,14 +434,16 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
434434 </div>
435435 <div>
436436 <dt>
437-<kbd>s</kbd>
438-<kbd>t</kbd>
439-<kbd>c</kbd>
440-<kbd>r</kbd>
441-<kbd>m</kbd>
442-<kbd>i</kbd>
437+<kbd>g s</kbd>
438+<kbd>g f</kbd>
439+<kbd>g c</kbd>
440+<kbd>g b</kbd>
441+<kbd>g m</kbd>
442+<kbd>g i</kbd>
443+<kbd>g r</kbd>
444+<kbd>g a</kbd>
443445 </dt>
444-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
446+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues, releases, actions (when available)</dd>
445447 </div>
446448 <div>
447449 <dt>
@@ -540,45 +542,45 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
540542 <span class="k">go</span>
541543 </a>
542544 </li>
543-<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
545+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary (g s)">
544546 <a href="/diffs" tabindex="-1">
545-<span class="lbl">summary</span>
547+<span class="lbl">summary (g s)</span>
546548 <span class="k">section</span>
547549 </a>
548550 </li>
549-<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
551+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files (g f)">
550552 <a href="/diffs/tree/main" tabindex="-1">
551-<span class="lbl">files</span>
553+<span class="lbl">files (g f)</span>
552554 <span class="k">section</span>
553555 </a>
554556 </li>
555-<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
557+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits (g c)">
556558 <a href="/diffs/commits/main" tabindex="-1">
557-<span class="lbl">commits</span>
559+<span class="lbl">commits (g c)</span>
558560 <span class="k">section</span>
559561 </a>
560562 </li>
561-<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
563+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags (g b)">
562564 <a href="/diffs/refs" tabindex="-1">
563-<span class="lbl">branches &amp; tags</span>
565+<span class="lbl">branches &amp; tags (g b)</span>
564566 <span class="k">section</span>
565567 </a>
566568 </li>
567-<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
569+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests (g m)">
568570 <a href="/diffs/merge-requests" tabindex="-1">
569-<span class="lbl">merge requests</span>
571+<span class="lbl">merge requests (g m)</span>
570572 <span class="k">section</span>
571573 </a>
572574 </li>
573-<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
575+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases (g r)">
574576 <a href="/diffs/releases" tabindex="-1">
575-<span class="lbl">releases</span>
577+<span class="lbl">releases (g r)</span>
576578 <span class="k">section</span>
577579 </a>
578580 </li>
579-<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
581+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions (g a)">
580582 <a href="/diffs/actions" tabindex="-1">
581-<span class="lbl">actions</span>
583+<span class="lbl">actions (g a)</span>
582584 <span class="k">section</span>
583585 </a>
584586 </li>
@@ -631,7 +633,9 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
631633 k-filter from:body -> ermite.focusFilter();
632634 k-gh from:body -> ermite.go('.brand');
633635 k-s from:body -> ermite.tab('s');
634- k-t from:body -> ermite.tab('t');
636+ k-f from:body -> ermite.tab('f');
637+ k-b from:body -> ermite.tab('b');
638+ k-a from:body -> ermite.tab('a');
635639 k-c from:body -> ermite.tab('c');
636640 k-r from:body -> ermite.tab('r');
637641 k-m from:body -> ermite.tab('m');
@@ -647,7 +651,6 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
647651 k-permalink from:body -> ermite.permalink();
648652 k-merge from:body -> window.ermiteMR?.openMerge();
649653 k-read from:body -> window.ermiteMR?.markRead();
650- k-gf from:body -> window.ermiteMR?.focusFiles();
651654 k-keys from:body -> ermite.toggleKeys()">
652655 </div>
653656 </body>
M internal/app/testdata/web/commit-modify.html +33 -30
@@ -72,31 +72,31 @@
7272 <nav class="tabs" aria-label="Sections">
7373 <ul>
7474 <li>
75-<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
75+<a href="/diffs" data-key="g s">Summary <kbd aria-hidden="true">g s</kbd>
7676 </a>
7777 </li>
7878 <li>
79-<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files <kbd aria-hidden="true">t</kbd>
79+<a href="/diffs/tree/main" data-key="g f">Files <kbd aria-hidden="true">g f</kbd>
8080 </a>
8181 </li>
8282 <li>
83-<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits <kbd aria-hidden="true">c</kbd>
83+<a href="/diffs/commits/main" data-key="g c" aria-current="page">Commits <kbd aria-hidden="true">g c</kbd>
8484 </a>
8585 </li>
8686 <li>
87-<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
87+<a href="/diffs/refs" data-key="g b">Branches &amp; tags <kbd aria-hidden="true">g b</kbd>
8888 </a>
8989 </li>
9090 <li>
91-<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
91+<a href="/diffs/merge-requests" data-key="g m">Merge requests <kbd aria-hidden="true">g m</kbd>
9292 </a>
9393 </li>
9494 <li>
95-<a href="/diffs/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
95+<a href="/diffs/releases" data-key="g r">Releases <kbd aria-hidden="true">g r</kbd>
9696 </a>
9797 </li>
9898 <li>
99-<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
99+<a href="/diffs/actions" data-key="g a">Actions <kbd aria-hidden="true">g a</kbd>
100100 </a>
101101 </li>
102102 </ul>
@@ -434,14 +434,16 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
434434 </div>
435435 <div>
436436 <dt>
437-<kbd>s</kbd>
438-<kbd>t</kbd>
439-<kbd>c</kbd>
440-<kbd>r</kbd>
441-<kbd>m</kbd>
442-<kbd>i</kbd>
437+<kbd>g s</kbd>
438+<kbd>g f</kbd>
439+<kbd>g c</kbd>
440+<kbd>g b</kbd>
441+<kbd>g m</kbd>
442+<kbd>g i</kbd>
443+<kbd>g r</kbd>
444+<kbd>g a</kbd>
443445 </dt>
444-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
446+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues, releases, actions (when available)</dd>
445447 </div>
446448 <div>
447449 <dt>
@@ -540,45 +542,45 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
540542 <span class="k">go</span>
541543 </a>
542544 </li>
543-<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
545+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary (g s)">
544546 <a href="/diffs" tabindex="-1">
545-<span class="lbl">summary</span>
547+<span class="lbl">summary (g s)</span>
546548 <span class="k">section</span>
547549 </a>
548550 </li>
549-<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
551+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files (g f)">
550552 <a href="/diffs/tree/main" tabindex="-1">
551-<span class="lbl">files</span>
553+<span class="lbl">files (g f)</span>
552554 <span class="k">section</span>
553555 </a>
554556 </li>
555-<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
557+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits (g c)">
556558 <a href="/diffs/commits/main" tabindex="-1">
557-<span class="lbl">commits</span>
559+<span class="lbl">commits (g c)</span>
558560 <span class="k">section</span>
559561 </a>
560562 </li>
561-<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
563+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags (g b)">
562564 <a href="/diffs/refs" tabindex="-1">
563-<span class="lbl">branches &amp; tags</span>
565+<span class="lbl">branches &amp; tags (g b)</span>
564566 <span class="k">section</span>
565567 </a>
566568 </li>
567-<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
569+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests (g m)">
568570 <a href="/diffs/merge-requests" tabindex="-1">
569-<span class="lbl">merge requests</span>
571+<span class="lbl">merge requests (g m)</span>
570572 <span class="k">section</span>
571573 </a>
572574 </li>
573-<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
575+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases (g r)">
574576 <a href="/diffs/releases" tabindex="-1">
575-<span class="lbl">releases</span>
577+<span class="lbl">releases (g r)</span>
576578 <span class="k">section</span>
577579 </a>
578580 </li>
579-<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
581+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions (g a)">
580582 <a href="/diffs/actions" tabindex="-1">
581-<span class="lbl">actions</span>
583+<span class="lbl">actions (g a)</span>
582584 <span class="k">section</span>
583585 </a>
584586 </li>
@@ -631,7 +633,9 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
631633 k-filter from:body -> ermite.focusFilter();
632634 k-gh from:body -> ermite.go('.brand');
633635 k-s from:body -> ermite.tab('s');
634- k-t from:body -> ermite.tab('t');
636+ k-f from:body -> ermite.tab('f');
637+ k-b from:body -> ermite.tab('b');
638+ k-a from:body -> ermite.tab('a');
635639 k-c from:body -> ermite.tab('c');
636640 k-r from:body -> ermite.tab('r');
637641 k-m from:body -> ermite.tab('m');
@@ -647,7 +651,6 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
647651 k-permalink from:body -> ermite.permalink();
648652 k-merge from:body -> window.ermiteMR?.openMerge();
649653 k-read from:body -> window.ermiteMR?.markRead();
650- k-gf from:body -> window.ermiteMR?.focusFiles();
651654 k-keys from:body -> ermite.toggleKeys()">
652655 </div>
653656 </body>
M internal/app/testdata/web/commit-rename.html +33 -30
@@ -72,31 +72,31 @@
7272 <nav class="tabs" aria-label="Sections">
7373 <ul>
7474 <li>
75-<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
75+<a href="/diffs" data-key="g s">Summary <kbd aria-hidden="true">g s</kbd>
7676 </a>
7777 </li>
7878 <li>
79-<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files <kbd aria-hidden="true">t</kbd>
79+<a href="/diffs/tree/main" data-key="g f">Files <kbd aria-hidden="true">g f</kbd>
8080 </a>
8181 </li>
8282 <li>
83-<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits <kbd aria-hidden="true">c</kbd>
83+<a href="/diffs/commits/main" data-key="g c" aria-current="page">Commits <kbd aria-hidden="true">g c</kbd>
8484 </a>
8585 </li>
8686 <li>
87-<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
87+<a href="/diffs/refs" data-key="g b">Branches &amp; tags <kbd aria-hidden="true">g b</kbd>
8888 </a>
8989 </li>
9090 <li>
91-<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
91+<a href="/diffs/merge-requests" data-key="g m">Merge requests <kbd aria-hidden="true">g m</kbd>
9292 </a>
9393 </li>
9494 <li>
95-<a href="/diffs/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
95+<a href="/diffs/releases" data-key="g r">Releases <kbd aria-hidden="true">g r</kbd>
9696 </a>
9797 </li>
9898 <li>
99-<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
99+<a href="/diffs/actions" data-key="g a">Actions <kbd aria-hidden="true">g a</kbd>
100100 </a>
101101 </li>
102102 </ul>
@@ -338,14 +338,16 @@
338338 </div>
339339 <div>
340340 <dt>
341-<kbd>s</kbd>
342-<kbd>t</kbd>
343-<kbd>c</kbd>
344-<kbd>r</kbd>
345-<kbd>m</kbd>
346-<kbd>i</kbd>
341+<kbd>g s</kbd>
342+<kbd>g f</kbd>
343+<kbd>g c</kbd>
344+<kbd>g b</kbd>
345+<kbd>g m</kbd>
346+<kbd>g i</kbd>
347+<kbd>g r</kbd>
348+<kbd>g a</kbd>
347349 </dt>
348-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
350+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues, releases, actions (when available)</dd>
349351 </div>
350352 <div>
351353 <dt>
@@ -444,45 +446,45 @@
444446 <span class="k">go</span>
445447 </a>
446448 </li>
447-<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
449+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary (g s)">
448450 <a href="/diffs" tabindex="-1">
449-<span class="lbl">summary</span>
451+<span class="lbl">summary (g s)</span>
450452 <span class="k">section</span>
451453 </a>
452454 </li>
453-<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
455+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files (g f)">
454456 <a href="/diffs/tree/main" tabindex="-1">
455-<span class="lbl">files</span>
457+<span class="lbl">files (g f)</span>
456458 <span class="k">section</span>
457459 </a>
458460 </li>
459-<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
461+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits (g c)">
460462 <a href="/diffs/commits/main" tabindex="-1">
461-<span class="lbl">commits</span>
463+<span class="lbl">commits (g c)</span>
462464 <span class="k">section</span>
463465 </a>
464466 </li>
465-<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
467+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags (g b)">
466468 <a href="/diffs/refs" tabindex="-1">
467-<span class="lbl">branches &amp; tags</span>
469+<span class="lbl">branches &amp; tags (g b)</span>
468470 <span class="k">section</span>
469471 </a>
470472 </li>
471-<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
473+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests (g m)">
472474 <a href="/diffs/merge-requests" tabindex="-1">
473-<span class="lbl">merge requests</span>
475+<span class="lbl">merge requests (g m)</span>
474476 <span class="k">section</span>
475477 </a>
476478 </li>
477-<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
479+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases (g r)">
478480 <a href="/diffs/releases" tabindex="-1">
479-<span class="lbl">releases</span>
481+<span class="lbl">releases (g r)</span>
480482 <span class="k">section</span>
481483 </a>
482484 </li>
483-<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
485+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions (g a)">
484486 <a href="/diffs/actions" tabindex="-1">
485-<span class="lbl">actions</span>
487+<span class="lbl">actions (g a)</span>
486488 <span class="k">section</span>
487489 </a>
488490 </li>
@@ -535,7 +537,9 @@
535537 k-filter from:body -> ermite.focusFilter();
536538 k-gh from:body -> ermite.go('.brand');
537539 k-s from:body -> ermite.tab('s');
538- k-t from:body -> ermite.tab('t');
540+ k-f from:body -> ermite.tab('f');
541+ k-b from:body -> ermite.tab('b');
542+ k-a from:body -> ermite.tab('a');
539543 k-c from:body -> ermite.tab('c');
540544 k-r from:body -> ermite.tab('r');
541545 k-m from:body -> ermite.tab('m');
@@ -551,7 +555,6 @@
551555 k-permalink from:body -> ermite.permalink();
552556 k-merge from:body -> window.ermiteMR?.openMerge();
553557 k-read from:body -> window.ermiteMR?.markRead();
554- k-gf from:body -> window.ermiteMR?.focusFiles();
555558 k-keys from:body -> ermite.toggleKeys()">
556559 </div>
557560 </body>
M internal/app/testdata/web/commit-root.html +33 -30
@@ -72,31 +72,31 @@
7272 <nav class="tabs" aria-label="Sections">
7373 <ul>
7474 <li>
75-<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
75+<a href="/diffs" data-key="g s">Summary <kbd aria-hidden="true">g s</kbd>
7676 </a>
7777 </li>
7878 <li>
79-<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files <kbd aria-hidden="true">t</kbd>
79+<a href="/diffs/tree/main" data-key="g f">Files <kbd aria-hidden="true">g f</kbd>
8080 </a>
8181 </li>
8282 <li>
83-<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits <kbd aria-hidden="true">c</kbd>
83+<a href="/diffs/commits/main" data-key="g c" aria-current="page">Commits <kbd aria-hidden="true">g c</kbd>
8484 </a>
8585 </li>
8686 <li>
87-<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
87+<a href="/diffs/refs" data-key="g b">Branches &amp; tags <kbd aria-hidden="true">g b</kbd>
8888 </a>
8989 </li>
9090 <li>
91-<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
91+<a href="/diffs/merge-requests" data-key="g m">Merge requests <kbd aria-hidden="true">g m</kbd>
9292 </a>
9393 </li>
9494 <li>
95-<a href="/diffs/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
95+<a href="/diffs/releases" data-key="g r">Releases <kbd aria-hidden="true">g r</kbd>
9696 </a>
9797 </li>
9898 <li>
99-<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
99+<a href="/diffs/actions" data-key="g a">Actions <kbd aria-hidden="true">g a</kbd>
100100 </a>
101101 </li>
102102 </ul>
@@ -657,14 +657,16 @@
657657 </div>
658658 <div>
659659 <dt>
660-<kbd>s</kbd>
661-<kbd>t</kbd>
662-<kbd>c</kbd>
663-<kbd>r</kbd>
664-<kbd>m</kbd>
665-<kbd>i</kbd>
660+<kbd>g s</kbd>
661+<kbd>g f</kbd>
662+<kbd>g c</kbd>
663+<kbd>g b</kbd>
664+<kbd>g m</kbd>
665+<kbd>g i</kbd>
666+<kbd>g r</kbd>
667+<kbd>g a</kbd>
666668 </dt>
667-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
669+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues, releases, actions (when available)</dd>
668670 </div>
669671 <div>
670672 <dt>
@@ -763,45 +765,45 @@
763765 <span class="k">go</span>
764766 </a>
765767 </li>
766-<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
768+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary (g s)">
767769 <a href="/diffs" tabindex="-1">
768-<span class="lbl">summary</span>
770+<span class="lbl">summary (g s)</span>
769771 <span class="k">section</span>
770772 </a>
771773 </li>
772-<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
774+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files (g f)">
773775 <a href="/diffs/tree/main" tabindex="-1">
774-<span class="lbl">files</span>
776+<span class="lbl">files (g f)</span>
775777 <span class="k">section</span>
776778 </a>
777779 </li>
778-<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
780+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits (g c)">
779781 <a href="/diffs/commits/main" tabindex="-1">
780-<span class="lbl">commits</span>
782+<span class="lbl">commits (g c)</span>
781783 <span class="k">section</span>
782784 </a>
783785 </li>
784-<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
786+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags (g b)">
785787 <a href="/diffs/refs" tabindex="-1">
786-<span class="lbl">branches &amp; tags</span>
788+<span class="lbl">branches &amp; tags (g b)</span>
787789 <span class="k">section</span>
788790 </a>
789791 </li>
790-<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
792+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests (g m)">
791793 <a href="/diffs/merge-requests" tabindex="-1">
792-<span class="lbl">merge requests</span>
794+<span class="lbl">merge requests (g m)</span>
793795 <span class="k">section</span>
794796 </a>
795797 </li>
796-<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
798+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases (g r)">
797799 <a href="/diffs/releases" tabindex="-1">
798-<span class="lbl">releases</span>
800+<span class="lbl">releases (g r)</span>
799801 <span class="k">section</span>
800802 </a>
801803 </li>
802-<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
804+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions (g a)">
803805 <a href="/diffs/actions" tabindex="-1">
804-<span class="lbl">actions</span>
806+<span class="lbl">actions (g a)</span>
805807 <span class="k">section</span>
806808 </a>
807809 </li>
@@ -854,7 +856,9 @@
854856 k-filter from:body -> ermite.focusFilter();
855857 k-gh from:body -> ermite.go('.brand');
856858 k-s from:body -> ermite.tab('s');
857- k-t from:body -> ermite.tab('t');
859+ k-f from:body -> ermite.tab('f');
860+ k-b from:body -> ermite.tab('b');
861+ k-a from:body -> ermite.tab('a');
858862 k-c from:body -> ermite.tab('c');
859863 k-r from:body -> ermite.tab('r');
860864 k-m from:body -> ermite.tab('m');
@@ -870,7 +874,6 @@
870874 k-permalink from:body -> ermite.permalink();
871875 k-merge from:body -> window.ermiteMR?.openMerge();
872876 k-read from:body -> window.ermiteMR?.markRead();
873- k-gf from:body -> window.ermiteMR?.focusFiles();
874877 k-keys from:body -> ermite.toggleKeys()">
875878 </div>
876879 </body>
M internal/app/testdata/web/commit-trojan.html +33 -30
@@ -72,31 +72,31 @@
7272 <nav class="tabs" aria-label="Sections">
7373 <ul>
7474 <li>
75-<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
75+<a href="/diffs" data-key="g s">Summary <kbd aria-hidden="true">g s</kbd>
7676 </a>
7777 </li>
7878 <li>
79-<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files <kbd aria-hidden="true">t</kbd>
79+<a href="/diffs/tree/main" data-key="g f">Files <kbd aria-hidden="true">g f</kbd>
8080 </a>
8181 </li>
8282 <li>
83-<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits <kbd aria-hidden="true">c</kbd>
83+<a href="/diffs/commits/main" data-key="g c" aria-current="page">Commits <kbd aria-hidden="true">g c</kbd>
8484 </a>
8585 </li>
8686 <li>
87-<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
87+<a href="/diffs/refs" data-key="g b">Branches &amp; tags <kbd aria-hidden="true">g b</kbd>
8888 </a>
8989 </li>
9090 <li>
91-<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
91+<a href="/diffs/merge-requests" data-key="g m">Merge requests <kbd aria-hidden="true">g m</kbd>
9292 </a>
9393 </li>
9494 <li>
95-<a href="/diffs/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
95+<a href="/diffs/releases" data-key="g r">Releases <kbd aria-hidden="true">g r</kbd>
9696 </a>
9797 </li>
9898 <li>
99-<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
99+<a href="/diffs/actions" data-key="g a">Actions <kbd aria-hidden="true">g a</kbd>
100100 </a>
101101 </li>
102102 </ul>
@@ -486,14 +486,16 @@
486486 </div>
487487 <div>
488488 <dt>
489-<kbd>s</kbd>
490-<kbd>t</kbd>
491-<kbd>c</kbd>
492-<kbd>r</kbd>
493-<kbd>m</kbd>
494-<kbd>i</kbd>
489+<kbd>g s</kbd>
490+<kbd>g f</kbd>
491+<kbd>g c</kbd>
492+<kbd>g b</kbd>
493+<kbd>g m</kbd>
494+<kbd>g i</kbd>
495+<kbd>g r</kbd>
496+<kbd>g a</kbd>
495497 </dt>
496-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
498+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues, releases, actions (when available)</dd>
497499 </div>
498500 <div>
499501 <dt>
@@ -592,45 +594,45 @@
592594 <span class="k">go</span>
593595 </a>
594596 </li>
595-<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
597+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary (g s)">
596598 <a href="/diffs" tabindex="-1">
597-<span class="lbl">summary</span>
599+<span class="lbl">summary (g s)</span>
598600 <span class="k">section</span>
599601 </a>
600602 </li>
601-<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
603+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files (g f)">
602604 <a href="/diffs/tree/main" tabindex="-1">
603-<span class="lbl">files</span>
605+<span class="lbl">files (g f)</span>
604606 <span class="k">section</span>
605607 </a>
606608 </li>
607-<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
609+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits (g c)">
608610 <a href="/diffs/commits/main" tabindex="-1">
609-<span class="lbl">commits</span>
611+<span class="lbl">commits (g c)</span>
610612 <span class="k">section</span>
611613 </a>
612614 </li>
613-<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
615+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags (g b)">
614616 <a href="/diffs/refs" tabindex="-1">
615-<span class="lbl">branches &amp; tags</span>
617+<span class="lbl">branches &amp; tags (g b)</span>
616618 <span class="k">section</span>
617619 </a>
618620 </li>
619-<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
621+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests (g m)">
620622 <a href="/diffs/merge-requests" tabindex="-1">
621-<span class="lbl">merge requests</span>
623+<span class="lbl">merge requests (g m)</span>
622624 <span class="k">section</span>
623625 </a>
624626 </li>
625-<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
627+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases (g r)">
626628 <a href="/diffs/releases" tabindex="-1">
627-<span class="lbl">releases</span>
629+<span class="lbl">releases (g r)</span>
628630 <span class="k">section</span>
629631 </a>
630632 </li>
631-<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
633+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions (g a)">
632634 <a href="/diffs/actions" tabindex="-1">
633-<span class="lbl">actions</span>
635+<span class="lbl">actions (g a)</span>
634636 <span class="k">section</span>
635637 </a>
636638 </li>
@@ -683,7 +685,9 @@
683685 k-filter from:body -> ermite.focusFilter();
684686 k-gh from:body -> ermite.go('.brand');
685687 k-s from:body -> ermite.tab('s');
686- k-t from:body -> ermite.tab('t');
688+ k-f from:body -> ermite.tab('f');
689+ k-b from:body -> ermite.tab('b');
690+ k-a from:body -> ermite.tab('a');
687691 k-c from:body -> ermite.tab('c');
688692 k-r from:body -> ermite.tab('r');
689693 k-m from:body -> ermite.tab('m');
@@ -699,7 +703,6 @@
699703 k-permalink from:body -> ermite.permalink();
700704 k-merge from:body -> window.ermiteMR?.openMerge();
701705 k-read from:body -> window.ermiteMR?.markRead();
702- k-gf from:body -> window.ermiteMR?.focusFiles();
703706 k-keys from:body -> ermite.toggleKeys()">
704707 </div>
705708 </body>
M internal/app/testdata/web/commits-page2.html +33 -30
@@ -70,31 +70,31 @@
7070 <nav class="tabs" aria-label="Sections">
7171 <ul>
7272 <li>
73-<a href="/long" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
73+<a href="/long" data-key="g s">Summary <kbd aria-hidden="true">g s</kbd>
7474 </a>
7575 </li>
7676 <li>
77-<a href="/long/tree/main" data-key="t" aria-keyshortcuts="t">Files <kbd aria-hidden="true">t</kbd>
77+<a href="/long/tree/main" data-key="g f">Files <kbd aria-hidden="true">g f</kbd>
7878 </a>
7979 </li>
8080 <li>
81-<a href="/long/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits <kbd aria-hidden="true">c</kbd>
81+<a href="/long/commits/main" data-key="g c" aria-current="page">Commits <kbd aria-hidden="true">g c</kbd>
8282 </a>
8383 </li>
8484 <li>
85-<a href="/long/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
85+<a href="/long/refs" data-key="g b">Branches &amp; tags <kbd aria-hidden="true">g b</kbd>
8686 </a>
8787 </li>
8888 <li>
89-<a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
89+<a href="/long/merge-requests" data-key="g m">Merge requests <kbd aria-hidden="true">g m</kbd>
9090 </a>
9191 </li>
9292 <li>
93-<a href="/long/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
93+<a href="/long/releases" data-key="g r">Releases <kbd aria-hidden="true">g r</kbd>
9494 </a>
9595 </li>
9696 <li>
97-<a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
97+<a href="/long/actions" data-key="g a">Actions <kbd aria-hidden="true">g a</kbd>
9898 </a>
9999 </li>
100100 </ul>
@@ -253,14 +253,16 @@
253253 </div>
254254 <div>
255255 <dt>
256-<kbd>s</kbd>
257-<kbd>t</kbd>
258-<kbd>c</kbd>
259-<kbd>r</kbd>
260-<kbd>m</kbd>
261-<kbd>i</kbd>
256+<kbd>g s</kbd>
257+<kbd>g f</kbd>
258+<kbd>g c</kbd>
259+<kbd>g b</kbd>
260+<kbd>g m</kbd>
261+<kbd>g i</kbd>
262+<kbd>g r</kbd>
263+<kbd>g a</kbd>
262264 </dt>
263-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
265+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues, releases, actions (when available)</dd>
264266 </div>
265267 <div>
266268 <dt>
@@ -351,45 +353,45 @@
351353 <span class="k">go</span>
352354 </a>
353355 </li>
354-<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
356+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary (g s)">
355357 <a href="/long" tabindex="-1">
356-<span class="lbl">summary</span>
358+<span class="lbl">summary (g s)</span>
357359 <span class="k">section</span>
358360 </a>
359361 </li>
360-<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
362+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files (g f)">
361363 <a href="/long/tree/main" tabindex="-1">
362-<span class="lbl">files</span>
364+<span class="lbl">files (g f)</span>
363365 <span class="k">section</span>
364366 </a>
365367 </li>
366-<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
368+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits (g c)">
367369 <a href="/long/commits/main" tabindex="-1">
368-<span class="lbl">commits</span>
370+<span class="lbl">commits (g c)</span>
369371 <span class="k">section</span>
370372 </a>
371373 </li>
372-<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
374+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags (g b)">
373375 <a href="/long/refs" tabindex="-1">
374-<span class="lbl">branches &amp; tags</span>
376+<span class="lbl">branches &amp; tags (g b)</span>
375377 <span class="k">section</span>
376378 </a>
377379 </li>
378-<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
380+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests (g m)">
379381 <a href="/long/merge-requests" tabindex="-1">
380-<span class="lbl">merge requests</span>
382+<span class="lbl">merge requests (g m)</span>
381383 <span class="k">section</span>
382384 </a>
383385 </li>
384-<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
386+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases (g r)">
385387 <a href="/long/releases" tabindex="-1">
386-<span class="lbl">releases</span>
388+<span class="lbl">releases (g r)</span>
387389 <span class="k">section</span>
388390 </a>
389391 </li>
390-<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
392+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions (g a)">
391393 <a href="/long/actions" tabindex="-1">
392-<span class="lbl">actions</span>
394+<span class="lbl">actions (g a)</span>
393395 <span class="k">section</span>
394396 </a>
395397 </li>
@@ -436,7 +438,9 @@
436438 k-filter from:body -> ermite.focusFilter();
437439 k-gh from:body -> ermite.go('.brand');
438440 k-s from:body -> ermite.tab('s');
439- k-t from:body -> ermite.tab('t');
441+ k-f from:body -> ermite.tab('f');
442+ k-b from:body -> ermite.tab('b');
443+ k-a from:body -> ermite.tab('a');
440444 k-c from:body -> ermite.tab('c');
441445 k-r from:body -> ermite.tab('r');
442446 k-m from:body -> ermite.tab('m');
@@ -452,7 +456,6 @@
452456 k-permalink from:body -> ermite.permalink();
453457 k-merge from:body -> window.ermiteMR?.openMerge();
454458 k-read from:body -> window.ermiteMR?.markRead();
455- k-gf from:body -> window.ermiteMR?.focusFiles();
456459 k-keys from:body -> ermite.toggleKeys()">
457460 </div>
458461 </body>
M internal/app/testdata/web/commits-path.html +33 -30
@@ -73,31 +73,31 @@
7373 <nav class="tabs" aria-label="Sections">
7474 <ul>
7575 <li>
76-<a href="/long" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
76+<a href="/long" data-key="g s">Summary <kbd aria-hidden="true">g s</kbd>
7777 </a>
7878 </li>
7979 <li>
80-<a href="/long/tree/main" data-key="t" aria-keyshortcuts="t">Files <kbd aria-hidden="true">t</kbd>
80+<a href="/long/tree/main" data-key="g f">Files <kbd aria-hidden="true">g f</kbd>
8181 </a>
8282 </li>
8383 <li>
84-<a href="/long/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits <kbd aria-hidden="true">c</kbd>
84+<a href="/long/commits/main" data-key="g c" aria-current="page">Commits <kbd aria-hidden="true">g c</kbd>
8585 </a>
8686 </li>
8787 <li>
88-<a href="/long/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
88+<a href="/long/refs" data-key="g b">Branches &amp; tags <kbd aria-hidden="true">g b</kbd>
8989 </a>
9090 </li>
9191 <li>
92-<a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
92+<a href="/long/merge-requests" data-key="g m">Merge requests <kbd aria-hidden="true">g m</kbd>
9393 </a>
9494 </li>
9595 <li>
96-<a href="/long/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
96+<a href="/long/releases" data-key="g r">Releases <kbd aria-hidden="true">g r</kbd>
9797 </a>
9898 </li>
9999 <li>
100-<a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
100+<a href="/long/actions" data-key="g a">Actions <kbd aria-hidden="true">g a</kbd>
101101 </a>
102102 </li>
103103 </ul>
@@ -268,14 +268,16 @@
268268 </div>
269269 <div>
270270 <dt>
271-<kbd>s</kbd>
272-<kbd>t</kbd>
273-<kbd>c</kbd>
274-<kbd>r</kbd>
275-<kbd>m</kbd>
276-<kbd>i</kbd>
271+<kbd>g s</kbd>
272+<kbd>g f</kbd>
273+<kbd>g c</kbd>
274+<kbd>g b</kbd>
275+<kbd>g m</kbd>
276+<kbd>g i</kbd>
277+<kbd>g r</kbd>
278+<kbd>g a</kbd>
277279 </dt>
278-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
280+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues, releases, actions (when available)</dd>
279281 </div>
280282 <div>
281283 <dt>
@@ -366,45 +368,45 @@
366368 <span class="k">go</span>
367369 </a>
368370 </li>
369-<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
371+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary (g s)">
370372 <a href="/long" tabindex="-1">
371-<span class="lbl">summary</span>
373+<span class="lbl">summary (g s)</span>
372374 <span class="k">section</span>
373375 </a>
374376 </li>
375-<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
377+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files (g f)">
376378 <a href="/long/tree/main" tabindex="-1">
377-<span class="lbl">files</span>
379+<span class="lbl">files (g f)</span>
378380 <span class="k">section</span>
379381 </a>
380382 </li>
381-<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
383+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits (g c)">
382384 <a href="/long/commits/main" tabindex="-1">
383-<span class="lbl">commits</span>
385+<span class="lbl">commits (g c)</span>
384386 <span class="k">section</span>
385387 </a>
386388 </li>
387-<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
389+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags (g b)">
388390 <a href="/long/refs" tabindex="-1">
389-<span class="lbl">branches &amp; tags</span>
391+<span class="lbl">branches &amp; tags (g b)</span>
390392 <span class="k">section</span>
391393 </a>
392394 </li>
393-<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
395+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests (g m)">
394396 <a href="/long/merge-requests" tabindex="-1">
395-<span class="lbl">merge requests</span>
397+<span class="lbl">merge requests (g m)</span>
396398 <span class="k">section</span>
397399 </a>
398400 </li>
399-<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
401+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases (g r)">
400402 <a href="/long/releases" tabindex="-1">
401-<span class="lbl">releases</span>
403+<span class="lbl">releases (g r)</span>
402404 <span class="k">section</span>
403405 </a>
404406 </li>
405-<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
407+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions (g a)">
406408 <a href="/long/actions" tabindex="-1">
407-<span class="lbl">actions</span>
409+<span class="lbl">actions (g a)</span>
408410 <span class="k">section</span>
409411 </a>
410412 </li>
@@ -451,7 +453,9 @@
451453 k-filter from:body -> ermite.focusFilter();
452454 k-gh from:body -> ermite.go('.brand');
453455 k-s from:body -> ermite.tab('s');
454- k-t from:body -> ermite.tab('t');
456+ k-f from:body -> ermite.tab('f');
457+ k-b from:body -> ermite.tab('b');
458+ k-a from:body -> ermite.tab('a');
455459 k-c from:body -> ermite.tab('c');
456460 k-r from:body -> ermite.tab('r');
457461 k-m from:body -> ermite.tab('m');
@@ -467,7 +471,6 @@
467471 k-permalink from:body -> ermite.permalink();
468472 k-merge from:body -> window.ermiteMR?.openMerge();
469473 k-read from:body -> window.ermiteMR?.markRead();
470- k-gf from:body -> window.ermiteMR?.focusFiles();
471474 k-keys from:body -> ermite.toggleKeys()">
472475 </div>
473476 </body>
M internal/app/testdata/web/commits-root.html +33 -30
@@ -70,31 +70,31 @@
7070 <nav class="tabs" aria-label="Sections">
7171 <ul>
7272 <li>
73-<a href="/long" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
73+<a href="/long" data-key="g s">Summary <kbd aria-hidden="true">g s</kbd>
7474 </a>
7575 </li>
7676 <li>
77-<a href="/long/tree/main" data-key="t" aria-keyshortcuts="t">Files <kbd aria-hidden="true">t</kbd>
77+<a href="/long/tree/main" data-key="g f">Files <kbd aria-hidden="true">g f</kbd>
7878 </a>
7979 </li>
8080 <li>
81-<a href="/long/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits <kbd aria-hidden="true">c</kbd>
81+<a href="/long/commits/main" data-key="g c" aria-current="page">Commits <kbd aria-hidden="true">g c</kbd>
8282 </a>
8383 </li>
8484 <li>
85-<a href="/long/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
85+<a href="/long/refs" data-key="g b">Branches &amp; tags <kbd aria-hidden="true">g b</kbd>
8686 </a>
8787 </li>
8888 <li>
89-<a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
89+<a href="/long/merge-requests" data-key="g m">Merge requests <kbd aria-hidden="true">g m</kbd>
9090 </a>
9191 </li>
9292 <li>
93-<a href="/long/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
93+<a href="/long/releases" data-key="g r">Releases <kbd aria-hidden="true">g r</kbd>
9494 </a>
9595 </li>
9696 <li>
97-<a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
97+<a href="/long/actions" data-key="g a">Actions <kbd aria-hidden="true">g a</kbd>
9898 </a>
9999 </li>
100100 </ul>
@@ -1058,14 +1058,16 @@
10581058 </div>
10591059 <div>
10601060 <dt>
1061-<kbd>s</kbd>
1062-<kbd>t</kbd>
1063-<kbd>c</kbd>
1064-<kbd>r</kbd>
1065-<kbd>m</kbd>
1066-<kbd>i</kbd>
1061+<kbd>g s</kbd>
1062+<kbd>g f</kbd>
1063+<kbd>g c</kbd>
1064+<kbd>g b</kbd>
1065+<kbd>g m</kbd>
1066+<kbd>g i</kbd>
1067+<kbd>g r</kbd>
1068+<kbd>g a</kbd>
10671069 </dt>
1068-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
1070+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues, releases, actions (when available)</dd>
10691071 </div>
10701072 <div>
10711073 <dt>
@@ -1156,45 +1158,45 @@
11561158 <span class="k">go</span>
11571159 </a>
11581160 </li>
1159-<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
1161+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary (g s)">
11601162 <a href="/long" tabindex="-1">
1161-<span class="lbl">summary</span>
1163+<span class="lbl">summary (g s)</span>
11621164 <span class="k">section</span>
11631165 </a>
11641166 </li>
1165-<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
1167+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files (g f)">
11661168 <a href="/long/tree/main" tabindex="-1">
1167-<span class="lbl">files</span>
1169+<span class="lbl">files (g f)</span>
11681170 <span class="k">section</span>
11691171 </a>
11701172 </li>
1171-<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
1173+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits (g c)">
11721174 <a href="/long/commits/main" tabindex="-1">
1173-<span class="lbl">commits</span>
1175+<span class="lbl">commits (g c)</span>
11741176 <span class="k">section</span>
11751177 </a>
11761178 </li>
1177-<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
1179+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags (g b)">
11781180 <a href="/long/refs" tabindex="-1">
1179-<span class="lbl">branches &amp; tags</span>
1181+<span class="lbl">branches &amp; tags (g b)</span>
11801182 <span class="k">section</span>
11811183 </a>
11821184 </li>
1183-<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
1185+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests (g m)">
11841186 <a href="/long/merge-requests" tabindex="-1">
1185-<span class="lbl">merge requests</span>
1187+<span class="lbl">merge requests (g m)</span>
11861188 <span class="k">section</span>
11871189 </a>
11881190 </li>
1189-<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
1191+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases (g r)">
11901192 <a href="/long/releases" tabindex="-1">
1191-<span class="lbl">releases</span>
1193+<span class="lbl">releases (g r)</span>
11921194 <span class="k">section</span>
11931195 </a>
11941196 </li>
1195-<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
1197+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions (g a)">
11961198 <a href="/long/actions" tabindex="-1">
1197-<span class="lbl">actions</span>
1199+<span class="lbl">actions (g a)</span>
11981200 <span class="k">section</span>
11991201 </a>
12001202 </li>
@@ -1241,7 +1243,9 @@
12411243 k-filter from:body -> ermite.focusFilter();
12421244 k-gh from:body -> ermite.go('.brand');
12431245 k-s from:body -> ermite.tab('s');
1244- k-t from:body -> ermite.tab('t');
1246+ k-f from:body -> ermite.tab('f');
1247+ k-b from:body -> ermite.tab('b');
1248+ k-a from:body -> ermite.tab('a');
12451249 k-c from:body -> ermite.tab('c');
12461250 k-r from:body -> ermite.tab('r');
12471251 k-m from:body -> ermite.tab('m');
@@ -1257,7 +1261,6 @@
12571261 k-permalink from:body -> ermite.permalink();
12581262 k-merge from:body -> window.ermiteMR?.openMerge();
12591263 k-read from:body -> window.ermiteMR?.markRead();
1260- k-gf from:body -> window.ermiteMR?.focusFiles();
12611264 k-keys from:body -> ermite.toggleKeys()">
12621265 </div>
12631266 </body>
M internal/app/testdata/web/commits-small.html +33 -30
@@ -70,31 +70,31 @@
7070 <nav class="tabs" aria-label="Sections">
7171 <ul>
7272 <li>
73-<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
73+<a href="/diffs" data-key="g s">Summary <kbd aria-hidden="true">g s</kbd>
7474 </a>
7575 </li>
7676 <li>
77-<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files <kbd aria-hidden="true">t</kbd>
77+<a href="/diffs/tree/main" data-key="g f">Files <kbd aria-hidden="true">g f</kbd>
7878 </a>
7979 </li>
8080 <li>
81-<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits <kbd aria-hidden="true">c</kbd>
81+<a href="/diffs/commits/main" data-key="g c" aria-current="page">Commits <kbd aria-hidden="true">g c</kbd>
8282 </a>
8383 </li>
8484 <li>
85-<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
85+<a href="/diffs/refs" data-key="g b">Branches &amp; tags <kbd aria-hidden="true">g b</kbd>
8686 </a>
8787 </li>
8888 <li>
89-<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
89+<a href="/diffs/merge-requests" data-key="g m">Merge requests <kbd aria-hidden="true">g m</kbd>
9090 </a>
9191 </li>
9292 <li>
93-<a href="/diffs/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
93+<a href="/diffs/releases" data-key="g r">Releases <kbd aria-hidden="true">g r</kbd>
9494 </a>
9595 </li>
9696 <li>
97-<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
97+<a href="/diffs/actions" data-key="g a">Actions <kbd aria-hidden="true">g a</kbd>
9898 </a>
9999 </li>
100100 </ul>
@@ -403,14 +403,16 @@
403403 </div>
404404 <div>
405405 <dt>
406-<kbd>s</kbd>
407-<kbd>t</kbd>
408-<kbd>c</kbd>
409-<kbd>r</kbd>
410-<kbd>m</kbd>
411-<kbd>i</kbd>
406+<kbd>g s</kbd>
407+<kbd>g f</kbd>
408+<kbd>g c</kbd>
409+<kbd>g b</kbd>
410+<kbd>g m</kbd>
411+<kbd>g i</kbd>
412+<kbd>g r</kbd>
413+<kbd>g a</kbd>
412414 </dt>
413-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
415+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues, releases, actions (when available)</dd>
414416 </div>
415417 <div>
416418 <dt>
@@ -509,45 +511,45 @@
509511 <span class="k">go</span>
510512 </a>
511513 </li>
512-<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
514+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary (g s)">
513515 <a href="/diffs" tabindex="-1">
514-<span class="lbl">summary</span>
516+<span class="lbl">summary (g s)</span>
515517 <span class="k">section</span>
516518 </a>
517519 </li>
518-<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
520+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files (g f)">
519521 <a href="/diffs/tree/main" tabindex="-1">
520-<span class="lbl">files</span>
522+<span class="lbl">files (g f)</span>
521523 <span class="k">section</span>
522524 </a>
523525 </li>
524-<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
526+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits (g c)">
525527 <a href="/diffs/commits/main" tabindex="-1">
526-<span class="lbl">commits</span>
528+<span class="lbl">commits (g c)</span>
527529 <span class="k">section</span>
528530 </a>
529531 </li>
530-<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
532+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags (g b)">
531533 <a href="/diffs/refs" tabindex="-1">
532-<span class="lbl">branches &amp; tags</span>
534+<span class="lbl">branches &amp; tags (g b)</span>
533535 <span class="k">section</span>
534536 </a>
535537 </li>
536-<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
538+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests (g m)">
537539 <a href="/diffs/merge-requests" tabindex="-1">
538-<span class="lbl">merge requests</span>
540+<span class="lbl">merge requests (g m)</span>
539541 <span class="k">section</span>
540542 </a>
541543 </li>
542-<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
544+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases (g r)">
543545 <a href="/diffs/releases" tabindex="-1">
544-<span class="lbl">releases</span>
546+<span class="lbl">releases (g r)</span>
545547 <span class="k">section</span>
546548 </a>
547549 </li>
548-<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
550+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions (g a)">
549551 <a href="/diffs/actions" tabindex="-1">
550-<span class="lbl">actions</span>
552+<span class="lbl">actions (g a)</span>
551553 <span class="k">section</span>
552554 </a>
553555 </li>
@@ -606,7 +608,9 @@
606608 k-filter from:body -> ermite.focusFilter();
607609 k-gh from:body -> ermite.go('.brand');
608610 k-s from:body -> ermite.tab('s');
609- k-t from:body -> ermite.tab('t');
611+ k-f from:body -> ermite.tab('f');
612+ k-b from:body -> ermite.tab('b');
613+ k-a from:body -> ermite.tab('a');
610614 k-c from:body -> ermite.tab('c');
611615 k-r from:body -> ermite.tab('r');
612616 k-m from:body -> ermite.tab('m');
@@ -622,7 +626,6 @@
622626 k-permalink from:body -> ermite.permalink();
623627 k-merge from:body -> window.ermiteMR?.openMerge();
624628 k-read from:body -> window.ermiteMR?.markRead();
625- k-gf from:body -> window.ermiteMR?.focusFiles();
626629 k-keys from:body -> ermite.toggleKeys()">
627630 </div>
628631 </body>
M internal/app/testdata/web/empty.html +12 -9
@@ -171,14 +171,16 @@
171171 </div>
172172 <div>
173173 <dt>
174-<kbd>s</kbd>
175-<kbd>t</kbd>
176-<kbd>c</kbd>
177-<kbd>r</kbd>
178-<kbd>m</kbd>
179-<kbd>i</kbd>
174+<kbd>g s</kbd>
175+<kbd>g f</kbd>
176+<kbd>g c</kbd>
177+<kbd>g b</kbd>
178+<kbd>g m</kbd>
179+<kbd>g i</kbd>
180+<kbd>g r</kbd>
181+<kbd>g a</kbd>
180182 </dt>
181-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
183+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues, releases, actions (when available)</dd>
182184 </div>
183185 <div>
184186 <dt>
@@ -279,7 +281,9 @@
279281 k-filter from:body -> ermite.focusFilter();
280282 k-gh from:body -> ermite.go('.brand');
281283 k-s from:body -> ermite.tab('s');
282- k-t from:body -> ermite.tab('t');
284+ k-f from:body -> ermite.tab('f');
285+ k-b from:body -> ermite.tab('b');
286+ k-a from:body -> ermite.tab('a');
283287 k-c from:body -> ermite.tab('c');
284288 k-r from:body -> ermite.tab('r');
285289 k-m from:body -> ermite.tab('m');
@@ -295,7 +299,6 @@
295299 k-permalink from:body -> ermite.permalink();
296300 k-merge from:body -> window.ermiteMR?.openMerge();
297301 k-read from:body -> window.ermiteMR?.markRead();
298- k-gf from:body -> window.ermiteMR?.focusFiles();
299302 k-keys from:body -> ermite.toggleKeys()">
300303 </div>
301304 </body>
M internal/app/testdata/web/home.html +12 -9
@@ -275,14 +275,16 @@
275275 </div>
276276 <div>
277277 <dt>
278-<kbd>s</kbd>
279-<kbd>t</kbd>
280-<kbd>c</kbd>
281-<kbd>r</kbd>
282-<kbd>m</kbd>
283-<kbd>i</kbd>
278+<kbd>g s</kbd>
279+<kbd>g f</kbd>
280+<kbd>g c</kbd>
281+<kbd>g b</kbd>
282+<kbd>g m</kbd>
283+<kbd>g i</kbd>
284+<kbd>g r</kbd>
285+<kbd>g a</kbd>
284286 </dt>
285-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
287+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues, releases, actions (when available)</dd>
286288 </div>
287289 <div>
288290 <dt>
@@ -389,7 +391,9 @@
389391 k-filter from:body -> ermite.focusFilter();
390392 k-gh from:body -> ermite.go('.brand');
391393 k-s from:body -> ermite.tab('s');
392- k-t from:body -> ermite.tab('t');
394+ k-f from:body -> ermite.tab('f');
395+ k-b from:body -> ermite.tab('b');
396+ k-a from:body -> ermite.tab('a');
393397 k-c from:body -> ermite.tab('c');
394398 k-r from:body -> ermite.tab('r');
395399 k-m from:body -> ermite.tab('m');
@@ -405,7 +409,6 @@
405409 k-permalink from:body -> ermite.permalink();
406410 k-merge from:body -> window.ermiteMR?.openMerge();
407411 k-read from:body -> window.ermiteMR?.markRead();
408- k-gf from:body -> window.ermiteMR?.focusFiles();
409412 k-keys from:body -> ermite.toggleKeys()">
410413 </div>
411414 </body>
M internal/app/testdata/web/not-found-page.html +12 -9
@@ -160,14 +160,16 @@
160160 </div>
161161 <div>
162162 <dt>
163-<kbd>s</kbd>
164-<kbd>t</kbd>
165-<kbd>c</kbd>
166-<kbd>r</kbd>
167-<kbd>m</kbd>
168-<kbd>i</kbd>
163+<kbd>g s</kbd>
164+<kbd>g f</kbd>
165+<kbd>g c</kbd>
166+<kbd>g b</kbd>
167+<kbd>g m</kbd>
168+<kbd>g i</kbd>
169+<kbd>g r</kbd>
170+<kbd>g a</kbd>
169171 </dt>
170-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
172+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues, releases, actions (when available)</dd>
171173 </div>
172174 <div>
173175 <dt>
@@ -262,7 +264,9 @@
262264 k-filter from:body -> ermite.focusFilter();
263265 k-gh from:body -> ermite.go('.brand');
264266 k-s from:body -> ermite.tab('s');
265- k-t from:body -> ermite.tab('t');
267+ k-f from:body -> ermite.tab('f');
268+ k-b from:body -> ermite.tab('b');
269+ k-a from:body -> ermite.tab('a');
266270 k-c from:body -> ermite.tab('c');
267271 k-r from:body -> ermite.tab('r');
268272 k-m from:body -> ermite.tab('m');
@@ -278,7 +282,6 @@
278282 k-permalink from:body -> ermite.permalink();
279283 k-merge from:body -> window.ermiteMR?.openMerge();
280284 k-read from:body -> window.ermiteMR?.markRead();
281- k-gf from:body -> window.ermiteMR?.focusFiles();
282285 k-keys from:body -> ermite.toggleKeys()">
283286 </div>
284287 </body>
M internal/app/testdata/web/not-found.html +12 -9
@@ -160,14 +160,16 @@
160160 </div>
161161 <div>
162162 <dt>
163-<kbd>s</kbd>
164-<kbd>t</kbd>
165-<kbd>c</kbd>
166-<kbd>r</kbd>
167-<kbd>m</kbd>
168-<kbd>i</kbd>
163+<kbd>g s</kbd>
164+<kbd>g f</kbd>
165+<kbd>g c</kbd>
166+<kbd>g b</kbd>
167+<kbd>g m</kbd>
168+<kbd>g i</kbd>
169+<kbd>g r</kbd>
170+<kbd>g a</kbd>
169171 </dt>
170-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
172+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues, releases, actions (when available)</dd>
171173 </div>
172174 <div>
173175 <dt>
@@ -262,7 +264,9 @@
262264 k-filter from:body -> ermite.focusFilter();
263265 k-gh from:body -> ermite.go('.brand');
264266 k-s from:body -> ermite.tab('s');
265- k-t from:body -> ermite.tab('t');
267+ k-f from:body -> ermite.tab('f');
268+ k-b from:body -> ermite.tab('b');
269+ k-a from:body -> ermite.tab('a');
266270 k-c from:body -> ermite.tab('c');
267271 k-r from:body -> ermite.tab('r');
268272 k-m from:body -> ermite.tab('m');
@@ -278,7 +282,6 @@
278282 k-permalink from:body -> ermite.permalink();
279283 k-merge from:body -> window.ermiteMR?.openMerge();
280284 k-read from:body -> window.ermiteMR?.markRead();
281- k-gf from:body -> window.ermiteMR?.focusFiles();
282285 k-keys from:body -> ermite.toggleKeys()">
283286 </div>
284287 </body>
M internal/app/testdata/web/refs.html +33 -30
@@ -67,31 +67,31 @@
6767 <nav class="tabs" aria-label="Sections">
6868 <ul>
6969 <li>
70-<a href="/demo" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
70+<a href="/demo" data-key="g s">Summary <kbd aria-hidden="true">g s</kbd>
7171 </a>
7272 </li>
7373 <li>
74-<a href="/demo/tree/main" data-key="t" aria-keyshortcuts="t">Files <kbd aria-hidden="true">t</kbd>
74+<a href="/demo/tree/main" data-key="g f">Files <kbd aria-hidden="true">g f</kbd>
7575 </a>
7676 </li>
7777 <li>
78-<a href="/demo/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
78+<a href="/demo/commits/main" data-key="g c">Commits <kbd aria-hidden="true">g c</kbd>
7979 </a>
8080 </li>
8181 <li>
82-<a href="/demo/refs" data-key="r" aria-keyshortcuts="r" aria-current="page">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
82+<a href="/demo/refs" data-key="g b" aria-current="page">Branches &amp; tags <kbd aria-hidden="true">g b</kbd>
8383 </a>
8484 </li>
8585 <li>
86-<a href="/demo/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
86+<a href="/demo/merge-requests" data-key="g m">Merge requests <kbd aria-hidden="true">g m</kbd>
8787 </a>
8888 </li>
8989 <li>
90-<a href="/demo/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
90+<a href="/demo/releases" data-key="g r">Releases <kbd aria-hidden="true">g r</kbd>
9191 </a>
9292 </li>
9393 <li>
94-<a href="/demo/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
94+<a href="/demo/actions" data-key="g a">Actions <kbd aria-hidden="true">g a</kbd>
9595 </a>
9696 </li>
9797 </ul>
@@ -285,14 +285,16 @@
285285 </div>
286286 <div>
287287 <dt>
288-<kbd>s</kbd>
289-<kbd>t</kbd>
290-<kbd>c</kbd>
291-<kbd>r</kbd>
292-<kbd>m</kbd>
293-<kbd>i</kbd>
288+<kbd>g s</kbd>
289+<kbd>g f</kbd>
290+<kbd>g c</kbd>
291+<kbd>g b</kbd>
292+<kbd>g m</kbd>
293+<kbd>g i</kbd>
294+<kbd>g r</kbd>
295+<kbd>g a</kbd>
294296 </dt>
295-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
297+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues, releases, actions (when available)</dd>
296298 </div>
297299 <div>
298300 <dt>
@@ -395,45 +397,45 @@
395397 <span class="k">go</span>
396398 </a>
397399 </li>
398-<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
400+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary (g s)">
399401 <a href="/demo" tabindex="-1">
400-<span class="lbl">summary</span>
402+<span class="lbl">summary (g s)</span>
401403 <span class="k">section</span>
402404 </a>
403405 </li>
404-<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
406+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files (g f)">
405407 <a href="/demo/tree/main" tabindex="-1">
406-<span class="lbl">files</span>
408+<span class="lbl">files (g f)</span>
407409 <span class="k">section</span>
408410 </a>
409411 </li>
410-<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
412+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits (g c)">
411413 <a href="/demo/commits/main" tabindex="-1">
412-<span class="lbl">commits</span>
414+<span class="lbl">commits (g c)</span>
413415 <span class="k">section</span>
414416 </a>
415417 </li>
416-<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
418+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags (g b)">
417419 <a href="/demo/refs" tabindex="-1">
418-<span class="lbl">branches &amp; tags</span>
420+<span class="lbl">branches &amp; tags (g b)</span>
419421 <span class="k">section</span>
420422 </a>
421423 </li>
422-<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
424+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests (g m)">
423425 <a href="/demo/merge-requests" tabindex="-1">
424-<span class="lbl">merge requests</span>
426+<span class="lbl">merge requests (g m)</span>
425427 <span class="k">section</span>
426428 </a>
427429 </li>
428-<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
430+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases (g r)">
429431 <a href="/demo/releases" tabindex="-1">
430-<span class="lbl">releases</span>
432+<span class="lbl">releases (g r)</span>
431433 <span class="k">section</span>
432434 </a>
433435 </li>
434-<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
436+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions (g a)">
435437 <a href="/demo/actions" tabindex="-1">
436-<span class="lbl">actions</span>
438+<span class="lbl">actions (g a)</span>
437439 <span class="k">section</span>
438440 </a>
439441 </li>
@@ -498,7 +500,9 @@
498500 k-filter from:body -> ermite.focusFilter();
499501 k-gh from:body -> ermite.go('.brand');
500502 k-s from:body -> ermite.tab('s');
501- k-t from:body -> ermite.tab('t');
503+ k-f from:body -> ermite.tab('f');
504+ k-b from:body -> ermite.tab('b');
505+ k-a from:body -> ermite.tab('a');
502506 k-c from:body -> ermite.tab('c');
503507 k-r from:body -> ermite.tab('r');
504508 k-m from:body -> ermite.tab('m');
@@ -514,7 +518,6 @@
514518 k-permalink from:body -> ermite.permalink();
515519 k-merge from:body -> window.ermiteMR?.openMerge();
516520 k-read from:body -> window.ermiteMR?.markRead();
517- k-gf from:body -> window.ermiteMR?.focusFiles();
518521 k-keys from:body -> ermite.toggleKeys()">
519522 </div>
520523 </body>
M internal/app/testdata/web/summary.html +33 -30
@@ -64,31 +64,31 @@
6464 <nav class="tabs" aria-label="Sections">
6565 <ul>
6666 <li>
67-<a href="/demo" data-key="s" aria-keyshortcuts="s" aria-current="page">Summary <kbd aria-hidden="true">s</kbd>
67+<a href="/demo" data-key="g s" aria-current="page">Summary <kbd aria-hidden="true">g s</kbd>
6868 </a>
6969 </li>
7070 <li>
71-<a href="/demo/tree/main" data-key="t" aria-keyshortcuts="t">Files <kbd aria-hidden="true">t</kbd>
71+<a href="/demo/tree/main" data-key="g f">Files <kbd aria-hidden="true">g f</kbd>
7272 </a>
7373 </li>
7474 <li>
75-<a href="/demo/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
75+<a href="/demo/commits/main" data-key="g c">Commits <kbd aria-hidden="true">g c</kbd>
7676 </a>
7777 </li>
7878 <li>
79-<a href="/demo/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
79+<a href="/demo/refs" data-key="g b">Branches &amp; tags <kbd aria-hidden="true">g b</kbd>
8080 </a>
8181 </li>
8282 <li>
83-<a href="/demo/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
83+<a href="/demo/merge-requests" data-key="g m">Merge requests <kbd aria-hidden="true">g m</kbd>
8484 </a>
8585 </li>
8686 <li>
87-<a href="/demo/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
87+<a href="/demo/releases" data-key="g r">Releases <kbd aria-hidden="true">g r</kbd>
8888 </a>
8989 </li>
9090 <li>
91-<a href="/demo/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
91+<a href="/demo/actions" data-key="g a">Actions <kbd aria-hidden="true">g a</kbd>
9292 </a>
9393 </li>
9494 </ul>
@@ -311,14 +311,16 @@
311311 </div>
312312 <div>
313313 <dt>
314-<kbd>s</kbd>
315-<kbd>t</kbd>
316-<kbd>c</kbd>
317-<kbd>r</kbd>
318-<kbd>m</kbd>
319-<kbd>i</kbd>
314+<kbd>g s</kbd>
315+<kbd>g f</kbd>
316+<kbd>g c</kbd>
317+<kbd>g b</kbd>
318+<kbd>g m</kbd>
319+<kbd>g i</kbd>
320+<kbd>g r</kbd>
321+<kbd>g a</kbd>
320322 </dt>
321-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
323+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues, releases, actions (when available)</dd>
322324 </div>
323325 <div>
324326 <dt>
@@ -421,45 +423,45 @@
421423 <span class="k">go</span>
422424 </a>
423425 </li>
424-<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
426+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary (g s)">
425427 <a href="/demo" tabindex="-1">
426-<span class="lbl">summary</span>
428+<span class="lbl">summary (g s)</span>
427429 <span class="k">section</span>
428430 </a>
429431 </li>
430-<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
432+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files (g f)">
431433 <a href="/demo/tree/main" tabindex="-1">
432-<span class="lbl">files</span>
434+<span class="lbl">files (g f)</span>
433435 <span class="k">section</span>
434436 </a>
435437 </li>
436-<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
438+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits (g c)">
437439 <a href="/demo/commits/main" tabindex="-1">
438-<span class="lbl">commits</span>
440+<span class="lbl">commits (g c)</span>
439441 <span class="k">section</span>
440442 </a>
441443 </li>
442-<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
444+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags (g b)">
443445 <a href="/demo/refs" tabindex="-1">
444-<span class="lbl">branches &amp; tags</span>
446+<span class="lbl">branches &amp; tags (g b)</span>
445447 <span class="k">section</span>
446448 </a>
447449 </li>
448-<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
450+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests (g m)">
449451 <a href="/demo/merge-requests" tabindex="-1">
450-<span class="lbl">merge requests</span>
452+<span class="lbl">merge requests (g m)</span>
451453 <span class="k">section</span>
452454 </a>
453455 </li>
454-<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
456+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases (g r)">
455457 <a href="/demo/releases" tabindex="-1">
456-<span class="lbl">releases</span>
458+<span class="lbl">releases (g r)</span>
457459 <span class="k">section</span>
458460 </a>
459461 </li>
460-<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
462+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions (g a)">
461463 <a href="/demo/actions" tabindex="-1">
462-<span class="lbl">actions</span>
464+<span class="lbl">actions (g a)</span>
463465 <span class="k">section</span>
464466 </a>
465467 </li>
@@ -524,7 +526,9 @@
524526 k-filter from:body -> ermite.focusFilter();
525527 k-gh from:body -> ermite.go('.brand');
526528 k-s from:body -> ermite.tab('s');
527- k-t from:body -> ermite.tab('t');
529+ k-f from:body -> ermite.tab('f');
530+ k-b from:body -> ermite.tab('b');
531+ k-a from:body -> ermite.tab('a');
528532 k-c from:body -> ermite.tab('c');
529533 k-r from:body -> ermite.tab('r');
530534 k-m from:body -> ermite.tab('m');
@@ -540,7 +544,6 @@
540544 k-permalink from:body -> ermite.permalink();
541545 k-merge from:body -> window.ermiteMR?.openMerge();
542546 k-read from:body -> window.ermiteMR?.markRead();
543- k-gf from:body -> window.ermiteMR?.focusFiles();
544547 k-keys from:body -> ermite.toggleKeys()">
545548 </div>
546549 </body>
M internal/app/testdata/web/tree-nested.html +33 -30
@@ -76,31 +76,31 @@
7676 <nav class="tabs" aria-label="Sections">
7777 <ul>
7878 <li>
79-<a href="/files" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
79+<a href="/files" data-key="g s">Summary <kbd aria-hidden="true">g s</kbd>
8080 </a>
8181 </li>
8282 <li>
83-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files <kbd aria-hidden="true">t</kbd>
83+<a href="/files/tree/main" data-key="g f" aria-current="page">Files <kbd aria-hidden="true">g f</kbd>
8484 </a>
8585 </li>
8686 <li>
87-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
87+<a href="/files/commits/main" data-key="g c">Commits <kbd aria-hidden="true">g c</kbd>
8888 </a>
8989 </li>
9090 <li>
91-<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
91+<a href="/files/refs" data-key="g b">Branches &amp; tags <kbd aria-hidden="true">g b</kbd>
9292 </a>
9393 </li>
9494 <li>
95-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
95+<a href="/files/merge-requests" data-key="g m">Merge requests <kbd aria-hidden="true">g m</kbd>
9696 </a>
9797 </li>
9898 <li>
99-<a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
99+<a href="/files/releases" data-key="g r">Releases <kbd aria-hidden="true">g r</kbd>
100100 </a>
101101 </li>
102102 <li>
103-<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
103+<a href="/files/actions" data-key="g a">Actions <kbd aria-hidden="true">g a</kbd>
104104 </a>
105105 </li>
106106 </ul>
@@ -241,14 +241,16 @@
241241 </div>
242242 <div>
243243 <dt>
244-<kbd>s</kbd>
245-<kbd>t</kbd>
246-<kbd>c</kbd>
247-<kbd>r</kbd>
248-<kbd>m</kbd>
249-<kbd>i</kbd>
244+<kbd>g s</kbd>
245+<kbd>g f</kbd>
246+<kbd>g c</kbd>
247+<kbd>g b</kbd>
248+<kbd>g m</kbd>
249+<kbd>g i</kbd>
250+<kbd>g r</kbd>
251+<kbd>g a</kbd>
250252 </dt>
251-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
253+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues, releases, actions (when available)</dd>
252254 </div>
253255 <div>
254256 <dt>
@@ -347,45 +349,45 @@
347349 <span class="k">go</span>
348350 </a>
349351 </li>
350-<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
352+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary (g s)">
351353 <a href="/files" tabindex="-1">
352-<span class="lbl">summary</span>
354+<span class="lbl">summary (g s)</span>
353355 <span class="k">section</span>
354356 </a>
355357 </li>
356-<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
358+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files (g f)">
357359 <a href="/files/tree/main" tabindex="-1">
358-<span class="lbl">files</span>
360+<span class="lbl">files (g f)</span>
359361 <span class="k">section</span>
360362 </a>
361363 </li>
362-<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
364+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits (g c)">
363365 <a href="/files/commits/main" tabindex="-1">
364-<span class="lbl">commits</span>
366+<span class="lbl">commits (g c)</span>
365367 <span class="k">section</span>
366368 </a>
367369 </li>
368-<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
370+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags (g b)">
369371 <a href="/files/refs" tabindex="-1">
370-<span class="lbl">branches &amp; tags</span>
372+<span class="lbl">branches &amp; tags (g b)</span>
371373 <span class="k">section</span>
372374 </a>
373375 </li>
374-<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
376+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests (g m)">
375377 <a href="/files/merge-requests" tabindex="-1">
376-<span class="lbl">merge requests</span>
378+<span class="lbl">merge requests (g m)</span>
377379 <span class="k">section</span>
378380 </a>
379381 </li>
380-<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
382+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases (g r)">
381383 <a href="/files/releases" tabindex="-1">
382-<span class="lbl">releases</span>
384+<span class="lbl">releases (g r)</span>
383385 <span class="k">section</span>
384386 </a>
385387 </li>
386-<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
388+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions (g a)">
387389 <a href="/files/actions" tabindex="-1">
388-<span class="lbl">actions</span>
390+<span class="lbl">actions (g a)</span>
389391 <span class="k">section</span>
390392 </a>
391393 </li>
@@ -438,7 +440,9 @@
438440 k-filter from:body -> ermite.focusFilter();
439441 k-gh from:body -> ermite.go('.brand');
440442 k-s from:body -> ermite.tab('s');
441- k-t from:body -> ermite.tab('t');
443+ k-f from:body -> ermite.tab('f');
444+ k-b from:body -> ermite.tab('b');
445+ k-a from:body -> ermite.tab('a');
442446 k-c from:body -> ermite.tab('c');
443447 k-r from:body -> ermite.tab('r');
444448 k-m from:body -> ermite.tab('m');
@@ -454,7 +458,6 @@
454458 k-permalink from:body -> ermite.permalink();
455459 k-merge from:body -> window.ermiteMR?.openMerge();
456460 k-read from:body -> window.ermiteMR?.markRead();
457- k-gf from:body -> window.ermiteMR?.focusFiles();
458461 k-keys from:body -> ermite.toggleKeys()">
459462 </div>
460463 </body>
M internal/app/testdata/web/tree-root.html +33 -30
@@ -70,31 +70,31 @@
7070 <nav class="tabs" aria-label="Sections">
7171 <ul>
7272 <li>
73-<a href="/files" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
73+<a href="/files" data-key="g s">Summary <kbd aria-hidden="true">g s</kbd>
7474 </a>
7575 </li>
7676 <li>
77-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files <kbd aria-hidden="true">t</kbd>
77+<a href="/files/tree/main" data-key="g f" aria-current="page">Files <kbd aria-hidden="true">g f</kbd>
7878 </a>
7979 </li>
8080 <li>
81-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
81+<a href="/files/commits/main" data-key="g c">Commits <kbd aria-hidden="true">g c</kbd>
8282 </a>
8383 </li>
8484 <li>
85-<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
85+<a href="/files/refs" data-key="g b">Branches &amp; tags <kbd aria-hidden="true">g b</kbd>
8686 </a>
8787 </li>
8888 <li>
89-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
89+<a href="/files/merge-requests" data-key="g m">Merge requests <kbd aria-hidden="true">g m</kbd>
9090 </a>
9191 </li>
9292 <li>
93-<a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
93+<a href="/files/releases" data-key="g r">Releases <kbd aria-hidden="true">g r</kbd>
9494 </a>
9595 </li>
9696 <li>
97-<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
97+<a href="/files/actions" data-key="g a">Actions <kbd aria-hidden="true">g a</kbd>
9898 </a>
9999 </li>
100100 </ul>
@@ -462,14 +462,16 @@
462462 </div>
463463 <div>
464464 <dt>
465-<kbd>s</kbd>
466-<kbd>t</kbd>
467-<kbd>c</kbd>
468-<kbd>r</kbd>
469-<kbd>m</kbd>
470-<kbd>i</kbd>
465+<kbd>g s</kbd>
466+<kbd>g f</kbd>
467+<kbd>g c</kbd>
468+<kbd>g b</kbd>
469+<kbd>g m</kbd>
470+<kbd>g i</kbd>
471+<kbd>g r</kbd>
472+<kbd>g a</kbd>
471473 </dt>
472-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
474+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues, releases, actions (when available)</dd>
473475 </div>
474476 <div>
475477 <dt>
@@ -568,45 +570,45 @@
568570 <span class="k">go</span>
569571 </a>
570572 </li>
571-<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
573+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary (g s)">
572574 <a href="/files" tabindex="-1">
573-<span class="lbl">summary</span>
575+<span class="lbl">summary (g s)</span>
574576 <span class="k">section</span>
575577 </a>
576578 </li>
577-<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
579+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files (g f)">
578580 <a href="/files/tree/main" tabindex="-1">
579-<span class="lbl">files</span>
581+<span class="lbl">files (g f)</span>
580582 <span class="k">section</span>
581583 </a>
582584 </li>
583-<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
585+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits (g c)">
584586 <a href="/files/commits/main" tabindex="-1">
585-<span class="lbl">commits</span>
587+<span class="lbl">commits (g c)</span>
586588 <span class="k">section</span>
587589 </a>
588590 </li>
589-<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
591+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags (g b)">
590592 <a href="/files/refs" tabindex="-1">
591-<span class="lbl">branches &amp; tags</span>
593+<span class="lbl">branches &amp; tags (g b)</span>
592594 <span class="k">section</span>
593595 </a>
594596 </li>
595-<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
597+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests (g m)">
596598 <a href="/files/merge-requests" tabindex="-1">
597-<span class="lbl">merge requests</span>
599+<span class="lbl">merge requests (g m)</span>
598600 <span class="k">section</span>
599601 </a>
600602 </li>
601-<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
603+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases (g r)">
602604 <a href="/files/releases" tabindex="-1">
603-<span class="lbl">releases</span>
605+<span class="lbl">releases (g r)</span>
604606 <span class="k">section</span>
605607 </a>
606608 </li>
607-<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
609+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions (g a)">
608610 <a href="/files/actions" tabindex="-1">
609-<span class="lbl">actions</span>
611+<span class="lbl">actions (g a)</span>
610612 <span class="k">section</span>
611613 </a>
612614 </li>
@@ -659,7 +661,9 @@
659661 k-filter from:body -> ermite.focusFilter();
660662 k-gh from:body -> ermite.go('.brand');
661663 k-s from:body -> ermite.tab('s');
662- k-t from:body -> ermite.tab('t');
664+ k-f from:body -> ermite.tab('f');
665+ k-b from:body -> ermite.tab('b');
666+ k-a from:body -> ermite.tab('a');
663667 k-c from:body -> ermite.tab('c');
664668 k-r from:body -> ermite.tab('r');
665669 k-m from:body -> ermite.tab('m');
@@ -675,7 +679,6 @@
675679 k-permalink from:body -> ermite.permalink();
676680 k-merge from:body -> window.ermiteMR?.openMerge();
677681 k-read from:body -> window.ermiteMR?.markRead();
678- k-gf from:body -> window.ermiteMR?.focusFiles();
679682 k-keys from:body -> ermite.toggleKeys()">
680683 </div>
681684 </body>
M internal/app/testdata/web/tree-slash-ref.html +33 -30
@@ -73,31 +73,31 @@
7373 <nav class="tabs" aria-label="Sections">
7474 <ul>
7575 <li>
76-<a href="/files" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
76+<a href="/files" data-key="g s">Summary <kbd aria-hidden="true">g s</kbd>
7777 </a>
7878 </li>
7979 <li>
80-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files <kbd aria-hidden="true">t</kbd>
80+<a href="/files/tree/main" data-key="g f" aria-current="page">Files <kbd aria-hidden="true">g f</kbd>
8181 </a>
8282 </li>
8383 <li>
84-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
84+<a href="/files/commits/main" data-key="g c">Commits <kbd aria-hidden="true">g c</kbd>
8585 </a>
8686 </li>
8787 <li>
88-<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
88+<a href="/files/refs" data-key="g b">Branches &amp; tags <kbd aria-hidden="true">g b</kbd>
8989 </a>
9090 </li>
9191 <li>
92-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
92+<a href="/files/merge-requests" data-key="g m">Merge requests <kbd aria-hidden="true">g m</kbd>
9393 </a>
9494 </li>
9595 <li>
96-<a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
96+<a href="/files/releases" data-key="g r">Releases <kbd aria-hidden="true">g r</kbd>
9797 </a>
9898 </li>
9999 <li>
100-<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
100+<a href="/files/actions" data-key="g a">Actions <kbd aria-hidden="true">g a</kbd>
101101 </a>
102102 </li>
103103 </ul>
@@ -238,14 +238,16 @@
238238 </div>
239239 <div>
240240 <dt>
241-<kbd>s</kbd>
242-<kbd>t</kbd>
243-<kbd>c</kbd>
244-<kbd>r</kbd>
245-<kbd>m</kbd>
246-<kbd>i</kbd>
241+<kbd>g s</kbd>
242+<kbd>g f</kbd>
243+<kbd>g c</kbd>
244+<kbd>g b</kbd>
245+<kbd>g m</kbd>
246+<kbd>g i</kbd>
247+<kbd>g r</kbd>
248+<kbd>g a</kbd>
247249 </dt>
248-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
250+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues, releases, actions (when available)</dd>
249251 </div>
250252 <div>
251253 <dt>
@@ -344,45 +346,45 @@
344346 <span class="k">go</span>
345347 </a>
346348 </li>
347-<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
349+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary (g s)">
348350 <a href="/files" tabindex="-1">
349-<span class="lbl">summary</span>
351+<span class="lbl">summary (g s)</span>
350352 <span class="k">section</span>
351353 </a>
352354 </li>
353-<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
355+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files (g f)">
354356 <a href="/files/tree/main" tabindex="-1">
355-<span class="lbl">files</span>
357+<span class="lbl">files (g f)</span>
356358 <span class="k">section</span>
357359 </a>
358360 </li>
359-<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
361+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits (g c)">
360362 <a href="/files/commits/main" tabindex="-1">
361-<span class="lbl">commits</span>
363+<span class="lbl">commits (g c)</span>
362364 <span class="k">section</span>
363365 </a>
364366 </li>
365-<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
367+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags (g b)">
366368 <a href="/files/refs" tabindex="-1">
367-<span class="lbl">branches &amp; tags</span>
369+<span class="lbl">branches &amp; tags (g b)</span>
368370 <span class="k">section</span>
369371 </a>
370372 </li>
371-<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
373+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests (g m)">
372374 <a href="/files/merge-requests" tabindex="-1">
373-<span class="lbl">merge requests</span>
375+<span class="lbl">merge requests (g m)</span>
374376 <span class="k">section</span>
375377 </a>
376378 </li>
377-<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
379+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases (g r)">
378380 <a href="/files/releases" tabindex="-1">
379-<span class="lbl">releases</span>
381+<span class="lbl">releases (g r)</span>
380382 <span class="k">section</span>
381383 </a>
382384 </li>
383-<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
385+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions (g a)">
384386 <a href="/files/actions" tabindex="-1">
385-<span class="lbl">actions</span>
387+<span class="lbl">actions (g a)</span>
386388 <span class="k">section</span>
387389 </a>
388390 </li>
@@ -435,7 +437,9 @@
435437 k-filter from:body -> ermite.focusFilter();
436438 k-gh from:body -> ermite.go('.brand');
437439 k-s from:body -> ermite.tab('s');
438- k-t from:body -> ermite.tab('t');
440+ k-f from:body -> ermite.tab('f');
441+ k-b from:body -> ermite.tab('b');
442+ k-a from:body -> ermite.tab('a');
439443 k-c from:body -> ermite.tab('c');
440444 k-r from:body -> ermite.tab('r');
441445 k-m from:body -> ermite.tab('m');
@@ -451,7 +455,6 @@
451455 k-permalink from:body -> ermite.permalink();
452456 k-merge from:body -> window.ermiteMR?.openMerge();
453457 k-read from:body -> window.ermiteMR?.markRead();
454- k-gf from:body -> window.ermiteMR?.focusFiles();
455458 k-keys from:body -> ermite.toggleKeys()">
456459 </div>
457460 </body>
M internal/app/testdata/web/tree-subdir.html +33 -30
@@ -73,31 +73,31 @@
7373 <nav class="tabs" aria-label="Sections">
7474 <ul>
7575 <li>
76-<a href="/files" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
76+<a href="/files" data-key="g s">Summary <kbd aria-hidden="true">g s</kbd>
7777 </a>
7878 </li>
7979 <li>
80-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files <kbd aria-hidden="true">t</kbd>
80+<a href="/files/tree/main" data-key="g f" aria-current="page">Files <kbd aria-hidden="true">g f</kbd>
8181 </a>
8282 </li>
8383 <li>
84-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
84+<a href="/files/commits/main" data-key="g c">Commits <kbd aria-hidden="true">g c</kbd>
8585 </a>
8686 </li>
8787 <li>
88-<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
88+<a href="/files/refs" data-key="g b">Branches &amp; tags <kbd aria-hidden="true">g b</kbd>
8989 </a>
9090 </li>
9191 <li>
92-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
92+<a href="/files/merge-requests" data-key="g m">Merge requests <kbd aria-hidden="true">g m</kbd>
9393 </a>
9494 </li>
9595 <li>
96-<a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd>
96+<a href="/files/releases" data-key="g r">Releases <kbd aria-hidden="true">g r</kbd>
9797 </a>
9898 </li>
9999 <li>
100-<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
100+<a href="/files/actions" data-key="g a">Actions <kbd aria-hidden="true">g a</kbd>
101101 </a>
102102 </li>
103103 </ul>
@@ -254,14 +254,16 @@
254254 </div>
255255 <div>
256256 <dt>
257-<kbd>s</kbd>
258-<kbd>t</kbd>
259-<kbd>c</kbd>
260-<kbd>r</kbd>
261-<kbd>m</kbd>
262-<kbd>i</kbd>
257+<kbd>g s</kbd>
258+<kbd>g f</kbd>
259+<kbd>g c</kbd>
260+<kbd>g b</kbd>
261+<kbd>g m</kbd>
262+<kbd>g i</kbd>
263+<kbd>g r</kbd>
264+<kbd>g a</kbd>
263265 </dt>
264-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
266+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues, releases, actions (when available)</dd>
265267 </div>
266268 <div>
267269 <dt>
@@ -360,45 +362,45 @@
360362 <span class="k">go</span>
361363 </a>
362364 </li>
363-<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary">
365+<li id="cmd-o1" role="option" aria-selected="false" data-k="section summary (g s)">
364366 <a href="/files" tabindex="-1">
365-<span class="lbl">summary</span>
367+<span class="lbl">summary (g s)</span>
366368 <span class="k">section</span>
367369 </a>
368370 </li>
369-<li id="cmd-o2" role="option" aria-selected="false" data-k="section files">
371+<li id="cmd-o2" role="option" aria-selected="false" data-k="section files (g f)">
370372 <a href="/files/tree/main" tabindex="-1">
371-<span class="lbl">files</span>
373+<span class="lbl">files (g f)</span>
372374 <span class="k">section</span>
373375 </a>
374376 </li>
375-<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits">
377+<li id="cmd-o3" role="option" aria-selected="false" data-k="section commits (g c)">
376378 <a href="/files/commits/main" tabindex="-1">
377-<span class="lbl">commits</span>
379+<span class="lbl">commits (g c)</span>
378380 <span class="k">section</span>
379381 </a>
380382 </li>
381-<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags">
383+<li id="cmd-o4" role="option" aria-selected="false" data-k="section branches &amp; tags (g b)">
382384 <a href="/files/refs" tabindex="-1">
383-<span class="lbl">branches &amp; tags</span>
385+<span class="lbl">branches &amp; tags (g b)</span>
384386 <span class="k">section</span>
385387 </a>
386388 </li>
387-<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests">
389+<li id="cmd-o5" role="option" aria-selected="false" data-k="section merge requests (g m)">
388390 <a href="/files/merge-requests" tabindex="-1">
389-<span class="lbl">merge requests</span>
391+<span class="lbl">merge requests (g m)</span>
390392 <span class="k">section</span>
391393 </a>
392394 </li>
393-<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases">
395+<li id="cmd-o6" role="option" aria-selected="false" data-k="section releases (g r)">
394396 <a href="/files/releases" tabindex="-1">
395-<span class="lbl">releases</span>
397+<span class="lbl">releases (g r)</span>
396398 <span class="k">section</span>
397399 </a>
398400 </li>
399-<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions">
401+<li id="cmd-o7" role="option" aria-selected="false" data-k="section actions (g a)">
400402 <a href="/files/actions" tabindex="-1">
401-<span class="lbl">actions</span>
403+<span class="lbl">actions (g a)</span>
402404 <span class="k">section</span>
403405 </a>
404406 </li>
@@ -451,7 +453,9 @@
451453 k-filter from:body -> ermite.focusFilter();
452454 k-gh from:body -> ermite.go('.brand');
453455 k-s from:body -> ermite.tab('s');
454- k-t from:body -> ermite.tab('t');
456+ k-f from:body -> ermite.tab('f');
457+ k-b from:body -> ermite.tab('b');
458+ k-a from:body -> ermite.tab('a');
455459 k-c from:body -> ermite.tab('c');
456460 k-r from:body -> ermite.tab('r');
457461 k-m from:body -> ermite.tab('m');
@@ -467,7 +471,6 @@
467471 k-permalink from:body -> ermite.permalink();
468472 k-merge from:body -> window.ermiteMR?.openMerge();
469473 k-read from:body -> window.ermiteMR?.markRead();
470- k-gf from:body -> window.ermiteMR?.focusFiles();
471474 k-keys from:body -> ermite.toggleKeys()">
472475 </div>
473476 </body>
M internal/app/web_issue_test.go +1 -1
@@ -28,7 +28,7 @@ func TestWebIssuePagesEndToEnd(t *testing.T) {
2828 // Off by default: no page, no tab, for the owner as well.
2929 assert.Equal(t, 404, b.get("/proj/issues", as(token)).status)
3030 assert.Equal(t, 404, b.get("/proj/issues/new", as(token)).status)
31- assert.NotContains(t, b.get("/proj", as(token)).body, `data-key="i"`)
31+ assert.NotContains(t, b.get("/proj", as(token)).body, `data-key="g i"`)
3232
3333 mode := domain.IssuesOwner
3434 _, err := r.forge.Repos.Update(r.ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Issues: &mode})
M internal/app/web_mr_test.go +1 -1
@@ -93,7 +93,7 @@ func TestWebMergeRequestPagesRender(t *testing.T) {
9393 require.Equal(t, 200, list.status)
9494 assert.Contains(t, list.body, "no open merge requests")
9595 assert.Contains(t, list.body, `href="/proj/compare/feat/search"`)
96- assert.Contains(t, list.body, `data-key="m"`, "the section of the nav")
96+ assert.Contains(t, list.body, `data-key="g m"`, "the section of the nav")
9797 assert.NotContains(t, strings.ToLower(list.body), "proposal")
9898
9999 // Open one from the compare page.
M internal/ports/mocks/ReleaseStore.go +66 -0
@@ -311,6 +311,72 @@ func (_c *ReleaseStore_GetAssetByName_Call) RunAndReturn(run func(ctx context.Co
311311 return _c
312312 }
313313
314+// GetLatestRelease provides a mock function for the type ReleaseStore
315+func (_mock *ReleaseStore) GetLatestRelease(ctx context.Context, repo domain.RepoName) (domain.Release, error) {
316+ ret := _mock.Called(ctx, repo)
317+
318+ if len(ret) == 0 {
319+ panic("no return value specified for GetLatestRelease")
320+ }
321+
322+ var r0 domain.Release
323+ var r1 error
324+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) (domain.Release, error)); ok {
325+ return returnFunc(ctx, repo)
326+ }
327+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) domain.Release); ok {
328+ r0 = returnFunc(ctx, repo)
329+ } else {
330+ r0 = ret.Get(0).(domain.Release)
331+ }
332+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName) error); ok {
333+ r1 = returnFunc(ctx, repo)
334+ } else {
335+ r1 = ret.Error(1)
336+ }
337+ return r0, r1
338+}
339+
340+// ReleaseStore_GetLatestRelease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetLatestRelease'
341+type ReleaseStore_GetLatestRelease_Call struct {
342+ *mock.Call
343+}
344+
345+// GetLatestRelease is a helper method to define mock.On call
346+// - ctx context.Context
347+// - repo domain.RepoName
348+func (_e *ReleaseStore_Expecter) GetLatestRelease(ctx any, repo any) *ReleaseStore_GetLatestRelease_Call {
349+ return &ReleaseStore_GetLatestRelease_Call{Call: _e.mock.On("GetLatestRelease", ctx, repo)}
350+}
351+
352+func (_c *ReleaseStore_GetLatestRelease_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *ReleaseStore_GetLatestRelease_Call {
353+ _c.Call.Run(func(args mock.Arguments) {
354+ var arg0 context.Context
355+ if args[0] != nil {
356+ arg0 = args[0].(context.Context)
357+ }
358+ var arg1 domain.RepoName
359+ if args[1] != nil {
360+ arg1 = args[1].(domain.RepoName)
361+ }
362+ run(
363+ arg0,
364+ arg1,
365+ )
366+ })
367+ return _c
368+}
369+
370+func (_c *ReleaseStore_GetLatestRelease_Call) Return(release domain.Release, err error) *ReleaseStore_GetLatestRelease_Call {
371+ _c.Call.Return(release, err)
372+ return _c
373+}
374+
375+func (_c *ReleaseStore_GetLatestRelease_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) (domain.Release, error)) *ReleaseStore_GetLatestRelease_Call {
376+ _c.Call.Return(run)
377+ return _c
378+}
379+
314380 // GetRelease provides a mock function for the type ReleaseStore
315381 func (_mock *ReleaseStore) GetRelease(ctx context.Context, repo domain.RepoName, id int64) (domain.Release, error) {
316382 ret := _mock.Called(ctx, repo, id)
M internal/ports/release.go +3 -0
@@ -13,6 +13,8 @@ import (
1313 // Used includes both uploading and uploaded assets.
1414 type ReleaseWrite struct {
1515 Repo domain.RepoName
16+ SetLatest bool // false leaves the latest index unchanged
17+ LatestID int64 // zero means no eligible release when SetLatest
1618 Used int64
1719 UsageVersion uint64
1820 PutReleases, DeleteReleases []domain.Release
@@ -21,6 +23,7 @@ type ReleaseWrite struct {
2123
2224 // ReleaseStore keeps metadata, never contents. *kvstore.Releases implements it.
2325 type ReleaseStore interface {
26+ GetLatestRelease(ctx context.Context, repo domain.RepoName) (domain.Release, error)
2427 NextReleaseID(ctx context.Context) (int64, error)
2528 ReleaseUsage(ctx context.Context, repo domain.RepoName) (used int64, version uint64, err error)
2629 GetRelease(ctx context.Context, repo domain.RepoName, id int64) (domain.Release, error)
M internal/usecase/releases.go +25 -9
@@ -92,6 +92,30 @@ func (r *Releases) updateReleases(ctx context.Context, repo domain.RepoName, fn
9292 if err = fn(&w); err != nil {
9393 return err
9494 }
95+ if len(w.PutReleases) > 0 || len(w.DeleteReleases) > 0 {
96+ w.SetLatest = true
97+ all, err := r.store.ListReleases(ctx, repo)
98+ if err != nil {
99+ return err
100+ }
101+ byID := make(map[int64]domain.Release, len(all)+len(w.PutReleases))
102+ for _, rel := range all {
103+ byID[rel.ID] = rel
104+ }
105+ for _, rel := range w.DeleteReleases {
106+ delete(byID, rel.ID)
107+ }
108+ for _, rel := range w.PutReleases {
109+ byID[rel.ID] = rel
110+ }
111+ all = all[:0]
112+ for _, rel := range byID {
113+ all = append(all, rel)
114+ }
115+ if latest, ok := domain.LatestRelease(all); ok {
116+ w.LatestID = latest.ID
117+ }
118+ }
95119 err = r.store.ApplyReleases(ctx, w)
96120 if errors.Is(err, domain.ErrConflict) {
97121 continue
@@ -245,15 +269,7 @@ func (r *Releases) GetRelease(ctx context.Context, p domain.Principal, name doma
245269 case ref.Tag != "":
246270 rel, err = r.store.GetReleaseByTag(ctx, name, ref.Tag)
247271 default:
248- var all []domain.Release
249- all, err = r.store.ListReleases(ctx, name)
250- if err == nil {
251- var ok bool
252- rel, ok = domain.LatestRelease(all)
253- if !ok {
254- err = domain.ErrNotFound
255- }
256- }
272+ rel, err = r.store.GetLatestRelease(ctx, name)
257273 }
258274 if err == nil && rel.Draft && !releaseWriter(p, name) {
259275 err = domain.ErrNotFound
M internal/usecase/releases_integration_test.go +59 -0
@@ -263,6 +263,7 @@ func TestReleaseWriteRetriesUsageConflict(t *testing.T) {
263263 reader.EXPECT().ResolveRevision(ctx, repo, rev).Return(domain.ResolvedRevision{}, domain.ErrNotFound)
264264 store.EXPECT().GetReleaseByTag(ctx, repo, "v1").Return(domain.Release{}, domain.ErrNotFound)
265265 store.EXPECT().NextReleaseID(ctx).Return(int64(1), nil)
266+ store.EXPECT().ListReleases(ctx, repo).Return(nil, nil).Twice()
266267 store.EXPECT().ReleaseUsage(ctx, repo).Return(int64(0), uint64(1), nil).Once()
267268 store.EXPECT().ApplyReleases(ctx, mock.MatchedBy(func(w ports.ReleaseWrite) bool { return w.UsageVersion == 1 && len(w.PutReleases) == 1 })).Return(domain.ErrConflict).Once()
268269 store.EXPECT().ReleaseUsage(ctx, repo).Return(int64(0), uint64(2), nil).Once()
@@ -334,3 +335,61 @@ func TestReleaseUpdateTargetNeverMovesExistingTag(t *testing.T) {
334335 require.NoError(t, err)
335336 require.Equal(t, oidA, got.TargetCommitish)
336337 }
338+
339+func TestReleaseLatestIndexTransitions(t *testing.T) {
340+ r := newReleaseRig(t, ports.ReleaseLimits{})
341+ latest := func(id int64) {
342+ t.Helper()
343+ got, err := r.svc.GetRelease(r.ctx, domain.Anonymous(), r.repo, ports.ReleaseRef{Latest: true})
344+ if id == 0 {
345+ require.ErrorIs(t, err, domain.ErrNotFound)
346+ return
347+ }
348+ require.NoError(t, err)
349+ require.Equal(t, id, got.ID)
350+ }
351+ update := func(id int64, p ports.UpdateReleaseParams) {
352+ t.Helper()
353+ _, err := r.svc.UpdateRelease(r.ctx, domain.Root(), r.repo, id, p)
354+ require.NoError(t, err)
355+ }
356+ yes, no := true, false
357+ draft := r.create(t, "draft", true)
358+ latest(0)
359+ first := r.create(t, "v1", false)
360+ latest(first.ID)
361+ update(draft.ID, ports.UpdateReleaseParams{Draft: &no})
362+ latest(first.ID) // equal creation times: higher ID wins
363+ second := r.create(t, "v2", false)
364+ latest(second.ID)
365+ update(second.ID, ports.UpdateReleaseParams{Prerelease: &yes})
366+ latest(first.ID)
367+ update(second.ID, ports.UpdateReleaseParams{Prerelease: &no})
368+ latest(second.ID)
369+ update(first.ID, ports.UpdateReleaseParams{MakeLatest: &yes})
370+ latest(first.ID)
371+ tag := "renamed"
372+ r.resolve(t, tag, nil)
373+ update(first.ID, ports.UpdateReleaseParams{Tag: &tag})
374+ latest(first.ID)
375+ require.NoError(t, r.svc.DeleteRelease(r.ctx, domain.Root(), r.repo, first.ID))
376+ latest(second.ID)
377+ update(second.ID, ports.UpdateReleaseParams{Draft: &yes})
378+ latest(draft.ID)
379+ update(draft.ID, ports.UpdateReleaseParams{Draft: &yes})
380+ latest(0)
381+}
382+
383+// No ListReleases expectation: latest resolution must use only point reads.
384+func TestReleaseLatestUsesIndex(t *testing.T) {
385+ ctx := t.Context()
386+ repo := name(t, "indexed")
387+ store := mocks.NewReleaseStore(t)
388+ meta := mocks.NewRepoMetaStore(t)
389+ meta.EXPECT().Get(ctx, repo).Return(domain.Repo{Name: repo, Visibility: domain.VisibilityPublic}, nil)
390+ store.EXPECT().GetLatestRelease(ctx, repo).Return(domain.Release{ID: 42, Repo: repo}, nil).Once()
391+ svc := usecase.NewReleases(store, nil, meta, nil, nil, nil, nil, nil, ports.ReleaseLimits{})
392+ got, err := svc.GetRelease(ctx, domain.Anonymous(), repo, ports.ReleaseRef{Latest: true})
393+ require.NoError(t, err)
394+ require.EqualValues(t, 42, got.ID)
395+}
A internal/usecase/releases_write_test.go +27 -0
@@ -0,0 +1,27 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package usecase
3+
4+import (
5+ "testing"
6+
7+ "bnema.dev/ermite/internal/domain"
8+ "bnema.dev/ermite/internal/ports"
9+ "bnema.dev/ermite/internal/ports/mocks"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+// No ListReleases expectation: asset-only changes must not scan releases.
14+func TestAssetOnlyWriteDoesNotListReleases(t *testing.T) {
15+ ctx := t.Context()
16+ repo, err := domain.ParseRepoName("assets")
17+ require.NoError(t, err)
18+ store := mocks.NewReleaseStore(t)
19+ asset := domain.ReleaseAsset{ID: 1, Repo: repo}
20+ store.EXPECT().ReleaseUsage(ctx, repo).Return(int64(0), uint64(2), nil).Once()
21+ store.EXPECT().ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: 2, PutAssets: []domain.ReleaseAsset{asset}}).Return(nil).Once()
22+ svc := &Releases{store: store}
23+ require.NoError(t, svc.updateReleases(ctx, repo, func(w *ports.ReleaseWrite) error {
24+ w.PutAssets = append(w.PutAssets, asset)
25+ return nil
26+ }))
27+}

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