v0.20.0 032b9ef

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(() => {
15  // Marks the page as scripted: what does nothing without scripts (the filters,
16  // the "go to" and help buttons) is hidden until this class is set (see the
17  // utilities of 10-base.css).
18  document.documentElement.classList.add('js');
19
20  const $ = (sel, root = document) => root.querySelector(sel);
21  const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
22  const body = () => document.body;
23
24  // flash shows a message in the hint badge (body[data-flash], a status
25  // region), then clears it unless another message came in the meantime.
26  let flashTimer = 0;
27  const flash = (msg, ms = 2000) => {
28    body().dataset.flash = msg;
29    clearTimeout(flashTimer);
30    flashTimer = setTimeout(() => (body().dataset.flash = ''), ms);
31  };
32
33  const visibleRows = () => $$('[data-rows] > :not([hidden])');
34  // setCursor puts data-cursor on el and takes it from the others.
35  const setCursor = (el, others) => {
36    for (const o of others) if (o !== el) o.removeAttribute('data-cursor');
37    el.setAttribute('data-cursor', '');
38  };
39  const scrollPage = (to) => {
40    scrollTo(0, to);
41    $('#main')?.scrollTo(0, to);
42  };
43
44  const ermite = {
45    flash,
46    fire(name) {
47      body().dispatchEvent(new CustomEvent('k-' + name, { bubbles: false }));
48    },
49
50    // Row cursor: j / k. In the side panel they move the focus between its
51    // controls instead.
52    move(d) {
53      if ($('dialog[open]')) return;
54      if (window.ermiteMR?.stepFiles(d) || window.ermiteRail.step(d)) return;
55      const rows = visibleRows();
56      if (!rows.length) {
57        scrollBy(0, d * 180);
58        $('#main')?.scrollBy(0, d * 180);
59        return;
60      }
61      const i = rows.findIndex((r) => r.hasAttribute('data-cursor'));
62      const j = Math.max(0, Math.min(rows.length - 1, i + d));
63      setCursor(rows[j], $$('[data-rows] > [data-cursor]'));
64      rows[j].scrollIntoView({ block: 'nearest' });
65      const mark = j + 1 + '/' + rows.length;
66      body().dataset.nav = mark;
67      setTimeout(() => body().dataset.nav === mark && (body().dataset.nav = ''), 1500);
68    },
69    // gg / G: first or last row, or the top or bottom of the page.
70    edge(last) {
71      const rows = visibleRows();
72      const row = last ? rows.at(-1) : rows[0];
73      if (!row) return scrollPage(last ? 1e9 : 0);
74      setCursor(row, $$('[data-rows] > [data-cursor]'));
75      row.scrollIntoView({ block: 'nearest' });
76    },
77    open() {
78      if (!$('dialog[open]')) $('[data-cursor] a')?.click();
79    },
80    // l / h: into the side panel (the current file of a merge request, else
81    // its main action, else its first control) and back to the content.
82    // Enter then activates the control.
83    rail(into) {
84      if ($('dialog[open]')) return;
85      if (into && window.ermiteMR?.focusFiles()) return flash('files · j k move · enter open · h back');
86      if (!into && window.ermiteMR?.leaveFiles()) return;
87      const msg = into ? window.ermiteRail.enter() : window.ermiteRail.leave();
88      if (msg) flash(msg);
89    },
90    // openFile opens a file of the diff the table of contents links to.
91    openFile(id) {
92      const f = document.getElementById(id);
93      if (f) f.open = true;
94    },
95    focusFilter() {
96      $('[data-filter]')?.focus();
97    },
98    go(sel) {
99      $(sel)?.click();
100    },
101    tab(key) {
102      $(`.tabs [data-key=${key}]`)?.click();
103    },
104
105    // Dialogs.
106    closeDialogs() {
107      for (const d of $$('dialog[open]')) d.close();
108    },
109    dialog(id, focusId) {
110      const d = document.getElementById(id);
111      if (!d) return;
112      ermite.closeDialogs();
113      d.showModal();
114      if (focusId) document.getElementById(focusId)?.focus();
115    },
116
117    // filter hides the rows that do not contain the text of an input. The
118    // input says which rows (data-filter, or data-pick for a list without a
119    // cursor) and, optionally, the groups to hide when they have no row left
120    // (data-groups). The cursor goes to the first row left.
121    filter(input) {
122      const rows = ermite.hide(input);
123      if (input.dataset.filter) {
124        const first = rows.find((r) => !r.hidden);
125        for (const r of rows) r.toggleAttribute('data-cursor', r === first);
126      }
127    },
128    // hide applies the text of a filter input to its rows and groups, without
129    // moving the cursor, and returns the rows.
130    hide(input) {
131      const t = input.value.toLowerCase();
132      // A bare data-filter marks a field with its own search (the file finder
133      // of the Files view): there are no rows to hide.
134      const sel = input.dataset.filter || input.dataset.pick;
135      if (!sel) return [];
136      const rows = $$(sel);
137      for (const r of rows) r.hidden = !r.textContent.toLowerCase().includes(t);
138      if (input.dataset.groups) {
139        for (const g of $$(input.dataset.groups)) g.hidden = !rows.some((r) => !r.hidden && g.contains(r));
140      }
141      return rows;
142    },
143    // The "go to" palette.
144    cmdFilter(input) {
145      const t = input.value.toLowerCase().trim();
146      // The file hits the finder adds (li.finder-hit, files.js) have no data-k:
147      // the finder owns them.
148      const items = $$('#cmd-list > li[data-k]');
149      for (const li of items) li.hidden = !li.dataset.k.includes(t);
150      const first = items.find((li) => !li.hidden);
151      if (first) setCursor(first, $$('#cmd-list > li'));
152    },
153    cmdKey(e) {
154      const vis = $$('#cmd-list > li:not([hidden])');
155      const i = vis.findIndex((li) => li.hasAttribute('data-cursor'));
156      const step = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : 0;
157      if (step) {
158        e.preventDefault();
159        const n = vis[Math.max(0, Math.min(vis.length - 1, i + step))];
160        if (n) {
161          setCursor(n, $$('#cmd-list > li'));
162          n.scrollIntoView({ block: 'nearest' });
163        }
164      } else if (e.key === 'Enter') {
165        e.preventDefault();
166        vis[Math.max(i, 0)]?.querySelector('a')?.click();
167      }
168    },
169
170    // Diff.
171    layout(mode) {
172      const el = $('[data-diff]');
173      if (!el) return;
174      el.dataset.diff = mode;
175      try { sessionStorage.setItem('diff', mode); } catch { /* storage refused: the layout holds for this page */ }
176      for (const d of $$('[data-file]')) if ($('.dt', d)) d.open = true;
177    },
178    collapse() {
179      const files = $$('[data-file]');
180      const open = !files.some((d) => d.open);
181      for (const d of files) d.open = open;
182    },
183    // e / E: the whole file in view, or every file of the diff, with every
184    // line its hunks hide.
185    expandFile() {
186      const f = window.ermiteGaps.current();
187      if (!f) return flash('no file in view');
188      f.open = true;
189      window.ermiteGaps.file(f).then((r) => {
190        if (!r.done) return; // load said what failed
191        flash(r.lines ? 'whole file' : 'nothing hidden in this file');
192      });
193    },
194    async expandAll() {
195      const files = $$('[data-file]').filter((f) => f.querySelector('tr.gap'));
196      if (!files.length) return flash('nothing hidden in the loaded files');
197      flash('loading every line…', 10000);
198      for (const f of files) {
199        if (!(await window.ermiteGaps.file(f)).done) return; // load said what failed
200      }
201      flash('every line of the loaded files');
202    },
203    nextNote() {
204      const notes = $$('#diffs .cmt');
205      if (!notes.length) return;
206      const i = notes.findIndex((n) => n.getBoundingClientRect().top > innerHeight / 3);
207      const n = notes[i < 0 ? 0 : i];
208      const d = n.closest('details');
209      if (d) d.open = true;
210      n.scrollIntoView({ block: 'center' });
211    },
212
213    // y: pin the URL to the commit and copy it.
214    async permalink() {
215      const b = body().dataset;
216      if (!b.sha) return flash('no ref to pin on this page');
217      const enc = (b.ref || '').split('/').map(encodeURIComponent).join('/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
218      // The ref follows the repository and the route: /<repo>/<tree|blob|commits>/<ref>.
219      const path = enc ? location.pathname.replace(new RegExp('^(/[^/]+/[^/]+/)' + enc + '(?=/|$)'), '$1' + b.sha) : location.pathname;
220      if (path === location.pathname) return flash('this page has no ref to pin');
221      history.replaceState(null, '', path + location.hash);
222      try {
223        await navigator.clipboard.writeText(location.href);
224        flash('permalink ' + b.sha.slice(0, 7) + ' copied');
225      } catch {
226        flash('permalink in the address bar (copy refused)');
227      }
228    },
229
230    // The [copy] button of a clone line: the label says "Copied" for 1.5 s,
231    // and so does the accessible name; the hint badge (a status region)
232    // announces it.
233    async copy(btn) {
234      const line = btn.closest('.clone-line')?.querySelector('code')?.textContent ?? '';
235      btn.dataset.idleName ||= btn.getAttribute('aria-label') || '';
236      let ok = true;
237      try { await navigator.clipboard.writeText(line); } catch { ok = false; }
238      btn.textContent = ok ? 'Copied' : 'Copy failed';
239      btn.setAttribute('aria-label', (ok ? 'copied: ' : 'copy failed: ') + line);
240      flash(ok ? 'copied' : 'copy failed', 1500);
241      clearTimeout(btn._restore);
242      btn._restore = setTimeout(() => {
243        btn.textContent = btn.dataset.label;
244        btn.setAttribute('aria-label', btn.dataset.idleName);
245      }, 1500);
246    },
247  };
248  window.ermite = ermite;
249
250  // The side panel (aside.rail) by keyboard: l enters it on its main action
251  // (the primary button) or its first control, j / k step between its shown
252  // controls (not hidden, not in a closed <details>), h leaves it for <main>.
253  // Each returns the hint to flash, if any. Self-contained: jstest/rail.test.mjs
254  // evaluates this block alone.
255  window.ermiteRail = (() => {
256    const CONTROLS = ['a', 'button', 'select', 'summary', 'input', 'textarea'];
257    const reachable = (e) => {
258      if (e.disabled || e.closest('[hidden]')) return false;
259      if (e.tagName === 'A' && !e.hasAttribute('href')) return false;
260      if (e.tagName === 'INPUT' && e.getAttribute('type') === 'hidden') return false;
261      return !e.getClientRects || e.getClientRects().length > 0;
262    };
263    const rail = () => document.querySelector('.rail');
264    // items walks the panel in document order.
265    const items = (r) => {
266      const out = [];
267      const walk = (e) => {
268        for (const c of e.children) {
269          if (CONTROLS.includes(c.tagName.toLowerCase())) { if (reachable(c)) out.push(c); }
270          else walk(c);
271        }
272      };
273      walk(r);
274      return out;
275    };
276    const inside = (r) => r && r.contains(document.activeElement);
277    const go = (e) => { e.focus(); e.scrollIntoView({ block: 'nearest' }); };
278    return {
279      enter() {
280        const r = rail();
281        if (!r) return 'no side panel on this page';
282        const list = items(r);
283        const target = list.find((e) => e.matches('.btn.primary')) || list[0];
284        if (!target) return 'nothing to act on in the side panel';
285        go(target);
286        return 'side panel · j k move · enter act · h back';
287      },
288      leave() {
289        if (inside(rail())) document.getElementById('main')?.focus({ preventScroll: true });
290      },
291      // step moves d controls within the panel; false when the focus is not in it.
292      step(d) {
293        const r = rail();
294        if (!inside(r)) return false;
295        const list = items(r);
296        const i = list.indexOf(document.activeElement);
297        const next = list[Math.max(0, Math.min(list.length - 1, i + d))];
298        if (next) go(next);
299        return true;
300      },
301    };
302  })();
303
304  // The hidden lines of a diff: a row .gap holds a button that loads them
305  // (/context); the rows that come back replace the gap in the table of each
306  // layout (the unified and the split tables hold one row each for it). A
307  // file whose lines are all loaded has no gap left.
308  window.ermiteGaps = (() => {
309    const pending = new Map();
310    // load fills the gap the button is in, in both tables of its file.
311    function load(btn) {
312      const src = btn.dataset.src;
313      const row = btn.closest('tr.gap');
314      const file = row?.closest('[data-file]');
315      if (!src || !file) return Promise.resolve(0);
316      if (pending.has(src)) return pending.get(src);
317      const key = row.dataset.gap;
318      const table = btn.closest('table');
319      const focused = document.activeElement === btn;
320      btn.disabled = true;
321      const p = fetch(src, { headers: { Accept: 'text/html' }, credentials: 'same-origin' })
322        .then(async (res) => {
323          if (!res.ok) throw new Error('loading the hidden lines: ' + res.status);
324          const t = document.createElement('template');
325          t.innerHTML = await res.text();
326          let n = 0;
327          for (const table of file.querySelectorAll('table.dt')) {
328            const kind = table.classList.contains('spl') ? 'spl' : 'uni';
329            const from = t.content.querySelector('tbody[data-for="' + kind + '"]');
330            const at = [...table.querySelectorAll('tr.gap')].find((r) => r.dataset.gap === key);
331            if (!from || !at) continue;
332            const rows = [...from.children].map((r) => r.cloneNode(true));
333            n = Math.max(n, rows.filter((r) => !r.classList.contains('gap')).length);
334            at.replaceWith(...rows);
335          }
336          // The focus was on the button, which is gone: on the gap that is
337          // left in the same table, else on the content.
338          if (focused && (document.activeElement === btn || document.activeElement === document.body)) {
339            const next = [...(table?.querySelectorAll('tr.gap') || [])].find((r) => r.dataset.gap === key)?.querySelector('button');
340            if (next) next.focus({ preventScroll: true });
341            else document.getElementById('main')?.focus({ preventScroll: true });
342          }
343          return n;
344        })
345        .catch((err) => {
346          btn.disabled = false;
347          window.ermite?.flash?.(err.message, 4000);
348          return -1;
349        })
350        .finally(() => pending.delete(src));
351      pending.set(src, p);
352      return p;
353    }
354    // file loads every gap of a file, until none is left (a long gap comes
355    // back with a gap for the rest). A gap already loading is waited for.
356    // done is false when a load failed (it said why) or the file never ran
357    // out of gaps.
358    async function file(f) {
359      let lines = 0;
360      for (let guard = 0; guard < 1000; guard++) {
361        const btn = f.querySelector('tr.gap button[data-src]');
362        if (!btn) return { done: true, lines };
363        const n = await load(btn);
364        if (n < 0) return { done: false, lines };
365        if (btn.isConnected) {
366          // an answer that did not take the gap's place: stop, not loop
367          window.ermite?.flash?.('some hidden lines could not be shown', 4000);
368          return { done: false, lines };
369        }
370        lines += n;
371      }
372      window.ermite?.flash?.('the file is too long to show every line at once', 4000);
373      return { done: false, lines };
374    }
375    // current is the file in view: the one the table of contents marks, else
376    // the last whose top is above the top third of the window.
377    function current() {
378      const a = document.querySelector('.files a[aria-current="true"]');
379      const marked = a && document.getElementById(a.hash.slice(1));
380      if (marked?.matches('[data-file]')) return marked;
381      const files = [...document.querySelectorAll('[data-file]')];
382      return files.findLast((f) => f.getBoundingClientRect().top < innerHeight / 3) ?? files[0] ?? null;
383    }
384    addEventListener('click', (e) => {
385      const btn = e.target.closest?.('tr.gap button[data-src]');
386      if (btn) load(btn);
387    });
388    return { load, file, current };
389  })();
390
391  // 1. Keys.
392  const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink',
393    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',
394    // the merge request page (mr.js): the merge menu, mark read
395    M: 'merge', R: 'read' };
396  // "g t" is the key "t": the tabs have one event each.
397  const goto = { h: 'gh', t: 't', c: 'c', r: 'r', m: 'm', g: 'top', f: 'gf' };
398  let pending = 0;
399  let pendingTimer = 0;
400  addEventListener('keydown', (e) => {
401    const typing = e.target.closest('input:not([type=checkbox], [type=radio], [type=submit], [type=button]), textarea, select, [contenteditable]');
402    // ctrl k / cmd k opens the palette; in a field only cmd k, so that the
403    // field keeps ctrl k (kill to end of line on macOS).
404    if ((e.metaKey || (e.ctrlKey && !typing)) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'k') { e.preventDefault(); pending = 0; return ermite.fire('cmd'); }
405    if (e.ctrlKey || e.metaKey || e.altKey) return;
406    const mr = window.ermiteMR;
407    if (e.key === 'Escape') {
408      pending = 0;
409      if (mr?.closeMenus() || mr?.leaveFiles()) return;
410      if (typing) e.target.blur();
411      return ermite.fire('esc');
412    }
413    // An open merge menu takes its keys (1-9, D) before the field check: its
414    // radios and its checkbox hold the focus.
415    if (mr?.menuKey(e)) return;
416    if (typing) return;
417    // An open dialog takes only the keys that move between dialogs: the page
418    // behind it does not react (scripted clicks would go through inert).
419    if ($('dialog[open]') && !['?', 'b', ':', 'f', 'Enter'].includes(e.key)) { pending = 0; return; }
420    if (e.key === 'Enter' && e.target.closest('a, button, summary')) return;
421    if (pending) {
422      const name = goto[e.key];
423      pending = 0;
424      if (name) { e.preventDefault(); ermite.fire(name); }
425      return;
426    }
427    if (e.key === 'g') { pending = 1; clearTimeout(pendingTimer); pendingTimer = setTimeout(() => (pending = 0), 1200); return; }
428    const name = single[e.key];
429    if (!name) return;
430    if (e.key !== 'Enter' || !$('dialog[open]')) e.preventDefault();
431    ermite.fire(name);
432  });
433
434  // 2. A [data-fire] element does what the key of that name does (the header
435  // buttons, the diff layout links). The link to the refs page works without
436  // scripts and is upgraded here to open the dialog; a click with a modifier
437  // keeps its meaning (new tab).
438  addEventListener('click', (e) => {
439    const el = e.target.closest?.('[data-fire]');
440    if (!el || e.button || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
441    e.preventDefault();
442    ermite.fire(el.dataset.fire);
443  });
444  for (const a of $$('a[data-fire="refs"]')) {
445    a.setAttribute('aria-haspopup', 'dialog');
446    a.setAttribute('aria-controls', 'panel-refs');
447  }
448
449  // A login link signs in without a click: its page submits its form once a
450  // browser shows it, not while it is prerendered.
451  const auto = $('form[data-autosubmit]');
452  if (auto) {
453    const go = () => auto.requestSubmit();
454    if (document.prerendering) document.addEventListener('prerenderingchange', go, { once: true });
455    else go();
456  }
457
458  // In the app shell the page does not scroll, <main> does: give it focus so arrows,
459  // PageUp/PageDown and space scroll the content without a click first.
460  if (!location.hash) document.getElementById('main')?.focus({ preventScroll: true });
461
462  // 4. The table of contents of a diff marks the file in view: the last file
463  // in the top third of the window, else the last file whose top is above that
464  // line (the first file when none is). One observer tracks the files in the
465  // band; it reports only when a file enters or leaves it, so a scroll costs
466  // nothing in between, and a jump that skips the band is caught by the scan.
467  let files = $$('.dfile');
468  const links = new Map($$('.files a[href^="#"]').map((a) => [a.hash.slice(1), a]));
469  if (files.length && links.size && 'IntersectionObserver' in window) {
470    const inBand = new Set();
471    let current = null;
472    const mark = () => {
473      const f = files.findLast((x) => inBand.has(x))
474        ?? files.findLast((x) => x.getBoundingClientRect().top < innerHeight / 3)
475        ?? files[0];
476      if (f === current) return;
477      links.get(current?.id)?.removeAttribute('aria-current');
478      const a = links.get(f.id);
479      a?.setAttribute('aria-current', 'true');
480      // The list scrolls on its own: the current file stays in it. Only the
481      // list moves, never the page (scrollIntoView would scroll the page to
482      // the list when the rail is under the diff).
483      const box = a?.closest('.files');
484      if (a && box && box.scrollHeight > box.clientHeight && !box.contains(document.activeElement)) {
485        const top = a.offsetTop;
486        if (top < box.scrollTop) box.scrollTop = top;
487        else if (top + a.offsetHeight > box.scrollTop + box.clientHeight) box.scrollTop = top + a.offsetHeight - box.clientHeight;
488      }
489      current = f;
490    };
491    const io = new IntersectionObserver((entries) => {
492      for (const e of entries) {
493        if (e.isIntersecting) inBand.add(e.target);
494        else inBand.delete(e.target);
495      }
496      mark();
497    }, { rootMargin: '0px 0px -67% 0px' });
498    for (const f of files) io.observe(f);
499    // A window of files of a merge request came in (mr.js): its files replace
500    // placeholders, which the observer and the list knew by element.
501    ermite.watchFile = (f, old) => {
502      if (old) { io.unobserve(old); inBand.delete(old); }
503      files = $$('.dfile');
504      for (const x of [...inBand]) if (!x.isConnected) inBand.delete(x);
505      if (current && !current.isConnected) current = document.getElementById(current.id);
506      io.observe(f);
507    };
508  }
509
510  // 5. A failure of a handler or of a request (htmx logs it to the console
511  // already) is shown, so that a key that does nothing is not a mystery.
512  addEventListener('htmx:error', (e) => {
513    if (e.target.closest?.('.hint-live')) return; // the stream says so itself ("live updates paused")
514    const err = e.detail?.error;
515    flash('something went wrong' + (err?.message ? ': ' + err.message : ''), 4000);
516  });
517})();
518
519// ermiteLive applies the fragments of the event stream (/-/events, see
520// internal/adapters/web/events.go). A fragment is a top-level element with an
521// id: it replaces the element of the page that has the same id. A fragment
522// marked data-gone removes it; a row of a repository the home page does not
523// list yet is added at the top of the list. A slot (data-slot: the place of a
524// banner, a persistent status region) keeps its element and only gets the
525// children of the fragment, so that screen readers announce the change. A gap,
526// which says the page may have missed events or that what it shows changed,
527// reloads the page. A stream the server refuses on reconnection (the
528// repository is gone, the session ended) makes the page reload.
529window.ermiteLive = (() => {
530  let reloading = false;
531  let stopped = false;
532  // A stream that drops and comes back within LOST_AFTER (a proxy or the
533  // server closing it, a network hiccup) is not worth a message.
534  const LOST_AFTER = 5000;
535  let lostTimer = 0;
536  const setState = (s) => { const el = document.querySelector('.hint-live'); if (el) el.dataset.state = s; };
537  return {
538    // lost is htmx:sse:close and htmx:sse:error: "live updates paused" shows
539    // only if the stream is still down LOST_AFTER later.
540    lost() {
541      if (lostTimer) return;
542      lostTimer = setTimeout(() => { lostTimer = 0; setState('offline'); }, LOST_AFTER);
543    },
544    // back is htmx:sse:after:connection.
545    back() {
546      clearTimeout(lostTimer);
547      lostTimer = 0;
548      setState('live');
549    },
550    swap(html) {
551      const t = document.createElement('template');
552      t.innerHTML = html;
553      const cursor = document.querySelector('[data-rows] > [data-cursor]');
554      const cursorKey = cursor?.id || cursor?.querySelector('a')?.getAttribute('href');
555      for (const el of [...t.content.children]) {
556        const old = el.id && document.getElementById(el.id);
557        if (el.hasAttribute('data-gone')) { old?.remove(); continue; }
558        if (!old) {
559          const rows = document.getElementById('repo-rows');
560          if (rows && el.id.startsWith('repo-')) { rows.prepend(el); window.htmx?.process(el); }
561          continue;
562        }
563        if (old.hasAttribute('data-slot')) { old.replaceChildren(...el.childNodes); continue; }
564        if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', '');
565        old.replaceWith(el);
566        window.htmx?.process(el);
567      }
568      // The rows may have changed under an active filter: apply it again and
569      // keep the cursor on its row while that row is still shown.
570      for (const input of document.querySelectorAll('input[data-filter]')) {
571        if (input.value) window.ermite?.hide(input);
572      }
573      if (!cursorKey) return;
574      const rows = [...document.querySelectorAll('[data-rows] > *')];
575      const back = rows.find((r) => !r.hidden && (r.id === cursorKey || r.querySelector('a')?.getAttribute('href') === cursorKey));
576      if (back) for (const r of rows) r.toggleAttribute('data-cursor', r === back);
577    },
578    reload() {
579      if (reloading) return;
580      reloading = true;
581      setTimeout(() => location.reload(), 300);
582    },
583    // error handles htmx:sse:error (detail.status is the status of a refused
584    // reconnection). hx-sse retries for ever; a 404 or a 401 will not change by
585    // itself, and the fetched page says what is the matter (or has no stream).
586    // A page that reloaded for this a moment ago and still gets refused would
587    // loop: it stops following the stream instead (see gate).
588    error(status) {
589      if (status !== 404 && status !== 401) return;
590      let last = 0;
591      try { last = +sessionStorage.getItem('ermite-live-reload') || 0; } catch { /* no storage: no guard */ }
592      if (Date.now() - last < 15000) { stopped = true; return; }
593      try { sessionStorage.setItem('ermite-live-reload', String(Date.now())); } catch { /* idem */ }
594      this.reload();
595    },
596    // gate is htmx:sse:before:connection: once stopped, no more attempts.
597    gate(event) {
598      if (stopped) event.preventDefault();
599    },
600  };
601})();

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 46

↑↓ moveenter openesc close