detached 84a2d29

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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() {
3535 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
3636 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
3737 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();
3939 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
4040 k-gh from:body -> document.querySelector('.brand')?.click();
4141 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) {
8686 // OverlayCmd is the ":" command line: one filterable list mixing sections,
8787 // refs and repos.
8888 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()">
9090 <div class="panel-head">
9191 <label for="cmd-q" class="sr">go to</label>
9292 <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 <input92 <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 @@
5454 & li[data-cursor] a { background: var(--bg-strong); color: var(--ink-hi); }
5555 & .lbl { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); font-size: var(--fs-s); }
5656 & .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; }
5760 }
5861
5962 .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 @@
7979 margin-top: var(--s3);
8080 & #finder-status { margin-bottom: var(--s2); font-size: var(--fs-s); }
8181 }
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; }
8582
8683 /* section headings inside a page */
8784 .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 = (() => {
1010 const depthOf = (tr) => Number(tr.dataset.depth || 0);
1111 const linkOf = (tr) => tr.querySelector('a[data-expand]');
1212 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); };
1315
1416 function setOpen(tr, a, open) {
1517 a.setAttribute('aria-expanded', open ? 'true' : 'false');
@@ -64,7 +66,6 @@ window.ermiteTree = (() => {
6466 if (covered) r.hidden = true;
6567 }
6668 tr.after(...rows);
67- tr.dataset.loaded = '1';
6869 tr.removeAttribute('aria-busy');
6970 if (covered) setOpen(tr, a, true);
7071 else expand(tr, a);
@@ -77,7 +78,7 @@ window.ermiteTree = (() => {
7778 const a = linkOf(tr);
7879 if (!a || tr.getAttribute('aria-busy') === 'true') return;
7980 if (isOpen(a)) collapse(tr, a);
80- else if (tr.dataset.loaded) expand(tr, a);
81+ else if (isLoaded(tr)) expand(tr, a);
8182 else load(tr, a);
8283 }
8384
@@ -385,7 +386,7 @@ window.ermiteFinder = (() => {
385386 // ---- DOM glue -----------------------------------------------------------
386387
387388 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
389390 let rendered = null; // the result the finder list shows
390391 let frame = 0;
391392 let treeCursor = null; // the row of the tree that had the cursor when the results came
@@ -395,27 +396,22 @@ window.ermiteFinder = (() => {
395396 function load() {
396397 const url = document.body.dataset.paths;
397398 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' } })
401401 .then((res) => {
402402 if (!res.ok) throw new Error('HTTP ' + res.status);
403403 return res.json();
404404 })
405405 .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
408408 }
409- return p;
409+ return loading;
410410 }
411411
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('/');
419415
420416 const isHigh = (c) => c >= 0xd800 && c <= 0xdbff;
421417 const isLow = (c) => c >= 0xdc00 && c <= 0xdfff;
@@ -471,22 +467,35 @@ window.ermiteFinder = (() => {
471467 return lbl;
472468 }
473469
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+
474480 function option(res, hit, n) {
475- const path = res.idx.paths[hit.i];
476481 const li = document.createElement('li');
477482 li.setAttribute('role', 'option');
478483 li.setAttribute('aria-selected', 'false');
479484 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));
485486 return li;
486487 }
487488
488489 const status = (text) => { $('finder-status').textContent = text; };
489490
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+
490499 // setActive makes li the active option (null: none) of the listbox.
491500 function setActive(el, li) {
492501 $('finder-list').querySelectorAll(':scope > li[aria-selected="true"]').forEach((o) => o.setAttribute('aria-selected', 'false'));
@@ -563,44 +572,68 @@ window.ermiteFinder = (() => {
563572 setActive(el, items[0]);
564573 rendered = res;
565574 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';
567576 status(msg);
568577 const open = pendingEnter === q;
569578 pendingEnter = null;
570579 if (open) items[0]?.querySelector('a').click();
571580 }
572581
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.
573589 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) {
577609 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+ }
604637 }
605638
606639 // input runs the search of the field, once per frame.
@@ -615,7 +648,7 @@ window.ermiteFinder = (() => {
615648 }
616649
617650 return {
618- index, search, positions, visible, load, palette, input,
651+ index, search, positions, visible, label, load, palette, paletteClose, input,
619652 // key moves the cursor of the results, opens one, or clears the field.
620653 key(el, e) {
621654 if (e.isComposing) return; // the Enter or Escape of an input method is its own
@@ -626,8 +659,7 @@ window.ermiteFinder = (() => {
626659 return;
627660 }
628661 if (!results) return;
629- const list = $('finder-list');
630- const cur = list.querySelector(':scope > li[data-cursor]');
662+ const cur = syncActive(el);
631663 if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
632664 e.preventDefault();
633665 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 shows390 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 came392 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 use406+ 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 own654 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;

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