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