v0.21.0 ef38ad0

ermite.js RawHistoryFolder

1// ermite.js: the behaviour of the pages that htmx and hx-live do not give.
2//
3//  1. Keys. A global key *sequence* state machine ("g" then "h") and the
4//     keystrokes aimed at inputs cannot be declared with hx-on. The keydown
5//     handler only translates keys into DOM events named "k-<name>" on <body>;
6//     keys.templ binds each of them (hx-on, one line each) to a function of
7//     window.ermite below, which does the work: row cursor, dialogs, filters,
8//     diff layout, permalink.
9//  2. Clicks on [data-fire] elements fire the same events (header buttons).
10//  3. ermiteLive applies the fragments of the event stream (see events.go).
11//  4. The file of the diff in view is marked in the table of contents.
12//  5. A handler that fails is shown in the hint badge.
13//  6. The lines a diff hides load in place (ermiteGaps; e and E).
14//  7. The service worker and the "install app" button (ermiteInstall).
15(() => {
16  // Marks the page as scripted: what does nothing without scripts (the filters,
17  // the "go to" and help buttons) is hidden until this class is set (see the
18  // utilities of 10-base.css).
19  document.documentElement.classList.add('js');
20
21  const $ = (sel, root = document) => root.querySelector(sel);
22  const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
23  const body = () => document.body;
24
25  // flash shows a message in the hint badge (body[data-flash], a status
26  // region), then clears it unless another message came in the meantime.
27  let flashTimer = 0;
28  const flash = (msg, ms = 2000) => {
29    body().dataset.flash = msg;
30    clearTimeout(flashTimer);
31    flashTimer = setTimeout(() => (body().dataset.flash = ''), ms);
32  };
33
34  const visibleRows = () => $$('[data-rows] > :not([hidden])');
35  // setCursor puts data-cursor on el and takes it from the others.
36  const setCursor = (el, others) => {
37    for (const o of others) if (o !== el) o.removeAttribute('data-cursor');
38    el.setAttribute('data-cursor', '');
39  };
40  const scrollPage = (to) => {
41    scrollTo(0, to);
42    $('#main')?.scrollTo(0, to);
43  };
44
45  const ermite = {
46    flash,
47    fire(name) {
48      body().dispatchEvent(new CustomEvent('k-' + name, { bubbles: false }));
49    },
50
51    // Row cursor: j / k. In the side panel they move the focus between its
52    // controls instead.
53    move(d) {
54      if ($('dialog[open]')) return;
55      if (window.ermiteMR?.stepFiles(d) || window.ermiteRail.step(d)) return;
56      const rows = visibleRows();
57      if (!rows.length) {
58        scrollBy(0, d * 180);
59        $('#main')?.scrollBy(0, d * 180);
60        return;
61      }
62      const i = rows.findIndex((r) => r.hasAttribute('data-cursor'));
63      const j = Math.max(0, Math.min(rows.length - 1, i + d));
64      setCursor(rows[j], $$('[data-rows] > [data-cursor]'));
65      rows[j].scrollIntoView({ block: 'nearest' });
66      window.ermiteTree?.sync(rows[j]);
67      window.ermiteFinder?.sync(); // the rows may be the file finder's options
68      const mark = j + 1 + '/' + rows.length;
69      body().dataset.nav = mark;
70      setTimeout(() => body().dataset.nav === mark && (body().dataset.nav = ''), 1500);
71    },
72    // gg / G: first or last row, or the top or bottom of the page.
73    edge(last) {
74      const rows = visibleRows();
75      const row = last ? rows.at(-1) : rows[0];
76      if (!row) return scrollPage(last ? 1e9 : 0);
77      setCursor(row, $$('[data-rows] > [data-cursor]'));
78      row.scrollIntoView({ block: 'nearest' });
79      window.ermiteTree?.sync(row);
80      window.ermiteFinder?.sync();
81    },
82    open() {
83      if ($('dialog[open]') || window.ermiteTree?.open()) return;
84      $('[data-cursor] a')?.click();
85    },
86    // l / h: in the Files view, open a directory or a file / close a directory
87    // or go up. Elsewhere: into the side panel (the current file of a merge
88    // request, else its main action, else its first control) and back to the
89    // content. Enter then activates the control.
90    rail(into) {
91      if ($('dialog[open]')) return;
92      if (window.ermiteTree?.key(into)) return;
93      if (into && window.ermiteMR?.focusFiles()) return flash('files · j k move · enter open · h back');
94      if (!into && window.ermiteMR?.leaveFiles()) return;
95      const msg = into ? window.ermiteRail.enter() : window.ermiteRail.leave();
96      if (msg) flash(msg);
97    },
98    // openFile opens a file of the diff the table of contents links to.
99    openFile(id) {
100      const f = document.getElementById(id);
101      if (f) f.open = true;
102    },
103    focusFilter() {
104      $('[data-filter]')?.focus();
105    },
106    go(sel) {
107      $(sel)?.click();
108    },
109    tab(key) {
110      $(`.tabs [data-key=${key}]`)?.click();
111    },
112
113    // The preference "single-key shortcuts" (ermiteKeys): the checkbox of the
114    // help dialog and the action of the palette.
115    setKeysOff(off) {
116      window.ermiteKeys.set(off);
117    },
118    toggleKeys() {
119      const off = !window.ermiteKeys.off();
120      window.ermiteKeys.set(off);
121      ermite.closeDialogs();
122      flash(off ? 'single-key shortcuts off · ctrl k and esc still work' : 'single-key shortcuts on');
123    },
124
125    // Dialogs.
126    closeDialogs() {
127      for (const d of $$('dialog[open]')) d.close();
128    },
129    dialog(id, focusId) {
130      const d = document.getElementById(id);
131      if (!d) return;
132      ermite.closeDialogs();
133      d.showModal();
134      if (id === 'panel-cmd') window.ermiteCmd?.sync();
135      if (focusId) document.getElementById(focusId)?.focus();
136    },
137
138    // filter hides the rows that do not contain the text of an input. The
139    // input says which rows (data-filter, or data-pick for a list without a
140    // cursor) and, optionally, the groups to hide when they have no row left
141    // (data-groups). The cursor goes to the first row left.
142    filter(input) {
143      const rows = ermite.hide(input);
144      if (input.dataset.filter) {
145        const first = rows.find((r) => !r.hidden);
146        for (const r of rows) r.toggleAttribute('data-cursor', r === first);
147      }
148    },
149    // hide applies the text of a filter input to its rows and groups, without
150    // moving the cursor, and returns the rows.
151    hide(input) {
152      const t = input.value.toLowerCase();
153      // A bare data-filter marks a field with its own search (the file finder
154      // of the Files view): there are no rows to hide.
155      const sel = input.dataset.filter || input.dataset.pick;
156      if (!sel) return [];
157      const rows = $$(sel);
158      for (const r of rows) r.hidden = !r.textContent.toLowerCase().includes(t);
159      if (input.dataset.groups) {
160        for (const g of $$(input.dataset.groups)) g.hidden = !rows.some((r) => !r.hidden && g.contains(r));
161      }
162      return rows;
163    },
164    // The "go to" palette (ermiteCmd below).
165    cmdFilter(input) {
166      window.ermiteCmd.filter(input);
167    },
168    cmdKey(e) {
169      window.ermiteCmd.key(e);
170    },
171
172    // Diff.
173    layout(mode) {
174      const el = $('[data-diff]');
175      if (!el) return;
176      el.dataset.diff = mode;
177      try { sessionStorage.setItem('diff', mode); } catch { /* storage refused: the layout holds for this page */ }
178      for (const d of $$('[data-file]')) if ($('.dt', d)) d.open = true;
179    },
180    collapse() {
181      const files = $$('[data-file]');
182      const open = !files.some((d) => d.open);
183      for (const d of files) d.open = open;
184    },
185    // e / E: the whole file in view, or every file of the diff, with every
186    // line its hunks hide.
187    expandFile() {
188      const f = window.ermiteGaps.current();
189      if (!f) return flash('no file in view');
190      f.open = true;
191      window.ermiteGaps.file(f).then((r) => {
192        if (!r.done) return; // load said what failed
193        flash(r.lines ? 'whole file' : 'nothing hidden in this file');
194      });
195    },
196    async expandAll() {
197      const files = $$('[data-file]').filter((f) => f.querySelector('tr.gap'));
198      if (!files.length) return flash('nothing hidden in the loaded files');
199      flash('loading every line…', 10000);
200      for (const f of files) {
201        if (!(await window.ermiteGaps.file(f)).done) return; // load said what failed
202      }
203      flash('every line of the loaded files');
204    },
205    nextNote() {
206      const notes = $$('#diffs .cmt');
207      if (!notes.length) return;
208      const i = notes.findIndex((n) => n.getBoundingClientRect().top > innerHeight / 3);
209      const n = notes[i < 0 ? 0 : i];
210      const d = n.closest('details');
211      if (d) d.open = true;
212      n.scrollIntoView({ block: 'center' });
213    },
214
215    // y: pin the URL to the commit and copy it.
216    async permalink() {
217      const b = body().dataset;
218      if (!b.sha) return flash('no ref to pin on this page');
219      const enc = (b.ref || '').split('/').map(encodeURIComponent).join('/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
220      // The ref follows the repository and the route: /<repo>/<tree|blob|commits>/<ref>.
221      const path = enc ? location.pathname.replace(new RegExp('^(/[^/]+/[^/]+/)' + enc + '(?=/|$)'), '$1' + b.sha) : location.pathname;
222      if (path === location.pathname) return flash('this page has no ref to pin');
223      history.replaceState(null, '', path + location.hash);
224      try {
225        await navigator.clipboard.writeText(location.href);
226        flash('permalink ' + b.sha.slice(0, 7) + ' copied');
227      } catch {
228        flash('permalink in the address bar (copy refused)');
229      }
230    },
231
232    // The [copy] button of a clone line: the label says "Copied" for 1.5 s,
233    // and so does the accessible name; the hint badge (a status region)
234    // announces it.
235    async copy(btn) {
236      const line = btn.closest('.clone-line')?.querySelector('code')?.textContent ?? '';
237      btn.dataset.idleName ||= btn.getAttribute('aria-label') || '';
238      let ok = true;
239      try { await navigator.clipboard.writeText(line); } catch { ok = false; }
240      btn.textContent = ok ? 'Copied' : 'Copy failed';
241      btn.setAttribute('aria-label', (ok ? 'copied: ' : 'copy failed: ') + line);
242      flash(ok ? 'copied' : 'copy failed', 1500);
243      clearTimeout(btn._restore);
244      btn._restore = setTimeout(() => {
245        btn.textContent = btn.dataset.label;
246        btn.setAttribute('aria-label', btn.dataset.idleName);
247      }, 1500);
248    },
249  };
250  window.ermite = ermite;
251
252  // The "go to" palette is an editable combobox with a listbox popup: the
253  // focus stays in #cmd-q, the options are the <li> of #cmd-list, and the one
254  // with data-cursor is the active one (aria-selected on it, aria-activedescendant
255  // on the field). sync keeps the two in step; it runs on every change of the
256  // cursor or of the list, including the file hits files.js adds later.
257  // Self-contained: jstest/palette.test.mjs evaluates this block alone.
258  window.ermiteCmd = (() => {
259    const list = () => document.getElementById('cmd-list');
260    const field = () => document.getElementById('cmd-q');
261    const options = () => [...list().querySelectorAll(':scope > li')];
262    let seq = 0;
263    const moveCursor = (li) => {
264      for (const o of options()) if (o !== li) o.removeAttribute('data-cursor');
265      li.setAttribute('data-cursor', '');
266    };
267    function sync() {
268      const input = field();
269      if (!input || !list()) return;
270      const all = options();
271      const shown = all.filter((li) => !li.hidden);
272      const cur = shown.find((li) => li.hasAttribute('data-cursor'));
273      for (const li of all) {
274        li.id ||= 'cmd-h' + seq++;
275        li.setAttribute('role', 'option');
276        li.setAttribute('aria-selected', li === cur ? 'true' : 'false');
277      }
278      input.setAttribute('aria-expanded', shown.length ? 'true' : 'false');
279      if (cur) input.setAttribute('aria-activedescendant', cur.id);
280      else input.removeAttribute('aria-activedescendant');
281    }
282    return {
283      sync,
284      // filter hides the entries that do not contain the text of the field.
285      // The file hits the finder adds (li.finder-hit, files.js) have no data-k:
286      // the finder owns them.
287      filter(input) {
288        const t = input.value.toLowerCase().trim();
289        const items = options().filter((li) => li.hasAttribute('data-k'));
290        for (const li of items) li.hidden = !li.getAttribute('data-k').includes(t);
291        const first = items.find((li) => !li.hidden);
292        if (first) moveCursor(first);
293        sync();
294      },
295      key(e) {
296        const vis = options().filter((li) => !li.hidden);
297        const i = vis.findIndex((li) => li.hasAttribute('data-cursor'));
298        const step = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : 0;
299        if (step) {
300          e.preventDefault();
301          const n = vis[Math.max(0, Math.min(vis.length - 1, i + step))];
302          if (n) {
303            moveCursor(n);
304            n.scrollIntoView({ block: 'nearest' });
305          }
306          sync();
307        } else if (e.key === 'Enter') {
308          e.preventDefault();
309          vis[Math.max(i, 0)]?.querySelector('a')?.click();
310        }
311      },
312    };
313  })();
314
315  // The side panel (aside.rail) by keyboard: l enters it on its main action
316  // (the primary button) or its first control, j / k step between its shown
317  // controls (not hidden, not in a closed <details>), h leaves it for <main>.
318  // Each returns the hint to flash, if any. Self-contained: jstest/rail.test.mjs
319  // evaluates this block alone.
320  window.ermiteRail = (() => {
321    const CONTROLS = ['a', 'button', 'select', 'summary', 'input', 'textarea'];
322    const reachable = (e) => {
323      if (e.disabled || e.closest('[hidden]')) return false;
324      if (e.tagName === 'A' && !e.hasAttribute('href')) return false;
325      if (e.tagName === 'INPUT' && e.getAttribute('type') === 'hidden') return false;
326      return !e.getClientRects || e.getClientRects().length > 0;
327    };
328    const rail = () => document.querySelector('.rail');
329    // items walks the panel in document order.
330    const items = (r) => {
331      const out = [];
332      const walk = (e) => {
333        for (const c of e.children) {
334          if (CONTROLS.includes(c.tagName.toLowerCase())) { if (reachable(c)) out.push(c); }
335          else walk(c);
336        }
337      };
338      walk(r);
339      return out;
340    };
341    const inside = (r) => r && r.contains(document.activeElement);
342    const go = (e) => { e.focus(); e.scrollIntoView({ block: 'nearest' }); };
343    return {
344      enter() {
345        const r = rail();
346        if (!r) return 'no side panel on this page';
347        const list = items(r);
348        const target = list.find((e) => e.matches('.btn.primary')) || list[0];
349        if (!target) return 'nothing to act on in the side panel';
350        go(target);
351        return 'side panel · j k move · enter act · h back';
352      },
353      leave() {
354        if (inside(rail())) document.getElementById('main')?.focus({ preventScroll: true });
355      },
356      // step moves d controls within the panel; false when the focus is not in it.
357      step(d) {
358        const r = rail();
359        if (!inside(r)) return false;
360        const list = items(r);
361        const i = list.indexOf(document.activeElement);
362        const next = list[Math.max(0, Math.min(list.length - 1, i + d))];
363        if (next) go(next);
364        return true;
365      },
366    };
367  })();
368
369  // The installed app. The service worker (/-/sw.js) is registered once the page
370  // has loaded. Chrome offers the install by itself only as an icon in the
371  // address bar of a desktop (or a mini-bar on a phone): the page keeps the
372  // beforeinstallprompt event and shows its own button ([data-install] in the
373  // header), which asks the browser to prompt. The button stays hidden when the
374  // app is already running installed or once it was installed. Self-contained:
375  // jstest/install.test.mjs evaluates this block alone.
376  window.ermiteInstall = (() => {
377    let deferred = null;
378    const buttons = () => $$('[data-install]');
379    const show = (on) => { for (const b of buttons()) b.hidden = !on; };
380    const installed = () =>
381      !!window.matchMedia?.('(display-mode: standalone), (display-mode: minimal-ui), (display-mode: window-controls-overlay)').matches ||
382      navigator.standalone === true;
383    const api = {
384      // register installs the service worker; a browser without one, or a
385      // refusal, leaves the site working as before.
386      register() {
387        if (!navigator.serviceWorker) return Promise.resolve(null);
388        return navigator.serviceWorker.register('/-/sw.js', { scope: '/' }).catch(() => null);
389      },
390      // offer keeps the event the browser sent and shows the button.
391      offer(e) {
392        if (installed()) return;
393        e.preventDefault();
394        deferred = e;
395        show(true);
396      },
397      // prompt asks the browser to show its install dialog. The event works
398      // once: a new one comes if the visitor dismisses the dialog. The button
399      // stays while the dialog is up; once it hides, the focus it held moves
400      // to the search of the header rather than to the body.
401      async prompt() {
402        const e = deferred;
403        if (!e) return '';
404        deferred = null;
405        let outcome = '';
406        try {
407          await e.prompt();
408          outcome = (await e.userChoice).outcome;
409        } catch {
410          outcome = '';
411        }
412        const had = buttons().some((b) => b === document.activeElement);
413        show(false);
414        if (had) document.querySelector('[aria-controls="panel-cmd"]')?.focus?.();
415        return outcome;
416      },
417      done() {
418        deferred = null;
419        show(false);
420      },
421    };
422    addEventListener('beforeinstallprompt', api.offer);
423    addEventListener('appinstalled', api.done);
424    addEventListener('click', (e) => {
425      // pending is the prompt the last click started (its outcome)
426      if (e.target.closest?.('[data-install]')) api.pending = api.prompt();
427    });
428    if (document.readyState === 'complete') api.register();
429    else addEventListener('load', () => api.register(), { once: true });
430    return api;
431  })();
432
433  // The hidden lines of a diff: a row .gap holds a button that loads them
434  // (/context); the rows that come back replace the gap in the table of each
435  // layout (the unified and the split tables hold one row each for it). A
436  // file whose lines are all loaded has no gap left.
437  window.ermiteGaps = (() => {
438    const pending = new Map();
439    // load fills the gap the button is in, in both tables of its file.
440    function load(btn) {
441      const src = btn.dataset.src;
442      const row = btn.closest('tr.gap');
443      const file = row?.closest('[data-file]');
444      if (!src || !file) return Promise.resolve(0);
445      if (pending.has(src)) return pending.get(src);
446      const key = row.dataset.gap;
447      const table = btn.closest('table');
448      const focused = document.activeElement === btn;
449      btn.disabled = true;
450      const p = fetch(src, { headers: { Accept: 'text/html' }, credentials: 'same-origin' })
451        .then(async (res) => {
452          if (!res.ok) throw new Error('loading the hidden lines: ' + res.status);
453          const t = document.createElement('template');
454          t.innerHTML = await res.text();
455          let n = 0;
456          for (const table of file.querySelectorAll('table.dt')) {
457            const kind = table.classList.contains('spl') ? 'spl' : 'uni';
458            const from = t.content.querySelector('tbody[data-for="' + kind + '"]');
459            const at = [...table.querySelectorAll('tr.gap')].find((r) => r.dataset.gap === key);
460            if (!from || !at) continue;
461            const rows = [...from.children].map((r) => r.cloneNode(true));
462            n = Math.max(n, rows.filter((r) => !r.classList.contains('gap')).length);
463            at.replaceWith(...rows);
464          }
465          // The focus was on the button, which is gone: on the gap that is
466          // left in the same table, else on the content.
467          if (focused && (document.activeElement === btn || document.activeElement === document.body)) {
468            const next = [...(table?.querySelectorAll('tr.gap') || [])].find((r) => r.dataset.gap === key)?.querySelector('button');
469            if (next) next.focus({ preventScroll: true });
470            else document.getElementById('main')?.focus({ preventScroll: true });
471          }
472          return n;
473        })
474        .catch((err) => {
475          btn.disabled = false;
476          window.ermite?.flash?.(err.message, 4000);
477          return -1;
478        })
479        .finally(() => pending.delete(src));
480      pending.set(src, p);
481      return p;
482    }
483    // file loads every gap of a file, until none is left (a long gap comes
484    // back with a gap for the rest). A gap already loading is waited for.
485    // done is false when a load failed (it said why) or the file never ran
486    // out of gaps.
487    async function file(f) {
488      let lines = 0;
489      for (let guard = 0; guard < 1000; guard++) {
490        const btn = f.querySelector('tr.gap button[data-src]');
491        if (!btn) return { done: true, lines };
492        const n = await load(btn);
493        if (n < 0) return { done: false, lines };
494        if (btn.isConnected) {
495          // an answer that did not take the gap's place: stop, not loop
496          window.ermite?.flash?.('some hidden lines could not be shown', 4000);
497          return { done: false, lines };
498        }
499        lines += n;
500      }
501      window.ermite?.flash?.('the file is too long to show every line at once', 4000);
502      return { done: false, lines };
503    }
504    // current is the file in view: the one the table of contents marks, else
505    // the last whose top is above the top third of the window.
506    function current() {
507      const a = document.querySelector('.files a[aria-current="true"]');
508      const marked = a && document.getElementById(a.hash.slice(1));
509      if (marked?.matches('[data-file]')) return marked;
510      const files = [...document.querySelectorAll('[data-file]')];
511      return files.findLast((f) => f.getBoundingClientRect().top < innerHeight / 3) ?? files[0] ?? null;
512    }
513    addEventListener('click', (e) => {
514      const btn = e.target.closest?.('tr.gap button[data-src]');
515      if (btn) load(btn);
516    });
517    return { load, file, current };
518  })();
519
520  // The preference "single-character shortcuts" (WCAG 2.1.4): off, the keys
521  // that are one printable character (j, k, s, ?, :, G, 1-9 in the merge menu,
522  // …) do nothing. Shortcuts with ctrl or cmd, escape, enter and the arrows
523  // keep working. The choice lives in localStorage ("keys-off") and is made in
524  // the help dialog (a checkbox) or the palette (an action). Self-contained:
525  // jstest/keys.test.mjs evaluates this block alone.
526  window.ermiteKeys = (() => {
527    const KEY = 'keys-off';
528    let off = false;
529    try { off = localStorage.getItem(KEY) === '1'; } catch { /* no storage: on, and the choice holds for this page */ }
530    // apply shows the state: the checkbox and the note of the help dialog,
531    // and the label of the palette action.
532    function apply() {
533      const box = document.getElementById('keys-off');
534      if (box) box.checked = off;
535      const note = document.getElementById('keys-note');
536      if (note) {
537        note.textContent = off
538          ? 'Single-key shortcuts are off: only ctrl k and esc, enter and the arrows work. Turn them on again here.'
539          : 'Single-key shortcuts are on. ctrl k and esc work whatever this says.';
540      }
541      const act = document.querySelector('#cmd-list [data-fire="keys"] .lbl');
542      if (act) act.textContent = off ? 'turn single-key shortcuts on' : 'turn single-key shortcuts off';
543    }
544    function set(v) {
545      off = !!v;
546      try { localStorage.setItem(KEY, off ? '1' : ''); } catch { /* storage refused: holds for this page */ }
547      apply();
548    }
549    addEventListener('storage', (e) => {
550      if (e.key !== KEY) return;
551      off = e.newValue === '1';
552      apply();
553    });
554    apply();
555    return {
556      off: () => off,
557      set,
558      apply,
559      // blocks says whether the preference turns this key off.
560      blocks: (e) => off && e.key.length === 1,
561    };
562  })();
563
564  // 1. Keys.
565  const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink',
566    s: 's', t: 't', c: 'c', r: 'r', m: 'm', n: 'next', h: 'left', l: 'right', d: 'split', u: 'unified', x: 'collapse', e: 'expand', E: 'expandAll', Enter: 'open', Escape: 'esc',
567    // the merge request page (mr.js): the merge menu, mark read
568    M: 'merge', R: 'read' };
569  // "g t" is the key "t": the tabs have one event each.
570  const goto = { h: 'gh', t: 't', c: 'c', r: 'r', m: 'm', g: 'top', f: 'gf' };
571  let pending = 0;
572  let pendingTimer = 0;
573  addEventListener('keydown', (e) => {
574    const typing = e.target.closest('input:not([type=checkbox], [type=radio], [type=submit], [type=button]), textarea, select, [contenteditable]');
575    // ctrl k / cmd k opens the palette; in a field only cmd k, so that the
576    // field keeps ctrl k (kill to end of line on macOS).
577    if ((e.metaKey || (e.ctrlKey && !typing)) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'k') { e.preventDefault(); pending = 0; return ermite.fire('cmd'); }
578    if (e.ctrlKey || e.metaKey || e.altKey) return;
579    const mr = window.ermiteMR;
580    if (e.key === 'Escape') {
581      pending = 0;
582      if (mr?.closeMenus() || mr?.leaveFiles()) return;
583      if (typing) e.target.blur();
584      return ermite.fire('esc');
585    }
586    // Single-character shortcuts turned off: only the keys above work.
587    if (window.ermiteKeys.blocks(e)) { pending = 0; return; }
588    // An open merge menu takes its keys (1-9, D) before the field check: its
589    // radios and its checkbox hold the focus.
590    if (mr?.menuKey(e)) return;
591    if (typing) return;
592    // An open dialog takes only the keys that move between dialogs: the page
593    // behind it does not react (scripted clicks would go through inert).
594    if ($('dialog[open]') && !['?', 'b', ':', 'f', 'Enter'].includes(e.key)) { pending = 0; return; }
595    if (e.key === 'Enter' && e.target.closest('a, button, summary')) return;
596    if (pending) {
597      const name = goto[e.key];
598      pending = 0;
599      if (name) { e.preventDefault(); ermite.fire(name); }
600      return;
601    }
602    if (e.key === 'g') { pending = 1; clearTimeout(pendingTimer); pendingTimer = setTimeout(() => (pending = 0), 1200); return; }
603    // The arrows and Home / End are the tree's only while the focus is on one of
604    // its rows (they scroll, or move the focus, anywhere else); j k l h are
605    // page-level keys, as on the other lists.
606    if (!e.shiftKey && e.target.closest?.('#tree-rows') && window.ermiteTree?.arrow(e.key)) { e.preventDefault(); return; }
607    const name = single[e.key];
608    if (!name) return;
609    if (e.key !== 'Enter' || !$('dialog[open]')) e.preventDefault();
610    ermite.fire(name);
611  });
612
613  // 2. A [data-fire] element does what the key of that name does (the header
614  // buttons, the diff layout links). The link to the refs page works without
615  // scripts and is upgraded here to open the dialog; a click with a modifier
616  // keeps its meaning (new tab).
617  addEventListener('click', (e) => {
618    const el = e.target.closest?.('[data-fire]');
619    if (!el || e.button || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
620    e.preventDefault();
621    ermite.fire(el.dataset.fire);
622  });
623  for (const a of $$('a[data-fire="refs"]')) {
624    a.setAttribute('aria-haspopup', 'dialog');
625    a.setAttribute('aria-controls', 'panel-refs');
626  }
627
628  // A login link signs in without a click: its page submits its form once a
629  // browser shows it, not while it is prerendered.
630  const auto = $('form[data-autosubmit]');
631  if (auto) {
632    const go = () => auto.requestSubmit();
633    if (document.prerendering) document.addEventListener('prerenderingchange', go, { once: true });
634    else go();
635  }
636
637  // In the app shell the page does not scroll, <main> does: give it focus so arrows,
638  // PageUp/PageDown and space scroll the content without a click first.
639  if (!location.hash) document.getElementById('main')?.focus({ preventScroll: true });
640
641  // 4. The table of contents of a diff marks the file in view: the last file
642  // in the top third of the window, else the last file whose top is above that
643  // line (the first file when none is). One observer tracks the files in the
644  // band; it reports only when a file enters or leaves it, so a scroll costs
645  // nothing in between, and a jump that skips the band is caught by the scan.
646  let files = $$('.dfile');
647  const links = new Map($$('.files a[href^="#"]').map((a) => [a.hash.slice(1), a]));
648  if (files.length && links.size && 'IntersectionObserver' in window) {
649    const inBand = new Set();
650    let current = null;
651    const mark = () => {
652      const f = files.findLast((x) => inBand.has(x))
653        ?? files.findLast((x) => x.getBoundingClientRect().top < innerHeight / 3)
654        ?? files[0];
655      if (f === current) return;
656      links.get(current?.id)?.removeAttribute('aria-current');
657      const a = links.get(f.id);
658      a?.setAttribute('aria-current', 'true');
659      // The list scrolls on its own: the current file stays in it. Only the
660      // list moves, never the page (scrollIntoView would scroll the page to
661      // the list when the rail is under the diff).
662      const box = a?.closest('.files');
663      if (a && box && box.scrollHeight > box.clientHeight && !box.contains(document.activeElement)) {
664        const top = a.offsetTop;
665        if (top < box.scrollTop) box.scrollTop = top;
666        else if (top + a.offsetHeight > box.scrollTop + box.clientHeight) box.scrollTop = top + a.offsetHeight - box.clientHeight;
667      }
668      current = f;
669    };
670    const io = new IntersectionObserver((entries) => {
671      for (const e of entries) {
672        if (e.isIntersecting) inBand.add(e.target);
673        else inBand.delete(e.target);
674      }
675      mark();
676    }, { rootMargin: '0px 0px -67% 0px' });
677    for (const f of files) io.observe(f);
678    // A window of files of a merge request came in (mr.js): its files replace
679    // placeholders, which the observer and the list knew by element.
680    ermite.watchFile = (f, old) => {
681      if (old) { io.unobserve(old); inBand.delete(old); }
682      files = $$('.dfile');
683      for (const x of [...inBand]) if (!x.isConnected) inBand.delete(x);
684      if (current && !current.isConnected) current = document.getElementById(current.id);
685      io.observe(f);
686    };
687  }
688
689  // 5. A failure of a handler or of a request (htmx logs it to the console
690  // already) is shown, so that a key that does nothing is not a mystery.
691  addEventListener('htmx:error', (e) => {
692    if (e.target.closest?.('.hint-live')) return; // the stream says so itself ("live updates paused")
693    const err = e.detail?.error;
694    flash('something went wrong' + (err?.message ? ': ' + err.message : ''), 4000);
695  });
696})();
697
698// ermiteLive applies the fragments of the event stream (/-/events, see
699// internal/adapters/web/events.go). A fragment is a top-level element with an
700// id: it replaces the element of the page that has the same id. A fragment
701// marked data-gone removes it; a row of a repository the home page does not
702// list yet is added at the top of the list. A slot (data-slot: the place of a
703// banner, a persistent status region) keeps its element and only gets the
704// children of the fragment, so that screen readers announce the change. A gap,
705// which says the page may have missed events or that what it shows changed,
706// reloads the page. A stream the server refuses on reconnection (the
707// repository is gone, the session ended) makes the page reload.
708window.ermiteLive = (() => {
709  let reloading = false;
710  let stopped = false;
711  // A stream that drops and comes back within LOST_AFTER (a proxy or the
712  // server closing it, a network hiccup) is not worth a message.
713  const LOST_AFTER = 5000;
714  let lostTimer = 0;
715  // The focus survives a swap. focusables are the controls of a fragment in
716  // document order; where(root) says which one has the focus (its id, else its
717  // tag and href or name, and its index among the controls) and refocus finds
718  // it again in the element that replaced root.
719  const FOCUSABLE = 'a[href], button:not([disabled]), input:not([type=hidden]):not([disabled]), select:not([disabled]), textarea:not([disabled]), summary, [tabindex]';
720  // shown: not inside a hidden element (a filtered row, a folded block); the
721  // browser refuses the focus there.
722  const shown = (e) => !e.closest('[hidden]') && (!e.checkVisibility || e.checkVisibility());
723  const focusables = (root) => [...root.querySelectorAll(FOCUSABLE)].filter(shown);
724  // take focuses e and says whether the focus is there now.
725  const take = (e) => { e.focus({ preventScroll: true }); return document.activeElement === e; };
726  const where = (root) => {
727    const a = document.activeElement;
728    if (!a || a === document.body || !root.contains(a)) return null;
729    if (a === root) return { self: true };
730    const all = focusables(root);
731    return { id: a.id, tag: a.tagName, href: a.getAttribute('href'), name: a.getAttribute('name'), index: all.indexOf(a) };
732  };
733  const refocus = (root, w) => {
734    if (w.self) return root.focus({ preventScroll: true });
735    const all = focusables(root);
736    const same = (e) => e.tagName === w.tag;
737    const target =
738      (w.id && all.find((e) => e.id === w.id)) ||
739      (w.href != null && all.find((e) => same(e) && e.getAttribute('href') === w.href)) ||
740      (w.name != null && all.find((e) => same(e) && e.getAttribute('name') === w.name)) ||
741      (all[w.index] && same(all[w.index]) && all[w.index]) ||
742      all[0];
743    if (target) target.focus({ preventScroll: true });
744    else if (root.hasAttribute('tabindex')) root.focus({ preventScroll: true });
745  };
746  // lostFocus: the element that had the focus inside is gone. The focus goes to
747  // the first control of the next shown row, else of the nearest shown row
748  // before, else to the content.
749  const lostFocus = (after, before) => {
750    const tryRow = (e) => {
751      if (!shown(e)) return false;
752      const c = focusables(e)[0] || (e.hasAttribute('tabindex') ? e : null);
753      return !!c && take(c);
754    };
755    for (let e = after; e; e = e.nextElementSibling) if (tryRow(e)) return;
756    for (let e = before; e; e = e.previousElementSibling) if (tryRow(e)) return;
757    document.getElementById('main')?.focus({ preventScroll: true });
758  };
759  const setState = (s) => { const el = document.querySelector('.hint-live'); if (el) el.dataset.state = s; };
760  return {
761    // lost is htmx:sse:close and htmx:sse:error: "live updates paused" shows
762    // only if the stream is still down LOST_AFTER later.
763    lost() {
764      if (lostTimer) return;
765      lostTimer = setTimeout(() => { lostTimer = 0; setState('offline'); }, LOST_AFTER);
766    },
767    // back is htmx:sse:after:connection.
768    back() {
769      clearTimeout(lostTimer);
770      lostTimer = 0;
771      setState('live');
772    },
773    swap(html) {
774      const t = document.createElement('template');
775      t.innerHTML = html;
776      const cursor = document.querySelector('[data-rows] > [data-cursor]');
777      const cursorKey = cursor?.id || cursor?.querySelector('a')?.getAttribute('href');
778      for (const el of [...t.content.children]) {
779        const old = el.id && document.getElementById(el.id);
780        const had = old ? where(old) : null; // the focus is inside what is replaced
781        if (el.hasAttribute('data-gone')) {
782          const after = old?.nextElementSibling, before = old?.previousElementSibling;
783          old?.remove();
784          if (had) lostFocus(after, before);
785          continue;
786        }
787        if (!old) {
788          const rows = document.getElementById('repo-rows');
789          if (rows && el.id.startsWith('repo-')) { rows.prepend(el); window.htmx?.process(el); }
790          continue;
791        }
792        if (old.hasAttribute('data-slot')) {
793          old.replaceChildren(...el.childNodes);
794          if (had) refocus(old, had);
795          continue;
796        }
797        if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', '');
798        old.replaceWith(el);
799        window.htmx?.process(el);
800        if (had) refocus(el, had);
801      }
802      // The rows may have changed under an active filter: apply it again and
803      // keep the cursor on its row while that row is still shown.
804      for (const input of document.querySelectorAll('input[data-filter]')) {
805        if (input.value) window.ermite?.hide(input);
806      }
807      if (!cursorKey) return;
808      const rows = [...document.querySelectorAll('[data-rows] > *')];
809      const back = rows.find((r) => !r.hidden && (r.id === cursorKey || r.querySelector('a')?.getAttribute('href') === cursorKey));
810      if (back) for (const r of rows) r.toggleAttribute('data-cursor', r === back);
811    },
812    reload() {
813      if (reloading) return;
814      reloading = true;
815      setTimeout(() => location.reload(), 300);
816    },
817    // error handles htmx:sse:error (detail.status is the status of a refused
818    // reconnection). hx-sse retries for ever; a 404 or a 401 will not change by
819    // itself, and the fetched page says what is the matter (or has no stream).
820    // A page that reloaded for this a moment ago and still gets refused would
821    // loop: it stops following the stream instead (see gate).
822    error(status) {
823      if (status !== 404 && status !== 401) return;
824      let last = 0;
825      try { last = +sessionStorage.getItem('ermite-live-reload') || 0; } catch { /* no storage: no guard */ }
826      if (Date.now() - last < 15000) { stopped = true; return; }
827      try { sessionStorage.setItem('ermite-live-reload', String(Date.now())); } catch { /* idem */ }
828      this.reload();
829    },
830    // gate is htmx:sse:before:connection: once stopped, no more attempts.
831    gate(event) {
832      if (stopped) event.preventDefault();
833    },
834  };
835})();

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close