detached 4e3b6b3

fix(web): drop sequence aria-keyshortcuts and use fake timers in keyboard tests

commit
4e3b6b376a37926f4cf831499d2d1eff24b7b9ab
author
brice · · 2026-10-22 07:58 UTC
parent
a1f6bdf
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +87 -97
M internal/adapters/web/jstest/keyboard.test.mjs +38 -4
@@ -4,17 +4,25 @@ import { readFileSync } from 'node:fs';
44 import { test } from 'node:test';
55 import { runInNewContext } from 'node:vm';
66 const source = readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8');
7-function keyboard({ off = false, typing = false, dialog = false } = {}) {
7+function keyboard({ owner = true, off = false, typing = false, dialog = false } = {}) {
88 let handler;
99 const fired = [];
10+ const timers = new Map();
11+ let timerID = 0;
1012 runInNewContext(source.slice(source.indexOf(' const single ='), source.indexOf(' // 2. A [data-fire]')), {
1113 addEventListener: (_, fn) => { handler = fn; },
1214 window: { ermiteKeys: { blocks: () => off } },
1315 ermite: { fire: name => fired.push(name) },
14- $: selector => selector === '[data-inbox]' || (dialog && selector === 'dialog[open]'),
15- closeMenus: () => false, clearTimeout() {}, setTimeout() {},
16+ $: selector => (owner && selector === '[data-inbox]') || (dialog && selector === 'dialog[open]'),
17+ closeMenus: () => false,
18+ clearTimeout: id => timers.delete(id),
19+ setTimeout: fn => { timers.set(++timerID, fn); return timerID; },
1620 });
17- return { fired, press(key, shiftKey = false) {
21+ return { fired, expireTimers() {
22+ const callbacks = [...timers.values()];
23+ timers.clear();
24+ for (const callback of callbacks) callback();
25+ }, press(key, shiftKey = false) {
1826 handler({ key, shiftKey, target: { closest: selector => typing && selector.startsWith('input'), blur() {} }, preventDefault() {} });
1927 } };
2028 }
@@ -40,3 +48,29 @@ test('uppercase actions respect preference, fields and dialogs', () => {
4048 assert.deepEqual(k.fired, []);
4149 }
4250 });
51+
52+test('Shift+I opens inbox while issues need the g i sequence', () => {
53+ const k = keyboard();
54+ k.press('I', true); k.press('i');
55+ assert.deepEqual(k.fired, ['inbox']);
56+ k.press('g'); k.press('i');
57+ assert.deepEqual(k.fired, ['inbox', 'i']);
58+});
59+test('inbox shortcut respects preference, fields, dialogs, and owner target', () => {
60+ for (const options of [{ off: true }, { typing: true }, { dialog: true }, { owner: false }]) {
61+ const k = keyboard(options); k.press('I', true);
62+ assert.deepEqual(k.fired, [], JSON.stringify(options));
63+ }
64+});
65+test('shifted punctuation still opens help and the command palette', () => {
66+ const k = keyboard();
67+ k.press('?', true); k.press(':', true);
68+ assert.deepEqual(k.fired, ['help', 'cmd']);
69+});
70+test('g prefix expires without scheduling real timers', () => {
71+ const k = keyboard();
72+ k.press('g'); k.expireTimers(); k.press('s');
73+ assert.deepEqual(k.fired, []);
74+ k.press('g'); k.press('s');
75+ assert.deepEqual(k.fired, ['s']);
76+});
@@ -4,17 +4,25 @@ import { readFileSync } from 'node:fs';
4 import { test } from 'node:test';4 import { test } from 'node:test';
5 import { runInNewContext } from 'node:vm';5 import { runInNewContext } from 'node:vm';
6 const source = readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8');6 const source = readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8');
7-function keyboard({ off = false, typing = false, dialog = false } = {}) {7+function keyboard({ owner = true, off = false, typing = false, dialog = false } = {}) {
8 let handler;8 let handler;
9 const fired = [];9 const fired = [];
10+ const timers = new Map();
11+ let timerID = 0;
10 runInNewContext(source.slice(source.indexOf(' const single ='), source.indexOf(' // 2. A [data-fire]')), {12 runInNewContext(source.slice(source.indexOf(' const single ='), source.indexOf(' // 2. A [data-fire]')), {
11 addEventListener: (_, fn) => { handler = fn; },13 addEventListener: (_, fn) => { handler = fn; },
12 window: { ermiteKeys: { blocks: () => off } },14 window: { ermiteKeys: { blocks: () => off } },
13 ermite: { fire: name => fired.push(name) },15 ermite: { fire: name => fired.push(name) },
14- $: selector => selector === '[data-inbox]' || (dialog && selector === 'dialog[open]'),16+ $: selector => (owner && selector === '[data-inbox]') || (dialog && selector === 'dialog[open]'),
15- closeMenus: () => false, clearTimeout() {}, setTimeout() {},17+ closeMenus: () => false,
18+ clearTimeout: id => timers.delete(id),
19+ setTimeout: fn => { timers.set(++timerID, fn); return timerID; },
16 });20 });
17- return { fired, press(key, shiftKey = false) {21+ return { fired, expireTimers() {
22+ const callbacks = [...timers.values()];
23+ timers.clear();
24+ for (const callback of callbacks) callback();
25+ }, press(key, shiftKey = false) {
18 handler({ key, shiftKey, target: { closest: selector => typing && selector.startsWith('input'), blur() {} }, preventDefault() {} });26 handler({ key, shiftKey, target: { closest: selector => typing && selector.startsWith('input'), blur() {} }, preventDefault() {} });
19 } };27 } };
20 }28 }
@@ -40,3 +48,29 @@ test('uppercase actions respect preference, fields and dialogs', () => {
40 assert.deepEqual(k.fired, []);48 assert.deepEqual(k.fired, []);
41 }49 }
42 });50 });
51+
52+test('Shift+I opens inbox while issues need the g i sequence', () => {
53+ const k = keyboard();
54+ k.press('I', true); k.press('i');
55+ assert.deepEqual(k.fired, ['inbox']);
56+ k.press('g'); k.press('i');
57+ assert.deepEqual(k.fired, ['inbox', 'i']);
58+});
59+test('inbox shortcut respects preference, fields, dialogs, and owner target', () => {
60+ for (const options of [{ off: true }, { typing: true }, { dialog: true }, { owner: false }]) {
61+ const k = keyboard(options); k.press('I', true);
62+ assert.deepEqual(k.fired, [], JSON.stringify(options));
63+ }
64+});
65+test('shifted punctuation still opens help and the command palette', () => {
66+ const k = keyboard();
67+ k.press('?', true); k.press(':', true);
68+ assert.deepEqual(k.fired, ['help', 'cmd']);
69+});
70+test('g prefix expires without scheduling real timers', () => {
71+ const k = keyboard();
72+ k.press('g'); k.expireTimers(); k.press('s');
73+ assert.deepEqual(k.fired, []);
74+ k.press('g'); k.press('s');
75+ assert.deepEqual(k.fired, ['s']);
76+});
D internal/adapters/web/jstest/keys.test.mjs +0 -31
@@ -1,31 +0,0 @@
1-// Exercise the production keydown handler without loading the rest of the page.
2-import assert from 'node:assert/strict';
3-import { readFileSync } from 'node:fs';
4-import { test } from 'node:test';
5-const src = readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8');
6-const block = src.slice(src.indexOf(' // 1. Keys.'), src.indexOf(' // 2. A [data-fire]'));
7-
8-function page({ owner = true, off = false, typing = false, dialog = false } = {}) {
9- let handler;
10- const fired = [];
11- const window = { ermiteKeys: { blocks: () => off } };
12- const $ = (s) => s === 'dialog[open]' ? dialog : s === '[data-inbox]' ? owner : null;
13- new Function('window', '$', 'ermite', 'addEventListener', block)(window, $, { fire: (n) => fired.push(n) }, (_, f) => { handler = f; });
14- return (key) => {
15- const e = { key, shiftKey: key === 'I', preventDefault() {}, target: { closest: (s) => s.startsWith('input') && typing } };
16- handler(e);
17- return fired;
18- };
19-}
20-test('Shift+I opens inbox while issues need the g i sequence', () => {
21- const press = page();
22- assert.deepEqual(press('I'), ['inbox']);
23- assert.deepEqual(press('i'), ['inbox']);
24- press('g');
25- assert.deepEqual(press('i'), ['inbox', 'i']);
26-});
27-test('inbox shortcut respects preference, fields, dialogs, and owner target', () => {
28- for (const options of [{ off: true }, { typing: true }, { dialog: true }, { owner: false }]) {
29- assert.deepEqual(page(options)('I'), [], JSON.stringify(options));
30- }
31-});
@@ -1,31 +0,0 @@
1-// Exercise the production keydown handler without loading the rest of the page.
2-import assert from 'node:assert/strict';
3-import { readFileSync } from 'node:fs';
4-import { test } from 'node:test';
5-const src = readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8');
6-const block = src.slice(src.indexOf(' // 1. Keys.'), src.indexOf(' // 2. A [data-fire]'));
7-
8-function page({ owner = true, off = false, typing = false, dialog = false } = {}) {
9- let handler;
10- const fired = [];
11- const window = { ermiteKeys: { blocks: () => off } };
12- const $ = (s) => s === 'dialog[open]' ? dialog : s === '[data-inbox]' ? owner : null;
13- new Function('window', '$', 'ermite', 'addEventListener', block)(window, $, { fire: (n) => fired.push(n) }, (_, f) => { handler = f; });
14- return (key) => {
15- const e = { key, shiftKey: key === 'I', preventDefault() {}, target: { closest: (s) => s.startsWith('input') && typing } };
16- handler(e);
17- return fired;
18- };
19-}
20-test('Shift+I opens inbox while issues need the g i sequence', () => {
21- const press = page();
22- assert.deepEqual(press('I'), ['inbox']);
23- assert.deepEqual(press('i'), ['inbox']);
24- press('g');
25- assert.deepEqual(press('i'), ['inbox', 'i']);
26-});
27-test('inbox shortcut respects preference, fields, dialogs, and owner target', () => {
28- for (const options of [{ off: true }, { typing: true }, { dialog: true }, { owner: false }]) {
29- assert.deepEqual(page(options)('I'), [], JSON.stringify(options));
30- }
31-});
M internal/adapters/web/keys_test.go +1 -1
@@ -17,7 +17,7 @@ func TestSectionShortcutSequences(t *testing.T) {
1717 bindings := renderToString(t, Keys())
1818 for _, section := range sections {
1919 assert.Contains(t, header, `data-key="g `+section.Key+`"`)
20- assert.Contains(t, header, `aria-keyshortcuts="g `+section.Key+`"`)
20+ assert.NotContains(t, header, `aria-keyshortcuts="g `+section.Key+`"`)
2121 assert.Contains(t, header, `<kbd aria-hidden="true">g `+section.Key+`</kbd>`)
2222 assert.Contains(t, bindings, "ermite.tab('"+section.Key+"')")
2323 }
@@ -17,7 +17,7 @@ func TestSectionShortcutSequences(t *testing.T) {
17 bindings := renderToString(t, Keys())17 bindings := renderToString(t, Keys())
18 for _, section := range sections {18 for _, section := range sections {
19 assert.Contains(t, header, `data-key="g `+section.Key+`"`)19 assert.Contains(t, header, `data-key="g `+section.Key+`"`)
20- assert.Contains(t, header, `aria-keyshortcuts="g `+section.Key+`"`)20+ assert.NotContains(t, header, `aria-keyshortcuts="g `+section.Key+`"`)
21 assert.Contains(t, header, `<kbd aria-hidden="true">g `+section.Key+`</kbd>`)21 assert.Contains(t, header, `<kbd aria-hidden="true">g `+section.Key+`</kbd>`)
22 assert.Contains(t, bindings, "ermite.tab('"+section.Key+"')")22 assert.Contains(t, bindings, "ermite.tab('"+section.Key+"')")
23 }23 }
M internal/adapters/web/releases_test.go +2 -1
@@ -120,7 +120,8 @@ func TestReleasePages(t *testing.T) {
120120 assert.Contains(t, body, "2026-10-12")
121121 assert.Contains(t, body, `<td>1</td>`)
122122 assert.Contains(t, body, `?page=next%2B%2F%3D`)
123- assert.Contains(t, body, `aria-keyshortcuts="g r"`)
123+ assert.Contains(t, body, `data-key="g r"`)
124+ assert.NotContains(t, body, `aria-keyshortcuts="g r"`)
124125 assert.Contains(t, body, "view=releases")
125126 }
126127 owner := f.request("GET", "/proj/releases?page=cursor%2B%2F%3D", "tok", "")
@@ -120,7 +120,8 @@ 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="g r"`)123+ assert.Contains(t, body, `data-key="g r"`)
124+ assert.NotContains(t, body, `aria-keyshortcuts="g r"`)
124 assert.Contains(t, body, "view=releases")125 assert.Contains(t, body, "view=releases")
125 }126 }
126 owner := f.request("GET", "/proj/releases?page=cursor%2B%2F%3D", "tok", "")127 owner := f.request("GET", "/proj/releases?page=cursor%2B%2F%3D", "tok", "")
M internal/adapters/web/shell.templ +0 -1
@@ -173,7 +173,6 @@ templ Header(l Layout) {
173173 <a
174174 href={ templ.SafeURL(s.Href) }
175175 data-key={ "g " + s.Key }
176- aria-keyshortcuts={ "g " + s.Key }
177176 if s.Current {
178177 aria-current="page"
179178 }
@@ -173,7 +173,6 @@ templ Header(l Layout) {
173 <a173 <a
174 href={ templ.SafeURL(s.Href) }174 href={ templ.SafeURL(s.Href) }
175 data-key={ "g " + s.Key }175 data-key={ "g " + s.Key }
176- aria-keyshortcuts={ "g " + s.Key }
177 if s.Current {176 if s.Current {
178 aria-current="page"177 aria-current="page"
179 }178 }
M internal/adapters/web/shell_templ.go +46 -59 generated file: collapsed

Keyboard shortcuts

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

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

Branches 2

Tags 45

↑↓ moveenter openesc close