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