feat(web): navigate sections with g sequences and fix uppercase action keys
- commit
- c3ae83098b2f23037621ad83a0ae0d4c51a4118b
- author
- brice · · 2026-10-22 07:16 UTC
- committer
- brice · · 2026-10-22 08:51 UTC
- parent
- e08572c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 139 | 139 | - [Issues](docs/issues.md) | |
| 140 | 140 | - [Commit signatures](docs/commit-signatures.md): SSH and OpenPGP badges, trusted keys | |
| 141 | 141 | - [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 | 143 | - [CI](docs/ci.md): runners, trust, concurrency, reusable workflows | |
| 143 | 144 | - [CI events](docs/ci-events.md) | |
| 144 | 145 | - [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 keys | 140 | - [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 links | 141 | - [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 workflows | 143 | - [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 | |||
| 50 | 50 | ||
| 51 | 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 | 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 | ||
| @@ -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 | |||
| 142 | 142 | `N+` when the branch list is capped: the reader's existing count operation counts all reachable | |
| 143 | 143 | ancestors, not the source-minus-target range. | |
| 144 | 144 | ||
| 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 | |
| 146 | 146 | are read-only information in the More (⋯) menu, available to every viewer. | |
| 147 | 147 | Signing-key information is also there when a merge box offers signed modes; | |
| 148 | 148 | 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 reachable | 142 | `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 settings | 145 | + | 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) { | |||
| 100 | 100 | assert.Contains(t, body, "crash <b>on</b> start") | |
| 101 | 101 | assert.Contains(t, body, `href="/proj/issues?page=tok2"`, "older page") | |
| 102 | 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 | 104 | assert.NotContains(t, body, "New issue", "no form for an anonymous visitor") | |
| 105 | 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 | 170 | summary := off.do("GET", "/proj", false, nil) | |
| 171 | 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 | 174 | // The mode is unset in the settings: off as well. | |
| 175 | 175 | unset := newIssueRig(t, "", false) | |
| @@ -257,7 +257,7 @@ func TestIssueRoutesAbsentWithoutService(t *testing.T) { | |||
| 257 | 257 | } | |
| 258 | 258 | rec := httptest.NewRecorder() | |
| 259 | 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 | 263 | func TestIssueNumberRedirects(t *testing.T) { | |
| @@ -100,7 +100,7 @@ func TestIssueList(t *testing.T) { | |||||
| 100 | assert.Contains(t, body, "crash <b>on</b> start") | 100 | assert.Contains(t, body, "crash <b>on</b> 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() { | |||
| 23 | 23 | k-filter from:body -> ermite.focusFilter(); | |
| 24 | 24 | k-gh from:body -> ermite.go('.brand'); | |
| 25 | 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 | 29 | k-c from:body -> ermite.tab('c'); | |
| 28 | 30 | k-r from:body -> ermite.tab('r'); | |
| 29 | 31 | k-m from:body -> ermite.tab('m'); | |
| @@ -39,7 +41,6 @@ templ Keys() { | |||
| 39 | 41 | k-permalink from:body -> ermite.permalink(); | |
| 40 | 42 | k-merge from:body -> window.ermiteMR?.openMerge(); | |
| 41 | 43 | k-read from:body -> window.ermiteMR?.markRead(); | |
| 42 | - | k-gf from:body -> window.ermiteMR?.focusFiles(); | |
| 43 | 44 | k-keys from:body -> ermite.toggleKeys()" | |
| 44 | 45 | ></div> | |
| 45 | 46 | } | |
| @@ -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) { | |||
| 430 | 430 | templ mrRail(p MRPage) { | |
| 431 | 431 | <section aria-labelledby="r-files" class="r-files"> | |
| 432 | 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 | 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 | 435 | <span class="ico i-chevron" aria-hidden="true"></span><span class="sr">fold the list of files</span> | |
| 436 | 436 | </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
| @@ -2214,7 +2214,7 @@ func mrRail(p MRPage) templ.Component { | |||
| 2214 | 2214 | if templ_7745c5c3_Err != nil { | |
| 2215 | 2215 | return templ_7745c5c3_Err | |
| 2216 | 2216 | } | |
| 2217 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 208, "</span><kbd aria-hidden=\"true\">g f</kbd> <button type=\"button\" class=\"btn icon fold\" data-js aria-expanded=\"true\" title=\"fold the list of files\" hx-on:click=\"ermiteMR.foldFiles()\"><span class=\"ico i-chevron\" aria-hidden=\"true\"></span><span class=\"sr\">fold the list of files</span></button></h2>") | |
| 2217 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 208, "</span><kbd aria-hidden=\"true\">l</kbd> <button type=\"button\" class=\"btn icon fold\" data-js aria-expanded=\"true\" title=\"fold the list of files\" hx-on:click=\"ermiteMR.foldFiles()\"><span class=\"ico i-chevron\" aria-hidden=\"true\"></span><span class=\"sr\">fold the list of files</span></button></h2>") | |
| 2218 | 2218 | if templ_7745c5c3_Err != nil { | |
| 2219 | 2219 | return templ_7745c5c3_Err | |
| 2220 | 2220 | } | |
| @@ -2214,7 +2214,7 @@ func mrRail(p MRPage) templ.Component { | |||||
| 2214 | if templ_7745c5c3_Err != nil { | 2214 | if templ_7745c5c3_Err != nil { | ||
| 2215 | return templ_7745c5c3_Err | 2215 | return templ_7745c5c3_Err | ||
| 2216 | } | 2216 | } | ||
| 2217 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 208, "</span><kbd aria-hidden=\"true\">g f</kbd> <button type=\"button\" class=\"btn icon fold\" data-js aria-expanded=\"true\" title=\"fold the list of files\" hx-on:click=\"ermiteMR.foldFiles()\"><span class=\"ico i-chevron\" aria-hidden=\"true\"></span><span class=\"sr\">fold the list of files</span></button></h2>") | 2217 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 208, "</span><kbd aria-hidden=\"true\">l</kbd> <button type=\"button\" class=\"btn icon fold\" data-js aria-expanded=\"true\" title=\"fold the list of files\" hx-on:click=\"ermiteMR.foldFiles()\"><span class=\"ico i-chevron\" aria-hidden=\"true\"></span><span class=\"sr\">fold the list of files</span></button></h2>") |
| 2218 | if templ_7745c5c3_Err != nil { | 2218 | if templ_7745c5c3_Err != nil { | ||
| 2219 | return templ_7745c5c3_Err | 2219 | return templ_7745c5c3_Err | ||
| 2220 | } | 2220 | } | ||
M internal/adapters/web/mr_test.go +1 -1
| @@ -157,7 +157,7 @@ func TestMRPanelsAndMenus(t *testing.T) { | |||
| 157 | 157 | assert.Contains(t, footer, "more commits") | |
| 158 | 158 | assert.Contains(t, footer, "1/2 failed") | |
| 159 | 159 | rail := renderToString(t, mrRail(p)) | |
| 160 | - | assert.Contains(t, rail, "g f") | |
| 160 | + | assert.Contains(t, rail, `<kbd aria-hidden="true">l</kbd>`) | |
| 161 | 161 | for _, text := range []string{"Checks", "Commits", "Settings", "mr-checks"} { | |
| 162 | 162 | assert.NotContains(t, rail, text) | |
| 163 | 163 | } | |
| @@ -157,7 +157,7 @@ func TestMRPanelsAndMenus(t *testing.T) { | |||||
| 157 | assert.Contains(t, footer, "more commits") | 157 | assert.Contains(t, footer, "more commits") | ||
| 158 | assert.Contains(t, footer, "1/2 failed") | 158 | assert.Contains(t, footer, "1/2 failed") | ||
| 159 | rail := renderToString(t, mrRail(p)) | 159 | rail := renderToString(t, mrRail(p)) | ||
| 160 | - | assert.Contains(t, rail, "g f") | 160 | + | assert.Contains(t, rail, `<kbd aria-hidden="true">l</kbd>`) |
| 161 | for _, text := range []string{"Checks", "Commits", "Settings", "mr-checks"} { | 161 | for _, text := range []string{"Checks", "Commits", "Settings", "mr-checks"} { | ||
| 162 | assert.NotContains(t, rail, text) | 162 | assert.NotContains(t, rail, text) | ||
| 163 | } | 163 | } | ||
M internal/adapters/web/overlays.templ +2 -2
| @@ -26,7 +26,7 @@ templ OverlayHelp(mr bool) { | |||
| 26 | 26 | <div><dt><kbd>g g</kbd> <kbd>G</kbd></dt><dd>first / last row</dd></div> | |
| 27 | 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 | 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 & 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 & tags, merge requests, issues, releases, actions (when available)</dd></div> | |
| 30 | 30 | if SignedIn(ctx) { | |
| 31 | 31 | <div><dt><kbd>I</kbd></dt><dd>inbox</dd></div> | |
| 32 | 32 | } | |
| @@ -37,7 +37,7 @@ templ OverlayHelp(mr bool) { | |||
| 37 | 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 | 38 | <div><dt><kbd>n</kbd></dt><dd>merge request: next note in the diff</dd></div> | |
| 39 | 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 | 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 | 42 | <div><dt><kbd>R</kbd></dt><dd>merge request: mark the head read</dd></div> | |
| 43 | 43 | } | |
| @@ -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 & 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 & 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) { | |||
| 120 | 120 | assert.Contains(t, body, "2026-10-12") | |
| 121 | 121 | assert.Contains(t, body, `<td>1</td>`) | |
| 122 | 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 | 124 | assert.Contains(t, body, "view=releases") | |
| 125 | 125 | } | |
| 126 | 126 | 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 | |||
| 144 | 144 | } | |
| 145 | 145 | l.Sections = []Section{ | |
| 146 | 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 | 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 | 151 | if s.cfg.MergeRequests != nil { | |
| 152 | 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 | 157 | l.Sections = append(l.Sections, mk("i", "Issues", issuesBase(d.name()), "issues")) | |
| 158 | 158 | } | |
| 159 | 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 | 162 | l.Sections = s.withActions(l.Sections, d.name(), section) | |
| 163 | 163 | l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}} | |
| 164 | 164 | ||
| 165 | 165 | l.Cmd = append(l.Cmd, CmdItem{Kind: "go", Label: "home", Href: "/"}) | |
| 166 | 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 | 169 | if l.Ref != "" { | |
| 170 | 170 | 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) { | |||
| 172 | 172 | <li> | |
| 173 | 173 | <a | |
| 174 | 174 | 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 } | |
| 177 | 177 | if s.Current { | |
| 178 | 178 | aria-current="page" | |
| 179 | 179 | } | |
| @@ -182,7 +182,7 @@ templ Header(l Layout) { | |||
| 182 | 182 | if s.Count != "" { | |
| 183 | 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 | 186 | </a> | |
| 187 | 187 | </li> | |
| 188 | 188 | } | |
| @@ -172,8 +172,8 @@ templ Header(l Layout) { | |||||
| 172 | <li> | 172 | <li> | ||
| 173 | <a | 173 | <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 @@ | |||
| 112 | 112 | $(sel)?.click(); | |
| 113 | 113 | }, | |
| 114 | 114 | tab(key) { | |
| 115 | - | $(`.tabs [data-key=${key}]`)?.click(); | |
| 115 | + | $(`.tabs [data-key="g ${key}"]`)?.click(); | |
| 116 | 116 | }, | |
| 117 | 117 | ||
| 118 | 118 | // The preference "single-key shortcuts" (ermiteKeys): the checkbox of the | |
| @@ -617,11 +617,11 @@ | |||
| 617 | 617 | ||
| 618 | 618 | // 1. Keys. | |
| 619 | 619 | const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink', | |
| 620 | - | 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', | |
| 620 | + | I: 'inbox', n: 'next', h: 'left', l: 'right', d: 'split', u: 'unified', x: 'collapse', w: 'wide', e: 'expand', E: 'expandAll', Enter: 'open', Escape: 'esc', | |
| 621 | 621 | // the merge request page (mr.js): the merge menu, mark read | |
| 622 | 622 | M: 'merge', R: 'read' }; | |
| 623 | - | // "g t" is the key "t": the tabs have one event each. | |
| 624 | - | const goto = { h: 'gh', t: 't', c: 'c', r: 'r', m: 'm', i: 'i', g: 'top', f: 'gf' }; | |
| 623 | + | // Section keys are the second letter of the g sequence. | |
| 624 | + | const goto = { h: 'gh', s: 's', f: 'f', c: 'c', b: 'b', m: 'm', i: 'i', r: 'r', a: 'a', g: 'top' }; | |
| 625 | 625 | let pending = 0; | |
| 626 | 626 | let pendingTimer = 0; | |
| 627 | 627 | addEventListener('keydown', (e) => { | |
| @@ -647,6 +647,15 @@ | |||
| 647 | 647 | // behind it does not react (scripted clicks would go through inert). | |
| 648 | 648 | if ($('dialog[open]') && !['?', 'b', ':', 'f', 'Enter'].includes(e.key)) { pending = 0; return; } | |
| 649 | 649 | if (e.key === 'Enter' && e.target.closest('a, button, summary')) return; | |
| 650 | + | // Some layouts report lowercase e.key even with Shift. Uppercase actions | |
| 651 | + | // also interrupt a pending g rather than being swallowed by it. | |
| 652 | + | const actionKey = e.shiftKey ? e.key.toUpperCase() : e.key; | |
| 653 | + | if (['G', 'M', 'R', 'E', 'I'].includes(actionKey)) { | |
| 654 | + | pending = 0; | |
| 655 | + | if (actionKey === 'I' && !$('[data-inbox]')) return; | |
| 656 | + | e.preventDefault(); | |
| 657 | + | return ermite.fire(single[actionKey]); | |
| 658 | + | } | |
| 650 | 659 | if (pending) { | |
| 651 | 660 | const name = goto[e.key]; | |
| 652 | 661 | 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 the | 118 | // The preference "single-key shortcuts" (ermiteKeys): the checkbox of the | ||
| @@ -617,11 +617,11 @@ | |||||
| 617 | 617 | ||||
| 618 | // 1. Keys. | 618 | // 1. Keys. | ||
| 619 | const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink', | 619 | const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink', | ||
| 620 | - | 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', | 620 | + | I: 'inbox', n: 'next', h: 'left', l: 'right', d: 'split', u: 'unified', x: 'collapse', w: 'wide', e: 'expand', E: 'expandAll', Enter: 'open', Escape: 'esc', |
| 621 | // the merge request page (mr.js): the merge menu, mark read | 621 | // the merge request page (mr.js): the merge menu, mark read | ||
| 622 | M: 'merge', R: 'read' }; | 622 | M: 'merge', R: 'read' }; | ||
| 623 | - | // "g t" is the key "t": the tabs have one event each. | 623 | + | // Section keys are the second letter of the g sequence. |
| 624 | - | const goto = { h: 'gh', t: 't', c: 'c', r: 'r', m: 'm', i: 'i', g: 'top', f: 'gf' }; | 624 | + | const goto = { h: 'gh', s: 's', f: 'f', c: 'c', b: 'b', m: 'm', i: 'i', r: 'r', a: 'a', g: 'top' }; |
| 625 | let pending = 0; | 625 | let pending = 0; | ||
| 626 | let pendingTimer = 0; | 626 | let pendingTimer = 0; | ||
| 627 | addEventListener('keydown', (e) => { | 627 | addEventListener('keydown', (e) => { | ||
| @@ -647,6 +647,15 @@ | |||||
| 647 | // behind it does not react (scripted clicks would go through inert). | 647 | // behind it does not react (scripted clicks would go through inert). | ||
| 648 | if ($('dialog[open]') && !['?', 'b', ':', 'f', 'Enter'].includes(e.key)) { pending = 0; return; } | 648 | if ($('dialog[open]') && !['?', 'b', ':', 'f', 'Enter'].includes(e.key)) { pending = 0; return; } | ||
| 649 | if (e.key === 'Enter' && e.target.closest('a, button, summary')) return; | 649 | if (e.key === 'Enter' && e.target.closest('a, button, summary')) return; | ||
| 650 | + | // Some layouts report lowercase e.key even with Shift. Uppercase actions | |||
| 651 | + | // also interrupt a pending g rather than being swallowed by it. | |||
| 652 | + | const actionKey = e.shiftKey ? e.key.toUpperCase() : e.key; | |||
| 653 | + | if (['G', 'M', 'R', 'E', 'I'].includes(actionKey)) { | |||
| 654 | + | pending = 0; | |||
| 655 | + | if (actionKey === 'I' && !$('[data-inbox]')) return; | |||
| 656 | + | e.preventDefault(); | |||
| 657 | + | return ermite.fire(single[actionKey]); | |||
| 658 | + | } | |||
| 650 | if (pending) { | 659 | if (pending) { | ||
| 651 | const name = goto[e.key]; | 660 | const name = goto[e.key]; | ||
| 652 | pending = 0; | 661 | pending = 0; | ||
M internal/adapters/web/static/js/mr.js +2 -2
| @@ -8,7 +8,7 @@ | |||
| 8 | 8 | // on its button, 1-9 pick a mode, D toggles the branch deletion, Enter | |
| 9 | 9 | // submits (the browser does it: the focus is on the button), Esc closes. | |
| 10 | 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 / k | |
| 11 | + | // 3. l puts the focus in the list of files (opening it when folded); j / k | |
| 12 | 12 | // move in it, Enter opens the file, Esc goes back to the diff. The button | |
| 13 | 13 | // of its header folds the list, and the choice is remembered. | |
| 14 | 14 | // | |
| @@ -186,7 +186,7 @@ window.ermiteMR = (() => { | |||
| 186 | 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 | 190 | const links = () => $$('.r-files .files > li:not([hidden]) > a'); | |
| 191 | 191 | function focusFiles() { | |
| 192 | 192 | foldFiles(false); | |
| @@ -8,7 +8,7 @@ | |||||
| 8 | // on its button, 1-9 pick a mode, D toggles the branch deletion, Enter | 8 | // 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 / k | 11 | + | // 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 button | 12 | // 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); | ||