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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 146 | 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 | 158 | // installDom puts the globals files.js expects. parseHTML(text) answers the | |
| 152 | 159 | // nodes that a template's innerHTML makes. | |
| @@ -171,9 +178,14 @@ export function installDom({ parseHTML } = {}) { | |||
| 171 | 178 | globalThis.window = globalThis; | |
| 172 | 179 | globalThis.document = doc; | |
| 173 | 180 | 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); }; | |
| 177 | 189 | return doc; | |
| 178 | 190 | } | |
| 179 | 191 | ||
| @@ -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 the | 158 | // 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 @@ | |||
| 3 | 3 | // node --test 'internal/adapters/web/jstest/*.test.mjs' | |
| 4 | 4 | import assert from 'node:assert/strict'; | |
| 5 | 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 | 8 | installDom(); | |
| 9 | 9 | await import('../static/js/files.js'); | |
| @@ -199,6 +199,57 @@ test('the palette lists the file hits under its entries', async () => { | |||
| 199 | 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 | 253 | test('the palette drops the results of a query that is stale', async () => { | |
| 203 | 254 | const p = page(); | |
| 204 | 255 | 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 = (() => { | |||
| 583 | 583 | // dialog and the finder are searched independently. | |
| 584 | 584 | let pFrame = 0; | |
| 585 | 585 | let pPrev = null; | |
| 586 | + | let pGen = 0; // bumped at close: a search still loading then is dropped | |
| 586 | 587 | const clearHits = () => document.querySelectorAll('#cmd-list > li.finder-hit').forEach((li) => li.remove()); | |
| 587 | 588 | ||
| 588 | 589 | // palette lists the file hits of the ":" field under its entries, once per frame. | |
| @@ -603,17 +604,19 @@ window.ermiteFinder = (() => { | |||
| 603 | 604 | if (pFrame) cancelAnimationFrame(pFrame); | |
| 604 | 605 | pFrame = 0; | |
| 605 | 606 | pPrev = null; | |
| 607 | + | pGen++; | |
| 606 | 608 | } | |
| 607 | 609 | ||
| 608 | 610 | async function paletteRun(el) { | |
| 609 | 611 | const q = el.value; | |
| 612 | + | const gen = pGen; | |
| 610 | 613 | let loaded; | |
| 611 | 614 | try { | |
| 612 | 615 | loaded = await load(); | |
| 613 | 616 | } catch (e) { | |
| 614 | 617 | return; | |
| 615 | 618 | } | |
| 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 | |
| 617 | 620 | const res = search(loaded.idx, q, 20, pPrev); | |
| 618 | 621 | pPrev = res; | |
| 619 | 622 | clearHits(); | |
| @@ -658,7 +661,14 @@ window.ermiteFinder = (() => { | |||
| 658 | 661 | input(el); | |
| 659 | 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 | 672 | const cur = syncActive(el); | |
| 663 | 673 | if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { | |
| 664 | 674 | 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 wait | 619 | + | 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(); | ||