refactor(web): share the finder hit link and throttle the palette search
The palette searches once per frame, narrows its search with its own previous result and drops it when the dialog closes. Option and palette share hitLink. The truncated status gives the real count, the path list is a single promise reset on failure, a directory is loaded when deeper rows follow it, and Enter/arrows keep aria-selected in step with data-cursor. The .cmd-list label rules move into the panel block.
- commit
- 84a2d29380edc12e156db5bc0d9e1d2eab9f58ad
- author
- brice · · 2026-10-12 05:03 UTC
- parent
- 28d17a7
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +103 -71
M internal/adapters/web/keys.templ +1 -1
| @@ -35,7 +35,7 @@ templ Keys() { | |||
| 35 | 35 | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 36 | 36 | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 37 | 37 | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 38 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load(); | |
| 38 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |
| 39 | 39 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 40 | 40 | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 41 | 41 | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| @@ -35,7 +35,7 @@ templ Keys() { | |||||
| 35 | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | 35 | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | ||
| 36 | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | 36 | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | ||
| 37 | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | 37 | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | ||
| 38 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load(); | 38 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); |
| 39 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | 39 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | ||
| 40 | k-gh from:body -> document.querySelector('.brand')?.click(); | 40 | k-gh from:body -> document.querySelector('.brand')?.click(); | ||
| 41 | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | 41 | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | ||
M internal/adapters/web/keys_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/overlays.templ +1 -1
| @@ -86,7 +86,7 @@ templ refItem(r RefItem) { | |||
| 86 | 86 | // OverlayCmd is the ":" command line: one filterable list mixing sections, | |
| 87 | 87 | // refs and repos. | |
| 88 | 88 | templ OverlayCmd(items []CmdItem) { | |
| 89 | - | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 89 | + | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 90 | 90 | <div class="panel-head"> | |
| 91 | 91 | <label for="cmd-q" class="sr">go to</label> | |
| 92 | 92 | <input | |
| @@ -86,7 +86,7 @@ templ refItem(r RefItem) { | |||||
| 86 | // OverlayCmd is the ":" command line: one filterable list mixing sections, | 86 | // OverlayCmd is the ":" command line: one filterable list mixing sections, | ||
| 87 | // refs and repos. | 87 | // refs and repos. | ||
| 88 | templ OverlayCmd(items []CmdItem) { | 88 | templ OverlayCmd(items []CmdItem) { | ||
| 89 | - | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 89 | + | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> |
| 90 | <div class="panel-head"> | 90 | <div class="panel-head"> | ||
| 91 | <label for="cmd-q" class="sr">go to</label> | 91 | <label for="cmd-q" class="sr">go to</label> | ||
| 92 | <input | 92 | <input | ||
M internal/adapters/web/overlays_templ.go +10 -10 generated file: collapsed
M internal/adapters/web/static/css/25-panel.css +3 -0
| @@ -54,6 +54,9 @@ | |||
| 54 | 54 | & li[data-cursor] a { background: var(--bg-strong); color: var(--ink-hi); } | |
| 55 | 55 | & .lbl { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); font-size: var(--fs-s); } | |
| 56 | 56 | & .k { flex: none; font-size: var(--fs-xs); color: var(--ink-mute); } | |
| 57 | + | & .dir { color: var(--ink-dim); } | |
| 58 | + | & strong { color: var(--ink-hi); font-weight: 700; } | |
| 59 | + | & mark { background: none; color: var(--teal); font-weight: 700; text-decoration: underline; } | |
| 57 | 60 | } | |
| 58 | 61 | ||
| 59 | 62 | .keys { | |
| @@ -54,6 +54,9 @@ | |||||
| 54 | & li[data-cursor] a { background: var(--bg-strong); color: var(--ink-hi); } | 54 | & li[data-cursor] a { background: var(--bg-strong); color: var(--ink-hi); } | ||
| 55 | & .lbl { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); font-size: var(--fs-s); } | 55 | & .lbl { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); font-size: var(--fs-s); } | ||
| 56 | & .k { flex: none; font-size: var(--fs-xs); color: var(--ink-mute); } | 56 | & .k { flex: none; font-size: var(--fs-xs); color: var(--ink-mute); } | ||
| 57 | + | & .dir { color: var(--ink-dim); } | |||
| 58 | + | & strong { color: var(--ink-hi); font-weight: 700; } | |||
| 59 | + | & mark { background: none; color: var(--teal); font-weight: 700; text-decoration: underline; } | |||
| 57 | } | 60 | } | ||
| 58 | 61 | ||||
| 59 | .keys { | 62 | .keys { | ||
M internal/adapters/web/static/css/30-list.css +0 -3
| @@ -79,9 +79,6 @@ | |||
| 79 | 79 | margin-top: var(--s3); | |
| 80 | 80 | & #finder-status { margin-bottom: var(--s2); font-size: var(--fs-s); } | |
| 81 | 81 | } | |
| 82 | - | .cmd-list .dir { color: var(--ink-dim); } | |
| 83 | - | .cmd-list strong { color: var(--ink-hi); font-weight: 700; } | |
| 84 | - | .cmd-list mark { background: none; color: var(--teal); font-weight: 700; text-decoration: underline; } | |
| 85 | 82 | ||
| 86 | 83 | /* section headings inside a page */ | |
| 87 | 84 | .rule-h { | |
| @@ -79,9 +79,6 @@ | |||||
| 79 | margin-top: var(--s3); | 79 | margin-top: var(--s3); | ||
| 80 | & #finder-status { margin-bottom: var(--s2); font-size: var(--fs-s); } | 80 | & #finder-status { margin-bottom: var(--s2); font-size: var(--fs-s); } | ||
| 81 | } | 81 | } | ||
| 82 | - | .cmd-list .dir { color: var(--ink-dim); } | |||
| 83 | - | .cmd-list strong { color: var(--ink-hi); font-weight: 700; } | |||
| 84 | - | .cmd-list mark { background: none; color: var(--teal); font-weight: 700; text-decoration: underline; } | |||
| 85 | 82 | ||||
| 86 | /* section headings inside a page */ | 83 | /* section headings inside a page */ | ||
| 87 | .rule-h { | 84 | .rule-h { | ||
M internal/adapters/web/static/js/files.js +87 -55
| @@ -10,6 +10,8 @@ window.ermiteTree = (() => { | |||
| 10 | 10 | const depthOf = (tr) => Number(tr.dataset.depth || 0); | |
| 11 | 11 | const linkOf = (tr) => tr.querySelector('a[data-expand]'); | |
| 12 | 12 | const isOpen = (a) => a.getAttribute('aria-expanded') === 'true'; | |
| 13 | + | // A directory is loaded when rows deeper than it follow it. | |
| 14 | + | const isLoaded = (tr) => { const n = tr.nextElementSibling; return !!n && depthOf(n) > depthOf(tr); }; | |
| 13 | 15 | ||
| 14 | 16 | function setOpen(tr, a, open) { | |
| 15 | 17 | a.setAttribute('aria-expanded', open ? 'true' : 'false'); | |
| @@ -64,7 +66,6 @@ window.ermiteTree = (() => { | |||
| 64 | 66 | if (covered) r.hidden = true; | |
| 65 | 67 | } | |
| 66 | 68 | tr.after(...rows); | |
| 67 | - | tr.dataset.loaded = '1'; | |
| 68 | 69 | tr.removeAttribute('aria-busy'); | |
| 69 | 70 | if (covered) setOpen(tr, a, true); | |
| 70 | 71 | else expand(tr, a); | |
| @@ -77,7 +78,7 @@ window.ermiteTree = (() => { | |||
| 77 | 78 | const a = linkOf(tr); | |
| 78 | 79 | if (!a || tr.getAttribute('aria-busy') === 'true') return; | |
| 79 | 80 | if (isOpen(a)) collapse(tr, a); | |
| 80 | - | else if (tr.dataset.loaded) expand(tr, a); | |
| 81 | + | else if (isLoaded(tr)) expand(tr, a); | |
| 81 | 82 | else load(tr, a); | |
| 82 | 83 | } | |
| 83 | 84 | ||
| @@ -385,7 +386,7 @@ window.ermiteFinder = (() => { | |||
| 385 | 386 | // ---- DOM glue ----------------------------------------------------------- | |
| 386 | 387 | ||
| 387 | 388 | const $ = (id) => document.getElementById(id); | |
| 388 | - | const loads = new Map(); // URL -> promise of {idx, truncated} | |
| 389 | + | let loading = null; // the promise of {idx, truncated}; null before the first load and after a failure | |
| 389 | 390 | let rendered = null; // the result the finder list shows | |
| 390 | 391 | let frame = 0; | |
| 391 | 392 | let treeCursor = null; // the row of the tree that had the cursor when the results came | |
| @@ -395,27 +396,22 @@ window.ermiteFinder = (() => { | |||
| 395 | 396 | function load() { | |
| 396 | 397 | const url = document.body.dataset.paths; | |
| 397 | 398 | if (!url) return Promise.resolve(null); | |
| 398 | - | let p = loads.get(url); | |
| 399 | - | if (!p) { | |
| 400 | - | p = fetch(url, { headers: { Accept: 'application/json' } }) | |
| 399 | + | if (!loading) { | |
| 400 | + | const p = fetch(url, { headers: { Accept: 'application/json' } }) | |
| 401 | 401 | .then((res) => { | |
| 402 | 402 | if (!res.ok) throw new Error('HTTP ' + res.status); | |
| 403 | 403 | return res.json(); | |
| 404 | 404 | }) | |
| 405 | 405 | .then((j) => ({ idx: index(j.paths || []), truncated: !!j.truncated })); | |
| 406 | - | p.catch(() => loads.delete(url)); // a failure is retried at the next use | |
| 407 | - | loads.set(url, p); | |
| 406 | + | loading = p; | |
| 407 | + | p.catch(() => { if (loading === p) loading = null; }); // a failure is retried at the next use | |
| 408 | 408 | } | |
| 409 | - | return p; | |
| 409 | + | return loading; | |
| 410 | 410 | } | |
| 411 | 411 | ||
| 412 | - | function blobHref(path) { | |
| 413 | - | try { | |
| 414 | - | return document.body.dataset.blob + '/' + path.split('/').map(encodeURIComponent).join('/'); | |
| 415 | - | } catch (e) { | |
| 416 | - | return '#'; | |
| 417 | - | } | |
| 418 | - | } | |
| 412 | + | // The server's JSON never carries a lone surrogate, so encodeURIComponent | |
| 413 | + | // cannot throw here. | |
| 414 | + | const blobHref = (path) => document.body.dataset.blob + '/' + path.split('/').map(encodeURIComponent).join('/'); | |
| 419 | 415 | ||
| 420 | 416 | const isHigh = (c) => c >= 0xd800 && c <= 0xdbff; | |
| 421 | 417 | const isLow = (c) => c >= 0xdc00 && c <= 0xdfff; | |
| @@ -471,22 +467,35 @@ window.ermiteFinder = (() => { | |||
| 471 | 467 | return lbl; | |
| 472 | 468 | } | |
| 473 | 469 | ||
| 470 | + | // hitLink is the link of path i of the result: its label, out of the tab order. | |
| 471 | + | function hitLink(res, i) { | |
| 472 | + | const path = res.idx.paths[i]; | |
| 473 | + | const a = document.createElement('a'); | |
| 474 | + | a.href = blobHref(path); | |
| 475 | + | a.tabIndex = -1; | |
| 476 | + | a.append(label(path, positions(res.idx, i, res.q))); | |
| 477 | + | return a; | |
| 478 | + | } | |
| 479 | + | ||
| 474 | 480 | function option(res, hit, n) { | |
| 475 | - | const path = res.idx.paths[hit.i]; | |
| 476 | 481 | const li = document.createElement('li'); | |
| 477 | 482 | li.setAttribute('role', 'option'); | |
| 478 | 483 | li.setAttribute('aria-selected', 'false'); | |
| 479 | 484 | li.id = 'finder-o' + n; | |
| 480 | - | const a = document.createElement('a'); | |
| 481 | - | a.href = blobHref(path); | |
| 482 | - | a.tabIndex = -1; | |
| 483 | - | a.append(label(path, positions(res.idx, hit.i, res.q))); | |
| 484 | - | li.append(a); | |
| 485 | + | li.append(hitLink(res, hit.i)); | |
| 485 | 486 | return li; | |
| 486 | 487 | } | |
| 487 | 488 | ||
| 488 | 489 | const status = (text) => { $('finder-status').textContent = text; }; | |
| 489 | 490 | ||
| 491 | + | // syncActive makes the option that has data-cursor the active one: j/k | |
| 492 | + | // (keys.templ) move the cursor while the field is blurred. | |
| 493 | + | function syncActive(el) { | |
| 494 | + | const cur = $('finder-list').querySelector(':scope > li[data-cursor]'); | |
| 495 | + | if (cur && cur.getAttribute('aria-selected') !== 'true') setActive(el, cur); | |
| 496 | + | return cur; | |
| 497 | + | } | |
| 498 | + | ||
| 490 | 499 | // setActive makes li the active option (null: none) of the listbox. | |
| 491 | 500 | function setActive(el, li) { | |
| 492 | 501 | $('finder-list').querySelectorAll(':scope > li[aria-selected="true"]').forEach((o) => o.setAttribute('aria-selected', 'false')); | |
| @@ -563,44 +572,68 @@ window.ermiteFinder = (() => { | |||
| 563 | 572 | setActive(el, items[0]); | |
| 564 | 573 | rendered = res; | |
| 565 | 574 | let msg = res.all.length === 0 ? 'no file matches' : res.all.length + (res.all.length === 1 ? ' file matches' : ' files match'); | |
| 566 | - | if (loaded.truncated) msg += ' · only the first 100,000 files are searched'; | |
| 575 | + | if (loaded.truncated) msg += ' · only the first ' + loaded.idx.paths.length.toLocaleString('en') + ' files are searched'; | |
| 567 | 576 | status(msg); | |
| 568 | 577 | const open = pendingEnter === q; | |
| 569 | 578 | pendingEnter = null; | |
| 570 | 579 | if (open) items[0]?.querySelector('a').click(); | |
| 571 | 580 | } | |
| 572 | 581 | ||
| 582 | + | // The palette keeps its own frame and previous result: the field of the | |
| 583 | + | // dialog and the finder are searched independently. | |
| 584 | + | let pFrame = 0; | |
| 585 | + | let pPrev = null; | |
| 586 | + | const clearHits = () => document.querySelectorAll('#cmd-list > li.finder-hit').forEach((li) => li.remove()); | |
| 587 | + | ||
| 588 | + | // palette lists the file hits of the ":" field under its entries, once per frame. | |
| 573 | 589 | function palette(el) { | |
| 574 | - | if (!document.body.dataset.paths) return Promise.resolve(); | |
| 575 | - | const clear = () => document.querySelectorAll('#cmd-list > li.finder-hit').forEach((li) => li.remove()); | |
| 576 | - | clear(); | |
| 590 | + | if (!document.body.dataset.paths) return; | |
| 591 | + | clearHits(); | |
| 592 | + | if (pFrame) cancelAnimationFrame(pFrame); | |
| 593 | + | pFrame = 0; | |
| 594 | + | if (!el.value.trim()) { | |
| 595 | + | pPrev = null; | |
| 596 | + | return; | |
| 597 | + | } | |
| 598 | + | pFrame = requestAnimationFrame(() => { pFrame = 0; paletteRun(el); }); | |
| 599 | + | } | |
| 600 | + | ||
| 601 | + | // paletteClose drops the frame and the result kept for the next keystroke. | |
| 602 | + | function paletteClose() { | |
| 603 | + | if (pFrame) cancelAnimationFrame(pFrame); | |
| 604 | + | pFrame = 0; | |
| 605 | + | pPrev = null; | |
| 606 | + | } | |
| 607 | + | ||
| 608 | + | async function paletteRun(el) { | |
| 577 | 609 | const q = el.value; | |
| 578 | - | if (!q.trim()) return Promise.resolve(); | |
| 579 | - | return load().then((loaded) => { | |
| 580 | - | if (el.value !== q || !loaded) return; | |
| 581 | - | clear(); | |
| 582 | - | const res = search(loaded.idx, q, 20); | |
| 583 | - | const list = $('cmd-list'); | |
| 584 | - | for (const h of res.hits) { | |
| 585 | - | const path = res.idx.paths[h.i]; | |
| 586 | - | const li = document.createElement('li'); | |
| 587 | - | li.className = 'finder-hit'; | |
| 588 | - | const a = document.createElement('a'); | |
| 589 | - | a.href = blobHref(path); | |
| 590 | - | a.tabIndex = -1; | |
| 591 | - | const k = document.createElement('span'); | |
| 592 | - | k.className = 'k'; | |
| 593 | - | k.textContent = 'file'; | |
| 594 | - | a.append(label(path, positions(res.idx, h.i, q)), k); | |
| 595 | - | li.append(a); | |
| 596 | - | list.append(li); | |
| 597 | - | } | |
| 598 | - | const shown = Array.from(list.querySelectorAll(':scope > li:not([hidden])')); | |
| 599 | - | if (shown.length && !shown.some((li) => li.hasAttribute('data-cursor'))) { | |
| 600 | - | list.querySelectorAll(':scope > li[data-cursor]').forEach((li) => li.removeAttribute('data-cursor')); | |
| 601 | - | shown[0].setAttribute('data-cursor', ''); | |
| 602 | - | } | |
| 603 | - | }, () => {}); | |
| 610 | + | let loaded; | |
| 611 | + | try { | |
| 612 | + | loaded = await load(); | |
| 613 | + | } catch (e) { | |
| 614 | + | return; | |
| 615 | + | } | |
| 616 | + | if (el.value !== q || !loaded) return; // the field changed during the wait | |
| 617 | + | const res = search(loaded.idx, q, 20, pPrev); | |
| 618 | + | pPrev = res; | |
| 619 | + | clearHits(); | |
| 620 | + | const list = $('cmd-list'); | |
| 621 | + | for (const h of res.hits) { | |
| 622 | + | const li = document.createElement('li'); | |
| 623 | + | li.className = 'finder-hit'; | |
| 624 | + | const a = hitLink(res, h.i); | |
| 625 | + | const k = document.createElement('span'); | |
| 626 | + | k.className = 'k'; | |
| 627 | + | k.textContent = 'file'; | |
| 628 | + | a.append(k); | |
| 629 | + | li.append(a); | |
| 630 | + | list.append(li); | |
| 631 | + | } | |
| 632 | + | const shown = Array.from(list.querySelectorAll(':scope > li:not([hidden])')); | |
| 633 | + | if (shown.length && !shown.some((li) => li.hasAttribute('data-cursor'))) { | |
| 634 | + | list.querySelectorAll(':scope > li[data-cursor]').forEach((li) => li.removeAttribute('data-cursor')); | |
| 635 | + | shown[0].setAttribute('data-cursor', ''); | |
| 636 | + | } | |
| 604 | 637 | } | |
| 605 | 638 | ||
| 606 | 639 | // input runs the search of the field, once per frame. | |
| @@ -615,7 +648,7 @@ window.ermiteFinder = (() => { | |||
| 615 | 648 | } | |
| 616 | 649 | ||
| 617 | 650 | return { | |
| 618 | - | index, search, positions, visible, load, palette, input, | |
| 651 | + | index, search, positions, visible, label, load, palette, paletteClose, input, | |
| 619 | 652 | // key moves the cursor of the results, opens one, or clears the field. | |
| 620 | 653 | key(el, e) { | |
| 621 | 654 | if (e.isComposing) return; // the Enter or Escape of an input method is its own | |
| @@ -626,8 +659,7 @@ window.ermiteFinder = (() => { | |||
| 626 | 659 | return; | |
| 627 | 660 | } | |
| 628 | 661 | if (!results) return; | |
| 629 | - | const list = $('finder-list'); | |
| 630 | - | const cur = list.querySelector(':scope > li[data-cursor]'); | |
| 662 | + | const cur = syncActive(el); | |
| 631 | 663 | if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { | |
| 632 | 664 | e.preventDefault(); | |
| 633 | 665 | const next = e.key === 'ArrowDown' ? cur?.nextElementSibling : cur?.previousElementSibling; | |
| @@ -10,6 +10,8 @@ window.ermiteTree = (() => { | |||||
| 10 | const depthOf = (tr) => Number(tr.dataset.depth || 0); | 10 | const depthOf = (tr) => Number(tr.dataset.depth || 0); | ||
| 11 | const linkOf = (tr) => tr.querySelector('a[data-expand]'); | 11 | const linkOf = (tr) => tr.querySelector('a[data-expand]'); | ||
| 12 | const isOpen = (a) => a.getAttribute('aria-expanded') === 'true'; | 12 | const isOpen = (a) => a.getAttribute('aria-expanded') === 'true'; | ||
| 13 | + | // A directory is loaded when rows deeper than it follow it. | |||
| 14 | + | const isLoaded = (tr) => { const n = tr.nextElementSibling; return !!n && depthOf(n) > depthOf(tr); }; | |||
| 13 | 15 | ||||
| 14 | function setOpen(tr, a, open) { | 16 | function setOpen(tr, a, open) { | ||
| 15 | a.setAttribute('aria-expanded', open ? 'true' : 'false'); | 17 | a.setAttribute('aria-expanded', open ? 'true' : 'false'); | ||
| @@ -64,7 +66,6 @@ window.ermiteTree = (() => { | |||||
| 64 | if (covered) r.hidden = true; | 66 | if (covered) r.hidden = true; | ||
| 65 | } | 67 | } | ||
| 66 | tr.after(...rows); | 68 | tr.after(...rows); | ||
| 67 | - | tr.dataset.loaded = '1'; | |||
| 68 | tr.removeAttribute('aria-busy'); | 69 | tr.removeAttribute('aria-busy'); | ||
| 69 | if (covered) setOpen(tr, a, true); | 70 | if (covered) setOpen(tr, a, true); | ||
| 70 | else expand(tr, a); | 71 | else expand(tr, a); | ||
| @@ -77,7 +78,7 @@ window.ermiteTree = (() => { | |||||
| 77 | const a = linkOf(tr); | 78 | const a = linkOf(tr); | ||
| 78 | if (!a || tr.getAttribute('aria-busy') === 'true') return; | 79 | if (!a || tr.getAttribute('aria-busy') === 'true') return; | ||
| 79 | if (isOpen(a)) collapse(tr, a); | 80 | if (isOpen(a)) collapse(tr, a); | ||
| 80 | - | else if (tr.dataset.loaded) expand(tr, a); | 81 | + | else if (isLoaded(tr)) expand(tr, a); |
| 81 | else load(tr, a); | 82 | else load(tr, a); | ||
| 82 | } | 83 | } | ||
| 83 | 84 | ||||
| @@ -385,7 +386,7 @@ window.ermiteFinder = (() => { | |||||
| 385 | // ---- DOM glue ----------------------------------------------------------- | 386 | // ---- DOM glue ----------------------------------------------------------- | ||
| 386 | 387 | ||||
| 387 | const $ = (id) => document.getElementById(id); | 388 | const $ = (id) => document.getElementById(id); | ||
| 388 | - | const loads = new Map(); // URL -> promise of {idx, truncated} | 389 | + | let loading = null; // the promise of {idx, truncated}; null before the first load and after a failure |
| 389 | let rendered = null; // the result the finder list shows | 390 | let rendered = null; // the result the finder list shows | ||
| 390 | let frame = 0; | 391 | let frame = 0; | ||
| 391 | let treeCursor = null; // the row of the tree that had the cursor when the results came | 392 | let treeCursor = null; // the row of the tree that had the cursor when the results came | ||
| @@ -395,27 +396,22 @@ window.ermiteFinder = (() => { | |||||
| 395 | function load() { | 396 | function load() { | ||
| 396 | const url = document.body.dataset.paths; | 397 | const url = document.body.dataset.paths; | ||
| 397 | if (!url) return Promise.resolve(null); | 398 | if (!url) return Promise.resolve(null); | ||
| 398 | - | let p = loads.get(url); | 399 | + | if (!loading) { |
| 399 | - | if (!p) { | 400 | + | const p = fetch(url, { headers: { Accept: 'application/json' } }) |
| 400 | - | p = fetch(url, { headers: { Accept: 'application/json' } }) | |||
| 401 | .then((res) => { | 401 | .then((res) => { | ||
| 402 | if (!res.ok) throw new Error('HTTP ' + res.status); | 402 | if (!res.ok) throw new Error('HTTP ' + res.status); | ||
| 403 | return res.json(); | 403 | return res.json(); | ||
| 404 | }) | 404 | }) | ||
| 405 | .then((j) => ({ idx: index(j.paths || []), truncated: !!j.truncated })); | 405 | .then((j) => ({ idx: index(j.paths || []), truncated: !!j.truncated })); | ||
| 406 | - | p.catch(() => loads.delete(url)); // a failure is retried at the next use | 406 | + | loading = p; |
| 407 | - | loads.set(url, p); | 407 | + | p.catch(() => { if (loading === p) loading = null; }); // a failure is retried at the next use |
| 408 | } | 408 | } | ||
| 409 | - | return p; | 409 | + | return loading; |
| 410 | } | 410 | } | ||
| 411 | 411 | ||||
| 412 | - | function blobHref(path) { | 412 | + | // The server's JSON never carries a lone surrogate, so encodeURIComponent |
| 413 | - | try { | 413 | + | // cannot throw here. |
| 414 | - | return document.body.dataset.blob + '/' + path.split('/').map(encodeURIComponent).join('/'); | 414 | + | const blobHref = (path) => document.body.dataset.blob + '/' + path.split('/').map(encodeURIComponent).join('/'); |
| 415 | - | } catch (e) { | |||
| 416 | - | return '#'; | |||
| 417 | - | } | |||
| 418 | - | } | |||
| 419 | 415 | ||||
| 420 | const isHigh = (c) => c >= 0xd800 && c <= 0xdbff; | 416 | const isHigh = (c) => c >= 0xd800 && c <= 0xdbff; | ||
| 421 | const isLow = (c) => c >= 0xdc00 && c <= 0xdfff; | 417 | const isLow = (c) => c >= 0xdc00 && c <= 0xdfff; | ||
| @@ -471,22 +467,35 @@ window.ermiteFinder = (() => { | |||||
| 471 | return lbl; | 467 | return lbl; | ||
| 472 | } | 468 | } | ||
| 473 | 469 | ||||
| 470 | + | // hitLink is the link of path i of the result: its label, out of the tab order. | |||
| 471 | + | function hitLink(res, i) { | |||
| 472 | + | const path = res.idx.paths[i]; | |||
| 473 | + | const a = document.createElement('a'); | |||
| 474 | + | a.href = blobHref(path); | |||
| 475 | + | a.tabIndex = -1; | |||
| 476 | + | a.append(label(path, positions(res.idx, i, res.q))); | |||
| 477 | + | return a; | |||
| 478 | + | } | |||
| 479 | + | ||||
| 474 | function option(res, hit, n) { | 480 | function option(res, hit, n) { | ||
| 475 | - | const path = res.idx.paths[hit.i]; | |||
| 476 | const li = document.createElement('li'); | 481 | const li = document.createElement('li'); | ||
| 477 | li.setAttribute('role', 'option'); | 482 | li.setAttribute('role', 'option'); | ||
| 478 | li.setAttribute('aria-selected', 'false'); | 483 | li.setAttribute('aria-selected', 'false'); | ||
| 479 | li.id = 'finder-o' + n; | 484 | li.id = 'finder-o' + n; | ||
| 480 | - | const a = document.createElement('a'); | 485 | + | li.append(hitLink(res, hit.i)); |
| 481 | - | a.href = blobHref(path); | |||
| 482 | - | a.tabIndex = -1; | |||
| 483 | - | a.append(label(path, positions(res.idx, hit.i, res.q))); | |||
| 484 | - | li.append(a); | |||
| 485 | return li; | 486 | return li; | ||
| 486 | } | 487 | } | ||
| 487 | 488 | ||||
| 488 | const status = (text) => { $('finder-status').textContent = text; }; | 489 | const status = (text) => { $('finder-status').textContent = text; }; | ||
| 489 | 490 | ||||
| 491 | + | // syncActive makes the option that has data-cursor the active one: j/k | |||
| 492 | + | // (keys.templ) move the cursor while the field is blurred. | |||
| 493 | + | function syncActive(el) { | |||
| 494 | + | const cur = $('finder-list').querySelector(':scope > li[data-cursor]'); | |||
| 495 | + | if (cur && cur.getAttribute('aria-selected') !== 'true') setActive(el, cur); | |||
| 496 | + | return cur; | |||
| 497 | + | } | |||
| 498 | + | ||||
| 490 | // setActive makes li the active option (null: none) of the listbox. | 499 | // setActive makes li the active option (null: none) of the listbox. | ||
| 491 | function setActive(el, li) { | 500 | function setActive(el, li) { | ||
| 492 | $('finder-list').querySelectorAll(':scope > li[aria-selected="true"]').forEach((o) => o.setAttribute('aria-selected', 'false')); | 501 | $('finder-list').querySelectorAll(':scope > li[aria-selected="true"]').forEach((o) => o.setAttribute('aria-selected', 'false')); | ||
| @@ -563,44 +572,68 @@ window.ermiteFinder = (() => { | |||||
| 563 | setActive(el, items[0]); | 572 | setActive(el, items[0]); | ||
| 564 | rendered = res; | 573 | rendered = res; | ||
| 565 | let msg = res.all.length === 0 ? 'no file matches' : res.all.length + (res.all.length === 1 ? ' file matches' : ' files match'); | 574 | let msg = res.all.length === 0 ? 'no file matches' : res.all.length + (res.all.length === 1 ? ' file matches' : ' files match'); | ||
| 566 | - | if (loaded.truncated) msg += ' · only the first 100,000 files are searched'; | 575 | + | if (loaded.truncated) msg += ' · only the first ' + loaded.idx.paths.length.toLocaleString('en') + ' files are searched'; |
| 567 | status(msg); | 576 | status(msg); | ||
| 568 | const open = pendingEnter === q; | 577 | const open = pendingEnter === q; | ||
| 569 | pendingEnter = null; | 578 | pendingEnter = null; | ||
| 570 | if (open) items[0]?.querySelector('a').click(); | 579 | if (open) items[0]?.querySelector('a').click(); | ||
| 571 | } | 580 | } | ||
| 572 | 581 | ||||
| 582 | + | // The palette keeps its own frame and previous result: the field of the | |||
| 583 | + | // dialog and the finder are searched independently. | |||
| 584 | + | let pFrame = 0; | |||
| 585 | + | let pPrev = null; | |||
| 586 | + | const clearHits = () => document.querySelectorAll('#cmd-list > li.finder-hit').forEach((li) => li.remove()); | |||
| 587 | + | ||||
| 588 | + | // palette lists the file hits of the ":" field under its entries, once per frame. | |||
| 573 | function palette(el) { | 589 | function palette(el) { | ||
| 574 | - | if (!document.body.dataset.paths) return Promise.resolve(); | 590 | + | if (!document.body.dataset.paths) return; |
| 575 | - | const clear = () => document.querySelectorAll('#cmd-list > li.finder-hit').forEach((li) => li.remove()); | 591 | + | clearHits(); |
| 576 | - | clear(); | 592 | + | if (pFrame) cancelAnimationFrame(pFrame); |
| 593 | + | pFrame = 0; | |||
| 594 | + | if (!el.value.trim()) { | |||
| 595 | + | pPrev = null; | |||
| 596 | + | return; | |||
| 597 | + | } | |||
| 598 | + | pFrame = requestAnimationFrame(() => { pFrame = 0; paletteRun(el); }); | |||
| 599 | + | } | |||
| 600 | + | ||||
| 601 | + | // paletteClose drops the frame and the result kept for the next keystroke. | |||
| 602 | + | function paletteClose() { | |||
| 603 | + | if (pFrame) cancelAnimationFrame(pFrame); | |||
| 604 | + | pFrame = 0; | |||
| 605 | + | pPrev = null; | |||
| 606 | + | } | |||
| 607 | + | ||||
| 608 | + | async function paletteRun(el) { | |||
| 577 | const q = el.value; | 609 | const q = el.value; | ||
| 578 | - | if (!q.trim()) return Promise.resolve(); | 610 | + | let loaded; |
| 579 | - | return load().then((loaded) => { | 611 | + | try { |
| 580 | - | if (el.value !== q || !loaded) return; | 612 | + | loaded = await load(); |
| 581 | - | clear(); | 613 | + | } catch (e) { |
| 582 | - | const res = search(loaded.idx, q, 20); | 614 | + | return; |
| 583 | - | const list = $('cmd-list'); | 615 | + | } |
| 584 | - | for (const h of res.hits) { | 616 | + | if (el.value !== q || !loaded) return; // the field changed during the wait |
| 585 | - | const path = res.idx.paths[h.i]; | 617 | + | const res = search(loaded.idx, q, 20, pPrev); |
| 586 | - | const li = document.createElement('li'); | 618 | + | pPrev = res; |
| 587 | - | li.className = 'finder-hit'; | 619 | + | clearHits(); |
| 588 | - | const a = document.createElement('a'); | 620 | + | const list = $('cmd-list'); |
| 589 | - | a.href = blobHref(path); | 621 | + | for (const h of res.hits) { |
| 590 | - | a.tabIndex = -1; | 622 | + | const li = document.createElement('li'); |
| 591 | - | const k = document.createElement('span'); | 623 | + | li.className = 'finder-hit'; |
| 592 | - | k.className = 'k'; | 624 | + | const a = hitLink(res, h.i); |
| 593 | - | k.textContent = 'file'; | 625 | + | const k = document.createElement('span'); |
| 594 | - | a.append(label(path, positions(res.idx, h.i, q)), k); | 626 | + | k.className = 'k'; |
| 595 | - | li.append(a); | 627 | + | k.textContent = 'file'; |
| 596 | - | list.append(li); | 628 | + | a.append(k); |
| 597 | - | } | 629 | + | li.append(a); |
| 598 | - | const shown = Array.from(list.querySelectorAll(':scope > li:not([hidden])')); | 630 | + | list.append(li); |
| 599 | - | if (shown.length && !shown.some((li) => li.hasAttribute('data-cursor'))) { | 631 | + | } |
| 600 | - | list.querySelectorAll(':scope > li[data-cursor]').forEach((li) => li.removeAttribute('data-cursor')); | 632 | + | const shown = Array.from(list.querySelectorAll(':scope > li:not([hidden])')); |
| 601 | - | shown[0].setAttribute('data-cursor', ''); | 633 | + | if (shown.length && !shown.some((li) => li.hasAttribute('data-cursor'))) { |
| 602 | - | } | 634 | + | list.querySelectorAll(':scope > li[data-cursor]').forEach((li) => li.removeAttribute('data-cursor')); |
| 603 | - | }, () => {}); | 635 | + | shown[0].setAttribute('data-cursor', ''); |
| 636 | + | } | |||
| 604 | } | 637 | } | ||
| 605 | 638 | ||||
| 606 | // input runs the search of the field, once per frame. | 639 | // input runs the search of the field, once per frame. | ||
| @@ -615,7 +648,7 @@ window.ermiteFinder = (() => { | |||||
| 615 | } | 648 | } | ||
| 616 | 649 | ||||
| 617 | return { | 650 | return { | ||
| 618 | - | index, search, positions, visible, load, palette, input, | 651 | + | index, search, positions, visible, label, load, palette, paletteClose, input, |
| 619 | // key moves the cursor of the results, opens one, or clears the field. | 652 | // key moves the cursor of the results, opens one, or clears the field. | ||
| 620 | key(el, e) { | 653 | key(el, e) { | ||
| 621 | if (e.isComposing) return; // the Enter or Escape of an input method is its own | 654 | if (e.isComposing) return; // the Enter or Escape of an input method is its own | ||
| @@ -626,8 +659,7 @@ window.ermiteFinder = (() => { | |||||
| 626 | return; | 659 | return; | ||
| 627 | } | 660 | } | ||
| 628 | if (!results) return; | 661 | if (!results) return; | ||
| 629 | - | const list = $('finder-list'); | 662 | + | const cur = syncActive(el); |
| 630 | - | const cur = list.querySelector(':scope > li[data-cursor]'); | |||
| 631 | if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { | 663 | if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { | ||
| 632 | e.preventDefault(); | 664 | e.preventDefault(); | ||
| 633 | const next = e.key === 'ArrowDown' ? cur?.nextElementSibling : cur?.previousElementSibling; | 665 | const next = e.key === 'ArrowDown' ? cur?.nextElementSibling : cur?.previousElementSibling; | ||