1// files.js: the Files view. A directory opens in place: its entries are
2// fetched once as table rows (the tree page with ?rows), inserted under it
3// and kept in the DOM, so closing and opening it again needs no request.
4//
5// Rows opened in place carry data-depth and the --depth custom property
6// (set through the CSSOM: the CSP forbids style attributes). Rows of the
7// page itself have depth 0. Nothing here touches the document at load; the
8// handlers are declared in the templates (hx-on:click on #tree-rows).
9window.ermiteTree = (() => {
10 const depthOf = (tr) => Number(tr.dataset.depth || 0);
11 const linkOf = (tr) => tr.querySelector('a[data-expand]');
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); };
15
16 function setOpen(tr, a, open) {
17 a.setAttribute('aria-expanded', open ? 'true' : 'false');
18 const kind = tr.querySelector('.c-kind');
19 if (kind) kind.textContent = open ? '▾' : '▸';
20 }
21
22 // collapse hides every row under tr. The cursor of a row it hides moves to
23 // tr, so it never rests on a row that cannot be seen.
24 function collapse(tr, a) {
25 setOpen(tr, a, false);
26 const d = depthOf(tr);
27 for (let r = tr.nextElementSibling; r && depthOf(r) > d; r = r.nextElementSibling) {
28 r.hidden = true;
29 if (r.hasAttribute('data-cursor')) {
30 r.removeAttribute('data-cursor');
31 tr.setAttribute('data-cursor', '');
32 }
33 }
34 }
35
36 // expand shows the rows under tr that no collapsed directory between hides.
37 function expand(tr, a) {
38 setOpen(tr, a, true);
39 const d = depthOf(tr);
40 let closedAt = Infinity; // depth of the collapsed directory whose rows are skipped
41 for (let r = tr.nextElementSibling; r && depthOf(r) > d; r = r.nextElementSibling) {
42 const rd = depthOf(r);
43 if (rd <= closedAt) closedAt = Infinity;
44 r.hidden = rd > closedAt;
45 const la = linkOf(r);
46 if (closedAt === Infinity && la && !isOpen(la)) closedAt = rd;
47 }
48 }
49
50 async function load(tr, a) {
51 tr.setAttribute('aria-busy', 'true');
52 try {
53 const res = await fetch(a.dataset.expand, { headers: { Accept: 'text/html' } });
54 if (!res.ok) throw new Error('HTTP ' + res.status);
55 const t = document.createElement('template');
56 t.innerHTML = await res.text();
57 const d = depthOf(tr) + 1;
58 const rows = Array.from(t.content.children).filter((r) => r.tagName === 'TR');
59 if (!rows.length) throw new Error('no rows');
60 // An ancestor closed while the request was out: tr is hidden, and its
61 // rows come in hidden, for the expand of that ancestor to show.
62 const covered = tr.hidden;
63 for (const r of rows) {
64 r.dataset.depth = String(d);
65 r.style.setProperty('--depth', String(d));
66 if (covered) r.hidden = true;
67 }
68 tr.after(...rows);
69 tr.removeAttribute('aria-busy');
70 if (covered) setOpen(tr, a, true);
71 else expand(tr, a);
72 } catch (e) {
73 location.href = a.href;
74 }
75 }
76
77 function toggle(tr) {
78 const a = linkOf(tr);
79 if (!a || tr.getAttribute('aria-busy') === 'true') return;
80 if (isOpen(a)) collapse(tr, a);
81 else if (isLoaded(tr)) expand(tr, a);
82 else load(tr, a);
83 }
84
85 return {
86 // click opens or closes the directory under a plain left click; any
87 // modified or other click keeps the link's own navigation.
88 click(event) {
89 const a = event.target.closest('a[data-expand]');
90 if (!a || event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
91 event.preventDefault();
92 toggle(a.closest('tr'));
93 },
94 };
95})();
96
97// ermiteFinder: the fuzzy finder of the Files view and the file hits of the
98// ":" palette. The core (index, search, positions, visible) is pure and runs
99// in Node; the DOM glue below it runs only from the handlers declared in the
100// templates. A path reaches the page through textContent, never as HTML.
101window.ermiteFinder = (() => {
102 // ---- core ---------------------------------------------------------------
103
104 const NEG = -1e9;
105 const MAXLEN = 1024; // longer paths are scored on their greedy match
106 const B_OTHER = 1 << 30;
107
108 // BIT is the mask bit of an ASCII code: a-z 0-25, digit 26, "/" 27, "." 28,
109 // "-" and "_" 29, anything else 30.
110 const BIT = new Uint32Array(128).fill(B_OTHER);
111 for (let c = 97; c <= 122; c++) BIT[c] = 1 << (c - 97);
112 for (let c = 48; c <= 57; c++) BIT[c] = 1 << 26;
113 BIT[47] = 1 << 27;
114 BIT[46] = 1 << 28;
115 BIT[45] = BIT[95] = 1 << 29;
116 const bitOf = (c) => (c < 128 ? BIT[c] : B_OTHER);
117
118 const UPPER = /[A-Z]/;
119 // fold lowercases A-Z only: the length is kept, so a position in the folded
120 // string is a position in the original.
121 const fold = (s) => (UPPER.test(s) ? s.replace(/[A-Z]/g, (c) => String.fromCharCode(c.charCodeAt(0) | 32)) : s);
122
123 // index prepares the paths for search.
124 function index(paths) {
125 const n = paths.length;
126 const lower = new Array(n);
127 const mask = new Uint32Array(n);
128 const base = new Int32Array(n);
129 for (let i = 0; i < n; i++) {
130 const p = paths[i];
131 const l = fold(p);
132 lower[i] = l;
133 let m = 0;
134 for (let j = 0; j < l.length; j++) m |= bitOf(l.charCodeAt(j));
135 mask[i] = m;
136 base[i] = p.lastIndexOf('/') + 1;
137 }
138 return { paths, lower, mask, base };
139 }
140
141 // bonusAt rewards a match at a boundary: after "/" or at the start, after a
142 // separator, at a camelCase hump or a letter after a digit.
143 function bonusAt(orig, j) {
144 if (j === 0) return 10;
145 const p = orig.charCodeAt(j - 1);
146 if (p === 47) return 10;
147 if (p === 95 || p === 45 || p === 46 || p === 32) return 8;
148 const c = orig.charCodeAt(j);
149 const upper = c >= 65 && c <= 90;
150 if (p >= 97 && p <= 122 && upper) return 7;
151 if (p >= 48 && p <= 57 && (upper || (c >= 97 && c <= 122))) return 7;
152 return 0;
153 }
154
155 // Scratch space of matchTerm.
156 let sLo = new Int32Array(64);
157 let sHi = new Int32Array(64);
158 let sOut = new Int32Array(64);
159 let sPtr = new Int32Array(0);
160 let rowA = new Int32Array(MAXLEN + 2);
161 let rowB = new Int32Array(MAXLEN + 2);
162
163 // matchTerm scores the best placement of the characters of t in s (the
164 // folded path; orig is the path as it is), starting at from or later: NEG
165 // when there is none. With trace, the positions of the best placement are
166 // left in sOut[0..t.length). It is the DP of fzf v2: a matched character is
167 // worth 16 plus its boundary bonus (doubled for the first), one that follows
168 // the previous match gains max(bonus, 4), and a gap costs 3 to open and 1
169 // per further skipped character.
170 function matchTerm(orig, s, t, from, trace) {
171 const n = s.length;
172 const m = t.length;
173 if (sLo.length < m) {
174 sLo = new Int32Array(m * 2);
175 sHi = new Int32Array(m * 2);
176 sOut = new Int32Array(m * 2);
177 }
178 let pos = from - 1;
179 for (let i = 0; i < m; i++) {
180 pos = s.indexOf(t[i], pos + 1);
181 if (pos < 0) return NEG;
182 sLo[i] = pos;
183 }
184 pos = n;
185 for (let i = m - 1; i >= 0; i--) {
186 pos = s.lastIndexOf(t[i], pos - 1);
187 sHi[i] = pos;
188 }
189
190 if (n > MAXLEN) {
191 // The greedy match, scored by the same rules.
192 let score = 0;
193 for (let i = 0; i < m; i++) {
194 const j = sLo[i];
195 const b = bonusAt(orig, j);
196 score += 16 + (i === 0 ? 2 * b : b);
197 if (i > 0) {
198 const gap = j - sLo[i - 1] - 1;
199 score += gap === 0 ? Math.max(b, 4) : -(gap + 2);
200 }
201 if (trace) sOut[i] = j;
202 }
203 return score;
204 }
205
206 const width = n + 1;
207 if (trace && sPtr.length < m * width) sPtr = new Int32Array(m * width);
208 let prev = rowA;
209 let cur = rowB;
210 for (let i = 0; i < m; i++) {
211 cur.fill(NEG, 0, width);
212 const c = t.charCodeAt(i);
213 const hi = sHi[i];
214 let rm = NEG; // best of prev[k] + k over the k <= j-2 seen so far
215 let rmK = -1;
216 let kk = i > 0 ? sLo[i - 1] : 0;
217 for (let j = sLo[i]; j <= hi; j++) {
218 if (s.charCodeAt(j) !== c) continue;
219 const b = bonusAt(orig, j);
220 let sc = 16 + (i === 0 ? 2 * b : b);
221 let fromK = -1;
222 if (i > 0) {
223 let best = NEG;
224 if (j > 0 && prev[j - 1] > NEG) {
225 best = prev[j - 1] + (b > 4 ? b : 4);
226 fromK = j - 1;
227 }
228 for (; kk <= j - 2; kk++) {
229 if (prev[kk] > NEG && prev[kk] + kk > rm) {
230 rm = prev[kk] + kk;
231 rmK = kk;
232 }
233 }
234 if (rm > NEG && rm - j - 1 > best) {
235 best = rm - j - 1;
236 fromK = rmK;
237 }
238 if (best <= NEG) continue;
239 sc += best;
240 }
241 cur[j] = sc;
242 if (trace) sPtr[i * width + j] = fromK;
243 }
244 const tmp = prev;
245 prev = cur;
246 cur = tmp;
247 }
248 // prev is the row of the last character.
249 let bestScore = NEG;
250 let bestJ = -1;
251 for (let j = sLo[m - 1]; j <= sHi[m - 1]; j++) {
252 if (prev[j] > bestScore) {
253 bestScore = prev[j];
254 bestJ = j;
255 }
256 }
257 if (trace && bestJ >= 0) {
258 for (let i = m - 1, j = bestJ; i >= 0; i--) {
259 sOut[i] = j;
260 j = sPtr[i * width + j];
261 }
262 }
263 return bestScore;
264 }
265
266 // termScore is matchTerm plus, for the last term, the path bonuses: 24 when
267 // its match lies in the basename, 16 when the basename starts with it. With
268 // trace, the winning positions are in `got`.
269 let got = [];
270 function termScore(orig, s, t, base, isLast, trace) {
271 if (trace) got = [];
272 let best = matchTerm(orig, s, t, 0, trace);
273 if (best <= NEG) return NEG;
274 if (trace) got = Array.from(sOut.subarray(0, t.length));
275 if (!isLast) return best;
276 if (base === 0) {
277 best += 24;
278 } else {
279 const r = matchTerm(orig, s, t, base, trace);
280 if (r > NEG && r + 24 > best) {
281 best = r + 24;
282 if (trace) got = Array.from(sOut.subarray(0, t.length));
283 }
284 }
285 if (s.startsWith(t, base)) best += 16;
286 return best;
287 }
288
289 const termsOf = (query) => fold(query).split(/\s+/).filter(Boolean);
290
291 // search returns the k best paths for the query, and `all`, the indices of
292 // every path that matches. prev, the result of an earlier search of the same
293 // index, narrows the candidates when the new query extends it.
294 function search(idx, query, k = 50, prev = null) {
295 const terms = termsOf(query);
296 const res = { q: query, terms, hits: [], all: new Int32Array(0), idx };
297 if (!terms.length) return res;
298 let qmask = 0;
299 for (const t of terms) for (let j = 0; j < t.length; j++) qmask |= bitOf(t.charCodeAt(j));
300
301 let cand = null;
302 if (prev && prev.idx === idx && prev.terms.length > 0 && prev.terms.length <= terms.length
303 && prev.terms.every((t, n) => terms[n].startsWith(t))) cand = prev.all;
304 const count = cand ? cand.length : idx.paths.length;
305 const { lower, mask } = idx;
306 const all = new Int32Array(count);
307 let na = 0;
308 for (let c = 0; c < count; c++) {
309 const i = cand ? cand[c] : c;
310 if ((mask[i] & qmask) !== qmask) continue;
311 const s = lower[i];
312 let ok = true;
313 for (const t of terms) {
314 let pos = -1;
315 for (let n = 0; n < t.length; n++) {
316 pos = s.indexOf(t[n], pos + 1);
317 if (pos < 0) { ok = false; break; }
318 }
319 if (!ok) break;
320 }
321 if (ok) all[na++] = i;
322 }
323 res.all = all.subarray(0, na);
324
325 // The k best, by sorted insertion: score desc, length asc, path asc.
326 const { paths, base } = idx;
327 const topI = new Int32Array(k);
328 const topS = new Int32Array(k);
329 let nt = 0;
330 const last = terms.length - 1;
331 for (let c = 0; c < na; c++) {
332 const i = all[c];
333 const orig = paths[i];
334 const s = lower[i];
335 let score = 0;
336 for (let n = 0; n <= last; n++) {
337 const sc = termScore(orig, s, terms[n], base[i], n === last, false);
338 if (sc <= NEG) { score = NEG; break; }
339 score += sc;
340 }
341 if (score <= NEG) continue;
342 if (nt === k) {
343 const w = topI[k - 1];
344 if (score < topS[k - 1]) continue;
345 if (score === topS[k - 1]) {
346 const a = paths[w];
347 if (orig.length > a.length || (orig.length === a.length && orig >= a)) continue;
348 }
349 }
350 let at = nt < k ? nt : k - 1;
351 while (at > 0) {
352 const o = topI[at - 1];
353 const os = topS[at - 1];
354 const a = paths[o];
355 if (os > score || (os === score && (a.length < orig.length || (a.length === orig.length && a < orig)))) break;
356 topI[at] = o;
357 topS[at] = os;
358 at--;
359 }
360 topI[at] = i;
361 topS[at] = score;
362 if (nt < k) nt++;
363 }
364 for (let h = 0; h < nt; h++) res.hits.push({ i: topI[h], score: topS[h] });
365 return res;
366 }
367
368 // positions are the indices of path i that the query matches, ascending.
369 function positions(idx, i, query) {
370 const terms = termsOf(query);
371 const orig = idx.paths[i];
372 const s = idx.lower[i];
373 const set = new Set();
374 for (let n = 0; n < terms.length; n++) {
375 termScore(orig, s, terms[n], idx.base[i], n === terms.length - 1, true);
376 for (const p of got) set.add(p);
377 }
378 return Array.from(set).sort((a, b) => a - b);
379 }
380
381 // visible replaces the characters that read as nothing (the set of
382 // invisible in text.go) by their <U+XXXX> marker.
383 const INVISIBLE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\u115F\u1160\u3164\uFFA0]/gu;
384 const visible = (s) => s.replace(INVISIBLE, (c) => '<U+' + c.codePointAt(0).toString(16).toUpperCase().padStart(4, '0') + '>');
385
386 // ---- DOM glue -----------------------------------------------------------
387
388 const $ = (id) => document.getElementById(id);
389 let loading = null; // the promise of {idx, truncated}; null before the first load and after a failure
390 let rendered = null; // the result the finder list shows
391 let frame = 0;
392 let treeCursor = null; // the row of the tree that had the cursor when the results came
393 let pendingEnter = null; // the value of the field at an Enter pressed before its results
394
395 // load fetches the path list of the page once and indexes it.
396 function load() {
397 const url = document.body.dataset.paths;
398 if (!url) return Promise.resolve(null);
399 if (!loading) {
400 const p = fetch(url, { headers: { Accept: 'application/json' } })
401 .then((res) => {
402 if (!res.ok) throw new Error('HTTP ' + res.status);
403 return res.json();
404 })
405 .then((j) => ({ idx: index(j.paths || []), truncated: !!j.truncated }));
406 loading = p;
407 p.catch(() => { if (loading === p) loading = null; }); // a failure is retried at the next use
408 }
409 return loading;
410 }
411
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('/');
415
416 const isHigh = (c) => c >= 0xd800 && c <= 0xdbff;
417 const isLow = (c) => c >= 0xdc00 && c <= 0xdfff;
418
419 // runs appends text[from, to) to parent, the marked characters in <mark>. A
420 // surrogate pair is marked whole or not at all, so it is never split.
421 function runs(parent, text, from, to, marks) {
422 const marked = (j) => {
423 if (marks.has(j)) return true;
424 const c = text.charCodeAt(j);
425 if (isLow(c) && isHigh(text.charCodeAt(j - 1))) return marks.has(j - 1);
426 if (isHigh(c) && isLow(text.charCodeAt(j + 1))) return marks.has(j + 1);
427 return false;
428 };
429 for (let j = from; j < to;) {
430 const m = marked(j);
431 let e = j;
432 while (e < to && marked(e) === m) e++;
433 const part = text.slice(j, e);
434 if (m) {
435 const mk = document.createElement('mark');
436 mk.textContent = part;
437 parent.append(mk);
438 } else {
439 parent.append(document.createTextNode(part));
440 }
441 j = e;
442 }
443 }
444
445 // label is the span of a path: the directory dimmed, the name strong, the
446 // matched characters marked. A path with an invisible character shows its
447 // markers and no highlight.
448 function label(path, pos) {
449 const lbl = document.createElement('span');
450 lbl.className = 'lbl';
451 const shown = visible(path);
452 if (shown !== path) {
453 lbl.textContent = shown;
454 return lbl;
455 }
456 const cut = path.lastIndexOf('/') + 1;
457 const marks = new Set(pos);
458 if (cut > 0) {
459 const dir = document.createElement('span');
460 dir.className = 'dir';
461 runs(dir, path, 0, cut, marks);
462 lbl.append(dir);
463 }
464 const name = document.createElement('strong');
465 runs(name, path, cut, path.length, marks);
466 lbl.append(name);
467 return lbl;
468 }
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
480 function option(res, hit, n) {
481 const li = document.createElement('li');
482 li.setAttribute('role', 'option');
483 li.setAttribute('aria-selected', 'false');
484 li.id = 'finder-o' + n;
485 li.append(hitLink(res, hit.i));
486 return li;
487 }
488
489 const status = (text) => { $('finder-status').textContent = text; };
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
499 // setActive makes li the active option (null: none) of the listbox.
500 function setActive(el, li) {
501 $('finder-list').querySelectorAll(':scope > li[aria-selected="true"]').forEach((o) => o.setAttribute('aria-selected', 'false'));
502 if (li) {
503 li.setAttribute('aria-selected', 'true');
504 el.setAttribute('aria-activedescendant', li.id);
505 } else {
506 el.removeAttribute('aria-activedescendant');
507 }
508 }
509
510 // showTree: no query, the directory again, with j/k and Enter on its rows.
511 function showTree(el) {
512 rendered = null;
513 pendingEnter = null;
514 $('finder').hidden = true;
515 $('tree-view').hidden = false;
516 el.setAttribute('aria-expanded', 'false');
517 setActive(el, null);
518 const list = $('finder-list');
519 list.removeAttribute('data-rows');
520 list.replaceChildren();
521 const rows = $('tree-rows');
522 rows.setAttribute('data-rows', '');
523 const keep = treeCursor;
524 treeCursor = null;
525 if (!rows.querySelector(':scope > tr[data-cursor]')) {
526 const row = keep && keep.parentElement === rows && !keep.hidden ? keep : rows.querySelector(':scope > tr:not([hidden])');
527 row?.setAttribute('data-cursor', '');
528 }
529 }
530
531 // showResults: j/k and Enter act on the results, not on the rows behind.
532 function showResults(el) {
533 $('finder').hidden = false;
534 $('tree-view').hidden = true;
535 el.setAttribute('aria-expanded', 'true');
536 const rows = $('tree-rows');
537 rows.removeAttribute('data-rows');
538 rows.querySelectorAll(':scope > tr[data-cursor]').forEach((r) => {
539 treeCursor = r;
540 r.removeAttribute('data-cursor');
541 });
542 $('finder-list').setAttribute('data-rows', '');
543 }
544
545 async function run(el) {
546 const q = el.value;
547 if (pendingEnter !== null && pendingEnter !== q) pendingEnter = null; // the field moved on
548 if (!q.trim()) return showTree(el);
549 showResults(el);
550 if (!rendered) status('loading the file list…');
551 let loaded;
552 try {
553 loaded = await load();
554 } catch (e) {
555 if (el.value === q) {
556 pendingEnter = null;
557 status('could not load the file list');
558 }
559 return;
560 }
561 if (el.value !== q) return; // the field changed during the wait
562 if (!loaded) {
563 pendingEnter = null;
564 status('file search is not available here');
565 return;
566 }
567 const res = search(loaded.idx, q, 50, rendered);
568 const items = res.hits.map((h, n) => option(res, h, n));
569 const list = $('finder-list');
570 list.replaceChildren(...items);
571 items[0]?.setAttribute('data-cursor', '');
572 setActive(el, items[0]);
573 rendered = res;
574 let msg = res.all.length === 0 ? 'no file matches' : res.all.length + (res.all.length === 1 ? ' file matches' : ' files match');
575 if (loaded.truncated) msg += ' · only the first ' + loaded.idx.paths.length.toLocaleString('en') + ' files are searched';
576 status(msg);
577 const open = pendingEnter === q;
578 pendingEnter = null;
579 if (open) items[0]?.querySelector('a').click();
580 }
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 let pGen = 0; // bumped at close: a search still loading then is dropped
587 const clearHits = () => document.querySelectorAll('#cmd-list > li.finder-hit').forEach((li) => li.remove());
588
589 // palette lists the file hits of the ":" field under its entries, once per frame.
590 function palette(el) {
591 if (!document.body.dataset.paths) return;
592 clearHits();
593 if (pFrame) cancelAnimationFrame(pFrame);
594 pFrame = 0;
595 if (!el.value.trim()) {
596 pPrev = null;
597 return;
598 }
599 pFrame = requestAnimationFrame(() => { pFrame = 0; paletteRun(el); });
600 }
601
602 // paletteClose drops the frame and the result kept for the next keystroke.
603 function paletteClose() {
604 if (pFrame) cancelAnimationFrame(pFrame);
605 pFrame = 0;
606 pPrev = null;
607 pGen++;
608 }
609
610 async function paletteRun(el) {
611 const q = el.value;
612 const gen = pGen;
613 let loaded;
614 try {
615 loaded = await load();
616 } catch (e) {
617 return;
618 }
619 if (el.value !== q || gen !== pGen || !loaded) return; // the field changed or the dialog closed during the wait
620 const res = search(loaded.idx, q, 20, pPrev);
621 pPrev = res;
622 clearHits();
623 const list = $('cmd-list');
624 for (const h of res.hits) {
625 const li = document.createElement('li');
626 li.className = 'finder-hit';
627 const a = hitLink(res, h.i);
628 const k = document.createElement('span');
629 k.className = 'k';
630 k.textContent = 'file';
631 a.append(k);
632 li.append(a);
633 list.append(li);
634 }
635 const shown = Array.from(list.querySelectorAll(':scope > li:not([hidden])'));
636 if (shown.length && !shown.some((li) => li.hasAttribute('data-cursor'))) {
637 list.querySelectorAll(':scope > li[data-cursor]').forEach((li) => li.removeAttribute('data-cursor'));
638 shown[0].setAttribute('data-cursor', '');
639 }
640 }
641
642 // input runs the search of the field, once per frame.
643 function input(el) {
644 if (frame) cancelAnimationFrame(frame);
645 if (!el.value.trim()) {
646 frame = 0;
647 run(el);
648 return;
649 }
650 frame = requestAnimationFrame(() => { frame = 0; run(el); });
651 }
652
653 return {
654 index, search, positions, visible, label, load, palette, paletteClose, input,
655 // key moves the cursor of the results, opens one, or clears the field.
656 key(el, e) {
657 if (e.isComposing) return; // the Enter or Escape of an input method is its own
658 const results = !$('finder').hidden;
659 if (e.key === 'Escape') {
660 el.value = '';
661 input(el);
662 return;
663 }
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 }
672 const cur = syncActive(el);
673 if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
674 e.preventDefault();
675 const next = e.key === 'ArrowDown' ? cur?.nextElementSibling : cur?.previousElementSibling;
676 if (!next) return;
677 cur.removeAttribute('data-cursor');
678 next.setAttribute('data-cursor', '');
679 setActive(el, next);
680 next.scrollIntoView({ block: 'nearest' });
681 } else if (e.key === 'Enter') {
682 e.preventDefault();
683 if (rendered && rendered.q === el.value) cur?.querySelector('a')?.click();
684 else pendingEnter = el.value; // the results are stale: run opens the top hit
685 }
686 },
687 };
688})();