// ermite.js: the behaviour of the pages that htmx and hx-live do not give. // // 1. Keys. A global key *sequence* state machine ("g" then "h") and the // keystrokes aimed at inputs cannot be declared with hx-on. The keydown // handler only translates keys into DOM events named "k-" on ; // keys.templ binds each of them (hx-on, one line each) to a function of // window.ermite below, which does the work: row cursor, dialogs, filters, // diff layout, permalink. // 2. Clicks on [data-fire] elements fire the same events (header buttons). // 3. ermiteLive applies the fragments of the event stream (see events.go). // 4. The file of the diff in view is marked in the table of contents. // 5. A handler that fails is shown in the hint badge. // 6. The lines a diff hides load in place (ermiteGaps; e and E). // 7. The service worker (ermiteInstall). // 8. Chip lists kept on one line, with a … chip (ermiteFit). // 9. The layout of the home page, remembered in the browser (ermiteLayout). // 10. The wide mode: every page takes the whole width (ermiteWide). // // Preferences are kept by ermiteStore (store.js, loaded first). (() => { // Marks the page as scripted: what does nothing without scripts (the filters, // the "go to" and help buttons) is hidden until this class is set (see the // utilities of 10-base.css). document.documentElement.classList.add('js'); const $ = (sel, root = document) => root.querySelector(sel); const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; const body = () => document.body; // flash shows a message in the hint badge (body[data-flash], a status // region), then clears it unless another message came in the meantime. let flashTimer = 0; const flash = (msg, ms = 2000) => { body().dataset.flash = msg; clearTimeout(flashTimer); flashTimer = setTimeout(() => (body().dataset.flash = ''), ms); }; const visibleRows = () => $$('[data-rows] > :not([hidden])'); // setCursor puts data-cursor on el and takes it from the others. const setCursor = (el, others) => { for (const o of others) if (o !== el) o.removeAttribute('data-cursor'); el.setAttribute('data-cursor', ''); }; const scrollPage = (to) => { scrollTo(0, to); $('#main')?.scrollTo(0, to); }; const ermite = { flash, fire(name) { body().dispatchEvent(new CustomEvent('k-' + name, { bubbles: false })); }, // Row cursor: j / k. In the side panel they move the focus between its // controls instead. move(d) { if ($('dialog[open]')) return; if (window.ermiteMR?.stepFiles(d) || window.ermiteRail.step(d)) return; const rows = visibleRows(); if (!rows.length) { scrollBy(0, d * 180); $('#main')?.scrollBy(0, d * 180); return; } const i = rows.findIndex((r) => r.hasAttribute('data-cursor')); const j = Math.max(0, Math.min(rows.length - 1, i + d)); setCursor(rows[j], $$('[data-rows] > [data-cursor]')); rows[j].scrollIntoView({ block: 'nearest' }); window.ermiteTree?.sync(rows[j]); window.ermiteFinder?.sync(); // the rows may be the file finder's options const mark = j + 1 + '/' + rows.length; body().dataset.nav = mark; setTimeout(() => body().dataset.nav === mark && (body().dataset.nav = ''), 1500); }, // gg / G: first or last row, or the top or bottom of the page. edge(last) { const rows = visibleRows(); const row = last ? rows.at(-1) : rows[0]; if (!row) return scrollPage(last ? 1e9 : 0); setCursor(row, $$('[data-rows] > [data-cursor]')); row.scrollIntoView({ block: 'nearest' }); window.ermiteTree?.sync(row); window.ermiteFinder?.sync(); }, open() { if ($('dialog[open]') || window.ermiteTree?.open()) return; $('[data-cursor] a')?.click(); }, // l / h: in the Files view, open a directory or a file / close a directory // or go up. Elsewhere: into the side panel (the current file of a merge // request, else its main action, else its first control) and back to the // content. Enter then activates the control. rail(into) { if ($('dialog[open]')) return; if (window.ermiteTree?.key(into)) return; if (into && window.ermiteMR?.focusFiles()) return flash('files · j k move · enter open · h back'); if (!into && window.ermiteMR?.leaveFiles()) return; const msg = into ? window.ermiteRail.enter() : window.ermiteRail.leave(); if (msg) flash(msg); }, // openFile opens a file of the diff the table of contents links to. openFile(id) { const f = document.getElementById(id); if (f) f.open = true; }, focusFilter() { $('[data-filter]')?.focus(); }, go(sel) { $(sel)?.click(); }, tab(key) { $(`.tabs [data-key=${key}]`)?.click(); }, // The preference "single-key shortcuts" (ermiteKeys): the checkbox of the // help dialog and the action of the palette. setKeysOff(off) { window.ermiteKeys.set(off); }, toggleKeys() { const off = !window.ermiteKeys.off(); window.ermiteKeys.set(off); ermite.closeDialogs(); flash(off ? 'single-key shortcuts off · ctrl k and esc still work' : 'single-key shortcuts on'); }, // Dialogs. closeDialogs() { for (const d of $$('dialog[open]')) d.close(); }, dialog(id, focusId) { const d = document.getElementById(id); if (!d) return; ermite.closeDialogs(); d.showModal(); if (id === 'panel-cmd') window.ermiteCmd?.sync(); if (focusId) document.getElementById(focusId)?.focus(); }, // filter hides the rows that do not contain the text of an input. The // input says which rows (data-filter, or data-pick for a list without a // cursor) and, optionally, the groups to hide when they have no row left // (data-groups). The cursor goes to the first row left. filter(input) { const rows = ermite.hide(input); if (input.dataset.filter) { const first = rows.find((r) => !r.hidden); for (const r of rows) r.toggleAttribute('data-cursor', r === first); } }, // hide applies the text of a filter input to its rows and groups, without // moving the cursor, and returns the rows. hide(input) { const t = input.value.toLowerCase(); // A bare data-filter marks a field with its own search (the file finder // of the Files view): there are no rows to hide. const sel = input.dataset.filter || input.dataset.pick; if (!sel) return []; const rows = $$(sel); for (const r of rows) { const was = r.hidden; r.hidden = !r.textContent.toLowerCase().includes(t); // A row shown again lays out its chips again (it had no width hidden). if (was && !r.hidden) for (const ul of $$('[data-fit]', r)) window.ermiteFit?.fit(ul); } if (input.dataset.groups) { for (const g of $$(input.dataset.groups)) g.hidden = !rows.some((r) => !r.hidden && g.contains(r)); } return rows; }, // The "go to" palette (ermiteCmd below). cmdFilter(input) { window.ermiteCmd.filter(input); }, cmdKey(e) { window.ermiteCmd.key(e); }, // Diff. layout(mode) { const el = $('[data-diff]'); if (!el) return; el.dataset.diff = mode; try { sessionStorage.setItem('diff', mode); } catch { /* storage refused: the layout holds for this page */ } for (const d of $$('[data-file]')) if ($('.dt', d)) d.open = true; }, collapse() { const files = $$('[data-file]'); const open = !files.some((d) => d.open); for (const d of files) d.open = open; }, // e / E: the whole file in view, or every file of the diff, with every // line its hunks hide. expandFile() { const f = window.ermiteGaps.current(); if (!f) return flash('no file in view'); f.open = true; window.ermiteGaps.file(f).then((r) => { if (!r.done) return; // load said what failed flash(r.lines ? 'whole file' : 'nothing hidden in this file'); }); }, async expandAll() { const files = $$('[data-file]').filter((f) => f.querySelector('tr.gap')); if (!files.length) return flash('nothing hidden in the loaded files'); flash('loading every line…', 10000); for (const f of files) { if (!(await window.ermiteGaps.file(f)).done) return; // load said what failed } flash('every line of the loaded files'); }, nextNote() { const notes = $$('#diffs .cmt'); if (!notes.length) return; const i = notes.findIndex((n) => n.getBoundingClientRect().top > innerHeight / 3); const n = notes[i < 0 ? 0 : i]; const d = n.closest('details'); if (d) d.open = true; n.scrollIntoView({ block: 'center' }); }, // y: pin the URL to the commit and copy it. async permalink() { const b = body().dataset; if (!b.sha) return flash('no ref to pin on this page'); const enc = (b.ref || '').split('/').map(encodeURIComponent).join('/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // The ref follows the repository and the route: ///. const path = enc ? location.pathname.replace(new RegExp('^(/[^/]+/[^/]+/)' + enc + '(?=/|$)'), '$1' + b.sha) : location.pathname; if (path === location.pathname) return flash('this page has no ref to pin'); history.replaceState(null, '', path + location.hash); try { await navigator.clipboard.writeText(location.href); flash('permalink ' + b.sha.slice(0, 7) + ' copied'); } catch { flash('permalink in the address bar (copy refused)'); } }, // The [copy] button of a clone line: the label says "Copied" for 1.5 s, // and so does the accessible name; the hint badge (a status region) // announces it. async copy(btn) { const line = btn.closest('.clone-line')?.querySelector('code')?.textContent ?? ''; btn.dataset.idleName ||= btn.getAttribute('aria-label') || ''; let ok = true; try { await navigator.clipboard.writeText(line); } catch { ok = false; } btn.textContent = ok ? 'Copied' : 'Copy failed'; btn.setAttribute('aria-label', (ok ? 'copied: ' : 'copy failed: ') + line); flash(ok ? 'copied' : 'copy failed', 1500); clearTimeout(btn._restore); btn._restore = setTimeout(() => { btn.textContent = btn.dataset.label; btn.setAttribute('aria-label', btn.dataset.idleName); }, 1500); }, }; window.ermite = ermite; // The "go to" palette is an editable combobox with a listbox popup: the // focus stays in #cmd-q, the options are the
  • of #cmd-list, and the one // with data-cursor is the active one (aria-selected on it, aria-activedescendant // on the field). sync keeps the two in step; it runs on every change of the // cursor or of the list, including the file hits files.js adds later. // Self-contained: jstest/palette.test.mjs evaluates this block alone. window.ermiteCmd = (() => { const list = () => document.getElementById('cmd-list'); const field = () => document.getElementById('cmd-q'); const options = () => [...list().querySelectorAll(':scope > li')]; let seq = 0; const moveCursor = (li) => { for (const o of options()) if (o !== li) o.removeAttribute('data-cursor'); li.setAttribute('data-cursor', ''); }; function sync() { const input = field(); if (!input || !list()) return; const all = options(); const shown = all.filter((li) => !li.hidden); const cur = shown.find((li) => li.hasAttribute('data-cursor')); for (const li of all) { li.id ||= 'cmd-h' + seq++; li.setAttribute('role', 'option'); li.setAttribute('aria-selected', li === cur ? 'true' : 'false'); } input.setAttribute('aria-expanded', shown.length ? 'true' : 'false'); if (cur) input.setAttribute('aria-activedescendant', cur.id); else input.removeAttribute('aria-activedescendant'); } return { sync, // filter hides the entries that do not contain the text of the field. // The file hits the finder adds (li.finder-hit, files.js) have no data-k: // the finder owns them. filter(input) { const t = input.value.toLowerCase().trim(); const items = options().filter((li) => li.hasAttribute('data-k')); for (const li of items) li.hidden = !li.getAttribute('data-k').includes(t); const first = items.find((li) => !li.hidden); if (first) moveCursor(first); sync(); }, key(e) { const vis = options().filter((li) => !li.hidden); const i = vis.findIndex((li) => li.hasAttribute('data-cursor')); const step = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : 0; if (step) { e.preventDefault(); const n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; if (n) { moveCursor(n); n.scrollIntoView({ block: 'nearest' }); } sync(); } else if (e.key === 'Enter') { e.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a')?.click(); } }, }; })(); // The side panel (aside.rail) by keyboard: l enters it on its main action // (the primary button) or its first control, j / k step between its shown // controls (not hidden, not in a closed
    ), h leaves it for
    . // Each returns the hint to flash, if any. Self-contained: jstest/rail.test.mjs // evaluates this block alone. window.ermiteRail = (() => { const CONTROLS = ['a', 'button', 'select', 'summary', 'input', 'textarea']; const reachable = (e) => { if (e.disabled || e.closest('[hidden]')) return false; if (e.tagName === 'A' && !e.hasAttribute('href')) return false; if (e.tagName === 'INPUT' && e.getAttribute('type') === 'hidden') return false; return !e.getClientRects || e.getClientRects().length > 0; }; const rail = () => document.querySelector('.rail'); // items walks the panel in document order. const items = (r) => { const out = []; const walk = (e) => { for (const c of e.children) { if (CONTROLS.includes(c.tagName.toLowerCase())) { if (reachable(c)) out.push(c); } else walk(c); } }; walk(r); return out; }; const inside = (r) => r && r.contains(document.activeElement); const go = (e) => { e.focus(); e.scrollIntoView({ block: 'nearest' }); }; return { enter() { const r = rail(); if (!r) return 'no side panel on this page'; const list = items(r); const target = list.find((e) => e.matches('.btn.primary')) || list[0]; if (!target) return 'nothing to act on in the side panel'; go(target); return 'side panel · j k move · enter act · h back'; }, leave() { if (inside(rail())) document.getElementById('main')?.focus({ preventScroll: true }); }, // step moves d controls within the panel; false when the focus is not in it. step(d) { const r = rail(); if (!inside(r)) return false; const list = items(r); const i = list.indexOf(document.activeElement); const next = list[Math.max(0, Math.min(list.length - 1, i + d))]; if (next) go(next); return true; }, }; })(); // The installed app: the service worker (/-/sw.js) is registered once the // page has loaded. With it and the manifest the browser offers the install // by itself (the address bar of a desktop, the menu or a banner on a phone); // the page leaves that offer alone. Self-contained: jstest/install.test.mjs // evaluates this block alone. window.ermiteInstall = (() => { const api = { // register installs the service worker; a browser without one, or a // refusal, leaves the site working as before. register() { if (!navigator.serviceWorker) return Promise.resolve(null); return navigator.serviceWorker.register('/-/sw.js', { scope: '/' }).catch(() => null); }, }; if (document.readyState === 'complete') api.register(); else addEventListener('load', () => api.register(), { once: true }); return api; })(); // The hidden lines of a diff: a row .gap holds a button that loads them // (/context); the rows that come back replace the gap in the table of each // layout (the unified and the split tables hold one row each for it). A // file whose lines are all loaded has no gap left. window.ermiteGaps = (() => { const pending = new Map(); // selectLoaded marks the loaded rows that fall in the range of lines the // URL selects (?on=new:21-149:a.go, a shift + click), as the server marks // the rows it sent: the range stays one block once its hidden lines show. // The loaded rows are context lines, the same on both sides. function selectLoaded(file, rows) { const m = /^(new|old):(?:(\d+)-)?(\d+):(.+)$/.exec(new URLSearchParams(location.search).get('on') || ''); // data-file is the path comments name (the new path of a rename), as ?on= if (!m || file.dataset.file !== m[4]) return; const end = Number(m[3]); const start = m[2] ? Number(m[2]) : end; for (const r of rows) { const cells = [...r.querySelectorAll('td')]; // unified: old, new, mark, text; split: old side, then new side const num = Number((cells.length === 4 ? cells[m[1] === 'old' ? 0 : 1] : cells[m[1] === 'old' ? 0 : 3])?.textContent.trim()); if (!(num >= start && num <= end)) continue; if (cells.length === 4) r.classList.add('sel'); else for (const c of cells) c.classList.add('sel'); } } // load fills the gap the button is in, in both tables of its file. function load(btn) { const src = btn.dataset.src; const row = btn.closest('tr.gap'); const file = row?.closest('[data-file]'); if (!src || !file) return Promise.resolve(0); if (pending.has(src)) return pending.get(src); const key = row.dataset.gap; const table = btn.closest('table'); const focused = document.activeElement === btn; btn.disabled = true; const p = fetch(src, { headers: { Accept: 'text/html' }, credentials: 'same-origin' }) .then(async (res) => { if (!res.ok) throw new Error('loading the hidden lines: ' + res.status); const t = document.createElement('template'); t.innerHTML = await res.text(); let n = 0; for (const table of file.querySelectorAll('table.dt')) { const kind = table.classList.contains('spl') ? 'spl' : 'uni'; const from = t.content.querySelector('tbody[data-for="' + kind + '"]'); const at = [...table.querySelectorAll('tr.gap')].find((r) => r.dataset.gap === key); if (!from || !at) continue; const rows = [...from.children].map((r) => r.cloneNode(true)); n = Math.max(n, rows.filter((r) => !r.classList.contains('gap')).length); selectLoaded(file, rows.filter((r) => !r.classList.contains('gap'))); at.replaceWith(...rows); } // The focus was on the button, which is gone: on the gap that is // left in the same table, else on the content. if (focused && (document.activeElement === btn || document.activeElement === document.body)) { const next = [...(table?.querySelectorAll('tr.gap') || [])].find((r) => r.dataset.gap === key)?.querySelector('button'); if (next) next.focus({ preventScroll: true }); else document.getElementById('main')?.focus({ preventScroll: true }); } return n; }) .catch((err) => { btn.disabled = false; window.ermite?.flash?.(err.message, 4000); return -1; }) .finally(() => pending.delete(src)); pending.set(src, p); return p; } // file loads every gap of a file, until none is left (a long gap comes // back with a gap for the rest). A gap already loading is waited for. // done is false when a load failed (it said why) or the file never ran // out of gaps. async function file(f) { let lines = 0; for (let guard = 0; guard < 1000; guard++) { const btn = f.querySelector('tr.gap button[data-src]'); if (!btn) return { done: true, lines }; const n = await load(btn); if (n < 0) return { done: false, lines }; if (btn.isConnected) { // an answer that did not take the gap's place: stop, not loop window.ermite?.flash?.('some hidden lines could not be shown', 4000); return { done: false, lines }; } lines += n; } window.ermite?.flash?.('the file is too long to show every line at once', 4000); return { done: false, lines }; } // current is the file in view: the one the table of contents marks, else // the last whose top is above the top third of the window. function current() { const a = document.querySelector('.files a[aria-current="true"]'); const marked = a && document.getElementById(a.hash.slice(1)); if (marked?.matches('[data-file]')) return marked; const files = [...document.querySelectorAll('[data-file]')]; return files.findLast((f) => f.getBoundingClientRect().top < innerHeight / 3) ?? files[0] ?? null; } addEventListener('click', (e) => { const btn = e.target.closest?.('tr.gap button[data-src]'); if (btn) load(btn); }); return { load, file, current }; })(); // The preference "single-character shortcuts" (WCAG 2.1.4): off, the keys // that are one printable character (j, k, s, ?, :, G, 1-9 in the merge menu, // …) do nothing. Shortcuts with ctrl or cmd, escape, enter and the arrows // keep working. The choice is kept by ermiteStore ("keys-off": '1' when off) // and is made in the help dialog (a checkbox) or the palette (an action). // Self-contained with store.js: jstest/a11y.test.mjs evaluates this block. window.ermiteKeys = (() => { const pref = window.ermiteStore.pref('keys-off', { values: ['1', ''], fallback: '' }); const isOff = () => pref.get() === '1'; // apply shows the state: the checkbox and the note of the help dialog, // and the label of the palette action. function apply() { const off = isOff(); const box = document.getElementById('keys-off'); if (box) box.checked = off; const note = document.getElementById('keys-note'); if (note) { note.textContent = off ? 'Single-key shortcuts are off: only ctrl k and esc, enter and the arrows work. Turn them on again here.' : 'Single-key shortcuts are on. ctrl k and esc work whatever this says.'; } const act = document.querySelector('#cmd-list [data-fire="keys"] .lbl'); if (act) act.textContent = off ? 'turn single-key shortcuts on' : 'turn single-key shortcuts off'; } function set(v) { pref.set(v ? '1' : ''); apply(); } pref.watch(apply); apply(); return { off: isOff, set, apply, // blocks says whether the preference turns this key off. blocks: (e) => isOff() && e.key.length === 1, }; })(); // The layout of the home page: "cards" (the default), "split" (two // repositories per row) or "compact" (one line each). The choice is kept by // ermiteStore ("home-layout"), made with the buttons [data-set-layout] of // the layout switch, and shown as .home[data-layout], which the styles // follow, and as aria-pressed on the buttons. Another tab's change applies // here at once. The topic chips are fitted again: their room changes with the // layout. Self-contained with store.js: jstest/layout.test.mjs evaluates // this block. window.ermiteLayout = (() => { const values = ['cards', 'split', 'compact']; const pref = window.ermiteStore.pref('home-layout', { values, fallback: 'cards' }); function apply() { const layout = pref.get(); const home = document.querySelector('.home'); if (home) home.setAttribute('data-layout', layout); for (const b of document.querySelectorAll('[data-set-layout]')) { b.setAttribute('aria-pressed', String(b.getAttribute('data-set-layout') === layout)); } window.ermiteFit?.all(); } function set(v) { pref.set(v); apply(); } addEventListener('click', (e) => { const btn = e.target.closest?.('[data-set-layout]'); if (btn) set(btn.getAttribute('data-set-layout')); }); pref.watch(apply); apply(); return { get: pref.get, set, apply, values }; })(); // The wide mode (w, on every page): the work area takes the whole width of // the screen, the rail at the right edge and the content in the rest. // The choice is kept by ermiteStore ("wide": '1' when wide) and shown // as html.wide, which the styles follow. Another tab's change applies here // at once. window.ermiteWide = (() => { const pref = window.ermiteStore.pref('wide', { values: ['1', ''], fallback: '' }); function apply() { const wide = pref.get() === '1'; document.documentElement.classList.toggle?.('wide', wide); } function toggle() { pref.set(pref.get() === '1' ? '' : '1'); apply(); } pref.watch(apply); apply(); return { toggle, apply }; })(); // The menus of the bars (