1// files.js: the Files view. The entries of a directory are the rows of a tree
2// (li, flat in one ul, each with its depth). A directory opens in place: its
3// entries are fetched once as rows (the tree page with ?rows), inserted under
4// it and kept in the DOM, so closing and opening it again needs no request.
5//
6// Rows opened in place carry data-depth, aria-level and the --depth custom
7// property (set through the CSSOM: the CSP forbids style attributes), which
8// the stylesheet turns into indentation guides. Rows of the page itself have
9// depth 0.
10//
11// Focus model: a roving tabindex on the rows. The row under the cursor
12// (data-cursor) is the only one in the tab order (tabindex 0) and it has the
13// DOM focus whenever the keyboard or a click moved the cursor, so j k l h, the
14// arrows, Enter and Tab all act on the row that is highlighted. The name link
15// of a row is out of the tab order (the row stands for it: Enter opens it);
16// the link to the last commit is tabbable only in the row under the cursor
17// (Tab from the row reaches it, the next Tab leaves the tree). Without
18// scripts nothing is taken out of the tab order. The handlers are declared in
19// the templates (hx-on:click on #tree-rows); init() runs once at load (a page
20// without a tree: nothing); ermite.js calls ermiteTree.key and open for h, l
21// and Enter, and ermiteTree.sync for the cursor moves of j k gg G.
22window.ermiteTree = (() => {
23 const depthOf = (li) => Number(li.dataset.depth || 0);
24 const isDir = (li) => li.hasAttribute('data-expand');
25 const isOpen = (li) => li.getAttribute('aria-expanded') === 'true';
26 // A directory is loaded when rows deeper than it follow it.
27 const isLoaded = (li) => { const n = li.nextElementSibling; return !!n && depthOf(n) > depthOf(li); };
28 const nameLink = (li) => li.querySelector('a.t-name');
29
30 // tab puts a row in or out of the tab order, with its link to the last commit.
31 function tab(li, on) {
32 li.setAttribute('tabindex', on ? '0' : '-1');
33 for (const a of li.querySelectorAll('a.t-commit')) a.setAttribute('tabindex', on ? '0' : '-1');
34 }
35
36 // prep takes a row out of the tab order: the one under the cursor comes back.
37 function prep(li) {
38 tab(li, false);
39 nameLink(li)?.setAttribute('tabindex', '-1');
40 }
41
42 // mark makes li the row under the cursor, the one stop of the tab order,
43 // and with focus gives it the DOM focus (without scrolling: the caller does).
44 function mark(li, focus) {
45 for (const r of Array.from(li.parentElement.children)) {
46 if (r === li || !(r.hasAttribute('data-cursor') || r.getAttribute('tabindex') === '0')) continue;
47 r.removeAttribute('data-cursor');
48 tab(r, false);
49 }
50 li.setAttribute('data-cursor', '');
51 tab(li, true);
52 if (focus) li.focus({ preventScroll: true });
53 }
54
55 // setCursor moves the cursor, and so the focus, to li, and shows it.
56 function setCursor(li) {
57 mark(li, true);
58 li.scrollIntoView({ block: 'nearest' });
59 }
60
61 // collapse hides every row under li. The cursor, and the focus, of a row it
62 // hides move to li, so they never rest on a row that cannot be seen.
63 function collapse(li) {
64 li.setAttribute('aria-expanded', 'false');
65 const d = depthOf(li);
66 let lostCursor = false;
67 let lostFocus = false;
68 for (let r = li.nextElementSibling; r && depthOf(r) > d; r = r.nextElementSibling) {
69 r.hidden = true;
70 if (r.hasAttribute('data-cursor')) lostCursor = true;
71 if (r.contains(document.activeElement)) lostFocus = true;
72 }
73 if (lostCursor || lostFocus) mark(li, lostFocus);
74 }
75
76 // expand shows the rows under li that no collapsed directory between hides.
77 function expand(li) {
78 li.setAttribute('aria-expanded', 'true');
79 const d = depthOf(li);
80 let closedAt = Infinity; // depth of the collapsed directory whose rows are skipped
81 for (let r = li.nextElementSibling; r && depthOf(r) > d; r = r.nextElementSibling) {
82 const rd = depthOf(r);
83 if (rd <= closedAt) closedAt = Infinity;
84 r.hidden = rd > closedAt;
85 if (closedAt === Infinity && isDir(r) && !isOpen(r)) closedAt = rd;
86 }
87 }
88
89 async function load(li) {
90 li.setAttribute('aria-busy', 'true');
91 try {
92 const res = await fetch(li.dataset.expand, { headers: { Accept: 'text/html' } });
93 if (!res.ok) throw new Error('HTTP ' + res.status);
94 const t = document.createElement('template');
95 t.innerHTML = await res.text();
96 const d = depthOf(li) + 1;
97 const rows = Array.from(t.content.children).filter((r) => r.tagName === 'LI');
98 if (!rows.length) throw new Error('no rows');
99 // An ancestor closed while the request was out: li is hidden, and its
100 // rows come in hidden, for the expand of that ancestor to show.
101 const covered = li.hidden;
102 for (const r of rows) {
103 r.dataset.depth = String(d);
104 r.setAttribute('aria-level', String(d + 1));
105 r.style.setProperty('--depth', String(d));
106 prep(r);
107 if (covered) r.hidden = true;
108 }
109 li.after(...rows);
110 li.removeAttribute('aria-busy');
111 if (covered) li.setAttribute('aria-expanded', 'true');
112 else expand(li);
113 } catch (e) {
114 location.href = nameLink(li).href;
115 }
116 }
117
118 function toggle(li) {
119 if (!isDir(li) || li.getAttribute('aria-busy') === 'true') return;
120 if (isOpen(li)) collapse(li);
121 else if (isLoaded(li)) expand(li);
122 else load(li);
123 }
124
125 const rowsEl = () => document.getElementById('tree-rows');
126 const cursorRow = () => rowsEl()?.querySelector(':scope > [data-cursor]') ?? null;
127 const flash = (m) => window.ermite?.flash?.(m);
128
129 // parentOf is the visible row of the directory that holds li: the closest
130 // row above it that is less deep.
131 function parentOf(li) {
132 const d = depthOf(li);
133 for (let r = li.previousElementSibling; r; r = r.previousElementSibling) if (depthOf(r) < d) return r;
134 return null;
135 }
136
137 return {
138 // click puts the cursor and the focus on the row under any click, and
139 // opens or closes the directory under a plain left click on its name or its
140 // node; any modified or other click, and a click on the last commit, keep
141 // their own navigation.
142 click(event) {
143 const li = event.target.closest('li');
144 if (!li || !li.parentElement) return;
145 mark(li, true);
146 if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
147 if (!event.target.closest('.t-main') || !isDir(li)) return;
148 event.preventDefault();
149 toggle(li);
150 },
151
152 // init takes the rows out of the tab order but the one under the cursor
153 // (the first when none has it). It runs once the page has loaded, and
154 // not at all without a tree.
155 init() {
156 const rows = typeof document === 'undefined' ? null : rowsEl();
157 if (!rows) return;
158 const lis = Array.from(rows.children);
159 for (const li of lis) prep(li);
160 const cur = cursorRow() ?? lis[0];
161 if (cur) mark(cur, false);
162 },
163
164 // sync follows a cursor that ermite.js moved (j k gg G): the focus goes
165 // with it, and the tab stop.
166 sync(li) {
167 if (li.parentElement && li.parentElement.id === 'tree-rows') mark(li, true);
168 },
169
170 // mark is for the finder, which takes the cursor away and gives it back.
171 mark(li) { mark(li, false); },
172
173 // active says that h and l (and the arrows) walk the tree: it is shown,
174 // not replaced by the results of the finder.
175 active() {
176 const rows = rowsEl();
177 return !!rows && rows.hasAttribute('data-rows');
178 },
179
180 // open is Enter on the row under the cursor: a directory opens or closes
181 // in place, a file opens. It answers whether the tree took the key.
182 open() {
183 const li = this.active() && cursorRow();
184 if (!li) return false;
185 if (isDir(li)) toggle(li);
186 else if (nameLink(li)) nameLink(li).click();
187 else flash('a submodule has no page here');
188 return true;
189 },
190
191 // arrow is a key of the tree pattern (WAI-ARIA APG Tree View) pressed with
192 // the focus on a row: it only moves the focus or the expansion, and never
193 // opens a file or leaves the page. It answers whether the key is one the
194 // tree handles (the caller then keeps the browser from scrolling).
195 // Down, Up: the next, the previous visible row. Home, End: the first, the last.
196 // Right: a closed directory opens, an open one moves to its first entry, a
197 // file does nothing.
198 // Left: an open directory closes, any other row moves to its directory,
199 // and a top-level row does nothing.
200 arrow(key) {
201 if (!this.active()) return false;
202 const li = cursorRow();
203 if (!li) return false;
204 const rows = Array.from(li.parentElement.children).filter((r) => !r.hidden);
205 const i = rows.indexOf(li);
206 switch (key) {
207 case 'ArrowDown': if (rows[i + 1]) setCursor(rows[i + 1]); break;
208 case 'ArrowUp': if (i > 0) setCursor(rows[i - 1]); break;
209 case 'Home': if (rows[0]) setCursor(rows[0]); break;
210 case 'End': if (rows.length) setCursor(rows[rows.length - 1]); break;
211 case 'ArrowRight':
212 if (!isDir(li)) break;
213 if (!isOpen(li)) toggle(li);
214 else if (rows[i + 1] && depthOf(rows[i + 1]) > depthOf(li)) setCursor(rows[i + 1]);
215 break;
216 case 'ArrowLeft':
217 if (isDir(li) && isOpen(li)) collapse(li);
218 else if (parentOf(li)) setCursor(parentOf(li));
219 break;
220 default: return false;
221 }
222 return true;
223 },
224
225 // key is l (into = true) or h on the row under the cursor, the Vim keys
226 // with their richer behaviour; it answers whether the tree took the key.
227 // l: a closed directory opens, an open one moves to its first entry, a
228 // file is opened.
229 // h: an open directory closes, any other row goes to its directory, and a
230 // top-level row to the parent directory's page.
231 key(into) {
232 if (!this.active()) return false;
233 const li = cursorRow();
234 if (!li) return false;
235 // The focus joins the cursor row first (it may still be on the page, as
236 // after a load): Enter then acts on the row the key acted on.
237 if (document.activeElement !== li && !li.contains(document.activeElement)) mark(li, true);
238 if (into) {
239 if (isDir(li)) {
240 if (!isOpen(li)) return toggle(li), true;
241 const n = li.nextElementSibling;
242 if (n && !n.hidden && depthOf(n) > depthOf(li)) setCursor(n);
243 return true;
244 }
245 const a = nameLink(li);
246 if (a) a.click();
247 else flash('a submodule has no page here');
248 return true;
249 }
250 if (isDir(li) && isOpen(li)) {
251 collapse(li);
252 return true;
253 }
254 const up = parentOf(li);
255 if (up) setCursor(up);
256 else if (rowsEl().dataset.up) location.href = rowsEl().dataset.up;
257 else flash('top of the tree');
258 return true;
259 },
260 };
261})();
262
263window.ermiteTree.init();
264
265// ermiteFinder: the fuzzy finder of the Files view and the file hits of the
266// ":" palette. The core (index, search, positions, visible) is pure and runs
267// in Node; the DOM glue below it runs only from the handlers declared in the
268// templates. A path reaches the page through textContent, never as HTML.
269window.ermiteFinder = (() => {
270 // ---- core ---------------------------------------------------------------
271
272 const NEG = -1e9;
273 const MAXLEN = 1024; // longer paths are scored on their greedy match
274 const B_OTHER = 1 << 30;
275
276 // BIT is the mask bit of an ASCII code: a-z 0-25, digit 26, "/" 27, "." 28,
277 // "-" and "_" 29, anything else 30.
278 const BIT = new Uint32Array(128).fill(B_OTHER);
279 for (let c = 97; c <= 122; c++) BIT[c] = 1 << (c - 97);
280 for (let c = 48; c <= 57; c++) BIT[c] = 1 << 26;
281 BIT[47] = 1 << 27;
282 BIT[46] = 1 << 28;
283 BIT[45] = BIT[95] = 1 << 29;
284 const bitOf = (c) => (c < 128 ? BIT[c] : B_OTHER);
285
286 const UPPER = /[A-Z]/;
287 // fold lowercases A-Z only: the length is kept, so a position in the folded
288 // string is a position in the original.
289 const fold = (s) => (UPPER.test(s) ? s.replace(/[A-Z]/g, (c) => String.fromCharCode(c.charCodeAt(0) | 32)) : s);
290
291 // index prepares the paths for search.
292 function index(paths) {
293 const n = paths.length;
294 const lower = new Array(n);
295 const mask = new Uint32Array(n);
296 const base = new Int32Array(n);
297 for (let i = 0; i < n; i++) {
298 const p = paths[i];
299 const l = fold(p);
300 lower[i] = l;
301 let m = 0;
302 for (let j = 0; j < l.length; j++) m |= bitOf(l.charCodeAt(j));
303 mask[i] = m;
304 base[i] = p.lastIndexOf('/') + 1;
305 }
306 return { paths, lower, mask, base };
307 }
308
309 // bonusAt rewards a match at a boundary: after "/" or at the start, after a
310 // separator, at a camelCase hump or a letter after a digit.
311 function bonusAt(orig, j) {
312 if (j === 0) return 10;
313 const p = orig.charCodeAt(j - 1);
314 if (p === 47) return 10;
315 if (p === 95 || p === 45 || p === 46 || p === 32) return 8;
316 const c = orig.charCodeAt(j);
317 const upper = c >= 65 && c <= 90;
318 if (p >= 97 && p <= 122 && upper) return 7;
319 if (p >= 48 && p <= 57 && (upper || (c >= 97 && c <= 122))) return 7;
320 return 0;
321 }
322
323 // Scratch space of matchTerm.
324 let sLo = new Int32Array(64);
325 let sHi = new Int32Array(64);
326 let sOut = new Int32Array(64);
327 let sPtr = new Int32Array(0);
328 let rowA = new Int32Array(MAXLEN + 2);
329 let rowB = new Int32Array(MAXLEN + 2);
330
331 // matchTerm scores the best placement of the characters of t in s (the
332 // folded path; orig is the path as it is), starting at from or later: NEG
333 // when there is none. With trace, the positions of the best placement are
334 // left in sOut[0..t.length). It is the DP of fzf v2: a matched character is
335 // worth 16 plus its boundary bonus (doubled for the first), one that follows
336 // the previous match gains max(bonus, 4), and a gap costs 3 to open and 1
337 // per further skipped character.
338 function matchTerm(orig, s, t, from, trace) {
339 const n = s.length;
340 const m = t.length;
341 if (sLo.length < m) {
342 sLo = new Int32Array(m * 2);
343 sHi = new Int32Array(m * 2);
344 sOut = new Int32Array(m * 2);
345 }
346 let pos = from - 1;
347 for (let i = 0; i < m; i++) {
348 pos = s.indexOf(t[i], pos + 1);
349 if (pos < 0) return NEG;
350 sLo[i] = pos;
351 }
352 pos = n;
353 for (let i = m - 1; i >= 0; i--) {
354 pos = s.lastIndexOf(t[i], pos - 1);
355 sHi[i] = pos;
356 }
357
358 if (n > MAXLEN) {
359 // The greedy match, scored by the same rules.
360 let score = 0;
361 for (let i = 0; i < m; i++) {
362 const j = sLo[i];
363 const b = bonusAt(orig, j);
364 score += 16 + (i === 0 ? 2 * b : b);
365 if (i > 0) {
366 const gap = j - sLo[i - 1] - 1;
367 score += gap === 0 ? Math.max(b, 4) : -(gap + 2);
368 }
369 if (trace) sOut[i] = j;
370 }
371 return score;
372 }
373
374 const width = n + 1;
375 if (trace && sPtr.length < m * width) sPtr = new Int32Array(m * width);
376 let prev = rowA;
377 let cur = rowB;
378 for (let i = 0; i < m; i++) {
379 cur.fill(NEG, 0, width);
380 const c = t.charCodeAt(i);
381 const hi = sHi[i];
382 let rm = NEG; // best of prev[k] + k over the k <= j-2 seen so far
383 let rmK = -1;
384 let kk = i > 0 ? sLo[i - 1] : 0;
385 for (let j = sLo[i]; j <= hi; j++) {
386 if (s.charCodeAt(j) !== c) continue;
387 const b = bonusAt(orig, j);
388 let sc = 16 + (i === 0 ? 2 * b : b);
389 let fromK = -1;
390 if (i > 0) {
391 let best = NEG;
392 if (j > 0 && prev[j - 1] > NEG) {
393 best = prev[j - 1] + (b > 4 ? b : 4);
394 fromK = j - 1;
395 }
396 for (; kk <= j - 2; kk++) {
397 if (prev[kk] > NEG && prev[kk] + kk > rm) {
398 rm = prev[kk] + kk;
399 rmK = kk;
400 }
401 }
402 if (rm > NEG && rm - j - 1 > best) {
403 best = rm - j - 1;
404 fromK = rmK;
405 }
406 if (best <= NEG) continue;
407 sc += best;
408 }
409 cur[j] = sc;
410 if (trace) sPtr[i * width + j] = fromK;
411 }
412 const tmp = prev;
413 prev = cur;
414 cur = tmp;
415 }
416 // prev is the row of the last character.
417 let bestScore = NEG;
418 let bestJ = -1;
419 for (let j = sLo[m - 1]; j <= sHi[m - 1]; j++) {
420 if (prev[j] > bestScore) {
421 bestScore = prev[j];
422 bestJ = j;
423 }
424 }
425 if (trace && bestJ >= 0) {
426 for (let i = m - 1, j = bestJ; i >= 0; i--) {
427 sOut[i] = j;
428 j = sPtr[i * width + j];
429 }
430 }
431 return bestScore;
432 }
433
434 // termScore is matchTerm plus, for the last term, the path bonuses: 24 when
435 // its match lies in the basename, 16 when the basename starts with it. With
436 // trace, the winning positions are in `got`.
437 let got = [];
438 function termScore(orig, s, t, base, isLast, trace) {
439 if (trace) got = [];
440 let best = matchTerm(orig, s, t, 0, trace);
441 if (best <= NEG) return NEG;
442 if (trace) got = Array.from(sOut.subarray(0, t.length));
443 if (!isLast) return best;
444 if (base === 0) {
445 best += 24;
446 } else {
447 const r = matchTerm(orig, s, t, base, trace);
448 if (r > NEG && r + 24 > best) {
449 best = r + 24;
450 if (trace) got = Array.from(sOut.subarray(0, t.length));
451 }
452 }
453 if (s.startsWith(t, base)) best += 16;
454 return best;
455 }
456
457 const termsOf = (query) => fold(query).split(/\s+/).filter(Boolean);
458
459 // search returns the k best paths for the query, and `all`, the indices of
460 // every path that matches. prev, the result of an earlier search of the same
461 // index, narrows the candidates when the new query extends it.
462 function search(idx, query, k = 50, prev = null) {
463 const terms = termsOf(query);
464 const res = { q: query, terms, hits: [], all: new Int32Array(0), idx };
465 if (!terms.length) return res;
466 let qmask = 0;
467 for (const t of terms) for (let j = 0; j < t.length; j++) qmask |= bitOf(t.charCodeAt(j));
468
469 let cand = null;
470 if (prev && prev.idx === idx && prev.terms.length > 0 && prev.terms.length <= terms.length
471 && prev.terms.every((t, n) => terms[n].startsWith(t))) cand = prev.all;
472 const count = cand ? cand.length : idx.paths.length;
473 const { lower, mask } = idx;
474 const all = new Int32Array(count);
475 let na = 0;
476 for (let c = 0; c < count; c++) {
477 const i = cand ? cand[c] : c;
478 if ((mask[i] & qmask) !== qmask) continue;
479 const s = lower[i];
480 let ok = true;
481 for (const t of terms) {
482 let pos = -1;
483 for (let n = 0; n < t.length; n++) {
484 pos = s.indexOf(t[n], pos + 1);
485 if (pos < 0) { ok = false; break; }
486 }
487 if (!ok) break;
488 }
489 if (ok) all[na++] = i;
490 }
491 res.all = all.subarray(0, na);
492
493 // The k best, by sorted insertion: score desc, length asc, path asc.
494 const { paths, base } = idx;
495 const topI = new Int32Array(k);
496 const topS = new Int32Array(k);
497 let nt = 0;
498 const last = terms.length - 1;
499 for (let c = 0; c < na; c++) {
500 const i = all[c];
501 const orig = paths[i];
502 const s = lower[i];
503 let score = 0;
504 for (let n = 0; n <= last; n++) {
505 const sc = termScore(orig, s, terms[n], base[i], n === last, false);
506 if (sc <= NEG) { score = NEG; break; }
507 score += sc;
508 }
509 if (score <= NEG) continue;
510 if (nt === k) {
511 const w = topI[k - 1];
512 if (score < topS[k - 1]) continue;
513 if (score === topS[k - 1]) {
514 const a = paths[w];
515 if (orig.length > a.length || (orig.length === a.length && orig >= a)) continue;
516 }
517 }
518 let at = nt < k ? nt : k - 1;
519 while (at > 0) {
520 const o = topI[at - 1];
521 const os = topS[at - 1];
522 const a = paths[o];
523 if (os > score || (os === score && (a.length < orig.length || (a.length === orig.length && a < orig)))) break;
524 topI[at] = o;
525 topS[at] = os;
526 at--;
527 }
528 topI[at] = i;
529 topS[at] = score;
530 if (nt < k) nt++;
531 }
532 for (let h = 0; h < nt; h++) res.hits.push({ i: topI[h], score: topS[h] });
533 return res;
534 }
535
536 // positions are the indices of path i that the query matches, ascending.
537 function positions(idx, i, query) {
538 const terms = termsOf(query);
539 const orig = idx.paths[i];
540 const s = idx.lower[i];
541 const set = new Set();
542 for (let n = 0; n < terms.length; n++) {
543 termScore(orig, s, terms[n], idx.base[i], n === terms.length - 1, true);
544 for (const p of got) set.add(p);
545 }
546 return Array.from(set).sort((a, b) => a - b);
547 }
548
549 // visible replaces the characters that read as nothing (the set of
550 // invisible in text.go) by their <U+XXXX> marker.
551 const INVISIBLE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\u115F\u1160\u3164\uFFA0]/gu;
552 const visible = (s) => s.replace(INVISIBLE, (c) => '<U+' + c.codePointAt(0).toString(16).toUpperCase().padStart(4, '0') + '>');
553
554 // ---- DOM glue -----------------------------------------------------------
555
556 const $ = (id) => document.getElementById(id);
557 let loading = null; // the promise of {idx, truncated}; null before the first load and after a failure
558 let rendered = null; // the result the finder list shows
559 let frame = 0;
560 let treeCursor = null; // the row of the tree that had the cursor when the results came
561 let pendingEnter = null; // the value of the field at an Enter pressed before its results
562
563 // load fetches the path list of the page once and indexes it.
564 function load() {
565 const url = document.body.dataset.paths;
566 if (!url) return Promise.resolve(null);
567 if (!loading) {
568 const p = fetch(url, { headers: { Accept: 'application/json' } })
569 .then((res) => {
570 if (!res.ok) throw new Error('HTTP ' + res.status);
571 return res.json();
572 })
573 .then((j) => ({ idx: index(j.paths || []), truncated: !!j.truncated }));
574 loading = p;
575 p.catch(() => { if (loading === p) loading = null; }); // a failure is retried at the next use
576 }
577 return loading;
578 }
579
580 // The server's JSON never carries a lone surrogate, so encodeURIComponent
581 // cannot throw here.
582 const blobHref = (path) => document.body.dataset.blob + '/' + path.split('/').map(encodeURIComponent).join('/');
583
584 const isHigh = (c) => c >= 0xd800 && c <= 0xdbff;
585 const isLow = (c) => c >= 0xdc00 && c <= 0xdfff;
586
587 // runs appends text[from, to) to parent, the marked characters in <mark>. A
588 // surrogate pair is marked whole or not at all, so it is never split.
589 function runs(parent, text, from, to, marks) {
590 const marked = (j) => {
591 if (marks.has(j)) return true;
592 const c = text.charCodeAt(j);
593 if (isLow(c) && isHigh(text.charCodeAt(j - 1))) return marks.has(j - 1);
594 if (isHigh(c) && isLow(text.charCodeAt(j + 1))) return marks.has(j + 1);
595 return false;
596 };
597 for (let j = from; j < to;) {
598 const m = marked(j);
599 let e = j;
600 while (e < to && marked(e) === m) e++;
601 const part = text.slice(j, e);
602 if (m) {
603 const mk = document.createElement('mark');
604 mk.textContent = part;
605 parent.append(mk);
606 } else {
607 parent.append(document.createTextNode(part));
608 }
609 j = e;
610 }
611 }
612
613 // label is the span of a path: the directory dimmed, the name strong, the
614 // matched characters marked. A path with an invisible character shows its
615 // markers and no highlight.
616 function label(path, pos) {
617 const lbl = document.createElement('span');
618 lbl.className = 'lbl';
619 const shown = visible(path);
620 if (shown !== path) {
621 lbl.textContent = shown;
622 return lbl;
623 }
624 const cut = path.lastIndexOf('/') + 1;
625 const marks = new Set(pos);
626 if (cut > 0) {
627 const dir = document.createElement('span');
628 dir.className = 'dir';
629 runs(dir, path, 0, cut, marks);
630 lbl.append(dir);
631 }
632 const name = document.createElement('strong');
633 runs(name, path, cut, path.length, marks);
634 lbl.append(name);
635 return lbl;
636 }
637
638 // hitLink is the presentation wrapper of path i; the option owns activation.
639 function hitLink(res, i) {
640 const path = res.idx.paths[i];
641 const a = document.createElement('a');
642 a.dataset.href = blobHref(path);
643 a.setAttribute('role', 'presentation');
644 a.append(label(path, positions(res.idx, i, res.q)));
645 return a;
646 }
647
648 function option(res, hit, n) {
649 const li = document.createElement('li');
650 li.setAttribute('role', 'option');
651 li.setAttribute('aria-selected', 'false');
652 li.id = 'finder-o' + n;
653 li.append(hitLink(res, hit.i));
654 return li;
655 }
656
657 const status = (text) => { $('finder-status').textContent = text; };
658
659 // syncActive makes the option that has data-cursor the active one: j/k
660 // (keys.templ) move the cursor while the field is blurred.
661 function syncActive(el) {
662 const cur = $('finder-list').querySelector(':scope > li[data-cursor]');
663 if (cur && cur.getAttribute('aria-selected') !== 'true') setActive(el, cur);
664 return cur;
665 }
666
667 // setActive makes li the active option (null: none) of the listbox.
668 function setActive(el, li) {
669 $('finder-list').querySelectorAll(':scope > li[aria-selected="true"]').forEach((o) => o.setAttribute('aria-selected', 'false'));
670 if (li) {
671 li.setAttribute('aria-selected', 'true');
672 el.setAttribute('aria-activedescendant', li.id);
673 } else {
674 el.removeAttribute('aria-activedescendant');
675 }
676 }
677
678 // sync is syncActive for whoever moves the cursor of the results from
679 // outside (j / k and gg / G, ermite.js): the options and the field follow it.
680 function sync() {
681 const el = $('finder-q');
682 if (el && $('finder') && !$('finder').hidden) syncActive(el);
683 }
684
685 // showTree: no query, the directory again, with j/k and Enter on its rows.
686 function showTree(el) {
687 status('');
688 rendered = null;
689 pendingEnter = null;
690 $('finder').hidden = true;
691 $('tree-view').hidden = false;
692 el.setAttribute('aria-expanded', 'false');
693 setActive(el, null);
694 const list = $('finder-list');
695 list.removeAttribute('data-rows');
696 list.replaceChildren();
697 const rows = $('tree-rows');
698 rows.setAttribute('data-rows', '');
699 const keep = treeCursor;
700 treeCursor = null;
701 if (!rows.querySelector(':scope > [data-cursor]')) {
702 const row = keep && keep.parentElement === rows && !keep.hidden ? keep : rows.querySelector(':scope > li:not([hidden])');
703 if (row) window.ermiteTree.mark(row);
704 }
705 }
706
707 // showResults: j/k and Enter act on the results, not on the rows behind.
708 function showResults(el) {
709 $('finder').hidden = false;
710 $('tree-view').hidden = true;
711 el.setAttribute('aria-expanded', 'true');
712 const rows = $('tree-rows');
713 rows.removeAttribute('data-rows');
714 rows.querySelectorAll(':scope > [data-cursor]').forEach((r) => {
715 treeCursor = r;
716 r.removeAttribute('data-cursor');
717 });
718 $('finder-list').setAttribute('data-rows', '');
719 }
720
721 async function run(el) {
722 const q = el.value;
723 if (pendingEnter !== null && pendingEnter !== q) pendingEnter = null; // the field moved on
724 if (!q.trim()) return showTree(el);
725 showResults(el);
726 if (!rendered) status('loading the file list…');
727 let loaded;
728 try {
729 loaded = await load();
730 } catch (e) {
731 if (el.value === q) {
732 pendingEnter = null;
733 status('could not load the file list');
734 }
735 return;
736 }
737 if (el.value !== q) return; // the field changed during the wait
738 if (!loaded) {
739 pendingEnter = null;
740 status('file search is not available here');
741 return;
742 }
743 const res = search(loaded.idx, q, 50, rendered);
744 const items = res.hits.map((h, n) => option(res, h, n));
745 const list = $('finder-list');
746 list.replaceChildren(...items);
747 items[0]?.setAttribute('data-cursor', '');
748 setActive(el, items[0]);
749 rendered = res;
750 let msg = res.all.length === 0 ? 'no file matches' : res.all.length + (res.all.length === 1 ? ' file matches' : ' files match');
751 if (loaded.truncated) msg += ' · only the first ' + loaded.idx.paths.length.toLocaleString('en') + ' files are searched';
752 status(msg);
753 const open = pendingEnter === q;
754 pendingEnter = null;
755 if (open) items[0]?.querySelector('a').click();
756 }
757
758 // The palette keeps its own frame and previous result: the field of the
759 // dialog and the finder are searched independently.
760 let pFrame = 0;
761 let pPrev = null;
762 let pGen = 0; // bumped at close: a search still loading then is dropped
763 const clearHits = () => document.querySelectorAll('#cmd-list > li.finder-hit').forEach((li) => li.remove());
764
765 // palette lists the file hits of the ":" field under its entries, once per frame.
766 function palette(el) {
767 if (!document.body.dataset.paths) return;
768 clearHits();
769 window.ermiteCmd?.sync(); // the cursor may have been on a hit
770 if (pFrame) cancelAnimationFrame(pFrame);
771 pFrame = 0;
772 if (!el.value.trim()) {
773 pPrev = null;
774 return;
775 }
776 pFrame = requestAnimationFrame(() => { pFrame = 0; paletteRun(el); });
777 }
778
779 // paletteClose drops the frame and the result kept for the next keystroke.
780 function paletteClose() {
781 if (pFrame) cancelAnimationFrame(pFrame);
782 pFrame = 0;
783 pPrev = null;
784 pGen++;
785 }
786
787 async function paletteRun(el) {
788 const q = el.value;
789 const gen = pGen;
790 let loaded;
791 try {
792 loaded = await load();
793 } catch (e) {
794 return;
795 }
796 if (el.value !== q || gen !== pGen || !loaded) return; // the field changed or the dialog closed during the wait
797 const res = search(loaded.idx, q, 20, pPrev);
798 pPrev = res;
799 clearHits();
800 const list = $('cmd-list');
801 for (const h of res.hits) {
802 const li = document.createElement('li');
803 li.className = 'finder-hit';
804 const a = hitLink(res, h.i);
805 const k = document.createElement('span');
806 k.className = 'k';
807 k.textContent = 'file';
808 a.append(k);
809 li.append(a);
810 list.append(li);
811 }
812 const shown = Array.from(list.querySelectorAll(':scope > li:not([hidden])'));
813 if (shown.length && !shown.some((li) => li.hasAttribute('data-cursor'))) {
814 list.querySelectorAll(':scope > li[data-cursor]').forEach((li) => li.removeAttribute('data-cursor'));
815 shown[0].setAttribute('data-cursor', '');
816 }
817 window.ermiteCmd?.sync(); // roles, ids and the active option of the new hits
818 }
819
820 // input runs the search of the field, once per frame.
821 function input(el) {
822 if (frame) cancelAnimationFrame(frame);
823 if (!el.value.trim()) {
824 frame = 0;
825 run(el);
826 return;
827 }
828 frame = requestAnimationFrame(() => { frame = 0; run(el); });
829 }
830
831 return {
832 index, search, positions, visible, label, load, palette, paletteClose, input, sync,
833 // key moves the cursor of the results, opens one, or clears the field.
834 key(el, e) {
835 if (e.isComposing) return; // the Enter or Escape of an input method is its own
836 const results = !$('finder').hidden;
837 if (e.key === 'Escape') {
838 // With a query, Escape only clears it: the field keeps the focus (the
839 // global handler of ermite.js would blur it). Without one, it leaves the field.
840 if (!el.value) return;
841 e.preventDefault();
842 e.stopPropagation();
843 el.value = '';
844 input(el);
845 return;
846 }
847 if (!results) {
848 // A query whose first frame has not run yet: open its top hit once it does.
849 if (e.key === 'Enter' && el.value.trim()) {
850 e.preventDefault();
851 pendingEnter = el.value;
852 }
853 return;
854 }
855 const cur = syncActive(el);
856 if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
857 e.preventDefault();
858 const next = e.key === 'ArrowDown' ? cur?.nextElementSibling : cur?.previousElementSibling;
859 if (!next) return;
860 cur.removeAttribute('data-cursor');
861 next.setAttribute('data-cursor', '');
862 setActive(el, next);
863 next.scrollIntoView({ block: 'nearest' });
864 } else if (e.key === 'Enter') {
865 e.preventDefault();
866 if (rendered && rendered.q === el.value) cur?.querySelector('a')?.click();
867 else pendingEnter = el.value; // the results are stale: run opens the top hit
868 }
869 },
870 };
871})();