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