v0.6.0 d5cc8fe

ermite.js RawHistoryFolder

1// ermite.js: the one thing hx-live cannot do is a global key *sequence* state machine
2// ("g" then "h"), nor ignore keystrokes aimed at inputs before any element is focused.
3// It only translates keys into DOM events named "k-<name>" on <body>; every reaction
4// (cursor, panels, filters, permalink) is declared in templates/components/keys.tmpl with hx-on + hx-live.
5(() => {
6  const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink',
7    s: 's', t: 't', c: 'c', r: 'r', m: 'm', n: 'next', d: 'split', u: 'unified', x: 'collapse', Enter: 'open', Escape: 'esc' };
8  const goto = { h: 'gh', t: 'gt', c: 'gc', r: 'gr', m: 'gm', g: 'top' };
9  let pending = 0;
10  const fire = (name) => document.body.dispatchEvent(new CustomEvent('k-' + name, { bubbles: false }));
11  addEventListener('keydown', (e) => {
12    const typing = e.target.closest('input, textarea, select, [contenteditable]');
13    // ctrl k / cmd k opens the palette; in a field only cmd k, so that the
14    // field keeps ctrl k (kill to end of line on macOS).
15    if ((e.metaKey || (e.ctrlKey && !typing)) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'k') { e.preventDefault(); pending = 0; return fire('cmd'); }
16    if (e.ctrlKey || e.metaKey || e.altKey) return;
17    if (e.key === 'Escape') { pending = 0; if (typing) e.target.blur(); return fire('esc'); }
18    if (typing) return;
19    // An open dialog takes only the keys that move between dialogs: the page
20    // behind it does not react (scripted clicks would go through inert).
21    if (document.querySelector('dialog[open]') && !['?', 'b', ':', 'f', 'Enter'].includes(e.key)) { pending = 0; return; }
22    if (e.key === 'Enter' && e.target.closest('a, button, summary')) return;
23    if (pending) {
24      const name = goto[e.key];
25      pending = 0;
26      if (name) { e.preventDefault(); fire(name); }
27      return;
28    }
29    if (e.key === 'g') { pending = 1; setTimeout(() => (pending = 0), 1200); return; }
30    const name = single[e.key];
31    if (!name) return;
32    if (e.key !== 'Enter' || !document.querySelector('dialog[open]')) e.preventDefault();
33    fire(name);
34  });
35  // In the app shell the page does not scroll, <main> does: give it focus so arrows,
36  // PageUp/PageDown and space scroll the content without a click first.
37  if (!location.hash) document.getElementById('main')?.focus({ preventScroll: true });
38})();
39
40// ermiteLive applies the fragments of the event stream (/-/events, see
41// internal/adapters/web/events.go). A fragment is a top-level element with an
42// id: it replaces the element of the page that has the same id. A fragment
43// marked data-gone removes it; a row of a repository the home page does not
44// list yet is added at the top of the list. A gap, which says the page may
45// have missed events or that what it shows changed, reloads the page.
46window.ermiteLive = (() => {
47  let reloading = false;
48  return {
49    swap(html) {
50      const t = document.createElement('template');
51      t.innerHTML = html;
52      for (const el of t.content.children) {
53        const old = el.id && document.getElementById(el.id);
54        if (el.hasAttribute('data-gone')) { old?.remove(); continue; }
55        if (!old) {
56          const rows = document.getElementById('repo-rows');
57          if (rows && el.id.startsWith('repo-')) { rows.prepend(el); window.htmx?.process(el); }
58          continue;
59        }
60        if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', '');
61        if (old.hidden && old.tagName === 'TR') el.hidden = true;
62        old.replaceWith(el);
63        window.htmx?.process(el);
64      }
65    },
66    reload() {
67      if (reloading) return;
68      reloading = true;
69      setTimeout(() => location.reload(), 300);
70    },
71  };
72})();

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