detached 53b75ef

fix(web): open the top file hit on an Enter typed before the first search frame

commit
53b75efd0689becd8fbcaaeebb4d9847f28a6eb4
author
brice · · 2026-10-12 05:22 UTC
parent
570c3c0
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +80 -7
M internal/adapters/web/jstest/dom.mjs +16 -4
@@ -146,7 +146,14 @@ export class El {
146146 click() { dom.clicked.push(this); }
147147 }
148148
149-export const dom = { clicked: [], body: null };
149+export const dom = { clicked: [], body: null, holdFrames: false, frames: new Map(), frameID: 0 };
150+
151+// flushFrames runs the frames held since the last flush.
152+export function flushFrames() {
153+ const fs = [...dom.frames.values()];
154+ dom.frames.clear();
155+ fs.forEach((f) => f());
156+}
150157
151158 // installDom puts the globals files.js expects. parseHTML(text) answers the
152159 // nodes that a template's innerHTML makes.
@@ -171,9 +178,14 @@ export function installDom({ parseHTML } = {}) {
171178 globalThis.window = globalThis;
172179 globalThis.document = doc;
173180 globalThis.location = { href: '' };
174- // A frame runs at once; a test still orders its steps around the awaits.
175- globalThis.requestAnimationFrame = (f) => { f(); return 0; };
176- globalThis.cancelAnimationFrame = () => {};
181+ // A frame runs at once, unless a test holds the frames (dom.holdFrames) to
182+ // act between a keystroke and its frame, as in a browser; flushFrames runs them.
183+ globalThis.requestAnimationFrame = (f) => {
184+ if (!dom.holdFrames) { f(); return 0; }
185+ dom.frames.set(++dom.frameID, f);
186+ return dom.frameID;
187+ };
188+ globalThis.cancelAnimationFrame = (id) => { dom.frames.delete(id); };
177189 return doc;
178190 }
179191
@@ -146,7 +146,14 @@ export class El {
146 click() { dom.clicked.push(this); }146 click() { dom.clicked.push(this); }
147 }147 }
148 148
149-export const dom = { clicked: [], body: null };149+export const dom = { clicked: [], body: null, holdFrames: false, frames: new Map(), frameID: 0 };
150+
151+// flushFrames runs the frames held since the last flush.
152+export function flushFrames() {
153+ const fs = [...dom.frames.values()];
154+ dom.frames.clear();
155+ fs.forEach((f) => f());
156+}
150 157
151 // installDom puts the globals files.js expects. parseHTML(text) answers the158 // installDom puts the globals files.js expects. parseHTML(text) answers the
152 // nodes that a template's innerHTML makes.159 // nodes that a template's innerHTML makes.
@@ -171,9 +178,14 @@ export function installDom({ parseHTML } = {}) {
171 globalThis.window = globalThis;178 globalThis.window = globalThis;
172 globalThis.document = doc;179 globalThis.document = doc;
173 globalThis.location = { href: '' };180 globalThis.location = { href: '' };
174- // A frame runs at once; a test still orders its steps around the awaits.181+ // A frame runs at once, unless a test holds the frames (dom.holdFrames) to
175- globalThis.requestAnimationFrame = (f) => { f(); return 0; };182+ // act between a keystroke and its frame, as in a browser; flushFrames runs them.
176- globalThis.cancelAnimationFrame = () => {};183+ globalThis.requestAnimationFrame = (f) => {
184+ if (!dom.holdFrames) { f(); return 0; }
185+ dom.frames.set(++dom.frameID, f);
186+ return dom.frameID;
187+ };
188+ globalThis.cancelAnimationFrame = (id) => { dom.frames.delete(id); };
177 return doc;189 return doc;
178 }190 }
179 191
M internal/adapters/web/jstest/finder.test.mjs +52 -1
@@ -3,7 +3,7 @@
33 // node --test 'internal/adapters/web/jstest/*.test.mjs'
44 import assert from 'node:assert/strict';
55 import { test } from 'node:test';
6-import { dom, el, installDom, settle } from './dom.mjs';
6+import { dom, el, flushFrames, installDom, settle } from './dom.mjs';
77
88 installDom();
99 await import('../static/js/files.js');
@@ -199,6 +199,57 @@ test('the palette lists the file hits under its entries', async () => {
199199 assert.equal(p.cmdList.children.length, 1);
200200 });
201201
202+test('Enter typed before the frame of the query runs opens the top hit', async () => {
203+ const p = page();
204+ release();
205+ await F.load(); // the path list is loaded: only the frame is pending
206+ dom.holdFrames = true;
207+ try {
208+ p.q.value = 'maingo';
209+ F.input(p.q);
210+ const e = key('Enter');
211+ F.key(p.q, e);
212+ assert.equal(e.prevented, true);
213+ assert.equal(dom.clicked.length, 0, 'nothing to open before the frame');
214+ flushFrames();
215+ await settle();
216+ assert.deepEqual(dom.clicked.map((a) => a.href), ['/blob/main/cmd/ermite/main.go']);
217+ } finally {
218+ dom.holdFrames = false;
219+ }
220+});
221+
222+test('the palette keeps one search per frame and drops a search when it closes', async () => {
223+ const p = page();
224+ release();
225+ await F.load();
226+ dom.holdFrames = true;
227+ try {
228+ for (const q of ['t', 'tr', 'tre', 'tree']) {
229+ p.cmdQ.value = q;
230+ F.palette(p.cmdQ);
231+ }
232+ assert.equal(dom.frames.size, 1, 'the keystrokes of a frame share one search');
233+ flushFrames();
234+ await settle();
235+ assert.equal(p.cmdList.children.filter((li) => li.className === 'finder-hit').length, 4);
236+
237+ p.cmdQ.value = 'readme';
238+ F.palette(p.cmdQ);
239+ F.paletteClose();
240+ assert.equal(dom.frames.size, 0, 'closing cancels the pending frame');
241+
242+ // A search past its frame, still awaiting the list, is dropped at close.
243+ F.palette(p.cmdQ);
244+ flushFrames();
245+ F.paletteClose();
246+ await settle();
247+ assert.equal(p.cmdList.children.filter((li) => li.className === 'finder-hit').length, 0);
248+ } finally {
249+ dom.holdFrames = false;
250+ }
251+});
252+
202253 test('the palette drops the results of a query that is stale', async () => {
203254 const p = page();
204255 p.cmdQ.value = 'tree';
@@ -3,7 +3,7 @@
3 // node --test 'internal/adapters/web/jstest/*.test.mjs'3 // node --test 'internal/adapters/web/jstest/*.test.mjs'
4 import assert from 'node:assert/strict';4 import assert from 'node:assert/strict';
5 import { test } from 'node:test';5 import { test } from 'node:test';
6-import { dom, el, installDom, settle } from './dom.mjs';6+import { dom, el, flushFrames, installDom, settle } from './dom.mjs';
7 7
8 installDom();8 installDom();
9 await import('../static/js/files.js');9 await import('../static/js/files.js');
@@ -199,6 +199,57 @@ test('the palette lists the file hits under its entries', async () => {
199 assert.equal(p.cmdList.children.length, 1);199 assert.equal(p.cmdList.children.length, 1);
200 });200 });
201 201
202+test('Enter typed before the frame of the query runs opens the top hit', async () => {
203+ const p = page();
204+ release();
205+ await F.load(); // the path list is loaded: only the frame is pending
206+ dom.holdFrames = true;
207+ try {
208+ p.q.value = 'maingo';
209+ F.input(p.q);
210+ const e = key('Enter');
211+ F.key(p.q, e);
212+ assert.equal(e.prevented, true);
213+ assert.equal(dom.clicked.length, 0, 'nothing to open before the frame');
214+ flushFrames();
215+ await settle();
216+ assert.deepEqual(dom.clicked.map((a) => a.href), ['/blob/main/cmd/ermite/main.go']);
217+ } finally {
218+ dom.holdFrames = false;
219+ }
220+});
221+
222+test('the palette keeps one search per frame and drops a search when it closes', async () => {
223+ const p = page();
224+ release();
225+ await F.load();
226+ dom.holdFrames = true;
227+ try {
228+ for (const q of ['t', 'tr', 'tre', 'tree']) {
229+ p.cmdQ.value = q;
230+ F.palette(p.cmdQ);
231+ }
232+ assert.equal(dom.frames.size, 1, 'the keystrokes of a frame share one search');
233+ flushFrames();
234+ await settle();
235+ assert.equal(p.cmdList.children.filter((li) => li.className === 'finder-hit').length, 4);
236+
237+ p.cmdQ.value = 'readme';
238+ F.palette(p.cmdQ);
239+ F.paletteClose();
240+ assert.equal(dom.frames.size, 0, 'closing cancels the pending frame');
241+
242+ // A search past its frame, still awaiting the list, is dropped at close.
243+ F.palette(p.cmdQ);
244+ flushFrames();
245+ F.paletteClose();
246+ await settle();
247+ assert.equal(p.cmdList.children.filter((li) => li.className === 'finder-hit').length, 0);
248+ } finally {
249+ dom.holdFrames = false;
250+ }
251+});
252+
202 test('the palette drops the results of a query that is stale', async () => {253 test('the palette drops the results of a query that is stale', async () => {
203 const p = page();254 const p = page();
204 p.cmdQ.value = 'tree';255 p.cmdQ.value = 'tree';
M internal/adapters/web/static/js/files.js +12 -2
@@ -583,6 +583,7 @@ window.ermiteFinder = (() => {
583583 // dialog and the finder are searched independently.
584584 let pFrame = 0;
585585 let pPrev = null;
586+ let pGen = 0; // bumped at close: a search still loading then is dropped
586587 const clearHits = () => document.querySelectorAll('#cmd-list > li.finder-hit').forEach((li) => li.remove());
587588
588589 // palette lists the file hits of the ":" field under its entries, once per frame.
@@ -603,17 +604,19 @@ window.ermiteFinder = (() => {
603604 if (pFrame) cancelAnimationFrame(pFrame);
604605 pFrame = 0;
605606 pPrev = null;
607+ pGen++;
606608 }
607609
608610 async function paletteRun(el) {
609611 const q = el.value;
612+ const gen = pGen;
610613 let loaded;
611614 try {
612615 loaded = await load();
613616 } catch (e) {
614617 return;
615618 }
616- if (el.value !== q || !loaded) return; // the field changed during the wait
619+ if (el.value !== q || gen !== pGen || !loaded) return; // the field changed or the dialog closed during the wait
617620 const res = search(loaded.idx, q, 20, pPrev);
618621 pPrev = res;
619622 clearHits();
@@ -658,7 +661,14 @@ window.ermiteFinder = (() => {
658661 input(el);
659662 return;
660663 }
661- if (!results) return;
664+ if (!results) {
665+ // A query whose first frame has not run yet: open its top hit once it does.
666+ if (e.key === 'Enter' && el.value.trim()) {
667+ e.preventDefault();
668+ pendingEnter = el.value;
669+ }
670+ return;
671+ }
662672 const cur = syncActive(el);
663673 if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
664674 e.preventDefault();
@@ -583,6 +583,7 @@ window.ermiteFinder = (() => {
583 // dialog and the finder are searched independently.583 // dialog and the finder are searched independently.
584 let pFrame = 0;584 let pFrame = 0;
585 let pPrev = null;585 let pPrev = null;
586+ let pGen = 0; // bumped at close: a search still loading then is dropped
586 const clearHits = () => document.querySelectorAll('#cmd-list > li.finder-hit').forEach((li) => li.remove());587 const clearHits = () => document.querySelectorAll('#cmd-list > li.finder-hit').forEach((li) => li.remove());
587 588
588 // palette lists the file hits of the ":" field under its entries, once per frame.589 // palette lists the file hits of the ":" field under its entries, once per frame.
@@ -603,17 +604,19 @@ window.ermiteFinder = (() => {
603 if (pFrame) cancelAnimationFrame(pFrame);604 if (pFrame) cancelAnimationFrame(pFrame);
604 pFrame = 0;605 pFrame = 0;
605 pPrev = null;606 pPrev = null;
607+ pGen++;
606 }608 }
607 609
608 async function paletteRun(el) {610 async function paletteRun(el) {
609 const q = el.value;611 const q = el.value;
612+ const gen = pGen;
610 let loaded;613 let loaded;
611 try {614 try {
612 loaded = await load();615 loaded = await load();
613 } catch (e) {616 } catch (e) {
614 return;617 return;
615 }618 }
616- if (el.value !== q || !loaded) return; // the field changed during the wait619+ if (el.value !== q || gen !== pGen || !loaded) return; // the field changed or the dialog closed during the wait
617 const res = search(loaded.idx, q, 20, pPrev);620 const res = search(loaded.idx, q, 20, pPrev);
618 pPrev = res;621 pPrev = res;
619 clearHits();622 clearHits();
@@ -658,7 +661,14 @@ window.ermiteFinder = (() => {
658 input(el);661 input(el);
659 return;662 return;
660 }663 }
661- if (!results) return;664+ if (!results) {
665+ // A query whose first frame has not run yet: open its top hit once it does.
666+ if (e.key === 'Enter' && el.value.trim()) {
667+ e.preventDefault();
668+ pendingEnter = el.value;
669+ }
670+ return;
671+ }
662 const cur = syncActive(el);672 const cur = syncActive(el);
663 if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {673 if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
664 e.preventDefault();674 e.preventDefault();

Keyboard shortcuts

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

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

Branches 2

Tags 42

↑↓ moveenter openesc close