v0.24.0 166837a

files.js RawHistoryFolder

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 link of path i of the result: its label, out of the tab order.
639  function hitLink(res, i) {
640    const path = res.idx.paths[i];
641    const a = document.createElement('a');
642    a.href = blobHref(path);
643    a.tabIndex = -1;
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    rendered = null;
688    pendingEnter = null;
689    $('finder').hidden = true;
690    $('tree-view').hidden = false;
691    el.setAttribute('aria-expanded', 'false');
692    setActive(el, null);
693    const list = $('finder-list');
694    list.removeAttribute('data-rows');
695    list.replaceChildren();
696    const rows = $('tree-rows');
697    rows.setAttribute('data-rows', '');
698    const keep = treeCursor;
699    treeCursor = null;
700    if (!rows.querySelector(':scope > [data-cursor]')) {
701      const row = keep && keep.parentElement === rows && !keep.hidden ? keep : rows.querySelector(':scope > li:not([hidden])');
702      if (row) window.ermiteTree.mark(row);
703    }
704  }
705
706  // showResults: j/k and Enter act on the results, not on the rows behind.
707  function showResults(el) {
708    $('finder').hidden = false;
709    $('tree-view').hidden = true;
710    el.setAttribute('aria-expanded', 'true');
711    const rows = $('tree-rows');
712    rows.removeAttribute('data-rows');
713    rows.querySelectorAll(':scope > [data-cursor]').forEach((r) => {
714      treeCursor = r;
715      r.removeAttribute('data-cursor');
716    });
717    $('finder-list').setAttribute('data-rows', '');
718  }
719
720  async function run(el) {
721    const q = el.value;
722    if (pendingEnter !== null && pendingEnter !== q) pendingEnter = null; // the field moved on
723    if (!q.trim()) return showTree(el);
724    showResults(el);
725    if (!rendered) status('loading the file list…');
726    let loaded;
727    try {
728      loaded = await load();
729    } catch (e) {
730      if (el.value === q) {
731        pendingEnter = null;
732        status('could not load the file list');
733      }
734      return;
735    }
736    if (el.value !== q) return; // the field changed during the wait
737    if (!loaded) {
738      pendingEnter = null;
739      status('file search is not available here');
740      return;
741    }
742    const res = search(loaded.idx, q, 50, rendered);
743    const items = res.hits.map((h, n) => option(res, h, n));
744    const list = $('finder-list');
745    list.replaceChildren(...items);
746    items[0]?.setAttribute('data-cursor', '');
747    setActive(el, items[0]);
748    rendered = res;
749    let msg = res.all.length === 0 ? 'no file matches' : res.all.length + (res.all.length === 1 ? ' file matches' : ' files match');
750    if (loaded.truncated) msg += ' · only the first ' + loaded.idx.paths.length.toLocaleString('en') + ' files are searched';
751    status(msg);
752    const open = pendingEnter === q;
753    pendingEnter = null;
754    if (open) items[0]?.querySelector('a').click();
755  }
756
757  // The palette keeps its own frame and previous result: the field of the
758  // dialog and the finder are searched independently.
759  let pFrame = 0;
760  let pPrev = null;
761  let pGen = 0; // bumped at close: a search still loading then is dropped
762  const clearHits = () => document.querySelectorAll('#cmd-list > li.finder-hit').forEach((li) => li.remove());
763
764  // palette lists the file hits of the ":" field under its entries, once per frame.
765  function palette(el) {
766    if (!document.body.dataset.paths) return;
767    clearHits();
768    window.ermiteCmd?.sync(); // the cursor may have been on a hit
769    if (pFrame) cancelAnimationFrame(pFrame);
770    pFrame = 0;
771    if (!el.value.trim()) {
772      pPrev = null;
773      return;
774    }
775    pFrame = requestAnimationFrame(() => { pFrame = 0; paletteRun(el); });
776  }
777
778  // paletteClose drops the frame and the result kept for the next keystroke.
779  function paletteClose() {
780    if (pFrame) cancelAnimationFrame(pFrame);
781    pFrame = 0;
782    pPrev = null;
783    pGen++;
784  }
785
786  async function paletteRun(el) {
787    const q = el.value;
788    const gen = pGen;
789    let loaded;
790    try {
791      loaded = await load();
792    } catch (e) {
793      return;
794    }
795    if (el.value !== q || gen !== pGen || !loaded) return; // the field changed or the dialog closed during the wait
796    const res = search(loaded.idx, q, 20, pPrev);
797    pPrev = res;
798    clearHits();
799    const list = $('cmd-list');
800    for (const h of res.hits) {
801      const li = document.createElement('li');
802      li.className = 'finder-hit';
803      const a = hitLink(res, h.i);
804      const k = document.createElement('span');
805      k.className = 'k';
806      k.textContent = 'file';
807      a.append(k);
808      li.append(a);
809      list.append(li);
810    }
811    const shown = Array.from(list.querySelectorAll(':scope > li:not([hidden])'));
812    if (shown.length && !shown.some((li) => li.hasAttribute('data-cursor'))) {
813      list.querySelectorAll(':scope > li[data-cursor]').forEach((li) => li.removeAttribute('data-cursor'));
814      shown[0].setAttribute('data-cursor', '');
815    }
816    window.ermiteCmd?.sync(); // roles, ids and the active option of the new hits
817  }
818
819  // input runs the search of the field, once per frame.
820  function input(el) {
821    if (frame) cancelAnimationFrame(frame);
822    if (!el.value.trim()) {
823      frame = 0;
824      run(el);
825      return;
826    }
827    frame = requestAnimationFrame(() => { frame = 0; run(el); });
828  }
829
830  return {
831    index, search, positions, visible, label, load, palette, paletteClose, input, sync,
832    // key moves the cursor of the results, opens one, or clears the field.
833    key(el, e) {
834      if (e.isComposing) return; // the Enter or Escape of an input method is its own
835      const results = !$('finder').hidden;
836      if (e.key === 'Escape') {
837        // With a query, Escape only clears it: the field keeps the focus (the
838        // global handler of ermite.js would blur it). Without one, it leaves the field.
839        if (!el.value) return;
840        e.preventDefault();
841        e.stopPropagation();
842        el.value = '';
843        input(el);
844        return;
845      }
846      if (!results) {
847        // A query whose first frame has not run yet: open its top hit once it does.
848        if (e.key === 'Enter' && el.value.trim()) {
849          e.preventDefault();
850          pendingEnter = el.value;
851        }
852        return;
853      }
854      const cur = syncActive(el);
855      if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
856        e.preventDefault();
857        const next = e.key === 'ArrowDown' ? cur?.nextElementSibling : cur?.previousElementSibling;
858        if (!next) return;
859        cur.removeAttribute('data-cursor');
860        next.setAttribute('data-cursor', '');
861        setActive(el, next);
862        next.scrollIntoView({ block: 'nearest' });
863      } else if (e.key === 'Enter') {
864        e.preventDefault();
865        if (rendered && rendered.q === el.value) cur?.querySelector('a')?.click();
866        else pendingEnter = el.value; // the results are stale: run opens the top hit
867      }
868    },
869  };
870})();

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