detached ab716d9

feat(web): navigate sections with g sequences and fix uppercase action keys

commit
ab716d95dc4016e6b8468f736d519dca7bf43143
author
brice · · 2026-10-22 07:16 UTC
parent
3f91157
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
20 files · +145 -35
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)
@@ -139,6 +139,7 @@ Give the agent the [`skills/ermite`](skills/ermite/SKILL.md) skill so it knows t
139 - [Issues](docs/issues.md)139 - [Issues](docs/issues.md)
140 - [Commit signatures](docs/commit-signatures.md): SSH and OpenPGP badges, trusted keys140 - [Commit signatures](docs/commit-signatures.md): SSH and OpenPGP badges, trusted keys
141 - [Web sign-in](docs/web-sign-in.md): passkeys and one-time links141 - [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
142 - [CI](docs/ci.md): runners, trust, concurrency, reusable workflows143 - [CI](docs/ci.md): runners, trust, concurrency, reusable workflows
143 - [CI events](docs/ci-events.md)144 - [CI events](docs/ci-events.md)
144 - [OIDC](docs/ci-oidc.md)145 - [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
@@ -50,7 +50,7 @@ Limits: the 100 most recent commits of the merge request, 16 KiB of each text, 1
50 50
51 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.51 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.
52 52
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.
54 54
55 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.55 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.
56 56
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.
@@ -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.
@@ -142,7 +142,7 @@ the diff opens the Checks panel and continues to update live. Commit totals use
142 `N+` when the branch list is capped: the reader's existing count operation counts all reachable142 `N+` when the branch list is capped: the reader's existing count operation counts all reachable
143 ancestors, not the source-minus-target range.143 ancestors, not the source-minus-target range.
144 144
145-The rail keeps Files (including its `g f` shortcut) and Verdicts. Merge settings145+The rail keeps Files (including its `l` shortcut) and Verdicts. Merge settings
146 are read-only information in the More (⋯) menu, available to every viewer.146 are read-only information in the More (⋯) menu, available to every viewer.
147 Signing-key information is also there when a merge box offers signed modes;147 Signing-key information is also there when a merge box offers signed modes;
148 merge actions remain restricted to their existing permissions.148 merge actions remain restricted to their existing permissions.
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) {
@@ -100,7 +100,7 @@ func TestIssueList(t *testing.T) {
100 assert.Contains(t, body, "crash &lt;b&gt;on&lt;/b&gt; start")100 assert.Contains(t, body, "crash &lt;b&gt;on&lt;/b&gt; start")
101 assert.Contains(t, body, `href="/proj/issues?page=tok2"`, "older page")101 assert.Contains(t, body, `href="/proj/issues?page=tok2"`, "older page")
102 assert.Contains(t, body, `href="/proj/issues?state=closed"`, "closed tab")102 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")
104 assert.NotContains(t, body, "New issue", "no form for an anonymous visitor")104 assert.NotContains(t, body, "New issue", "no form for an anonymous visitor")
105 assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control"))105 assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control"))
106 106
@@ -169,7 +169,7 @@ func TestIssueNotFound(t *testing.T) {
169 }169 }
170 summary := off.do("GET", "/proj", false, nil)170 summary := off.do("GET", "/proj", false, nil)
171 require.Equal(t, 200, summary.Code)171 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"`)
173 173
174 // The mode is unset in the settings: off as well.174 // The mode is unset in the settings: off as well.
175 unset := newIssueRig(t, "", false)175 unset := newIssueRig(t, "", false)
@@ -257,7 +257,7 @@ func TestIssueRoutesAbsentWithoutService(t *testing.T) {
257 }257 }
258 rec := httptest.NewRecorder()258 rec := httptest.NewRecorder()
259 srv.ServeHTTP(rec, httptest.NewRequest("GET", "/proj", nil))259 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"`)
261 }261 }
262 262
263 func TestIssueNumberRedirects(t *testing.T) {263 func TestIssueNumberRedirects(t *testing.T) {
A internal/adapters/web/keyboard_test.js +43 -0
@@ -0,0 +1,43 @@
1+// Run with node --test internal/adapters/web/keyboard_test.js.
2+// Exercise the production key handler without a browser or dependencies.
3+const { test } = require('node:test');
4+const assert = require('node:assert/strict');
5+const { readFileSync } = require('node:fs');
6+const { runInNewContext } = require('node:vm');
7+const source = readFileSync(__dirname + '/static/js/ermite.js', 'utf8');
8+function keyboard({ off = false, typing = false, dialog = false } = {}) {
9+ let handler;
10+ const fired = [];
11+ runInNewContext(source.slice(source.indexOf(' const single ='), source.indexOf(' // 2. A [data-fire]')), {
12+ addEventListener: (_, fn) => { handler = fn; },
13+ window: { ermiteKeys: { blocks: () => off } },
14+ ermite: { fire: name => fired.push(name) },
15+ $: selector => selector === '[data-inbox]' || (dialog && selector === 'dialog[open]'),
16+ closeMenus: () => false, clearTimeout() {}, setTimeout() {},
17+ });
18+ return { fired, press(key, shiftKey = false) {
19+ handler({ key, shiftKey, target: { closest: selector => typing && selector.startsWith('input'), blur() {} }, preventDefault() {} });
20+ } };
21+}
22+test('sections require g and have no old t alias', () => {
23+ const k = keyboard();
24+ for (const key of ['s', 't', 'c', 'r', 'm', 'i']) k.press(key);
25+ assert.deepEqual(k.fired, []);
26+ for (const key of ['s', 'f', 'c', 'b', 'm', 'i', 'r', 'a', 'h', 'g', 't']) { k.press('g'); k.press(key); }
27+ assert.deepEqual(k.fired, ['s', 'f', 'c', 'b', 'm', 'i', 'r', 'a', 'gh', 'top']);
28+});
29+test('uppercase actions work with either reported casing and interrupt prefixes', () => {
30+ for (const [key, action] of Object.entries({ G: 'bottom', M: 'merge', R: 'read', E: 'expandAll', I: 'inbox' })) {
31+ for (const reported of [key, key.toLowerCase()]) {
32+ const k = keyboard();
33+ k.press('g'); k.press(reported, true); k.press(reported, true);
34+ assert.deepEqual(k.fired, [action, action]);
35+ }
36+ }
37+});
38+test('uppercase actions respect preference, fields and dialogs', () => {
39+ for (const options of [{ off: true }, { typing: true }, { dialog: true }]) {
40+ const k = keyboard(options); k.press('g', true);
41+ assert.deepEqual(k.fired, []);
42+ }
43+});
@@ -0,0 +1,43 @@
1+// Run with node --test internal/adapters/web/keyboard_test.js.
2+// Exercise the production key handler without a browser or dependencies.
3+const { test } = require('node:test');
4+const assert = require('node:assert/strict');
5+const { readFileSync } = require('node:fs');
6+const { runInNewContext } = require('node:vm');
7+const source = readFileSync(__dirname + '/static/js/ermite.js', 'utf8');
8+function keyboard({ off = false, typing = false, dialog = false } = {}) {
9+ let handler;
10+ const fired = [];
11+ runInNewContext(source.slice(source.indexOf(' const single ='), source.indexOf(' // 2. A [data-fire]')), {
12+ addEventListener: (_, fn) => { handler = fn; },
13+ window: { ermiteKeys: { blocks: () => off } },
14+ ermite: { fire: name => fired.push(name) },
15+ $: selector => selector === '[data-inbox]' || (dialog && selector === 'dialog[open]'),
16+ closeMenus: () => false, clearTimeout() {}, setTimeout() {},
17+ });
18+ return { fired, press(key, shiftKey = false) {
19+ handler({ key, shiftKey, target: { closest: selector => typing && selector.startsWith('input'), blur() {} }, preventDefault() {} });
20+ } };
21+}
22+test('sections require g and have no old t alias', () => {
23+ const k = keyboard();
24+ for (const key of ['s', 't', 'c', 'r', 'm', 'i']) k.press(key);
25+ assert.deepEqual(k.fired, []);
26+ for (const key of ['s', 'f', 'c', 'b', 'm', 'i', 'r', 'a', 'h', 'g', 't']) { k.press('g'); k.press(key); }
27+ assert.deepEqual(k.fired, ['s', 'f', 'c', 'b', 'm', 'i', 'r', 'a', 'gh', 'top']);
28+});
29+test('uppercase actions work with either reported casing and interrupt prefixes', () => {
30+ for (const [key, action] of Object.entries({ G: 'bottom', M: 'merge', R: 'read', E: 'expandAll', I: 'inbox' })) {
31+ for (const reported of [key, key.toLowerCase()]) {
32+ const k = keyboard();
33+ k.press('g'); k.press(reported, true); k.press(reported, true);
34+ assert.deepEqual(k.fired, [action, action]);
35+ }
36+ }
37+});
38+test('uppercase actions respect preference, fields and dialogs', () => {
39+ for (const options of [{ off: true }, { typing: true }, { dialog: true }]) {
40+ const k = keyboard(options); k.press('g', true);
41+ assert.deepEqual(k.fired, []);
42+ }
43+});
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 }
@@ -23,7 +23,9 @@ templ Keys() {
23 k-filter from:body -> ermite.focusFilter();23 k-filter from:body -> ermite.focusFilter();
24 k-gh from:body -> ermite.go('.brand');24 k-gh from:body -> ermite.go('.brand');
25 k-s from:body -> ermite.tab('s');25 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');
27 k-c from:body -> ermite.tab('c');29 k-c from:body -> ermite.tab('c');
28 k-r from:body -> ermite.tab('r');30 k-r from:body -> ermite.tab('r');
29 k-m from:body -> ermite.tab('m');31 k-m from:body -> ermite.tab('m');
@@ -39,7 +41,6 @@ templ Keys() {
39 k-permalink from:body -> ermite.permalink();41 k-permalink from:body -> ermite.permalink();
40 k-merge from:body -> window.ermiteMR?.openMerge();42 k-merge from:body -> window.ermiteMR?.openMerge();
41 k-read from:body -> window.ermiteMR?.markRead();43 k-read from:body -> window.ermiteMR?.markRead();
42- k-gf from:body -> window.ermiteMR?.focusFiles();
43 k-keys from:body -> ermite.toggleKeys()"44 k-keys from:body -> ermite.toggleKeys()"
44 ></div>45 ></div>
45 }46 }
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.Contains(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+}
@@ -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.Contains(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>
@@ -430,7 +430,7 @@ templ mrFooter(p MRPage) {
430 templ mrRail(p MRPage) {430 templ mrRail(p MRPage) {
431 <section aria-labelledby="r-files" class="r-files">431 <section aria-labelledby="r-files" class="r-files">
432 <h2 id="r-files">432 <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>
434 <button type="button" class="btn icon fold" data-js aria-expanded="true" title="fold the list of files" hx-on:click="ermiteMR.foldFiles()">434 <button type="button" class="btn icon fold" data-js aria-expanded="true" title="fold the list of files" hx-on:click="ermiteMR.foldFiles()">
435 <span class="ico i-chevron" aria-hidden="true"></span><span class="sr">fold the list of files</span>435 <span class="ico i-chevron" aria-hidden="true"></span><span class="sr">fold the list of files</span>
436 </button>436 </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 }
@@ -156,7 +156,7 @@ func TestMRPanelsAndMenus(t *testing.T) {
156 assert.Contains(t, footer, "more commits")156 assert.Contains(t, footer, "more commits")
157 assert.Contains(t, footer, "1/2 failed")157 assert.Contains(t, footer, "1/2 failed")
158 rail := renderToString(t, mrRail(p))158 rail := renderToString(t, mrRail(p))
159- assert.Contains(t, rail, "g f")159+ assert.Contains(t, rail, `<kbd aria-hidden="true">l</kbd>`)
160 for _, text := range []string{"Checks", "Commits", "Settings", "mr-checks"} {160 for _, text := range []string{"Checks", "Commits", "Settings", "mr-checks"} {
161 assert.NotContains(t, rail, text)161 assert.NotContains(t, rail, text)
162 }162 }
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 }
@@ -26,7 +26,7 @@ templ OverlayHelp(mr bool) {
26 <div><dt><kbd>g g</kbd> <kbd>G</kbd></dt><dd>first / last row</dd></div>26 <div><dt><kbd>g g</kbd> <kbd>G</kbd></dt><dd>first / last row</dd></div>
27 <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>27 <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>
28 <div><dt><kbd>b</kbd></dt><dd>{ "switch" } branch or tag</dd></div>28 <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>
30 if SignedIn(ctx) {30 if SignedIn(ctx) {
31 <div><dt><kbd>I</kbd></dt><dd>inbox</dd></div>31 <div><dt><kbd>I</kbd></dt><dd>inbox</dd></div>
32 }32 }
@@ -37,7 +37,7 @@ templ OverlayHelp(mr bool) {
37 <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>37 <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>
38 <div><dt><kbd>n</kbd></dt><dd>merge request: next note in the diff</dd></div>38 <div><dt><kbd>n</kbd></dt><dd>merge request: next note in the diff</dd></div>
39 if mr {39 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>
41 <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>41 <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>
42 <div><dt><kbd>R</kbd></dt><dd>merge request: mark the head read</dd></div>42 <div><dt><kbd>R</kbd></dt><dd>merge request: mark the head read</dd></div>
43 }43 }
M internal/adapters/web/overlays_templ.go +2 -2 generated file: collapsed
M internal/adapters/web/releases_test.go +1 -1
@@ -120,7 +120,7 @@ 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, `aria-keyshortcuts="g r"`)
124124 assert.Contains(t, body, "view=releases")
125125 }
126126 owner := f.request("GET", "/proj/releases?page=cursor%2B%2F%3D", "tok", "")
@@ -120,7 +120,7 @@ func TestReleasePages(t *testing.T) {
120 assert.Contains(t, body, "2026-10-12")120 assert.Contains(t, body, "2026-10-12")
121 assert.Contains(t, body, `<td>1</td>`)121 assert.Contains(t, body, `<td>1</td>`)
122 assert.Contains(t, body, `?page=next%2B%2F%3D`)122 assert.Contains(t, body, `?page=next%2B%2F%3D`)
123- assert.Contains(t, body, `aria-keyshortcuts="l"`)123+ assert.Contains(t, body, `aria-keyshortcuts="g r"`)
124 assert.Contains(t, body, "view=releases")124 assert.Contains(t, body, "view=releases")
125 }125 }
126 owner := f.request("GET", "/proj/releases?page=cursor%2B%2F%3D", "tok", "")126 owner := f.request("GET", "/proj/releases?page=cursor%2B%2F%3D", "tok", "")
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 {
@@ -144,9 +144,9 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La
144 }144 }
145 l.Sections = []Section{145 l.Sections = []Section{
146 mk("s", "Summary", repoHref(d.name()), "summary"),146 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"),
148 mk("c", "Commits", repoHref(d.name(), "commits", def), "commits"),148 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"),
150 }150 }
151 if s.cfg.MergeRequests != nil {151 if s.cfg.MergeRequests != nil {
152 mrs := mk("m", "Merge requests", mrBase(d.name()), "merge-requests")152 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
157 l.Sections = append(l.Sections, mk("i", "Issues", issuesBase(d.name()), "issues"))157 l.Sections = append(l.Sections, mk("i", "Issues", issuesBase(d.name()), "issues"))
158 }158 }
159 if s.cfg.Releases != nil {159 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"))
161 }161 }
162 l.Sections = s.withActions(l.Sections, d.name(), section)162 l.Sections = s.withActions(l.Sections, d.name(), section)
163 l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}163 l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}
164 164
165 l.Cmd = append(l.Cmd, CmdItem{Kind: "go", Label: "home", Href: "/"})165 l.Cmd = append(l.Cmd, CmdItem{Kind: "go", Label: "home", Href: "/"})
166 for _, sec := range l.Sections {166 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})
168 }168 }
169 if l.Ref != "" {169 if l.Ref != "" {
170 for _, b := range d.branches {170 for _, b := range d.branches {
M internal/adapters/web/shell.templ +3 -3
@@ -172,8 +172,8 @@ 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 }
176+ aria-keyshortcuts={ "g " + s.Key }
177177 if s.Current {
178178 aria-current="page"
179179 }
@@ -182,7 +182,7 @@ templ Header(l Layout) {
182182 if s.Count != "" {
183183 <span class="count">{ s.Count }<span class="sr">{ " open" }</span></span>
184184 }
185- <kbd aria-hidden="true">{ s.Key }</kbd>
185+ <kbd aria-hidden="true">{ "g " + s.Key }</kbd>
186186 </a>
187187 </li>
188188 }
@@ -172,8 +172,8 @@ templ Header(l Layout) {
172 <li>172 <li>
173 <a173 <a
174 href={ templ.SafeURL(s.Href) }174 href={ templ.SafeURL(s.Href) }
175- data-key={ s.Key }175+ data-key={ "g " + s.Key }
176- aria-keyshortcuts={ s.Key }176+ aria-keyshortcuts={ "g " + s.Key }
177 if s.Current {177 if s.Current {
178 aria-current="page"178 aria-current="page"
179 }179 }
@@ -182,7 +182,7 @@ templ Header(l Layout) {
182 if s.Count != "" {182 if s.Count != "" {
183 <span class="count">{ s.Count }<span class="sr">{ " open" }</span></span>183 <span class="count">{ s.Count }<span class="sr">{ " open" }</span></span>
184 }184 }
185- <kbd aria-hidden="true">{ s.Key }</kbd>185+ <kbd aria-hidden="true">{ "g " + s.Key }</kbd>
186 </a>186 </a>
187 </li>187 </li>
188 }188 }
M internal/adapters/web/shell_templ.go +6 -6 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;
@@ -112,7 +112,7 @@
112 $(sel)?.click();112 $(sel)?.click();
113 },113 },
114 tab(key) {114 tab(key) {
115- $(`.tabs [data-key=${key}]`)?.click();115+ $(`.tabs [data-key="g ${key}"]`)?.click();
116 },116 },
117 117
118 // The preference "single-key shortcuts" (ermiteKeys): the checkbox of the118 // The preference "single-key shortcuts" (ermiteKeys): the checkbox of the
@@ -612,11 +612,11 @@
612 612
613 // 1. Keys.613 // 1. Keys.
614 const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink',614 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',
616 // the merge request page (mr.js): the merge menu, mark read616 // the merge request page (mr.js): the merge menu, mark read
617 M: 'merge', R: 'read' };617 M: 'merge', R: 'read' };
618- // "g t" is the key "t": the tabs have one event each.618+ // Section keys are the second letter of the g sequence.
619- const goto = { h: 'gh', t: 't', c: 'c', r: 'r', m: 'm', i: 'i', g: 'top', f: 'gf' };619+ const goto = { h: 'gh', s: 's', f: 'f', c: 'c', b: 'b', m: 'm', i: 'i', r: 'r', a: 'a', g: 'top' };
620 let pending = 0;620 let pending = 0;
621 let pendingTimer = 0;621 let pendingTimer = 0;
622 addEventListener('keydown', (e) => {622 addEventListener('keydown', (e) => {
@@ -642,6 +642,15 @@
642 // behind it does not react (scripted clicks would go through inert).642 // behind it does not react (scripted clicks would go through inert).
643 if ($('dialog[open]') && !['?', 'b', ':', 'f', 'Enter'].includes(e.key)) { pending = 0; return; }643 if ($('dialog[open]') && !['?', 'b', ':', 'f', 'Enter'].includes(e.key)) { pending = 0; return; }
644 if (e.key === 'Enter' && e.target.closest('a, button, summary')) return;644 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+ }
645 if (pending) {654 if (pending) {
646 const name = goto[e.key];655 const name = goto[e.key];
647 pending = 0;656 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);
@@ -8,7 +8,7 @@
8 // on its button, 1-9 pick a mode, D toggles the branch deletion, Enter8 // on its button, 1-9 pick a mode, D toggles the branch deletion, Enter
9 // submits (the browser does it: the focus is on the button), Esc closes.9 // submits (the browser does it: the focus is on the button), Esc closes.
10 // R marks the head read. Nothing merges on one key.10 // 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 / k11+// 3. l puts the focus in the list of files (opening it when folded); j / k
12 // move in it, Enter opens the file, Esc goes back to the diff. The button12 // move in it, Enter opens the file, Esc goes back to the diff. The button
13 // of its header folds the list, and the choice is remembered.13 // of its header folds the list, and the choice is remembered.
14 //14 //
@@ -186,7 +186,7 @@ window.ermiteMR = (() => {
186 return true;186 return true;
187 }187 }
188 188
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.
190 const links = () => $$('.r-files .files > li:not([hidden]) > a');190 const links = () => $$('.r-files .files > li:not([hidden]) > a');
191 function focusFiles() {191 function focusFiles() {
192 foldFiles(false);192 foldFiles(false);

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 4

Tags 45

↑↓ moveenter openesc close