v0.17.0 ab8e178

files.js RawHistoryFolder

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})();

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 4

Tags 45

↑↓ moveenter openesc close