v0.18.0 0cea275

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(() => {
14  // Marks the page as scripted: what does nothing without scripts (the filters,
15  // the "go to" and help buttons) is hidden until this class is set (see the
16  // utilities of 10-base.css).
17  document.documentElement.classList.add('js');
18
19  const $ = (sel, root = document) => root.querySelector(sel);
20  const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
21  const body = () => document.body;
22
23  // flash shows a message in the hint badge (body[data-flash], a status
24  // region), then clears it unless another message came in the meantime.
25  let flashTimer = 0;
26  const flash = (msg, ms = 2000) => {
27    body().dataset.flash = msg;
28    clearTimeout(flashTimer);
29    flashTimer = setTimeout(() => (body().dataset.flash = ''), ms);
30  };
31
32  const visibleRows = () => $$('[data-rows] > :not([hidden])');
33  // setCursor puts data-cursor on el and takes it from the others.
34  const setCursor = (el, others) => {
35    for (const o of others) if (o !== el) o.removeAttribute('data-cursor');
36    el.setAttribute('data-cursor', '');
37  };
38  const scrollPage = (to) => {
39    scrollTo(0, to);
40    $('#main')?.scrollTo(0, to);
41  };
42
43  const ermite = {
44    fire(name) {
45      body().dispatchEvent(new CustomEvent('k-' + name, { bubbles: false }));
46    },
47
48    // Row cursor: j / k.
49    move(d) {
50      if ($('dialog[open]')) return;
51      const rows = visibleRows();
52      if (!rows.length) {
53        scrollBy(0, d * 180);
54        $('#main')?.scrollBy(0, d * 180);
55        return;
56      }
57      const i = rows.findIndex((r) => r.hasAttribute('data-cursor'));
58      const j = Math.max(0, Math.min(rows.length - 1, i + d));
59      setCursor(rows[j], $$('[data-rows] > [data-cursor]'));
60      rows[j].scrollIntoView({ block: 'nearest' });
61      const mark = j + 1 + '/' + rows.length;
62      body().dataset.nav = mark;
63      setTimeout(() => body().dataset.nav === mark && (body().dataset.nav = ''), 1500);
64    },
65    // gg / G: first or last row, or the top or bottom of the page.
66    edge(last) {
67      const rows = visibleRows();
68      const row = last ? rows.at(-1) : rows[0];
69      if (!row) return scrollPage(last ? 1e9 : 0);
70      setCursor(row, $$('[data-rows] > [data-cursor]'));
71      row.scrollIntoView({ block: 'nearest' });
72    },
73    open() {
74      if (!$('dialog[open]')) $('[data-cursor] a')?.click();
75    },
76    focusFilter() {
77      $('[data-filter]')?.focus();
78    },
79    go(sel) {
80      $(sel)?.click();
81    },
82    tab(key) {
83      $(`.tabs [data-key=${key}]`)?.click();
84    },
85
86    // Dialogs.
87    closeDialogs() {
88      for (const d of $$('dialog[open]')) d.close();
89    },
90    dialog(id, focusId) {
91      const d = document.getElementById(id);
92      if (!d) return;
93      ermite.closeDialogs();
94      d.showModal();
95      if (focusId) document.getElementById(focusId)?.focus();
96    },
97
98    // filter hides the rows that do not contain the text of an input. The
99    // input says which rows (data-filter, or data-pick for a list without a
100    // cursor) and, optionally, the groups to hide when they have no row left
101    // (data-groups). The cursor goes to the first row left.
102    filter(input) {
103      const rows = ermite.hide(input);
104      if (input.dataset.filter) {
105        const first = rows.find((r) => !r.hidden);
106        for (const r of rows) r.toggleAttribute('data-cursor', r === first);
107      }
108    },
109    // hide applies the text of a filter input to its rows and groups, without
110    // moving the cursor, and returns the rows.
111    hide(input) {
112      const t = input.value.toLowerCase();
113      // A bare data-filter marks a field with its own search (the file finder
114      // of the Files view): there are no rows to hide.
115      const sel = input.dataset.filter || input.dataset.pick;
116      if (!sel) return [];
117      const rows = $$(sel);
118      for (const r of rows) r.hidden = !r.textContent.toLowerCase().includes(t);
119      if (input.dataset.groups) {
120        for (const g of $$(input.dataset.groups)) g.hidden = !rows.some((r) => !r.hidden && g.contains(r));
121      }
122      return rows;
123    },
124    // The "go to" palette.
125    cmdFilter(input) {
126      const t = input.value.toLowerCase().trim();
127      // The file hits the finder adds (li.finder-hit, files.js) have no data-k:
128      // the finder owns them.
129      const items = $$('#cmd-list > li[data-k]');
130      for (const li of items) li.hidden = !li.dataset.k.includes(t);
131      const first = items.find((li) => !li.hidden);
132      if (first) setCursor(first, $$('#cmd-list > li'));
133    },
134    cmdKey(e) {
135      const vis = $$('#cmd-list > li:not([hidden])');
136      const i = vis.findIndex((li) => li.hasAttribute('data-cursor'));
137      const step = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : 0;
138      if (step) {
139        e.preventDefault();
140        const n = vis[Math.max(0, Math.min(vis.length - 1, i + step))];
141        if (n) {
142          setCursor(n, $$('#cmd-list > li'));
143          n.scrollIntoView({ block: 'nearest' });
144        }
145      } else if (e.key === 'Enter') {
146        e.preventDefault();
147        vis[Math.max(i, 0)]?.querySelector('a')?.click();
148      }
149    },
150
151    // Diff.
152    layout(mode) {
153      const el = $('[data-diff]');
154      if (!el) return;
155      el.dataset.diff = mode;
156      try { sessionStorage.setItem('diff', mode); } catch { /* storage refused: the layout holds for this page */ }
157      for (const d of $$('[data-file]')) if ($('.dt', d)) d.open = true;
158    },
159    collapse() {
160      const files = $$('[data-file]');
161      const open = !files.some((d) => d.open);
162      for (const d of files) d.open = open;
163    },
164    nextNote() {
165      const notes = $$('#diffs .cmt');
166      if (!notes.length) return;
167      const i = notes.findIndex((n) => n.getBoundingClientRect().top > innerHeight / 3);
168      const n = notes[i < 0 ? 0 : i];
169      const d = n.closest('details');
170      if (d) d.open = true;
171      n.scrollIntoView({ block: 'center' });
172    },
173
174    // y: pin the URL to the commit and copy it.
175    async permalink() {
176      const b = body().dataset;
177      if (!b.sha) return flash('no ref to pin on this page');
178      const enc = (b.ref || '').split('/').map(encodeURIComponent).join('/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
179      // The ref follows the repository and the route: /<repo>/<tree|blob|commits>/<ref>.
180      const path = enc ? location.pathname.replace(new RegExp('^(/[^/]+/[^/]+/)' + enc + '(?=/|$)'), '$1' + b.sha) : location.pathname;
181      if (path === location.pathname) return flash('this page has no ref to pin');
182      history.replaceState(null, '', path + location.hash);
183      try {
184        await navigator.clipboard.writeText(location.href);
185        flash('permalink ' + b.sha.slice(0, 7) + ' copied');
186      } catch {
187        flash('permalink in the address bar (copy refused)');
188      }
189    },
190
191    // The [copy] button of a clone line: the label says "Copied" for 1.5 s,
192    // and so does the accessible name; the hint badge (a status region)
193    // announces it.
194    async copy(btn) {
195      const line = btn.closest('.clone-line')?.querySelector('code')?.textContent ?? '';
196      btn.dataset.idleName ||= btn.getAttribute('aria-label') || '';
197      let ok = true;
198      try { await navigator.clipboard.writeText(line); } catch { ok = false; }
199      btn.textContent = ok ? 'Copied' : 'Copy failed';
200      btn.setAttribute('aria-label', (ok ? 'copied: ' : 'copy failed: ') + line);
201      flash(ok ? 'copied' : 'copy failed', 1500);
202      clearTimeout(btn._restore);
203      btn._restore = setTimeout(() => {
204        btn.textContent = btn.dataset.label;
205        btn.setAttribute('aria-label', btn.dataset.idleName);
206      }, 1500);
207    },
208  };
209  window.ermite = ermite;
210
211  // 1. Keys.
212  const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink',
213    s: 's', t: 't', c: 'c', r: 'r', m: 'm', n: 'next', d: 'split', u: 'unified', x: 'collapse', Enter: 'open', Escape: 'esc' };
214  // "g t" is the key "t": the tabs have one event each.
215  const goto = { h: 'gh', t: 't', c: 'c', r: 'r', m: 'm', g: 'top' };
216  let pending = 0;
217  let pendingTimer = 0;
218  addEventListener('keydown', (e) => {
219    const typing = e.target.closest('input, textarea, select, [contenteditable]');
220    // ctrl k / cmd k opens the palette; in a field only cmd k, so that the
221    // field keeps ctrl k (kill to end of line on macOS).
222    if ((e.metaKey || (e.ctrlKey && !typing)) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'k') { e.preventDefault(); pending = 0; return ermite.fire('cmd'); }
223    if (e.ctrlKey || e.metaKey || e.altKey) return;
224    if (e.key === 'Escape') { pending = 0; if (typing) e.target.blur(); return ermite.fire('esc'); }
225    if (typing) return;
226    // An open dialog takes only the keys that move between dialogs: the page
227    // behind it does not react (scripted clicks would go through inert).
228    if ($('dialog[open]') && !['?', 'b', ':', 'f', 'Enter'].includes(e.key)) { pending = 0; return; }
229    if (e.key === 'Enter' && e.target.closest('a, button, summary')) return;
230    if (pending) {
231      const name = goto[e.key];
232      pending = 0;
233      if (name) { e.preventDefault(); ermite.fire(name); }
234      return;
235    }
236    if (e.key === 'g') { pending = 1; clearTimeout(pendingTimer); pendingTimer = setTimeout(() => (pending = 0), 1200); return; }
237    const name = single[e.key];
238    if (!name) return;
239    if (e.key !== 'Enter' || !$('dialog[open]')) e.preventDefault();
240    ermite.fire(name);
241  });
242
243  // 2. A [data-fire] element does what the key of that name does (the header
244  // buttons, the diff layout links). The link to the refs page works without
245  // scripts and is upgraded here to open the dialog; a click with a modifier
246  // keeps its meaning (new tab).
247  addEventListener('click', (e) => {
248    const el = e.target.closest?.('[data-fire]');
249    if (!el || e.button || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
250    e.preventDefault();
251    ermite.fire(el.dataset.fire);
252  });
253  for (const a of $$('a[data-fire="refs"]')) {
254    a.setAttribute('aria-haspopup', 'dialog');
255    a.setAttribute('aria-controls', 'panel-refs');
256  }
257
258  // A login link signs in without a click: its page submits its form once a
259  // browser shows it, not while it is prerendered.
260  const auto = $('form[data-autosubmit]');
261  if (auto) {
262    const go = () => auto.requestSubmit();
263    if (document.prerendering) document.addEventListener('prerenderingchange', go, { once: true });
264    else go();
265  }
266
267  // In the app shell the page does not scroll, <main> does: give it focus so arrows,
268  // PageUp/PageDown and space scroll the content without a click first.
269  if (!location.hash) document.getElementById('main')?.focus({ preventScroll: true });
270
271  // 4. The table of contents of a diff marks the file in view: the last file
272  // in the top third of the window, else the last file whose top is above that
273  // line (the first file when none is). One observer tracks the files in the
274  // band; it reports only when a file enters or leaves it, so a scroll costs
275  // nothing in between, and a jump that skips the band is caught by the scan.
276  const files = $$('.dfile');
277  const links = new Map($$('.files a[href^="#"]').map((a) => [a.hash.slice(1), a]));
278  if (files.length && links.size && 'IntersectionObserver' in window) {
279    const inBand = new Set();
280    let current = null;
281    const mark = () => {
282      const f = files.findLast((x) => inBand.has(x))
283        ?? files.findLast((x) => x.getBoundingClientRect().top < innerHeight / 3)
284        ?? files[0];
285      if (f === current) return;
286      links.get(current?.id)?.removeAttribute('aria-current');
287      links.get(f.id)?.setAttribute('aria-current', 'true');
288      current = f;
289    };
290    const io = new IntersectionObserver((entries) => {
291      for (const e of entries) {
292        if (e.isIntersecting) inBand.add(e.target);
293        else inBand.delete(e.target);
294      }
295      mark();
296    }, { rootMargin: '0px 0px -67% 0px' });
297    for (const f of files) io.observe(f);
298  }
299
300  // 5. A failure of a handler or of a request (htmx logs it to the console
301  // already) is shown, so that a key that does nothing is not a mystery.
302  addEventListener('htmx:error', (e) => {
303    if (e.target.closest?.('.hint-live')) return; // the stream says so itself ("live updates paused")
304    const err = e.detail?.error;
305    flash('something went wrong' + (err?.message ? ': ' + err.message : ''), 4000);
306  });
307})();
308
309// ermiteLive applies the fragments of the event stream (/-/events, see
310// internal/adapters/web/events.go). A fragment is a top-level element with an
311// id: it replaces the element of the page that has the same id. A fragment
312// marked data-gone removes it; a row of a repository the home page does not
313// list yet is added at the top of the list. A slot (data-slot: the place of a
314// banner, a persistent status region) keeps its element and only gets the
315// children of the fragment, so that screen readers announce the change. A gap,
316// which says the page may have missed events or that what it shows changed,
317// reloads the page. A stream the server refuses on reconnection (the
318// repository is gone, the session ended) makes the page reload.
319window.ermiteLive = (() => {
320  let reloading = false;
321  let stopped = false;
322  // A stream that drops and comes back within LOST_AFTER (a proxy or the
323  // server closing it, a network hiccup) is not worth a message.
324  const LOST_AFTER = 5000;
325  let lostTimer = 0;
326  const setState = (s) => { const el = document.querySelector('.hint-live'); if (el) el.dataset.state = s; };
327  return {
328    // lost is htmx:sse:close and htmx:sse:error: "live updates paused" shows
329    // only if the stream is still down LOST_AFTER later.
330    lost() {
331      if (lostTimer) return;
332      lostTimer = setTimeout(() => { lostTimer = 0; setState('offline'); }, LOST_AFTER);
333    },
334    // back is htmx:sse:after:connection.
335    back() {
336      clearTimeout(lostTimer);
337      lostTimer = 0;
338      setState('live');
339    },
340    swap(html) {
341      const t = document.createElement('template');
342      t.innerHTML = html;
343      const cursor = document.querySelector('[data-rows] > [data-cursor]');
344      const cursorKey = cursor?.id || cursor?.querySelector('a')?.getAttribute('href');
345      for (const el of [...t.content.children]) {
346        const old = el.id && document.getElementById(el.id);
347        if (el.hasAttribute('data-gone')) { old?.remove(); continue; }
348        if (!old) {
349          const rows = document.getElementById('repo-rows');
350          if (rows && el.id.startsWith('repo-')) { rows.prepend(el); window.htmx?.process(el); }
351          continue;
352        }
353        if (old.hasAttribute('data-slot')) { old.replaceChildren(...el.childNodes); continue; }
354        if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', '');
355        old.replaceWith(el);
356        window.htmx?.process(el);
357      }
358      // The rows may have changed under an active filter: apply it again and
359      // keep the cursor on its row while that row is still shown.
360      for (const input of document.querySelectorAll('input[data-filter]')) {
361        if (input.value) window.ermite?.hide(input);
362      }
363      if (!cursorKey) return;
364      const rows = [...document.querySelectorAll('[data-rows] > *')];
365      const back = rows.find((r) => !r.hidden && (r.id === cursorKey || r.querySelector('a')?.getAttribute('href') === cursorKey));
366      if (back) for (const r of rows) r.toggleAttribute('data-cursor', r === back);
367    },
368    reload() {
369      if (reloading) return;
370      reloading = true;
371      setTimeout(() => location.reload(), 300);
372    },
373    // error handles htmx:sse:error (detail.status is the status of a refused
374    // reconnection). hx-sse retries for ever; a 404 or a 401 will not change by
375    // itself, and the fetched page says what is the matter (or has no stream).
376    // A page that reloaded for this a moment ago and still gets refused would
377    // loop: it stops following the stream instead (see gate).
378    error(status) {
379      if (status !== 404 && status !== 401) return;
380      let last = 0;
381      try { last = +sessionStorage.getItem('ermite-live-reload') || 0; } catch { /* no storage: no guard */ }
382      if (Date.now() - last < 15000) { stopped = true; return; }
383      try { sessionStorage.setItem('ermite-live-reload', String(Date.now())); } catch { /* idem */ }
384      this.reload();
385    },
386    // gate is htmx:sse:before:connection: once stopped, no more attempts.
387    gate(event) {
388      if (stopped) event.preventDefault();
389    },
390  };
391})();

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