detached 0e9351b

fix(web): persistent live regions, scroll-aware diff TOC, ermite.js handlers

- live banners update children of a persistent role=status slot
- an IntersectionObserver marks the diff file in view
- live stream reloads once on 404/401 then stops reconnecting
- server values reach evaluated attributes through data-* only
- copy feedback updates aria-label and the hint region
- header refs is a real link; JS-only controls hide without scripts
- keyboard, filter and palette logic moves from hx-on blocks to ermite.js
- one htmx:error listener surfaces handler errors
commit
0e9351b3972ab03d7031cadebdb723e7604fbbf2
author
brice · · 2026-10-11 20:01 UTC
parent
94354ea
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
61 files · +2013 -3079
M cmd/ermite/testdata/web/blob-binary.html +27 -73
@@ -44,19 +44,19 @@
4444 </ol>
4545 </nav>
4646 <div class="hdr-acts">
47-<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
47+<a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)">
4848 <span class="ico i-branch" aria-hidden="true">
4949 </span>
5050 <span class="ref-name">main</span>
5151 <span class="sha">SHA7</span>
52-</button>
53-<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
52+</a>
53+<button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd">
5454 <span class="ico i-search" aria-hidden="true">
5555 </span>
5656 <span class="search-label">Go to…</span>
5757 <kbd aria-hidden="true">ctrl k</kbd>
5858 </button>
59-<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
59+<button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button>
6060 </div>
6161 </div>
6262 <nav class="tabs" aria-label="Sections">
@@ -229,7 +229,7 @@
229229 <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
230230 <div class="panel-head">
231231 <label for="refs-filter" class="sr">filter branches and tags</label>
232-<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
232+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)">
233233 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
234234 <kbd>esc</kbd>
235235 </button>
@@ -264,13 +264,7 @@
264264 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
265265 <div class="panel-head">
266266 <label for="cmd-q" class="sr">go to</label>
267-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
268- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
269- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
270- let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
271- let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
272- if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
273- else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
267+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
274268 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
275269 <kbd>esc</kbd>
276270 </button>
@@ -341,67 +335,27 @@
341335 <kbd>esc</kbd> close</span>
342336 </p>
343337 </dialog>
344-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
345- if (q('dialog[open]').count) return;
346- let rows = q('[data-rows] > :not([hidden])').arr();
347- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
348- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
349- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
350- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
351- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
352- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
353- })(1);
354- k-k from:body -> (async (d) => {
355- if (q('dialog[open]').count) return;
356- let rows = q('[data-rows] > :not([hidden])').arr();
357- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
358- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
359- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
360- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
361- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
362- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
363- })(-1);
364- k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
365- k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
366- k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
367- k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
368- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
369- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
370- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
371- k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
372- k-gh from:body -> document.querySelector('.brand')?.click();
373- k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
374- k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
375- k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
376- k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
377- k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
378- k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
379- k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
380- k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
381- k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
382- k-next from:body -> {
383- let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
384- let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
385- let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
386- };
387- k-split from:body -> {
388- let el = q('[data-diff]').arr()[0]; if (!el) return;
389- el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split');
390- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
391- };
392- k-unified from:body -> {
393- let el = q('[data-diff]').arr()[0]; if (!el) return;
394- el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified');
395- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
396- };
397- k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) };
398- k-permalink from:body -> {
399- let b = document.body.dataset;
400- if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
401- history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
402- navigator.clipboard?.writeText(location.href);
403- b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
404- }">
338+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
339+ k-k from:body -> ermite.move(-1);
340+ k-top from:body -> ermite.edge(false);
341+ k-bottom from:body -> ermite.edge(true);
342+ k-open from:body -> ermite.open();
343+ k-esc from:body -> ermite.closeDialogs();
344+ k-help from:body -> ermite.dialog('panel-help');
345+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
346+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
347+ k-filter from:body -> ermite.focusFilter();
348+ k-gh from:body -> ermite.go('.brand');
349+ k-s from:body -> ermite.tab('s');
350+ k-t from:body -> ermite.tab('t');
351+ k-c from:body -> ermite.tab('c');
352+ k-r from:body -> ermite.tab('r');
353+ k-m from:body -> ermite.tab('m');
354+ k-next from:body -> ermite.nextNote();
355+ k-split from:body -> ermite.layout('split');
356+ k-unified from:body -> ermite.layout('unified');
357+ k-collapse from:body -> ermite.collapse();
358+ k-permalink from:body -> ermite.permalink()">
405359 </div>
406360 </body>
407361 </html>
M cmd/ermite/testdata/web/blob-code.html +27 -73
@@ -44,19 +44,19 @@
4444 </ol>
4545 </nav>
4646 <div class="hdr-acts">
47-<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
47+<a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)">
4848 <span class="ico i-branch" aria-hidden="true">
4949 </span>
5050 <span class="ref-name">main</span>
5151 <span class="sha">SHA7</span>
52-</button>
53-<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
52+</a>
53+<button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd">
5454 <span class="ico i-search" aria-hidden="true">
5555 </span>
5656 <span class="search-label">Go to…</span>
5757 <kbd aria-hidden="true">ctrl k</kbd>
5858 </button>
59-<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
59+<button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button>
6060 </div>
6161 </div>
6262 <nav class="tabs" aria-label="Sections">
@@ -299,7 +299,7 @@
299299 <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
300300 <div class="panel-head">
301301 <label for="refs-filter" class="sr">filter branches and tags</label>
302-<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
302+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)">
303303 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
304304 <kbd>esc</kbd>
305305 </button>
@@ -334,13 +334,7 @@
334334 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
335335 <div class="panel-head">
336336 <label for="cmd-q" class="sr">go to</label>
337-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
338- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
339- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
340- let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
341- let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
342- if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
343- else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
337+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
344338 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
345339 <kbd>esc</kbd>
346340 </button>
@@ -411,67 +405,27 @@
411405 <kbd>esc</kbd> close</span>
412406 </p>
413407 </dialog>
414-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
415- if (q('dialog[open]').count) return;
416- let rows = q('[data-rows] > :not([hidden])').arr();
417- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
418- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
419- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
420- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
421- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
422- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
423- })(1);
424- k-k from:body -> (async (d) => {
425- if (q('dialog[open]').count) return;
426- let rows = q('[data-rows] > :not([hidden])').arr();
427- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
428- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
429- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
430- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
431- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
432- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
433- })(-1);
434- k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
435- k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
436- k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
437- k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
438- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
439- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
440- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
441- k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
442- k-gh from:body -> document.querySelector('.brand')?.click();
443- k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
444- k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
445- k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
446- k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
447- k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
448- k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
449- k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
450- k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
451- k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
452- k-next from:body -> {
453- let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
454- let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
455- let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
456- };
457- k-split from:body -> {
458- let el = q('[data-diff]').arr()[0]; if (!el) return;
459- el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split');
460- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
461- };
462- k-unified from:body -> {
463- let el = q('[data-diff]').arr()[0]; if (!el) return;
464- el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified');
465- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
466- };
467- k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) };
468- k-permalink from:body -> {
469- let b = document.body.dataset;
470- if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
471- history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
472- navigator.clipboard?.writeText(location.href);
473- b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
474- }">
408+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
409+ k-k from:body -> ermite.move(-1);
410+ k-top from:body -> ermite.edge(false);
411+ k-bottom from:body -> ermite.edge(true);
412+ k-open from:body -> ermite.open();
413+ k-esc from:body -> ermite.closeDialogs();
414+ k-help from:body -> ermite.dialog('panel-help');
415+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
416+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
417+ k-filter from:body -> ermite.focusFilter();
418+ k-gh from:body -> ermite.go('.brand');
419+ k-s from:body -> ermite.tab('s');
420+ k-t from:body -> ermite.tab('t');
421+ k-c from:body -> ermite.tab('c');
422+ k-r from:body -> ermite.tab('r');
423+ k-m from:body -> ermite.tab('m');
424+ k-next from:body -> ermite.nextNote();
425+ k-split from:body -> ermite.layout('split');
426+ k-unified from:body -> ermite.layout('unified');
427+ k-collapse from:body -> ermite.collapse();
428+ k-permalink from:body -> ermite.permalink()">
475429 </div>
476430 </body>
477431 </html>
M cmd/ermite/testdata/web/blob-empty.html +27 -73
@@ -44,19 +44,19 @@
4444 </ol>
4545 </nav>
4646 <div class="hdr-acts">
47-<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
47+<a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)">
4848 <span class="ico i-branch" aria-hidden="true">
4949 </span>
5050 <span class="ref-name">main</span>
5151 <span class="sha">SHA7</span>
52-</button>
53-<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
52+</a>
53+<button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd">
5454 <span class="ico i-search" aria-hidden="true">
5555 </span>
5656 <span class="search-label">Go to…</span>
5757 <kbd aria-hidden="true">ctrl k</kbd>
5858 </button>
59-<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
59+<button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button>
6060 </div>
6161 </div>
6262 <nav class="tabs" aria-label="Sections">
@@ -229,7 +229,7 @@
229229 <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
230230 <div class="panel-head">
231231 <label for="refs-filter" class="sr">filter branches and tags</label>
232-<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
232+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)">
233233 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
234234 <kbd>esc</kbd>
235235 </button>
@@ -264,13 +264,7 @@
264264 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
265265 <div class="panel-head">
266266 <label for="cmd-q" class="sr">go to</label>
267-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
268- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
269- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
270- let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
271- let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
272- if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
273- else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
267+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
274268 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
275269 <kbd>esc</kbd>
276270 </button>
@@ -341,67 +335,27 @@
341335 <kbd>esc</kbd> close</span>
342336 </p>
343337 </dialog>
344-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
345- if (q('dialog[open]').count) return;
346- let rows = q('[data-rows] > :not([hidden])').arr();
347- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
348- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
349- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
350- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
351- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
352- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
353- })(1);
354- k-k from:body -> (async (d) => {
355- if (q('dialog[open]').count) return;
356- let rows = q('[data-rows] > :not([hidden])').arr();
357- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
358- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
359- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
360- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
361- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
362- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
363- })(-1);
364- k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
365- k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
366- k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
367- k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
368- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
369- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
370- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
371- k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
372- k-gh from:body -> document.querySelector('.brand')?.click();
373- k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
374- k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
375- k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
376- k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
377- k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
378- k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
379- k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
380- k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
381- k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
382- k-next from:body -> {
383- let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
384- let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
385- let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
386- };
387- k-split from:body -> {
388- let el = q('[data-diff]').arr()[0]; if (!el) return;
389- el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split');
390- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
391- };
392- k-unified from:body -> {
393- let el = q('[data-diff]').arr()[0]; if (!el) return;
394- el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified');
395- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
396- };
397- k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) };
398- k-permalink from:body -> {
399- let b = document.body.dataset;
400- if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
401- history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
402- navigator.clipboard?.writeText(location.href);
403- b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
404- }">
338+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
339+ k-k from:body -> ermite.move(-1);
340+ k-top from:body -> ermite.edge(false);
341+ k-bottom from:body -> ermite.edge(true);
342+ k-open from:body -> ermite.open();
343+ k-esc from:body -> ermite.closeDialogs();
344+ k-help from:body -> ermite.dialog('panel-help');
345+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
346+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
347+ k-filter from:body -> ermite.focusFilter();
348+ k-gh from:body -> ermite.go('.brand');
349+ k-s from:body -> ermite.tab('s');
350+ k-t from:body -> ermite.tab('t');
351+ k-c from:body -> ermite.tab('c');
352+ k-r from:body -> ermite.tab('r');
353+ k-m from:body -> ermite.tab('m');
354+ k-next from:body -> ermite.nextNote();
355+ k-split from:body -> ermite.layout('split');
356+ k-unified from:body -> ermite.layout('unified');
357+ k-collapse from:body -> ermite.collapse();
358+ k-permalink from:body -> ermite.permalink()">
405359 </div>
406360 </body>
407361 </html>
M cmd/ermite/testdata/web/blob-image.html +27 -73
@@ -44,19 +44,19 @@
4444 </ol>
4545 </nav>
4646 <div class="hdr-acts">
47-<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
47+<a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)">
4848 <span class="ico i-branch" aria-hidden="true">
4949 </span>
5050 <span class="ref-name">main</span>
5151 <span class="sha">SHA7</span>
52-</button>
53-<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
52+</a>
53+<button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd">
5454 <span class="ico i-search" aria-hidden="true">
5555 </span>
5656 <span class="search-label">Go to…</span>
5757 <kbd aria-hidden="true">ctrl k</kbd>
5858 </button>
59-<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
59+<button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button>
6060 </div>
6161 </div>
6262 <nav class="tabs" aria-label="Sections">
@@ -231,7 +231,7 @@
231231 <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
232232 <div class="panel-head">
233233 <label for="refs-filter" class="sr">filter branches and tags</label>
234-<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
234+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)">
235235 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
236236 <kbd>esc</kbd>
237237 </button>
@@ -266,13 +266,7 @@
266266 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
267267 <div class="panel-head">
268268 <label for="cmd-q" class="sr">go to</label>
269-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
270- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
271- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
272- let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
273- let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
274- if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
275- else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
269+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
276270 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
277271 <kbd>esc</kbd>
278272 </button>
@@ -343,67 +337,27 @@
343337 <kbd>esc</kbd> close</span>
344338 </p>
345339 </dialog>
346-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
347- if (q('dialog[open]').count) return;
348- let rows = q('[data-rows] > :not([hidden])').arr();
349- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
350- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
351- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
352- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
353- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
354- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
355- })(1);
356- k-k from:body -> (async (d) => {
357- if (q('dialog[open]').count) return;
358- let rows = q('[data-rows] > :not([hidden])').arr();
359- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
360- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
361- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
362- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
363- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
364- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
365- })(-1);
366- k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
367- k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
368- k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
369- k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
370- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
371- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
372- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
373- k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
374- k-gh from:body -> document.querySelector('.brand')?.click();
375- k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
376- k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
377- k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
378- k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
379- k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
380- k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
381- k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
382- k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
383- k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
384- k-next from:body -> {
385- let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
386- let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
387- let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
388- };
389- k-split from:body -> {
390- let el = q('[data-diff]').arr()[0]; if (!el) return;
391- el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split');
392- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
393- };
394- k-unified from:body -> {
395- let el = q('[data-diff]').arr()[0]; if (!el) return;
396- el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified');
397- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
398- };
399- k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) };
400- k-permalink from:body -> {
401- let b = document.body.dataset;
402- if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
403- history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
404- navigator.clipboard?.writeText(location.href);
405- b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
406- }">
340+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
341+ k-k from:body -> ermite.move(-1);
342+ k-top from:body -> ermite.edge(false);
343+ k-bottom from:body -> ermite.edge(true);
344+ k-open from:body -> ermite.open();
345+ k-esc from:body -> ermite.closeDialogs();
346+ k-help from:body -> ermite.dialog('panel-help');
347+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
348+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
349+ k-filter from:body -> ermite.focusFilter();
350+ k-gh from:body -> ermite.go('.brand');
351+ k-s from:body -> ermite.tab('s');
352+ k-t from:body -> ermite.tab('t');
353+ k-c from:body -> ermite.tab('c');
354+ k-r from:body -> ermite.tab('r');
355+ k-m from:body -> ermite.tab('m');
356+ k-next from:body -> ermite.nextNote();
357+ k-split from:body -> ermite.layout('split');
358+ k-unified from:body -> ermite.layout('unified');
359+ k-collapse from:body -> ermite.collapse();
360+ k-permalink from:body -> ermite.permalink()">
407361 </div>
408362 </body>
409363 </html>
M cmd/ermite/testdata/web/blob-markdown.html +27 -73
@@ -47,19 +47,19 @@
4747 </ol>
4848 </nav>
4949 <div class="hdr-acts">
50-<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
50+<a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)">
5151 <span class="ico i-branch" aria-hidden="true">
5252 </span>
5353 <span class="ref-name">main</span>
5454 <span class="sha">SHA7</span>
55-</button>
56-<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
55+</a>
56+<button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd">
5757 <span class="ico i-search" aria-hidden="true">
5858 </span>
5959 <span class="search-label">Go to…</span>
6060 <kbd aria-hidden="true">ctrl k</kbd>
6161 </button>
62-<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
62+<button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button>
6363 </div>
6464 </div>
6565 <nav class="tabs" aria-label="Sections">
@@ -296,7 +296,7 @@
296296 <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
297297 <div class="panel-head">
298298 <label for="refs-filter" class="sr">filter branches and tags</label>
299-<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
299+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)">
300300 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
301301 <kbd>esc</kbd>
302302 </button>
@@ -331,13 +331,7 @@
331331 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
332332 <div class="panel-head">
333333 <label for="cmd-q" class="sr">go to</label>
334-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
335- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
336- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
337- let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
338- let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
339- if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
340- else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
334+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
341335 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
342336 <kbd>esc</kbd>
343337 </button>
@@ -408,67 +402,27 @@
408402 <kbd>esc</kbd> close</span>
409403 </p>
410404 </dialog>
411-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
412- if (q('dialog[open]').count) return;
413- let rows = q('[data-rows] > :not([hidden])').arr();
414- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
415- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
416- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
417- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
418- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
419- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
420- })(1);
421- k-k from:body -> (async (d) => {
422- if (q('dialog[open]').count) return;
423- let rows = q('[data-rows] > :not([hidden])').arr();
424- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
425- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
426- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
427- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
428- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
429- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
430- })(-1);
431- k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
432- k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
433- k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
434- k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
435- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
436- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
437- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
438- k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
439- k-gh from:body -> document.querySelector('.brand')?.click();
440- k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
441- k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
442- k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
443- k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
444- k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
445- k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
446- k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
447- k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
448- k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
449- k-next from:body -> {
450- let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
451- let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
452- let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
453- };
454- k-split from:body -> {
455- let el = q('[data-diff]').arr()[0]; if (!el) return;
456- el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split');
457- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
458- };
459- k-unified from:body -> {
460- let el = q('[data-diff]').arr()[0]; if (!el) return;
461- el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified');
462- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
463- };
464- k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) };
465- k-permalink from:body -> {
466- let b = document.body.dataset;
467- if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
468- history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
469- navigator.clipboard?.writeText(location.href);
470- b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
471- }">
405+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
406+ k-k from:body -> ermite.move(-1);
407+ k-top from:body -> ermite.edge(false);
408+ k-bottom from:body -> ermite.edge(true);
409+ k-open from:body -> ermite.open();
410+ k-esc from:body -> ermite.closeDialogs();
411+ k-help from:body -> ermite.dialog('panel-help');
412+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
413+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
414+ k-filter from:body -> ermite.focusFilter();
415+ k-gh from:body -> ermite.go('.brand');
416+ k-s from:body -> ermite.tab('s');
417+ k-t from:body -> ermite.tab('t');
418+ k-c from:body -> ermite.tab('c');
419+ k-r from:body -> ermite.tab('r');
420+ k-m from:body -> ermite.tab('m');
421+ k-next from:body -> ermite.nextNote();
422+ k-split from:body -> ermite.layout('split');
423+ k-unified from:body -> ermite.layout('unified');
424+ k-collapse from:body -> ermite.collapse();
425+ k-permalink from:body -> ermite.permalink()">
472426 </div>
473427 </body>
474428 </html>
M cmd/ermite/testdata/web/blob-svg.html +27 -73
@@ -44,19 +44,19 @@
4444 </ol>
4545 </nav>
4646 <div class="hdr-acts">
47-<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
47+<a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)">
4848 <span class="ico i-branch" aria-hidden="true">
4949 </span>
5050 <span class="ref-name">main</span>
5151 <span class="sha">SHA7</span>
52-</button>
53-<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
52+</a>
53+<button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd">
5454 <span class="ico i-search" aria-hidden="true">
5555 </span>
5656 <span class="search-label">Go to…</span>
5757 <kbd aria-hidden="true">ctrl k</kbd>
5858 </button>
59-<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
59+<button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button>
6060 </div>
6161 </div>
6262 <nav class="tabs" aria-label="Sections">
@@ -254,7 +254,7 @@
254254 <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
255255 <div class="panel-head">
256256 <label for="refs-filter" class="sr">filter branches and tags</label>
257-<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
257+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)">
258258 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
259259 <kbd>esc</kbd>
260260 </button>
@@ -289,13 +289,7 @@
289289 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
290290 <div class="panel-head">
291291 <label for="cmd-q" class="sr">go to</label>
292-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
293- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
294- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
295- let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
296- let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
297- if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
298- else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
292+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
299293 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
300294 <kbd>esc</kbd>
301295 </button>
@@ -366,67 +360,27 @@
366360 <kbd>esc</kbd> close</span>
367361 </p>
368362 </dialog>
369-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
370- if (q('dialog[open]').count) return;
371- let rows = q('[data-rows] > :not([hidden])').arr();
372- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
373- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
374- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
375- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
376- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
377- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
378- })(1);
379- k-k from:body -> (async (d) => {
380- if (q('dialog[open]').count) return;
381- let rows = q('[data-rows] > :not([hidden])').arr();
382- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
383- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
384- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
385- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
386- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
387- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
388- })(-1);
389- k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
390- k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
391- k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
392- k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
393- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
394- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
395- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
396- k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
397- k-gh from:body -> document.querySelector('.brand')?.click();
398- k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
399- k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
400- k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
401- k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
402- k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
403- k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
404- k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
405- k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
406- k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
407- k-next from:body -> {
408- let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
409- let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
410- let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
411- };
412- k-split from:body -> {
413- let el = q('[data-diff]').arr()[0]; if (!el) return;
414- el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split');
415- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
416- };
417- k-unified from:body -> {
418- let el = q('[data-diff]').arr()[0]; if (!el) return;
419- el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified');
420- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
421- };
422- k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) };
423- k-permalink from:body -> {
424- let b = document.body.dataset;
425- if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
426- history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
427- navigator.clipboard?.writeText(location.href);
428- b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
429- }">
363+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
364+ k-k from:body -> ermite.move(-1);
365+ k-top from:body -> ermite.edge(false);
366+ k-bottom from:body -> ermite.edge(true);
367+ k-open from:body -> ermite.open();
368+ k-esc from:body -> ermite.closeDialogs();
369+ k-help from:body -> ermite.dialog('panel-help');
370+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
371+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
372+ k-filter from:body -> ermite.focusFilter();
373+ k-gh from:body -> ermite.go('.brand');
374+ k-s from:body -> ermite.tab('s');
375+ k-t from:body -> ermite.tab('t');
376+ k-c from:body -> ermite.tab('c');
377+ k-r from:body -> ermite.tab('r');
378+ k-m from:body -> ermite.tab('m');
379+ k-next from:body -> ermite.nextNote();
380+ k-split from:body -> ermite.layout('split');
381+ k-unified from:body -> ermite.layout('unified');
382+ k-collapse from:body -> ermite.collapse();
383+ k-permalink from:body -> ermite.permalink()">
430384 </div>
431385 </body>
432386 </html>
M cmd/ermite/testdata/web/blob-text.html +27 -73
@@ -44,19 +44,19 @@
4444 </ol>
4545 </nav>
4646 <div class="hdr-acts">
47-<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
47+<a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)">
4848 <span class="ico i-branch" aria-hidden="true">
4949 </span>
5050 <span class="ref-name">main</span>
5151 <span class="sha">SHA7</span>
52-</button>
53-<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
52+</a>
53+<button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd">
5454 <span class="ico i-search" aria-hidden="true">
5555 </span>
5656 <span class="search-label">Go to…</span>
5757 <kbd aria-hidden="true">ctrl k</kbd>
5858 </button>
59-<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
59+<button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button>
6060 </div>
6161 </div>
6262 <nav class="tabs" aria-label="Sections">
@@ -249,7 +249,7 @@
249249 <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
250250 <div class="panel-head">
251251 <label for="refs-filter" class="sr">filter branches and tags</label>
252-<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
252+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)">
253253 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
254254 <kbd>esc</kbd>
255255 </button>
@@ -284,13 +284,7 @@
284284 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
285285 <div class="panel-head">
286286 <label for="cmd-q" class="sr">go to</label>
287-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
288- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
289- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
290- let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
291- let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
292- if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
293- else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
287+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
294288 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
295289 <kbd>esc</kbd>
296290 </button>
@@ -361,67 +355,27 @@
361355 <kbd>esc</kbd> close</span>
362356 </p>
363357 </dialog>
364-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
365- if (q('dialog[open]').count) return;
366- let rows = q('[data-rows] > :not([hidden])').arr();
367- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
368- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
369- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
370- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
371- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
372- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
373- })(1);
374- k-k from:body -> (async (d) => {
375- if (q('dialog[open]').count) return;
376- let rows = q('[data-rows] > :not([hidden])').arr();
377- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
378- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
379- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
380- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
381- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
382- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
383- })(-1);
384- k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
385- k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
386- k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
387- k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
388- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
389- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
390- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
391- k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
392- k-gh from:body -> document.querySelector('.brand')?.click();
393- k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
394- k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
395- k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
396- k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
397- k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
398- k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
399- k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
400- k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
401- k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
402- k-next from:body -> {
403- let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
404- let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
405- let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
406- };
407- k-split from:body -> {
408- let el = q('[data-diff]').arr()[0]; if (!el) return;
409- el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split');
410- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
411- };
412- k-unified from:body -> {
413- let el = q('[data-diff]').arr()[0]; if (!el) return;
414- el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified');
415- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
416- };
417- k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) };
418- k-permalink from:body -> {
419- let b = document.body.dataset;
420- if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
421- history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
422- navigator.clipboard?.writeText(location.href);
423- b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
424- }">
358+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
359+ k-k from:body -> ermite.move(-1);
360+ k-top from:body -> ermite.edge(false);
361+ k-bottom from:body -> ermite.edge(true);
362+ k-open from:body -> ermite.open();
363+ k-esc from:body -> ermite.closeDialogs();
364+ k-help from:body -> ermite.dialog('panel-help');
365+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
366+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
367+ k-filter from:body -> ermite.focusFilter();
368+ k-gh from:body -> ermite.go('.brand');
369+ k-s from:body -> ermite.tab('s');
370+ k-t from:body -> ermite.tab('t');
371+ k-c from:body -> ermite.tab('c');
372+ k-r from:body -> ermite.tab('r');
373+ k-m from:body -> ermite.tab('m');
374+ k-next from:body -> ermite.nextNote();
375+ k-split from:body -> ermite.layout('split');
376+ k-unified from:body -> ermite.layout('unified');
377+ k-collapse from:body -> ermite.collapse();
378+ k-permalink from:body -> ermite.permalink()">
425379 </div>
426380 </body>
427381 </html>
M cmd/ermite/testdata/web/blob-too-large.html +27 -73
@@ -44,19 +44,19 @@
4444 </ol>
4545 </nav>
4646 <div class="hdr-acts">
47-<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
47+<a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)">
4848 <span class="ico i-branch" aria-hidden="true">
4949 </span>
5050 <span class="ref-name">main</span>
5151 <span class="sha">SHA7</span>
52-</button>
53-<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
52+</a>
53+<button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd">
5454 <span class="ico i-search" aria-hidden="true">
5555 </span>
5656 <span class="search-label">Go to…</span>
5757 <kbd aria-hidden="true">ctrl k</kbd>
5858 </button>
59-<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
59+<button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button>
6060 </div>
6161 </div>
6262 <nav class="tabs" aria-label="Sections">
@@ -229,7 +229,7 @@
229229 <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
230230 <div class="panel-head">
231231 <label for="refs-filter" class="sr">filter branches and tags</label>
232-<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
232+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)">
233233 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
234234 <kbd>esc</kbd>
235235 </button>
@@ -264,13 +264,7 @@
264264 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
265265 <div class="panel-head">
266266 <label for="cmd-q" class="sr">go to</label>
267-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
268- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
269- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
270- let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
271- let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
272- if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
273- else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
267+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
274268 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
275269 <kbd>esc</kbd>
276270 </button>
@@ -341,67 +335,27 @@
341335 <kbd>esc</kbd> close</span>
342336 </p>
343337 </dialog>
344-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
345- if (q('dialog[open]').count) return;
346- let rows = q('[data-rows] > :not([hidden])').arr();
347- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
348- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
349- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
350- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
351- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
352- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
353- })(1);
354- k-k from:body -> (async (d) => {
355- if (q('dialog[open]').count) return;
356- let rows = q('[data-rows] > :not([hidden])').arr();
357- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
358- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
359- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
360- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
361- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
362- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
363- })(-1);
364- k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
365- k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
366- k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
367- k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
368- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
369- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
370- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
371- k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
372- k-gh from:body -> document.querySelector('.brand')?.click();
373- k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
374- k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
375- k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
376- k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
377- k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
378- k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
379- k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
380- k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
381- k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
382- k-next from:body -> {
383- let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
384- let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
385- let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
386- };
387- k-split from:body -> {
388- let el = q('[data-diff]').arr()[0]; if (!el) return;
389- el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split');
390- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
391- };
392- k-unified from:body -> {
393- let el = q('[data-diff]').arr()[0]; if (!el) return;
394- el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified');
395- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
396- };
397- k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) };
398- k-permalink from:body -> {
399- let b = document.body.dataset;
400- if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
401- history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
402- navigator.clipboard?.writeText(location.href);
403- b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
404- }">
338+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
339+ k-k from:body -> ermite.move(-1);
340+ k-top from:body -> ermite.edge(false);
341+ k-bottom from:body -> ermite.edge(true);
342+ k-open from:body -> ermite.open();
343+ k-esc from:body -> ermite.closeDialogs();
344+ k-help from:body -> ermite.dialog('panel-help');
345+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
346+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
347+ k-filter from:body -> ermite.focusFilter();
348+ k-gh from:body -> ermite.go('.brand');
349+ k-s from:body -> ermite.tab('s');
350+ k-t from:body -> ermite.tab('t');
351+ k-c from:body -> ermite.tab('c');
352+ k-r from:body -> ermite.tab('r');
353+ k-m from:body -> ermite.tab('m');
354+ k-next from:body -> ermite.nextNote();
355+ k-split from:body -> ermite.layout('split');
356+ k-unified from:body -> ermite.layout('unified');
357+ k-collapse from:body -> ermite.collapse();
358+ k-permalink from:body -> ermite.permalink()">
405359 </div>
406360 </body>
407361 </html>
M cmd/ermite/testdata/web/blob-trojan.html +27 -73
@@ -44,19 +44,19 @@
4444 </ol>
4545 </nav>
4646 <div class="hdr-acts">
47-<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
47+<a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)">
4848 <span class="ico i-branch" aria-hidden="true">
4949 </span>
5050 <span class="ref-name">main</span>
5151 <span class="sha">SHA7</span>
52-</button>
53-<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
52+</a>
53+<button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd">
5454 <span class="ico i-search" aria-hidden="true">
5555 </span>
5656 <span class="search-label">Go to…</span>
5757 <kbd aria-hidden="true">ctrl k</kbd>
5858 </button>
59-<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
59+<button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button>
6060 </div>
6161 </div>
6262 <nav class="tabs" aria-label="Sections">
@@ -296,7 +296,7 @@
296296 <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
297297 <div class="panel-head">
298298 <label for="refs-filter" class="sr">filter branches and tags</label>
299-<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
299+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)">
300300 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
301301 <kbd>esc</kbd>
302302 </button>
@@ -331,13 +331,7 @@
331331 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
332332 <div class="panel-head">
333333 <label for="cmd-q" class="sr">go to</label>
334-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
335- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
336- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
337- let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
338- let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
339- if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
340- else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
334+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
341335 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
342336 <kbd>esc</kbd>
343337 </button>
@@ -408,67 +402,27 @@
408402 <kbd>esc</kbd> close</span>
409403 </p>
410404 </dialog>
411-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
412- if (q('dialog[open]').count) return;
413- let rows = q('[data-rows] > :not([hidden])').arr();
414- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
415- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
416- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
417- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
418- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
419- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
420- })(1);
421- k-k from:body -> (async (d) => {
422- if (q('dialog[open]').count) return;
423- let rows = q('[data-rows] > :not([hidden])').arr();
424- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
425- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
426- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
427- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
428- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
429- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
430- })(-1);
431- k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
432- k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
433- k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
434- k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
435- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
436- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
437- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
438- k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
439- k-gh from:body -> document.querySelector('.brand')?.click();
440- k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
441- k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
442- k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
443- k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
444- k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
445- k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
446- k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
447- k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
448- k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
449- k-next from:body -> {
450- let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
451- let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
452- let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
453- };
454- k-split from:body -> {
455- let el = q('[data-diff]').arr()[0]; if (!el) return;
456- el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split');
457- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
458- };
459- k-unified from:body -> {
460- let el = q('[data-diff]').arr()[0]; if (!el) return;
461- el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified');
462- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
463- };
464- k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) };
465- k-permalink from:body -> {
466- let b = document.body.dataset;
467- if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
468- history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
469- navigator.clipboard?.writeText(location.href);
470- b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
471- }">
405+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
406+ k-k from:body -> ermite.move(-1);
407+ k-top from:body -> ermite.edge(false);
408+ k-bottom from:body -> ermite.edge(true);
409+ k-open from:body -> ermite.open();
410+ k-esc from:body -> ermite.closeDialogs();
411+ k-help from:body -> ermite.dialog('panel-help');
412+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
413+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
414+ k-filter from:body -> ermite.focusFilter();
415+ k-gh from:body -> ermite.go('.brand');
416+ k-s from:body -> ermite.tab('s');
417+ k-t from:body -> ermite.tab('t');
418+ k-c from:body -> ermite.tab('c');
419+ k-r from:body -> ermite.tab('r');
420+ k-m from:body -> ermite.tab('m');
421+ k-next from:body -> ermite.nextNote();
422+ k-split from:body -> ermite.layout('split');
423+ k-unified from:body -> ermite.layout('unified');
424+ k-collapse from:body -> ermite.collapse();
425+ k-permalink from:body -> ermite.permalink()">
472426 </div>
473427 </body>
474428 </html>
M cmd/ermite/testdata/web/commit-big.html +31 -77
@@ -41,19 +41,19 @@
4141 </ol>
4242 </nav>
4343 <div class="hdr-acts">
44-<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
44+<a class="btn ref" href="/diffs/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)">
4545 <span class="ico i-branch" aria-hidden="true">
4646 </span>
4747 <span class="ref-name">detached</span>
4848 <span class="sha">SHA7</span>
49-</button>
50-<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
49+</a>
50+<button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd">
5151 <span class="ico i-search" aria-hidden="true">
5252 </span>
5353 <span class="search-label">Go to…</span>
5454 <kbd aria-hidden="true">ctrl k</kbd>
5555 </button>
56-<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
56+<button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button>
5757 </div>
5858 </div>
5959 <nav class="tabs" aria-label="Sections">
@@ -106,11 +106,11 @@
106106 </span>
107107 <span class="grow">
108108 </span>
109-<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;unified&#39; ? &#39;true&#39; : false">Unified<kbd aria-hidden="true">u</kbd>
109+<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
110110 </a>
111-<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;split&#39; ? &#39;true&#39; : false">Split<kbd aria-hidden="true">d</kbd>
111+<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
112112 </a>
113-<button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn">
113+<button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">
114114 <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span>
115115 <kbd aria-hidden="true">x</kbd>
116116 </button>
@@ -135,7 +135,7 @@
135135 </h2>
136136 <ol class="files" aria-label="Changed files">
137137 <li>
138-<a href="#f1" :aria-current="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f1&#39; ? &#39;true&#39; : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
138+<a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
139139 <span class="st" title="A">A</span>
140140 <span class="p" title="big.txt">big.txt</span>
141141 <span class="n">
@@ -266,7 +266,7 @@
266266 <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
267267 <div class="panel-head">
268268 <label for="refs-filter" class="sr">filter branches and tags</label>
269-<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
269+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)">
270270 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
271271 <kbd>esc</kbd>
272272 </button>
@@ -301,13 +301,7 @@
301301 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
302302 <div class="panel-head">
303303 <label for="cmd-q" class="sr">go to</label>
304-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
305- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
306- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
307- let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
308- let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
309- if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
310- else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
304+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
311305 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
312306 <kbd>esc</kbd>
313307 </button>
@@ -378,67 +372,27 @@
378372 <kbd>esc</kbd> close</span>
379373 </p>
380374 </dialog>
381-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
382- if (q('dialog[open]').count) return;
383- let rows = q('[data-rows] > :not([hidden])').arr();
384- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
385- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
386- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
387- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
388- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
389- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
390- })(1);
391- k-k from:body -> (async (d) => {
392- if (q('dialog[open]').count) return;
393- let rows = q('[data-rows] > :not([hidden])').arr();
394- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
395- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
396- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
397- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
398- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
399- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
400- })(-1);
401- k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
402- k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
403- k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
404- k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
405- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
406- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
407- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
408- k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
409- k-gh from:body -> document.querySelector('.brand')?.click();
410- k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
411- k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
412- k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
413- k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
414- k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
415- k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
416- k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
417- k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
418- k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
419- k-next from:body -> {
420- let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
421- let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
422- let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
423- };
424- k-split from:body -> {
425- let el = q('[data-diff]').arr()[0]; if (!el) return;
426- el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split');
427- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
428- };
429- k-unified from:body -> {
430- let el = q('[data-diff]').arr()[0]; if (!el) return;
431- el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified');
432- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
433- };
434- k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) };
435- k-permalink from:body -> {
436- let b = document.body.dataset;
437- if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
438- history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
439- navigator.clipboard?.writeText(location.href);
440- b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
441- }">
375+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
376+ k-k from:body -> ermite.move(-1);
377+ k-top from:body -> ermite.edge(false);
378+ k-bottom from:body -> ermite.edge(true);
379+ k-open from:body -> ermite.open();
380+ k-esc from:body -> ermite.closeDialogs();
381+ k-help from:body -> ermite.dialog('panel-help');
382+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
383+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
384+ k-filter from:body -> ermite.focusFilter();
385+ k-gh from:body -> ermite.go('.brand');
386+ k-s from:body -> ermite.tab('s');
387+ k-t from:body -> ermite.tab('t');
388+ k-c from:body -> ermite.tab('c');
389+ k-r from:body -> ermite.tab('r');
390+ k-m from:body -> ermite.tab('m');
391+ k-next from:body -> ermite.nextNote();
392+ k-split from:body -> ermite.layout('split');
393+ k-unified from:body -> ermite.layout('unified');
394+ k-collapse from:body -> ermite.collapse();
395+ k-permalink from:body -> ermite.permalink()">
442396 </div>
443397 </body>
444398 </html>
M cmd/ermite/testdata/web/commit-binary.html +31 -77
@@ -41,19 +41,19 @@
4141 </ol>
4242 </nav>
4343 <div class="hdr-acts">
44-<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
44+<a class="btn ref" href="/diffs/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)">
4545 <span class="ico i-branch" aria-hidden="true">
4646 </span>
4747 <span class="ref-name">detached</span>
4848 <span class="sha">SHA7</span>
49-</button>
50-<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
49+</a>
50+<button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd">
5151 <span class="ico i-search" aria-hidden="true">
5252 </span>
5353 <span class="search-label">Go to…</span>
5454 <kbd aria-hidden="true">ctrl k</kbd>
5555 </button>
56-<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
56+<button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button>
5757 </div>
5858 </div>
5959 <nav class="tabs" aria-label="Sections">
@@ -102,11 +102,11 @@
102102 </span>
103103 <span class="grow">
104104 </span>
105-<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;unified&#39; ? &#39;true&#39; : false">Unified<kbd aria-hidden="true">u</kbd>
105+<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
106106 </a>
107-<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;split&#39; ? &#39;true&#39; : false">Split<kbd aria-hidden="true">d</kbd>
107+<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
108108 </a>
109-<button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn">
109+<button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">
110110 <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span>
111111 <kbd aria-hidden="true">x</kbd>
112112 </button>
@@ -130,7 +130,7 @@
130130 </h2>
131131 <ol class="files" aria-label="Changed files">
132132 <li>
133-<a href="#f1" :aria-current="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f1&#39; ? &#39;true&#39; : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
133+<a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
134134 <span class="st" title="M">M</span>
135135 <span class="p" title="logo.bin">logo.bin</span>
136136 <span class="n">
@@ -261,7 +261,7 @@
261261 <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
262262 <div class="panel-head">
263263 <label for="refs-filter" class="sr">filter branches and tags</label>
264-<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
264+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)">
265265 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
266266 <kbd>esc</kbd>
267267 </button>
@@ -296,13 +296,7 @@
296296 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
297297 <div class="panel-head">
298298 <label for="cmd-q" class="sr">go to</label>
299-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
300- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
301- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
302- let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
303- let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
304- if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
305- else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
299+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
306300 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
307301 <kbd>esc</kbd>
308302 </button>
@@ -373,67 +367,27 @@
373367 <kbd>esc</kbd> close</span>
374368 </p>
375369 </dialog>
376-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
377- if (q('dialog[open]').count) return;
378- let rows = q('[data-rows] > :not([hidden])').arr();
379- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
380- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
381- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
382- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
383- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
384- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
385- })(1);
386- k-k from:body -> (async (d) => {
387- if (q('dialog[open]').count) return;
388- let rows = q('[data-rows] > :not([hidden])').arr();
389- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
390- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
391- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
392- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
393- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
394- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
395- })(-1);
396- k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
397- k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
398- k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
399- k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
400- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
401- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
402- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
403- k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
404- k-gh from:body -> document.querySelector('.brand')?.click();
405- k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
406- k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
407- k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
408- k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
409- k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
410- k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
411- k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
412- k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
413- k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
414- k-next from:body -> {
415- let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
416- let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
417- let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
418- };
419- k-split from:body -> {
420- let el = q('[data-diff]').arr()[0]; if (!el) return;
421- el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split');
422- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
423- };
424- k-unified from:body -> {
425- let el = q('[data-diff]').arr()[0]; if (!el) return;
426- el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified');
427- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
428- };
429- k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) };
430- k-permalink from:body -> {
431- let b = document.body.dataset;
432- if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
433- history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
434- navigator.clipboard?.writeText(location.href);
435- b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
436- }">
370+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
371+ k-k from:body -> ermite.move(-1);
372+ k-top from:body -> ermite.edge(false);
373+ k-bottom from:body -> ermite.edge(true);
374+ k-open from:body -> ermite.open();
375+ k-esc from:body -> ermite.closeDialogs();
376+ k-help from:body -> ermite.dialog('panel-help');
377+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
378+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
379+ k-filter from:body -> ermite.focusFilter();
380+ k-gh from:body -> ermite.go('.brand');
381+ k-s from:body -> ermite.tab('s');
382+ k-t from:body -> ermite.tab('t');
383+ k-c from:body -> ermite.tab('c');
384+ k-r from:body -> ermite.tab('r');
385+ k-m from:body -> ermite.tab('m');
386+ k-next from:body -> ermite.nextNote();
387+ k-split from:body -> ermite.layout('split');
388+ k-unified from:body -> ermite.layout('unified');
389+ k-collapse from:body -> ermite.collapse();
390+ k-permalink from:body -> ermite.permalink()">
437391 </div>
438392 </body>
439393 </html>
M cmd/ermite/testdata/web/commit-merge.html +31 -77
@@ -41,19 +41,19 @@
4141 </ol>
4242 </nav>
4343 <div class="hdr-acts">
44-<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
44+<a class="btn ref" href="/diffs/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)">
4545 <span class="ico i-branch" aria-hidden="true">
4646 </span>
4747 <span class="ref-name">detached</span>
4848 <span class="sha">SHA7</span>
49-</button>
50-<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
49+</a>
50+<button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd">
5151 <span class="ico i-search" aria-hidden="true">
5252 </span>
5353 <span class="search-label">Go to…</span>
5454 <kbd aria-hidden="true">ctrl k</kbd>
5555 </button>
56-<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
56+<button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button>
5757 </div>
5858 </div>
5959 <nav class="tabs" aria-label="Sections">
@@ -101,11 +101,11 @@
101101 </span>
102102 <span class="grow">
103103 </span>
104-<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;unified&#39; ? &#39;true&#39; : false">Unified<kbd aria-hidden="true">u</kbd>
104+<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
105105 </a>
106-<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;split&#39; ? &#39;true&#39; : false">Split<kbd aria-hidden="true">d</kbd>
106+<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
107107 </a>
108-<button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn">
108+<button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">
109109 <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span>
110110 <kbd aria-hidden="true">x</kbd>
111111 </button>
@@ -169,7 +169,7 @@
169169 </h2>
170170 <ol class="files" aria-label="Changed files">
171171 <li>
172-<a href="#f1" :aria-current="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f1&#39; ? &#39;true&#39; : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
172+<a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
173173 <span class="st" title="A">A</span>
174174 <span class="p" title="side.txt">side.txt</span>
175175 <span class="n">
@@ -300,7 +300,7 @@
300300 <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
301301 <div class="panel-head">
302302 <label for="refs-filter" class="sr">filter branches and tags</label>
303-<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
303+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)">
304304 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
305305 <kbd>esc</kbd>
306306 </button>
@@ -335,13 +335,7 @@
335335 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
336336 <div class="panel-head">
337337 <label for="cmd-q" class="sr">go to</label>
338-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
339- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
340- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
341- let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
342- let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
343- if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
344- else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
338+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
345339 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
346340 <kbd>esc</kbd>
347341 </button>
@@ -412,67 +406,27 @@
412406 <kbd>esc</kbd> close</span>
413407 </p>
414408 </dialog>
415-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
416- if (q('dialog[open]').count) return;
417- let rows = q('[data-rows] > :not([hidden])').arr();
418- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
419- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
420- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
421- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
422- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
423- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
424- })(1);
425- k-k from:body -> (async (d) => {
426- if (q('dialog[open]').count) return;
427- let rows = q('[data-rows] > :not([hidden])').arr();
428- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
429- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
430- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
431- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
432- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
433- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
434- })(-1);
435- k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
436- k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
437- k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
438- k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
439- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
440- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
441- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
442- k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
443- k-gh from:body -> document.querySelector('.brand')?.click();
444- k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
445- k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
446- k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
447- k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
448- k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
449- k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
450- k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
451- k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
452- k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
453- k-next from:body -> {
454- let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
455- let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
456- let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
457- };
458- k-split from:body -> {
459- let el = q('[data-diff]').arr()[0]; if (!el) return;
460- el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split');
461- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
462- };
463- k-unified from:body -> {
464- let el = q('[data-diff]').arr()[0]; if (!el) return;
465- el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified');
466- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
467- };
468- k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) };
469- k-permalink from:body -> {
470- let b = document.body.dataset;
471- if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
472- history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
473- navigator.clipboard?.writeText(location.href);
474- b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
475- }">
409+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
410+ k-k from:body -> ermite.move(-1);
411+ k-top from:body -> ermite.edge(false);
412+ k-bottom from:body -> ermite.edge(true);
413+ k-open from:body -> ermite.open();
414+ k-esc from:body -> ermite.closeDialogs();
415+ k-help from:body -> ermite.dialog('panel-help');
416+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
417+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
418+ k-filter from:body -> ermite.focusFilter();
419+ k-gh from:body -> ermite.go('.brand');
420+ k-s from:body -> ermite.tab('s');
421+ k-t from:body -> ermite.tab('t');
422+ k-c from:body -> ermite.tab('c');
423+ k-r from:body -> ermite.tab('r');
424+ k-m from:body -> ermite.tab('m');
425+ k-next from:body -> ermite.nextNote();
426+ k-split from:body -> ermite.layout('split');
427+ k-unified from:body -> ermite.layout('unified');
428+ k-collapse from:body -> ermite.collapse();
429+ k-permalink from:body -> ermite.permalink()">
476430 </div>
477431 </body>
478432 </html>
M cmd/ermite/testdata/web/commit-modify-split.html +32 -78
@@ -41,19 +41,19 @@
4141 </ol>
4242 </nav>
4343 <div class="hdr-acts">
44-<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
44+<a class="btn ref" href="/diffs/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)">
4545 <span class="ico i-branch" aria-hidden="true">
4646 </span>
4747 <span class="ref-name">detached</span>
4848 <span class="sha">SHA7</span>
49-</button>
50-<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
49+</a>
50+<button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd">
5151 <span class="ico i-search" aria-hidden="true">
5252 </span>
5353 <span class="search-label">Go to…</span>
5454 <kbd aria-hidden="true">ctrl k</kbd>
5555 </button>
56-<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
56+<button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button>
5757 </div>
5858 </div>
5959 <nav class="tabs" aria-label="Sections">
@@ -108,11 +108,11 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
108108 </span>
109109 <span class="grow">
110110 </span>
111-<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;unified&#39; ? &#39;true&#39; : false">Unified<kbd aria-hidden="true">u</kbd>
111+<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
112112 </a>
113-<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;split&#39; ? &#39;true&#39; : false">Split<kbd aria-hidden="true">d</kbd>
113+<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" aria-current="true" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
114114 </a>
115-<button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn">
115+<button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">
116116 <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span>
117117 <kbd aria-hidden="true">x</kbd>
118118 </button>
@@ -276,7 +276,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
276276 </h2>
277277 <ol class="files" aria-label="Changed files">
278278 <li>
279-<a href="#f1" :aria-current="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f1&#39; ? &#39;true&#39; : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
279+<a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
280280 <span class="st" title="M">M</span>
281281 <span class="p" title="a.txt">a.txt</span>
282282 <span class="n">
@@ -290,7 +290,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
290290 </a>
291291 </li>
292292 <li>
293-<a href="#f2" :aria-current="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f2&#39; ? &#39;true&#39; : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
293+<a href="#f2" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
294294 <span class="st" title="M">M</span>
295295 <span class="p" title="run.sh">run.sh</span>
296296 <span class="n">
@@ -421,7 +421,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
421421 <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
422422 <div class="panel-head">
423423 <label for="refs-filter" class="sr">filter branches and tags</label>
424-<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
424+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)">
425425 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
426426 <kbd>esc</kbd>
427427 </button>
@@ -456,13 +456,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
456456 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
457457 <div class="panel-head">
458458 <label for="cmd-q" class="sr">go to</label>
459-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
460- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
461- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
462- let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
463- let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
464- if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
465- else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
459+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
466460 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
467461 <kbd>esc</kbd>
468462 </button>
@@ -533,67 +527,27 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
533527 <kbd>esc</kbd> close</span>
534528 </p>
535529 </dialog>
536-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
537- if (q('dialog[open]').count) return;
538- let rows = q('[data-rows] > :not([hidden])').arr();
539- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
540- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
541- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
542- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
543- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
544- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
545- })(1);
546- k-k from:body -> (async (d) => {
547- if (q('dialog[open]').count) return;
548- let rows = q('[data-rows] > :not([hidden])').arr();
549- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
550- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
551- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
552- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
553- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
554- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
555- })(-1);
556- k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
557- k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
558- k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
559- k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
560- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
561- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
562- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
563- k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
564- k-gh from:body -> document.querySelector('.brand')?.click();
565- k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
566- k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
567- k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
568- k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
569- k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
570- k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
571- k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
572- k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
573- k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
574- k-next from:body -> {
575- let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
576- let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
577- let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
578- };
579- k-split from:body -> {
580- let el = q('[data-diff]').arr()[0]; if (!el) return;
581- el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split');
582- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
583- };
584- k-unified from:body -> {
585- let el = q('[data-diff]').arr()[0]; if (!el) return;
586- el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified');
587- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
588- };
589- k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) };
590- k-permalink from:body -> {
591- let b = document.body.dataset;
592- if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
593- history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
594- navigator.clipboard?.writeText(location.href);
595- b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
596- }">
530+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
531+ k-k from:body -> ermite.move(-1);
532+ k-top from:body -> ermite.edge(false);
533+ k-bottom from:body -> ermite.edge(true);
534+ k-open from:body -> ermite.open();
535+ k-esc from:body -> ermite.closeDialogs();
536+ k-help from:body -> ermite.dialog('panel-help');
537+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
538+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
539+ k-filter from:body -> ermite.focusFilter();
540+ k-gh from:body -> ermite.go('.brand');
541+ k-s from:body -> ermite.tab('s');
542+ k-t from:body -> ermite.tab('t');
543+ k-c from:body -> ermite.tab('c');
544+ k-r from:body -> ermite.tab('r');
545+ k-m from:body -> ermite.tab('m');
546+ k-next from:body -> ermite.nextNote();
547+ k-split from:body -> ermite.layout('split');
548+ k-unified from:body -> ermite.layout('unified');
549+ k-collapse from:body -> ermite.collapse();
550+ k-permalink from:body -> ermite.permalink()">
597551 </div>
598552 </body>
599553 </html>
M cmd/ermite/testdata/web/commit-modify.html +32 -78
@@ -41,19 +41,19 @@
4141 </ol>
4242 </nav>
4343 <div class="hdr-acts">
44-<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
44+<a class="btn ref" href="/diffs/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)">
4545 <span class="ico i-branch" aria-hidden="true">
4646 </span>
4747 <span class="ref-name">detached</span>
4848 <span class="sha">SHA7</span>
49-</button>
50-<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
49+</a>
50+<button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd">
5151 <span class="ico i-search" aria-hidden="true">
5252 </span>
5353 <span class="search-label">Go to…</span>
5454 <kbd aria-hidden="true">ctrl k</kbd>
5555 </button>
56-<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
56+<button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button>
5757 </div>
5858 </div>
5959 <nav class="tabs" aria-label="Sections">
@@ -108,11 +108,11 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
108108 </span>
109109 <span class="grow">
110110 </span>
111-<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;unified&#39; ? &#39;true&#39; : false">Unified<kbd aria-hidden="true">u</kbd>
111+<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
112112 </a>
113-<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;split&#39; ? &#39;true&#39; : false">Split<kbd aria-hidden="true">d</kbd>
113+<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
114114 </a>
115-<button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn">
115+<button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">
116116 <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span>
117117 <kbd aria-hidden="true">x</kbd>
118118 </button>
@@ -276,7 +276,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
276276 </h2>
277277 <ol class="files" aria-label="Changed files">
278278 <li>
279-<a href="#f1" :aria-current="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f1&#39; ? &#39;true&#39; : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
279+<a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
280280 <span class="st" title="M">M</span>
281281 <span class="p" title="a.txt">a.txt</span>
282282 <span class="n">
@@ -290,7 +290,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
290290 </a>
291291 </li>
292292 <li>
293-<a href="#f2" :aria-current="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f2&#39; ? &#39;true&#39; : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
293+<a href="#f2" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
294294 <span class="st" title="M">M</span>
295295 <span class="p" title="run.sh">run.sh</span>
296296 <span class="n">
@@ -421,7 +421,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
421421 <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
422422 <div class="panel-head">
423423 <label for="refs-filter" class="sr">filter branches and tags</label>
424-<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
424+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)">
425425 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
426426 <kbd>esc</kbd>
427427 </button>
@@ -456,13 +456,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
456456 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
457457 <div class="panel-head">
458458 <label for="cmd-q" class="sr">go to</label>
459-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
460- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
461- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
462- let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
463- let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
464- if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
465- else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
459+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
466460 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
467461 <kbd>esc</kbd>
468462 </button>
@@ -533,67 +527,27 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
533527 <kbd>esc</kbd> close</span>
534528 </p>
535529 </dialog>
536-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
537- if (q('dialog[open]').count) return;
538- let rows = q('[data-rows] > :not([hidden])').arr();
539- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
540- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
541- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
542- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
543- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
544- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
545- })(1);
546- k-k from:body -> (async (d) => {
547- if (q('dialog[open]').count) return;
548- let rows = q('[data-rows] > :not([hidden])').arr();
549- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
550- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
551- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
552- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
553- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
554- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
555- })(-1);
556- k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
557- k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
558- k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
559- k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
560- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
561- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
562- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
563- k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
564- k-gh from:body -> document.querySelector('.brand')?.click();
565- k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
566- k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
567- k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
568- k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
569- k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
570- k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
571- k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
572- k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
573- k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
574- k-next from:body -> {
575- let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
576- let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
577- let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
578- };
579- k-split from:body -> {
580- let el = q('[data-diff]').arr()[0]; if (!el) return;
581- el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split');
582- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
583- };
584- k-unified from:body -> {
585- let el = q('[data-diff]').arr()[0]; if (!el) return;
586- el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified');
587- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
588- };
589- k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) };
590- k-permalink from:body -> {
591- let b = document.body.dataset;
592- if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
593- history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
594- navigator.clipboard?.writeText(location.href);
595- b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
596- }">
530+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
531+ k-k from:body -> ermite.move(-1);
532+ k-top from:body -> ermite.edge(false);
533+ k-bottom from:body -> ermite.edge(true);
534+ k-open from:body -> ermite.open();
535+ k-esc from:body -> ermite.closeDialogs();
536+ k-help from:body -> ermite.dialog('panel-help');
537+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
538+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
539+ k-filter from:body -> ermite.focusFilter();
540+ k-gh from:body -> ermite.go('.brand');
541+ k-s from:body -> ermite.tab('s');
542+ k-t from:body -> ermite.tab('t');
543+ k-c from:body -> ermite.tab('c');
544+ k-r from:body -> ermite.tab('r');
545+ k-m from:body -> ermite.tab('m');
546+ k-next from:body -> ermite.nextNote();
547+ k-split from:body -> ermite.layout('split');
548+ k-unified from:body -> ermite.layout('unified');
549+ k-collapse from:body -> ermite.collapse();
550+ k-permalink from:body -> ermite.permalink()">
597551 </div>
598552 </body>
599553 </html>
M cmd/ermite/testdata/web/commit-rename.html +32 -78
@@ -41,19 +41,19 @@
4141 </ol>
4242 </nav>
4343 <div class="hdr-acts">
44-<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
44+<a class="btn ref" href="/diffs/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)">
4545 <span class="ico i-branch" aria-hidden="true">
4646 </span>
4747 <span class="ref-name">detached</span>
4848 <span class="sha">SHA7</span>
49-</button>
50-<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
49+</a>
50+<button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd">
5151 <span class="ico i-search" aria-hidden="true">
5252 </span>
5353 <span class="search-label">Go to…</span>
5454 <kbd aria-hidden="true">ctrl k</kbd>
5555 </button>
56-<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
56+<button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button>
5757 </div>
5858 </div>
5959 <nav class="tabs" aria-label="Sections">
@@ -102,11 +102,11 @@
102102 </span>
103103 <span class="grow">
104104 </span>
105-<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;unified&#39; ? &#39;true&#39; : false">Unified<kbd aria-hidden="true">u</kbd>
105+<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
106106 </a>
107-<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;split&#39; ? &#39;true&#39; : false">Split<kbd aria-hidden="true">d</kbd>
107+<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
108108 </a>
109-<button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn">
109+<button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">
110110 <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span>
111111 <kbd aria-hidden="true">x</kbd>
112112 </button>
@@ -180,7 +180,7 @@
180180 </h2>
181181 <ol class="files" aria-label="Changed files">
182182 <li>
183-<a href="#f1" :aria-current="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f1&#39; ? &#39;true&#39; : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
183+<a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
184184 <span class="st" title="R">R</span>
185185 <span class="p" title="old.txt → docs-old.txt">old.txt → docs-old.txt</span>
186186 <span class="n">
@@ -195,7 +195,7 @@
195195 </a>
196196 </li>
197197 <li>
198-<a href="#f2" :aria-current="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f2&#39; ? &#39;true&#39; : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
198+<a href="#f2" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
199199 <span class="st" title="D">D</span>
200200 <span class="p" title="gone.txt">gone.txt</span>
201201 <span class="n">
@@ -326,7 +326,7 @@
326326 <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
327327 <div class="panel-head">
328328 <label for="refs-filter" class="sr">filter branches and tags</label>
329-<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
329+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)">
330330 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
331331 <kbd>esc</kbd>
332332 </button>
@@ -361,13 +361,7 @@
361361 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
362362 <div class="panel-head">
363363 <label for="cmd-q" class="sr">go to</label>
364-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
365- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
366- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
367- let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
368- let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
369- if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
370- else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
364+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
371365 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
372366 <kbd>esc</kbd>
373367 </button>
@@ -438,67 +432,27 @@
438432 <kbd>esc</kbd> close</span>
439433 </p>
440434 </dialog>
441-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
442- if (q('dialog[open]').count) return;
443- let rows = q('[data-rows] > :not([hidden])').arr();
444- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
445- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
446- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
447- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
448- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
449- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
450- })(1);
451- k-k from:body -> (async (d) => {
452- if (q('dialog[open]').count) return;
453- let rows = q('[data-rows] > :not([hidden])').arr();
454- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
455- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
456- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
457- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
458- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
459- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
460- })(-1);
461- k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
462- k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
463- k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
464- k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
465- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
466- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
467- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
468- k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
469- k-gh from:body -> document.querySelector('.brand')?.click();
470- k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
471- k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
472- k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
473- k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
474- k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
475- k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
476- k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
477- k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
478- k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
479- k-next from:body -> {
480- let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
481- let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
482- let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
483- };
484- k-split from:body -> {
485- let el = q('[data-diff]').arr()[0]; if (!el) return;
486- el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split');
487- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
488- };
489- k-unified from:body -> {
490- let el = q('[data-diff]').arr()[0]; if (!el) return;
491- el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified');
492- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
493- };
494- k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) };
495- k-permalink from:body -> {
496- let b = document.body.dataset;
497- if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
498- history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
499- navigator.clipboard?.writeText(location.href);
500- b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
501- }">
435+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
436+ k-k from:body -> ermite.move(-1);
437+ k-top from:body -> ermite.edge(false);
438+ k-bottom from:body -> ermite.edge(true);
439+ k-open from:body -> ermite.open();
440+ k-esc from:body -> ermite.closeDialogs();
441+ k-help from:body -> ermite.dialog('panel-help');
442+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
443+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
444+ k-filter from:body -> ermite.focusFilter();
445+ k-gh from:body -> ermite.go('.brand');
446+ k-s from:body -> ermite.tab('s');
447+ k-t from:body -> ermite.tab('t');
448+ k-c from:body -> ermite.tab('c');
449+ k-r from:body -> ermite.tab('r');
450+ k-m from:body -> ermite.tab('m');
451+ k-next from:body -> ermite.nextNote();
452+ k-split from:body -> ermite.layout('split');
453+ k-unified from:body -> ermite.layout('unified');
454+ k-collapse from:body -> ermite.collapse();
455+ k-permalink from:body -> ermite.permalink()">
502456 </div>
503457 </body>
504458 </html>
M cmd/ermite/testdata/web/commit-root.html +35 -81
@@ -41,19 +41,19 @@
4141 </ol>
4242 </nav>
4343 <div class="hdr-acts">
44-<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
44+<a class="btn ref" href="/diffs/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)">
4545 <span class="ico i-branch" aria-hidden="true">
4646 </span>
4747 <span class="ref-name">detached</span>
4848 <span class="sha">SHA7</span>
49-</button>
50-<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
49+</a>
50+<button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd">
5151 <span class="ico i-search" aria-hidden="true">
5252 </span>
5353 <span class="search-label">Go to…</span>
5454 <kbd aria-hidden="true">ctrl k</kbd>
5555 </button>
56-<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
56+<button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button>
5757 </div>
5858 </div>
5959 <nav class="tabs" aria-label="Sections">
@@ -100,11 +100,11 @@
100100 </span>
101101 <span class="grow">
102102 </span>
103-<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;unified&#39; ? &#39;true&#39; : false">Unified<kbd aria-hidden="true">u</kbd>
103+<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
104104 </a>
105-<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;split&#39; ? &#39;true&#39; : false">Split<kbd aria-hidden="true">d</kbd>
105+<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
106106 </a>
107-<button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn">
107+<button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">
108108 <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span>
109109 <kbd aria-hidden="true">x</kbd>
110110 </button>
@@ -454,7 +454,7 @@
454454 </h2>
455455 <ol class="files" aria-label="Changed files">
456456 <li>
457-<a href="#f1" :aria-current="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f1&#39; ? &#39;true&#39; : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
457+<a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
458458 <span class="st" title="A">A</span>
459459 <span class="p" title="a.txt">a.txt</span>
460460 <span class="n">
@@ -469,7 +469,7 @@
469469 </a>
470470 </li>
471471 <li>
472-<a href="#f2" :aria-current="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f2&#39; ? &#39;true&#39; : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
472+<a href="#f2" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
473473 <span class="st" title="A">A</span>
474474 <span class="p" title="gone.txt">gone.txt</span>
475475 <span class="n">
@@ -483,7 +483,7 @@
483483 </a>
484484 </li>
485485 <li>
486-<a href="#f3" :aria-current="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f3&#39; ? &#39;true&#39; : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
486+<a href="#f3" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
487487 <span class="st" title="A">A</span>
488488 <span class="p" title="logo.bin">logo.bin</span>
489489 <span class="n">
@@ -498,7 +498,7 @@
498498 </a>
499499 </li>
500500 <li>
501-<a href="#f4" :aria-current="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f4&#39; ? &#39;true&#39; : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
501+<a href="#f4" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
502502 <span class="st" title="A">A</span>
503503 <span class="p" title="old.txt">old.txt</span>
504504 <span class="n">
@@ -512,7 +512,7 @@
512512 </a>
513513 </li>
514514 <li>
515-<a href="#f5" :aria-current="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f5&#39; ? &#39;true&#39; : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
515+<a href="#f5" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
516516 <span class="st" title="A">A</span>
517517 <span class="p" title="run.sh">run.sh</span>
518518 <span class="n">
@@ -642,7 +642,7 @@
642642 <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
643643 <div class="panel-head">
644644 <label for="refs-filter" class="sr">filter branches and tags</label>
645-<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
645+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)">
646646 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
647647 <kbd>esc</kbd>
648648 </button>
@@ -677,13 +677,7 @@
677677 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
678678 <div class="panel-head">
679679 <label for="cmd-q" class="sr">go to</label>
680-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
681- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
682- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
683- let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
684- let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
685- if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
686- else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
680+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
687681 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
688682 <kbd>esc</kbd>
689683 </button>
@@ -754,67 +748,27 @@
754748 <kbd>esc</kbd> close</span>
755749 </p>
756750 </dialog>
757-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
758- if (q('dialog[open]').count) return;
759- let rows = q('[data-rows] > :not([hidden])').arr();
760- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
761- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
762- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
763- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
764- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
765- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
766- })(1);
767- k-k from:body -> (async (d) => {
768- if (q('dialog[open]').count) return;
769- let rows = q('[data-rows] > :not([hidden])').arr();
770- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
771- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
772- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
773- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
774- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
775- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
776- })(-1);
777- k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
778- k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
779- k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
780- k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
781- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
782- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
783- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
784- k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
785- k-gh from:body -> document.querySelector('.brand')?.click();
786- k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
787- k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
788- k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
789- k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
790- k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
791- k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
792- k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
793- k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
794- k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
795- k-next from:body -> {
796- let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
797- let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
798- let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
799- };
800- k-split from:body -> {
801- let el = q('[data-diff]').arr()[0]; if (!el) return;
802- el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split');
803- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
804- };
805- k-unified from:body -> {
806- let el = q('[data-diff]').arr()[0]; if (!el) return;
807- el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified');
808- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
809- };
810- k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) };
811- k-permalink from:body -> {
812- let b = document.body.dataset;
813- if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
814- history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
815- navigator.clipboard?.writeText(location.href);
816- b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
817- }">
751+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
752+ k-k from:body -> ermite.move(-1);
753+ k-top from:body -> ermite.edge(false);
754+ k-bottom from:body -> ermite.edge(true);
755+ k-open from:body -> ermite.open();
756+ k-esc from:body -> ermite.closeDialogs();
757+ k-help from:body -> ermite.dialog('panel-help');
758+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
759+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
760+ k-filter from:body -> ermite.focusFilter();
761+ k-gh from:body -> ermite.go('.brand');
762+ k-s from:body -> ermite.tab('s');
763+ k-t from:body -> ermite.tab('t');
764+ k-c from:body -> ermite.tab('c');
765+ k-r from:body -> ermite.tab('r');
766+ k-m from:body -> ermite.tab('m');
767+ k-next from:body -> ermite.nextNote();
768+ k-split from:body -> ermite.layout('split');
769+ k-unified from:body -> ermite.layout('unified');
770+ k-collapse from:body -> ermite.collapse();
771+ k-permalink from:body -> ermite.permalink()">
818772 </div>
819773 </body>
820774 </html>
M cmd/ermite/testdata/web/commit-trojan.html +31 -77
@@ -41,19 +41,19 @@
4141 </ol>
4242 </nav>
4343 <div class="hdr-acts">
44-<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
44+<a class="btn ref" href="/diffs/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)">
4545 <span class="ico i-branch" aria-hidden="true">
4646 </span>
4747 <span class="ref-name">detached</span>
4848 <span class="sha">SHA7</span>
49-</button>
50-<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
49+</a>
50+<button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd">
5151 <span class="ico i-search" aria-hidden="true">
5252 </span>
5353 <span class="search-label">Go to…</span>
5454 <kbd aria-hidden="true">ctrl k</kbd>
5555 </button>
56-<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
56+<button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button>
5757 </div>
5858 </div>
5959 <nav class="tabs" aria-label="Sections">
@@ -103,11 +103,11 @@
103103 </span>
104104 <span class="grow">
105105 </span>
106-<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;unified&#39; ? &#39;true&#39; : false">Unified<kbd aria-hidden="true">u</kbd>
106+<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
107107 </a>
108-<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;split&#39; ? &#39;true&#39; : false">Split<kbd aria-hidden="true">d</kbd>
108+<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
109109 </a>
110-<button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn">
110+<button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">
111111 <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span>
112112 <kbd aria-hidden="true">x</kbd>
113113 </button>
@@ -304,7 +304,7 @@
304304 </h2>
305305 <ol class="files" aria-label="Changed files">
306306 <li>
307-<a href="#f1" :aria-current="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f1&#39; ? &#39;true&#39; : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
307+<a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
308308 <span class="st" title="A">A</span>
309309 <span class="p" title="evil.go">evil.go</span>
310310 <span class="n">
@@ -435,7 +435,7 @@
435435 <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
436436 <div class="panel-head">
437437 <label for="refs-filter" class="sr">filter branches and tags</label>
438-<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
438+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)">
439439 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
440440 <kbd>esc</kbd>
441441 </button>
@@ -470,13 +470,7 @@
470470 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
471471 <div class="panel-head">
472472 <label for="cmd-q" class="sr">go to</label>
473-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
474- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
475- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
476- let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
477- let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
478- if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
479- else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
473+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
480474 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
481475 <kbd>esc</kbd>
482476 </button>
@@ -547,67 +541,27 @@
547541 <kbd>esc</kbd> close</span>
548542 </p>
549543 </dialog>
550-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
551- if (q('dialog[open]').count) return;
552- let rows = q('[data-rows] > :not([hidden])').arr();
553- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
554- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
555- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
556- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
557- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
558- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
559- })(1);
560- k-k from:body -> (async (d) => {
561- if (q('dialog[open]').count) return;
562- let rows = q('[data-rows] > :not([hidden])').arr();
563- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
564- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
565- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
566- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
567- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
568- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
569- })(-1);
570- k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
571- k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
572- k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
573- k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
574- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
575- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
576- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
577- k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
578- k-gh from:body -> document.querySelector('.brand')?.click();
579- k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
580- k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
581- k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
582- k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
583- k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
584- k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
585- k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
586- k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
587- k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
588- k-next from:body -> {
589- let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
590- let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
591- let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
592- };
593- k-split from:body -> {
594- let el = q('[data-diff]').arr()[0]; if (!el) return;
595- el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split');
596- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
597- };
598- k-unified from:body -> {
599- let el = q('[data-diff]').arr()[0]; if (!el) return;
600- el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified');
601- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
602- };
603- k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) };
604- k-permalink from:body -> {
605- let b = document.body.dataset;
606- if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
607- history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
608- navigator.clipboard?.writeText(location.href);
609- b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
610- }">
544+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
545+ k-k from:body -> ermite.move(-1);
546+ k-top from:body -> ermite.edge(false);
547+ k-bottom from:body -> ermite.edge(true);
548+ k-open from:body -> ermite.open();
549+ k-esc from:body -> ermite.closeDialogs();
550+ k-help from:body -> ermite.dialog('panel-help');
551+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
552+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
553+ k-filter from:body -> ermite.focusFilter();
554+ k-gh from:body -> ermite.go('.brand');
555+ k-s from:body -> ermite.tab('s');
556+ k-t from:body -> ermite.tab('t');
557+ k-c from:body -> ermite.tab('c');
558+ k-r from:body -> ermite.tab('r');
559+ k-m from:body -> ermite.tab('m');
560+ k-next from:body -> ermite.nextNote();
561+ k-split from:body -> ermite.layout('split');
562+ k-unified from:body -> ermite.layout('unified');
563+ k-collapse from:body -> ermite.collapse();
564+ k-permalink from:body -> ermite.permalink()">
611565 </div>
612566 </body>
613567 </html>
M cmd/ermite/testdata/web/commits-page2.html +28 -76
@@ -41,19 +41,19 @@
4141 </ol>
4242 </nav>
4343 <div class="hdr-acts">
44-<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
44+<a class="btn ref" href="/long/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)">
4545 <span class="ico i-branch" aria-hidden="true">
4646 </span>
4747 <span class="ref-name">main</span>
4848 <span class="sha">SHA7</span>
49-</button>
50-<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
49+</a>
50+<button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd">
5151 <span class="ico i-search" aria-hidden="true">
5252 </span>
5353 <span class="search-label">Go to…</span>
5454 <kbd aria-hidden="true">ctrl k</kbd>
5555 </button>
56-<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
56+<button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button>
5757 </div>
5858 </div>
5959 <nav class="tabs" aria-label="Sections">
@@ -85,9 +85,7 @@
8585 <main id="main" tabindex="-1">
8686 <div class="filter">
8787 <label for="commit-filter" class="sr">filter</label>
88-<input id="commit-filter" type="search" data-filter placeholder="Filter by subject, sha or author…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('.days tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t));
89- q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])'));
90- let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')">
88+<input id="commit-filter" type="search" data-filter=".days tr" data-groups=".days section" placeholder="Filter by subject, sha or author…" autocomplete="off" hx-on:input="ermite.filter(this)">
9189 </div>
9290 <div class="days">
9391 <section aria-label="2026-03-01">
@@ -258,7 +256,7 @@
258256 <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
259257 <div class="panel-head">
260258 <label for="refs-filter" class="sr">filter branches and tags</label>
261-<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
259+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)">
262260 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
263261 <kbd>esc</kbd>
264262 </button>
@@ -285,13 +283,7 @@
285283 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
286284 <div class="panel-head">
287285 <label for="cmd-q" class="sr">go to</label>
288-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
289- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
290- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
291- let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
292- let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
293- if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
294- else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
286+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
295287 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
296288 <kbd>esc</kbd>
297289 </button>
@@ -356,67 +348,27 @@
356348 <kbd>esc</kbd> close</span>
357349 </p>
358350 </dialog>
359-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
360- if (q('dialog[open]').count) return;
361- let rows = q('[data-rows] > :not([hidden])').arr();
362- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
363- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
364- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
365- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
366- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
367- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
368- })(1);
369- k-k from:body -> (async (d) => {
370- if (q('dialog[open]').count) return;
371- let rows = q('[data-rows] > :not([hidden])').arr();
372- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
373- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
374- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
375- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
376- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
377- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
378- })(-1);
379- k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
380- k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
381- k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
382- k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
383- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
384- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
385- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
386- k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
387- k-gh from:body -> document.querySelector('.brand')?.click();
388- k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
389- k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
390- k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
391- k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
392- k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
393- k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
394- k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
395- k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
396- k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
397- k-next from:body -> {
398- let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
399- let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
400- let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
401- };
402- k-split from:body -> {
403- let el = q('[data-diff]').arr()[0]; if (!el) return;
404- el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split');
405- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
406- };
407- k-unified from:body -> {
408- let el = q('[data-diff]').arr()[0]; if (!el) return;
409- el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified');
410- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
411- };
412- k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) };
413- k-permalink from:body -> {
414- let b = document.body.dataset;
415- if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
416- history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
417- navigator.clipboard?.writeText(location.href);
418- b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
419- }">
351+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
352+ k-k from:body -> ermite.move(-1);
353+ k-top from:body -> ermite.edge(false);
354+ k-bottom from:body -> ermite.edge(true);
355+ k-open from:body -> ermite.open();
356+ k-esc from:body -> ermite.closeDialogs();
357+ k-help from:body -> ermite.dialog('panel-help');
358+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
359+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
360+ k-filter from:body -> ermite.focusFilter();
361+ k-gh from:body -> ermite.go('.brand');
362+ k-s from:body -> ermite.tab('s');
363+ k-t from:body -> ermite.tab('t');
364+ k-c from:body -> ermite.tab('c');
365+ k-r from:body -> ermite.tab('r');
366+ k-m from:body -> ermite.tab('m');
367+ k-next from:body -> ermite.nextNote();
368+ k-split from:body -> ermite.layout('split');
369+ k-unified from:body -> ermite.layout('unified');
370+ k-collapse from:body -> ermite.collapse();
371+ k-permalink from:body -> ermite.permalink()">
420372 </div>
421373 </body>
422374 </html>
M cmd/ermite/testdata/web/commits-path.html +28 -76
@@ -44,19 +44,19 @@
4444 </ol>
4545 </nav>
4646 <div class="hdr-acts">
47-<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
47+<a class="btn ref" href="/long/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)">
4848 <span class="ico i-branch" aria-hidden="true">
4949 </span>
5050 <span class="ref-name">main</span>
5151 <span class="sha">SHA7</span>
52-</button>
53-<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
52+</a>
53+<button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd">
5454 <span class="ico i-search" aria-hidden="true">
5555 </span>
5656 <span class="search-label">Go to…</span>
5757 <kbd aria-hidden="true">ctrl k</kbd>
5858 </button>
59-<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
59+<button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button>
6060 </div>
6161 </div>
6262 <nav class="tabs" aria-label="Sections">
@@ -88,9 +88,7 @@
8888 <main id="main" tabindex="-1">
8989 <div class="filter">
9090 <label for="commit-filter" class="sr">filter</label>
91-<input id="commit-filter" type="search" data-filter placeholder="Filter by subject, sha or author…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('.days tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t));
92- q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])'));
93- let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')">
91+<input id="commit-filter" type="search" data-filter=".days tr" data-groups=".days section" placeholder="Filter by subject, sha or author…" autocomplete="off" hx-on:input="ermite.filter(this)">
9492 </div>
9593 <p class="lede dim">history of <code>a.txt</code> at main</p>
9694 <div class="days">
@@ -273,7 +271,7 @@
273271 <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
274272 <div class="panel-head">
275273 <label for="refs-filter" class="sr">filter branches and tags</label>
276-<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
274+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)">
277275 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
278276 <kbd>esc</kbd>
279277 </button>
@@ -300,13 +298,7 @@
300298 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
301299 <div class="panel-head">
302300 <label for="cmd-q" class="sr">go to</label>
303-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
304- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
305- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
306- let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
307- let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
308- if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
309- else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
301+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
310302 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
311303 <kbd>esc</kbd>
312304 </button>
@@ -371,67 +363,27 @@
371363 <kbd>esc</kbd> close</span>
372364 </p>
373365 </dialog>
374-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
375- if (q('dialog[open]').count) return;
376- let rows = q('[data-rows] > :not([hidden])').arr();
377- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
378- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
379- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
380- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
381- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
382- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
383- })(1);
384- k-k from:body -> (async (d) => {
385- if (q('dialog[open]').count) return;
386- let rows = q('[data-rows] > :not([hidden])').arr();
387- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
388- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
389- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
390- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
391- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
392- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
393- })(-1);
394- k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
395- k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
396- k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
397- k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
398- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
399- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
400- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
401- k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
402- k-gh from:body -> document.querySelector('.brand')?.click();
403- k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
404- k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
405- k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
406- k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
407- k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
408- k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
409- k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
410- k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
411- k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
412- k-next from:body -> {
413- let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
414- let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
415- let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
416- };
417- k-split from:body -> {
418- let el = q('[data-diff]').arr()[0]; if (!el) return;
419- el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split');
420- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
421- };
422- k-unified from:body -> {
423- let el = q('[data-diff]').arr()[0]; if (!el) return;
424- el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified');
425- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
426- };
427- k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) };
428- k-permalink from:body -> {
429- let b = document.body.dataset;
430- if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
431- history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
432- navigator.clipboard?.writeText(location.href);
433- b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
434- }">
366+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
367+ k-k from:body -> ermite.move(-1);
368+ k-top from:body -> ermite.edge(false);
369+ k-bottom from:body -> ermite.edge(true);
370+ k-open from:body -> ermite.open();
371+ k-esc from:body -> ermite.closeDialogs();
372+ k-help from:body -> ermite.dialog('panel-help');
373+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
374+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
375+ k-filter from:body -> ermite.focusFilter();
376+ k-gh from:body -> ermite.go('.brand');
377+ k-s from:body -> ermite.tab('s');
378+ k-t from:body -> ermite.tab('t');
379+ k-c from:body -> ermite.tab('c');
380+ k-r from:body -> ermite.tab('r');
381+ k-m from:body -> ermite.tab('m');
382+ k-next from:body -> ermite.nextNote();
383+ k-split from:body -> ermite.layout('split');
384+ k-unified from:body -> ermite.layout('unified');
385+ k-collapse from:body -> ermite.collapse();
386+ k-permalink from:body -> ermite.permalink()">
435387 </div>
436388 </body>
437389 </html>
M cmd/ermite/testdata/web/commits-root.html +31 -77
@@ -41,19 +41,19 @@
4141 </ol>
4242 </nav>
4343 <div class="hdr-acts">
44-<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
44+<a class="btn ref" href="/long/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)">
4545 <span class="ico i-branch" aria-hidden="true">
4646 </span>
4747 <span class="ref-name">main</span>
4848 <span class="sha">SHA7</span>
49-</button>
50-<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
49+</a>
50+<button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd">
5151 <span class="ico i-search" aria-hidden="true">
5252 </span>
5353 <span class="search-label">Go to…</span>
5454 <kbd aria-hidden="true">ctrl k</kbd>
5555 </button>
56-<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
56+<button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button>
5757 </div>
5858 </div>
5959 <nav class="tabs" aria-label="Sections">
@@ -85,11 +85,9 @@
8585 <main id="main" tabindex="-1">
8686 <div class="filter">
8787 <label for="commit-filter" class="sr">filter</label>
88-<input id="commit-filter" type="search" data-filter placeholder="Filter by subject, sha or author…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('.days tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t));
89- q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])'));
90- let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')">
88+<input id="commit-filter" type="search" data-filter=".days tr" data-groups=".days section" placeholder="Filter by subject, sha or author…" autocomplete="off" hx-on:input="ermite.filter(this)">
9189 </div>
92-<div id="commits-head">
90+<div id="commits-head" class="live-slot" role="status" data-slot>
9391 </div>
9492 <div class="days">
9593 <section aria-label="2026-03-26">
@@ -957,6 +955,8 @@
957955 </span>
958956 <span class="hint-live" data-state="connecting" hx-sse:connect="/-/events?topic=repo%3Along&amp;view=commits&amp;ref=main&amp;head=SHA40" hx-swap="none" :text="data.state === 'offline' ? 'live updates paused' : ''" hx-on="htmx:sse:after:connection -> data.state = 'live';
959957 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
958+ htmx:sse:error -> ermiteLive.error(event.detail.status);
959+ htmx:sse:before:connection -> ermiteLive.gate(event);
960960 home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
961961 gap -> ermiteLive.reload()">
962962 </span>
@@ -1060,7 +1060,7 @@
10601060 <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
10611061 <div class="panel-head">
10621062 <label for="refs-filter" class="sr">filter branches and tags</label>
1063-<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
1063+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)">
10641064 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
10651065 <kbd>esc</kbd>
10661066 </button>
@@ -1087,13 +1087,7 @@
10871087 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
10881088 <div class="panel-head">
10891089 <label for="cmd-q" class="sr">go to</label>
1090-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
1091- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
1092- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
1093- let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
1094- let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
1095- if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
1096- else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
1090+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
10971091 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
10981092 <kbd>esc</kbd>
10991093 </button>
@@ -1158,67 +1152,27 @@
11581152 <kbd>esc</kbd> close</span>
11591153 </p>
11601154 </dialog>
1161-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
1162- if (q('dialog[open]').count) return;
1163- let rows = q('[data-rows] > :not([hidden])').arr();
1164- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
1165- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
1166- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
1167- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
1168- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
1169- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
1170- })(1);
1171- k-k from:body -> (async (d) => {
1172- if (q('dialog[open]').count) return;
1173- let rows = q('[data-rows] > :not([hidden])').arr();
1174- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
1175- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
1176- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
1177- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
1178- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
1179- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
1180- })(-1);
1181- k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
1182- k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
1183- k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
1184- k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
1185- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
1186- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
1187- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
1188- k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
1189- k-gh from:body -> document.querySelector('.brand')?.click();
1190- k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
1191- k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
1192- k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
1193- k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
1194- k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
1195- k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
1196- k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
1197- k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
1198- k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
1199- k-next from:body -> {
1200- let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
1201- let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
1202- let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
1203- };
1204- k-split from:body -> {
1205- let el = q('[data-diff]').arr()[0]; if (!el) return;
1206- el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split');
1207- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
1208- };
1209- k-unified from:body -> {
1210- let el = q('[data-diff]').arr()[0]; if (!el) return;
1211- el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified');
1212- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
1213- };
1214- k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) };
1215- k-permalink from:body -> {
1216- let b = document.body.dataset;
1217- if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
1218- history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
1219- navigator.clipboard?.writeText(location.href);
1220- b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
1221- }">
1155+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
1156+ k-k from:body -> ermite.move(-1);
1157+ k-top from:body -> ermite.edge(false);
1158+ k-bottom from:body -> ermite.edge(true);
1159+ k-open from:body -> ermite.open();
1160+ k-esc from:body -> ermite.closeDialogs();
1161+ k-help from:body -> ermite.dialog('panel-help');
1162+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
1163+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
1164+ k-filter from:body -> ermite.focusFilter();
1165+ k-gh from:body -> ermite.go('.brand');
1166+ k-s from:body -> ermite.tab('s');
1167+ k-t from:body -> ermite.tab('t');
1168+ k-c from:body -> ermite.tab('c');
1169+ k-r from:body -> ermite.tab('r');
1170+ k-m from:body -> ermite.tab('m');
1171+ k-next from:body -> ermite.nextNote();
1172+ k-split from:body -> ermite.layout('split');
1173+ k-unified from:body -> ermite.layout('unified');
1174+ k-collapse from:body -> ermite.collapse();
1175+ k-permalink from:body -> ermite.permalink()">
12221176 </div>
12231177 </body>
12241178 </html>
M cmd/ermite/testdata/web/commits-small.html +31 -77
@@ -41,19 +41,19 @@
4141 </ol>
4242 </nav>
4343 <div class="hdr-acts">
44-<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
44+<a class="btn ref" href="/diffs/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)">
4545 <span class="ico i-branch" aria-hidden="true">
4646 </span>
4747 <span class="ref-name">main</span>
4848 <span class="sha">SHA7</span>
49-</button>
50-<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
49+</a>
50+<button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd">
5151 <span class="ico i-search" aria-hidden="true">
5252 </span>
5353 <span class="search-label">Go to…</span>
5454 <kbd aria-hidden="true">ctrl k</kbd>
5555 </button>
56-<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
56+<button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button>
5757 </div>
5858 </div>
5959 <nav class="tabs" aria-label="Sections">
@@ -85,11 +85,9 @@
8585 <main id="main" tabindex="-1">
8686 <div class="filter">
8787 <label for="commit-filter" class="sr">filter</label>
88-<input id="commit-filter" type="search" data-filter placeholder="Filter by subject, sha or author…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('.days tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t));
89- q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])'));
90- let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')">
88+<input id="commit-filter" type="search" data-filter=".days tr" data-groups=".days section" placeholder="Filter by subject, sha or author…" autocomplete="off" hx-on:input="ermite.filter(this)">
9189 </div>
92-<div id="commits-head">
90+<div id="commits-head" class="live-slot" role="status" data-slot>
9391 </div>
9492 <div class="days">
9593 <section aria-label="2026-02-08">
@@ -302,6 +300,8 @@
302300 </span>
303301 <span class="hint-live" data-state="connecting" hx-sse:connect="/-/events?topic=repo%3Adiffs&amp;view=commits&amp;ref=main&amp;head=SHA40" hx-swap="none" :text="data.state === 'offline' ? 'live updates paused' : ''" hx-on="htmx:sse:after:connection -> data.state = 'live';
304302 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
303+ htmx:sse:error -> ermiteLive.error(event.detail.status);
304+ htmx:sse:before:connection -> ermiteLive.gate(event);
305305 home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
306306 gap -> ermiteLive.reload()">
307307 </span>
@@ -405,7 +405,7 @@
405405 <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
406406 <div class="panel-head">
407407 <label for="refs-filter" class="sr">filter branches and tags</label>
408-<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
408+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)">
409409 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
410410 <kbd>esc</kbd>
411411 </button>
@@ -440,13 +440,7 @@
440440 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
441441 <div class="panel-head">
442442 <label for="cmd-q" class="sr">go to</label>
443-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
444- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
445- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
446- let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
447- let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
448- if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
449- else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
443+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
450444 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
451445 <kbd>esc</kbd>
452446 </button>
@@ -523,67 +517,27 @@
523517 <kbd>esc</kbd> close</span>
524518 </p>
525519 </dialog>
526-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
527- if (q('dialog[open]').count) return;
528- let rows = q('[data-rows] > :not([hidden])').arr();
529- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
530- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
531- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
532- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
533- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
534- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
535- })(1);
536- k-k from:body -> (async (d) => {
537- if (q('dialog[open]').count) return;
538- let rows = q('[data-rows] > :not([hidden])').arr();
539- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
540- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
541- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
542- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
543- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
544- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
545- })(-1);
546- k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
547- k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
548- k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
549- k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
550- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
551- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
552- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
553- k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
554- k-gh from:body -> document.querySelector('.brand')?.click();
555- k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
556- k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
557- k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
558- k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
559- k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
560- k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
561- k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
562- k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
563- k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
564- k-next from:body -> {
565- let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
566- let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
567- let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
568- };
569- k-split from:body -> {
570- let el = q('[data-diff]').arr()[0]; if (!el) return;
571- el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split');
572- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
573- };
574- k-unified from:body -> {
575- let el = q('[data-diff]').arr()[0]; if (!el) return;
576- el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified');
577- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
578- };
579- k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) };
580- k-permalink from:body -> {
581- let b = document.body.dataset;
582- if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
583- history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
584- navigator.clipboard?.writeText(location.href);
585- b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
586- }">
520+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
521+ k-k from:body -> ermite.move(-1);
522+ k-top from:body -> ermite.edge(false);
523+ k-bottom from:body -> ermite.edge(true);
524+ k-open from:body -> ermite.open();
525+ k-esc from:body -> ermite.closeDialogs();
526+ k-help from:body -> ermite.dialog('panel-help');
527+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
528+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
529+ k-filter from:body -> ermite.focusFilter();
530+ k-gh from:body -> ermite.go('.brand');
531+ k-s from:body -> ermite.tab('s');
532+ k-t from:body -> ermite.tab('t');
533+ k-c from:body -> ermite.tab('c');
534+ k-r from:body -> ermite.tab('r');
535+ k-m from:body -> ermite.tab('m');
536+ k-next from:body -> ermite.nextNote();
537+ k-split from:body -> ermite.layout('split');
538+ k-unified from:body -> ermite.layout('unified');
539+ k-collapse from:body -> ermite.collapse();
540+ k-permalink from:body -> ermite.permalink()">
587541 </div>
588542 </body>
589543 </html>
M cmd/ermite/testdata/web/empty.html +26 -72
@@ -35,13 +35,13 @@
3535 </ol>
3636 </nav>
3737 <div class="hdr-acts">
38-<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
38+<button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd">
3939 <span class="ico i-search" aria-hidden="true">
4040 </span>
4141 <span class="search-label">Go to…</span>
4242 <kbd aria-hidden="true">ctrl k</kbd>
4343 </button>
44-<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
44+<button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button>
4545 </div>
4646 </div>
4747 </header>
@@ -55,12 +55,12 @@
5555 <div class="clone-line">
5656 <span class="mute" aria-hidden="true">$ </span>
5757 <code>git remote add origin http://example.test/quiet.git</code>
58-<button type="button" class="btn copy" data-label="Copy" aria-label="copy: git remote add origin http://example.test/quiet.git" hx-on:click="navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = 'Copied'; await timeout('1500ms'); this.textContent = this.dataset.label">Copy</button>
58+<button type="button" class="btn copy" data-js data-label="Copy" aria-label="copy: git remote add origin http://example.test/quiet.git" hx-on:click="ermite.copy(this)">Copy</button>
5959 </div>
6060 <div class="clone-line">
6161 <span class="mute" aria-hidden="true">$ </span>
6262 <code>git push -u origin main</code>
63-<button type="button" class="btn copy" data-label="Copy" aria-label="copy: git push -u origin main" hx-on:click="navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = 'Copied'; await timeout('1500ms'); this.textContent = this.dataset.label">Copy</button>
63+<button type="button" class="btn copy" data-js data-label="Copy" aria-label="copy: git push -u origin main" hx-on:click="ermite.copy(this)">Copy</button>
6464 </div>
6565 </div>
6666 <p class="state-back">
@@ -182,13 +182,7 @@
182182 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
183183 <div class="panel-head">
184184 <label for="cmd-q" class="sr">go to</label>
185-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
186- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
187- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
188- let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
189- let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
190- if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
191- else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
185+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
192186 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
193187 <kbd>esc</kbd>
194188 </button>
@@ -217,67 +211,27 @@
217211 <kbd>esc</kbd> close</span>
218212 </p>
219213 </dialog>
220-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
221- if (q('dialog[open]').count) return;
222- let rows = q('[data-rows] > :not([hidden])').arr();
223- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
224- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
225- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
226- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
227- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
228- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
229- })(1);
230- k-k from:body -> (async (d) => {
231- if (q('dialog[open]').count) return;
232- let rows = q('[data-rows] > :not([hidden])').arr();
233- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
234- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
235- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
236- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
237- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
238- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
239- })(-1);
240- k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
241- k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
242- k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
243- k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
244- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
245- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
246- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
247- k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
248- k-gh from:body -> document.querySelector('.brand')?.click();
249- k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
250- k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
251- k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
252- k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
253- k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
254- k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
255- k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
256- k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
257- k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
258- k-next from:body -> {
259- let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
260- let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
261- let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
262- };
263- k-split from:body -> {
264- let el = q('[data-diff]').arr()[0]; if (!el) return;
265- el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split');
266- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
267- };
268- k-unified from:body -> {
269- let el = q('[data-diff]').arr()[0]; if (!el) return;
270- el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified');
271- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
272- };
273- k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) };
274- k-permalink from:body -> {
275- let b = document.body.dataset;
276- if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
277- history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
278- navigator.clipboard?.writeText(location.href);
279- b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
280- }">
214+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
215+ k-k from:body -> ermite.move(-1);
216+ k-top from:body -> ermite.edge(false);
217+ k-bottom from:body -> ermite.edge(true);
218+ k-open from:body -> ermite.open();
219+ k-esc from:body -> ermite.closeDialogs();
220+ k-help from:body -> ermite.dialog('panel-help');
221+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
222+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
223+ k-filter from:body -> ermite.focusFilter();
224+ k-gh from:body -> ermite.go('.brand');
225+ k-s from:body -> ermite.tab('s');
226+ k-t from:body -> ermite.tab('t');
227+ k-c from:body -> ermite.tab('c');
228+ k-r from:body -> ermite.tab('r');
229+ k-m from:body -> ermite.tab('m');
230+ k-next from:body -> ermite.nextNote();
231+ k-split from:body -> ermite.layout('split');
232+ k-unified from:body -> ermite.layout('unified');
233+ k-collapse from:body -> ermite.collapse();
234+ k-permalink from:body -> ermite.permalink()">
281235 </div>
282236 </body>
283237 </html>
M cmd/ermite/testdata/web/home.html +27 -71
@@ -28,13 +28,13 @@
2828 <span class="brand-name">example.test</span>
2929 </a>
3030 <div class="hdr-acts">
31-<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
31+<button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd">
3232 <span class="ico i-search" aria-hidden="true">
3333 </span>
3434 <span class="search-label">Go to…</span>
3535 <kbd aria-hidden="true">ctrl k</kbd>
3636 </button>
37-<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
37+<button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button>
3838 </div>
3939 </div>
4040 </header>
@@ -42,7 +42,7 @@
4242 <main id="main" tabindex="-1">
4343 <div class="filter">
4444 <label for="home-filter" class="sr">filter</label>
45-<input id="home-filter" type="search" data-filter placeholder="Filter repositories…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#repo-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#repo-rows > tr:not([hidden])')">
45+<input id="home-filter" type="search" data-filter="#repo-rows > tr" placeholder="Filter repositories…" autocomplete="off" hx-on:input="ermite.filter(this)">
4646 </div>
4747 <table class="list home" aria-label="Repositories">
4848 <thead>
@@ -109,6 +109,8 @@
109109 </span>
110110 <span class="hint-live" data-state="connecting" hx-sse:connect="/-/events?topic=home" hx-swap="none" :text="data.state === 'offline' ? 'live updates paused' : ''" hx-on="htmx:sse:after:connection -> data.state = 'live';
111111 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
112+ htmx:sse:error -> ermiteLive.error(event.detail.status);
113+ htmx:sse:before:connection -> ermiteLive.gate(event);
112114 home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
113115 gap -> ermiteLive.reload()">
114116 </span>
@@ -212,13 +214,7 @@
212214 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
213215 <div class="panel-head">
214216 <label for="cmd-q" class="sr">go to</label>
215-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
216- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
217- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
218- let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
219- let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
220- if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
221- else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
217+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
222218 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
223219 <kbd>esc</kbd>
224220 </button>
@@ -253,67 +249,27 @@
253249 <kbd>esc</kbd> close</span>
254250 </p>
255251 </dialog>
256-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
257- if (q('dialog[open]').count) return;
258- let rows = q('[data-rows] > :not([hidden])').arr();
259- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
260- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
261- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
262- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
263- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
264- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
265- })(1);
266- k-k from:body -> (async (d) => {
267- if (q('dialog[open]').count) return;
268- let rows = q('[data-rows] > :not([hidden])').arr();
269- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
270- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
271- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
272- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
273- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
274- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
275- })(-1);
276- k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
277- k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
278- k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
279- k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
280- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
281- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
282- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
283- k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
284- k-gh from:body -> document.querySelector('.brand')?.click();
285- k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
286- k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
287- k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
288- k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
289- k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
290- k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
291- k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
292- k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
293- k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
294- k-next from:body -> {
295- let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
296- let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
297- let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
298- };
299- k-split from:body -> {
300- let el = q('[data-diff]').arr()[0]; if (!el) return;
301- el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split');
302- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
303- };
304- k-unified from:body -> {
305- let el = q('[data-diff]').arr()[0]; if (!el) return;
306- el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified');
307- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
308- };
309- k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) };
310- k-permalink from:body -> {
311- let b = document.body.dataset;
312- if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
313- history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
314- navigator.clipboard?.writeText(location.href);
315- b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
316- }">
252+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
253+ k-k from:body -> ermite.move(-1);
254+ k-top from:body -> ermite.edge(false);
255+ k-bottom from:body -> ermite.edge(true);
256+ k-open from:body -> ermite.open();
257+ k-esc from:body -> ermite.closeDialogs();
258+ k-help from:body -> ermite.dialog('panel-help');
259+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
260+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
261+ k-filter from:body -> ermite.focusFilter();
262+ k-gh from:body -> ermite.go('.brand');
263+ k-s from:body -> ermite.tab('s');
264+ k-t from:body -> ermite.tab('t');
265+ k-c from:body -> ermite.tab('c');
266+ k-r from:body -> ermite.tab('r');
267+ k-m from:body -> ermite.tab('m');
268+ k-next from:body -> ermite.nextNote();
269+ k-split from:body -> ermite.layout('split');
270+ k-unified from:body -> ermite.layout('unified');
271+ k-collapse from:body -> ermite.collapse();
272+ k-permalink from:body -> ermite.permalink()">
317273 </div>
318274 </body>
319275 </html>
M cmd/ermite/testdata/web/not-found-page.html +24 -70
@@ -35,13 +35,13 @@
3535 </ol>
3636 </nav>
3737 <div class="hdr-acts">
38-<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
38+<button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd">
3939 <span class="ico i-search" aria-hidden="true">
4040 </span>
4141 <span class="search-label">Go to…</span>
4242 <kbd aria-hidden="true">ctrl k</kbd>
4343 </button>
44-<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
44+<button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button>
4545 </div>
4646 </div>
4747 </header>
@@ -171,13 +171,7 @@
171171 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
172172 <div class="panel-head">
173173 <label for="cmd-q" class="sr">go to</label>
174-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
175- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
176- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
177- let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
178- let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
179- if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
180- else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
174+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
181175 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
182176 <kbd>esc</kbd>
183177 </button>
@@ -200,67 +194,27 @@
200194 <kbd>esc</kbd> close</span>
201195 </p>
202196 </dialog>
203-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
204- if (q('dialog[open]').count) return;
205- let rows = q('[data-rows] > :not([hidden])').arr();
206- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
207- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
208- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
209- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
210- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
211- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
212- })(1);
213- k-k from:body -> (async (d) => {
214- if (q('dialog[open]').count) return;
215- let rows = q('[data-rows] > :not([hidden])').arr();
216- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
217- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
218- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
219- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
220- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
221- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
222- })(-1);
223- k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
224- k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
225- k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
226- k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
227- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
228- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
229- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
230- k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
231- k-gh from:body -> document.querySelector('.brand')?.click();
232- k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
233- k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
234- k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
235- k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
236- k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
237- k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
238- k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
239- k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
240- k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
241- k-next from:body -> {
242- let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
243- let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
244- let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
245- };
246- k-split from:body -> {
247- let el = q('[data-diff]').arr()[0]; if (!el) return;
248- el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split');
249- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
250- };
251- k-unified from:body -> {
252- let el = q('[data-diff]').arr()[0]; if (!el) return;
253- el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified');
254- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
255- };
256- k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) };
257- k-permalink from:body -> {
258- let b = document.body.dataset;
259- if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
260- history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
261- navigator.clipboard?.writeText(location.href);
262- b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
263- }">
197+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
198+ k-k from:body -> ermite.move(-1);
199+ k-top from:body -> ermite.edge(false);
200+ k-bottom from:body -> ermite.edge(true);
201+ k-open from:body -> ermite.open();
202+ k-esc from:body -> ermite.closeDialogs();
203+ k-help from:body -> ermite.dialog('panel-help');
204+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
205+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
206+ k-filter from:body -> ermite.focusFilter();
207+ k-gh from:body -> ermite.go('.brand');
208+ k-s from:body -> ermite.tab('s');
209+ k-t from:body -> ermite.tab('t');
210+ k-c from:body -> ermite.tab('c');
211+ k-r from:body -> ermite.tab('r');
212+ k-m from:body -> ermite.tab('m');
213+ k-next from:body -> ermite.nextNote();
214+ k-split from:body -> ermite.layout('split');
215+ k-unified from:body -> ermite.layout('unified');
216+ k-collapse from:body -> ermite.collapse();
217+ k-permalink from:body -> ermite.permalink()">
264218 </div>
265219 </body>
266220 </html>
M cmd/ermite/testdata/web/not-found.html +24 -70
@@ -35,13 +35,13 @@
3535 </ol>
3636 </nav>
3737 <div class="hdr-acts">
38-<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
38+<button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd">
3939 <span class="ico i-search" aria-hidden="true">
4040 </span>
4141 <span class="search-label">Go to…</span>
4242 <kbd aria-hidden="true">ctrl k</kbd>
4343 </button>
44-<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
44+<button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button>
4545 </div>
4646 </div>
4747 </header>
@@ -171,13 +171,7 @@
171171 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
172172 <div class="panel-head">
173173 <label for="cmd-q" class="sr">go to</label>
174-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
175- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
176- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
177- let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
178- let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
179- if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
180- else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
174+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
181175 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
182176 <kbd>esc</kbd>
183177 </button>
@@ -200,67 +194,27 @@
200194 <kbd>esc</kbd> close</span>
201195 </p>
202196 </dialog>
203-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
204- if (q('dialog[open]').count) return;
205- let rows = q('[data-rows] > :not([hidden])').arr();
206- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
207- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
208- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
209- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
210- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
211- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
212- })(1);
213- k-k from:body -> (async (d) => {
214- if (q('dialog[open]').count) return;
215- let rows = q('[data-rows] > :not([hidden])').arr();
216- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
217- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
218- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
219- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
220- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
221- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
222- })(-1);
223- k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
224- k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
225- k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
226- k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
227- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
228- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
229- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
230- k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
231- k-gh from:body -> document.querySelector('.brand')?.click();
232- k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
233- k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
234- k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
235- k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
236- k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
237- k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
238- k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
239- k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
240- k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
241- k-next from:body -> {
242- let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
243- let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
244- let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
245- };
246- k-split from:body -> {
247- let el = q('[data-diff]').arr()[0]; if (!el) return;
248- el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split');
249- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
250- };
251- k-unified from:body -> {
252- let el = q('[data-diff]').arr()[0]; if (!el) return;
253- el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified');
254- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
255- };
256- k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) };
257- k-permalink from:body -> {
258- let b = document.body.dataset;
259- if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
260- history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
261- navigator.clipboard?.writeText(location.href);
262- b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
263- }">
197+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
198+ k-k from:body -> ermite.move(-1);
199+ k-top from:body -> ermite.edge(false);
200+ k-bottom from:body -> ermite.edge(true);
201+ k-open from:body -> ermite.open();
202+ k-esc from:body -> ermite.closeDialogs();
203+ k-help from:body -> ermite.dialog('panel-help');
204+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
205+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
206+ k-filter from:body -> ermite.focusFilter();
207+ k-gh from:body -> ermite.go('.brand');
208+ k-s from:body -> ermite.tab('s');
209+ k-t from:body -> ermite.tab('t');
210+ k-c from:body -> ermite.tab('c');
211+ k-r from:body -> ermite.tab('r');
212+ k-m from:body -> ermite.tab('m');
213+ k-next from:body -> ermite.nextNote();
214+ k-split from:body -> ermite.layout('split');
215+ k-unified from:body -> ermite.layout('unified');
216+ k-collapse from:body -> ermite.collapse();
217+ k-permalink from:body -> ermite.permalink()">
264218 </div>
265219 </body>
266220 </html>
M cmd/ermite/testdata/web/refs.html +30 -75
@@ -38,19 +38,19 @@
3838 </ol>
3939 </nav>
4040 <div class="hdr-acts">
41-<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
41+<a class="btn ref" href="/demo/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)">
4242 <span class="ico i-branch" aria-hidden="true">
4343 </span>
4444 <span class="ref-name">main</span>
4545 <span class="sha">SHA7</span>
46-</button>
47-<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
46+</a>
47+<button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd">
4848 <span class="ico i-search" aria-hidden="true">
4949 </span>
5050 <span class="search-label">Go to…</span>
5151 <kbd aria-hidden="true">ctrl k</kbd>
5252 </button>
53-<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
53+<button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button>
5454 </div>
5555 </div>
5656 <nav class="tabs" aria-label="Sections">
@@ -82,8 +82,7 @@
8282 <main id="main" tabindex="-1">
8383 <div class="filter">
8484 <label for="ref-filter" class="sr">filter</label>
85-<input id="ref-filter" type="search" data-filter placeholder="Filter branches and tags…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#ref-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t));
86- let first = q('#ref-rows > tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#ref-rows > tr')">
85+<input id="ref-filter" type="search" data-filter="#ref-rows > tr" placeholder="Filter branches and tags…" autocomplete="off" hx-on:input="ermite.filter(this)">
8786 </div>
8887 <table class="list refs" aria-label="Branches and tags">
8988 <thead>
@@ -193,6 +192,8 @@
193192 </span>
194193 <span class="hint-live" data-state="connecting" hx-sse:connect="/-/events?topic=repo%3Ademo&amp;view=refs" hx-swap="none" :text="data.state === 'offline' ? 'live updates paused' : ''" hx-on="htmx:sse:after:connection -> data.state = 'live';
195194 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
195+ htmx:sse:error -> ermiteLive.error(event.detail.status);
196+ htmx:sse:before:connection -> ermiteLive.gate(event);
196197 home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
197198 gap -> ermiteLive.reload()">
198199 </span>
@@ -296,7 +297,7 @@
296297 <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
297298 <div class="panel-head">
298299 <label for="refs-filter" class="sr">filter branches and tags</label>
299-<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
300+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)">
300301 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
301302 <kbd>esc</kbd>
302303 </button>
@@ -335,13 +336,7 @@
335336 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
336337 <div class="panel-head">
337338 <label for="cmd-q" class="sr">go to</label>
338-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
339- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
340- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
341- let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
342- let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
343- if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
344- else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
339+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
345340 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
346341 <kbd>esc</kbd>
347342 </button>
@@ -424,67 +419,27 @@
424419 <kbd>esc</kbd> close</span>
425420 </p>
426421 </dialog>
427-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
428- if (q('dialog[open]').count) return;
429- let rows = q('[data-rows] > :not([hidden])').arr();
430- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
431- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
432- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
433- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
434- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
435- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
436- })(1);
437- k-k from:body -> (async (d) => {
438- if (q('dialog[open]').count) return;
439- let rows = q('[data-rows] > :not([hidden])').arr();
440- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
441- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
442- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
443- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
444- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
445- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
446- })(-1);
447- k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
448- k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
449- k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
450- k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
451- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
452- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
453- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
454- k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
455- k-gh from:body -> document.querySelector('.brand')?.click();
456- k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
457- k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
458- k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
459- k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
460- k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
461- k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
462- k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
463- k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
464- k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
465- k-next from:body -> {
466- let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
467- let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
468- let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
469- };
470- k-split from:body -> {
471- let el = q('[data-diff]').arr()[0]; if (!el) return;
472- el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split');
473- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
474- };
475- k-unified from:body -> {
476- let el = q('[data-diff]').arr()[0]; if (!el) return;
477- el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified');
478- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
479- };
480- k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) };
481- k-permalink from:body -> {
482- let b = document.body.dataset;
483- if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
484- history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
485- navigator.clipboard?.writeText(location.href);
486- b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
487- }">
422+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
423+ k-k from:body -> ermite.move(-1);
424+ k-top from:body -> ermite.edge(false);
425+ k-bottom from:body -> ermite.edge(true);
426+ k-open from:body -> ermite.open();
427+ k-esc from:body -> ermite.closeDialogs();
428+ k-help from:body -> ermite.dialog('panel-help');
429+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
430+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
431+ k-filter from:body -> ermite.focusFilter();
432+ k-gh from:body -> ermite.go('.brand');
433+ k-s from:body -> ermite.tab('s');
434+ k-t from:body -> ermite.tab('t');
435+ k-c from:body -> ermite.tab('c');
436+ k-r from:body -> ermite.tab('r');
437+ k-m from:body -> ermite.tab('m');
438+ k-next from:body -> ermite.nextNote();
439+ k-split from:body -> ermite.layout('split');
440+ k-unified from:body -> ermite.layout('unified');
441+ k-collapse from:body -> ermite.collapse();
442+ k-permalink from:body -> ermite.permalink()">
488443 </div>
489444 </body>
490445 </html>
M cmd/ermite/testdata/web/summary.html +30 -74
@@ -35,19 +35,19 @@
3535 </ol>
3636 </nav>
3737 <div class="hdr-acts">
38-<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
38+<a class="btn ref" href="/demo/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)">
3939 <span class="ico i-branch" aria-hidden="true">
4040 </span>
4141 <span class="ref-name">main</span>
4242 <span class="sha">SHA7</span>
43-</button>
44-<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
43+</a>
44+<button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd">
4545 <span class="ico i-search" aria-hidden="true">
4646 </span>
4747 <span class="search-label">Go to…</span>
4848 <kbd aria-hidden="true">ctrl k</kbd>
4949 </button>
50-<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
50+<button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button>
5151 </div>
5252 </div>
5353 <nav class="tabs" aria-label="Sections">
@@ -117,7 +117,7 @@
117117 <div class="clone-line">
118118 <span class="mute" aria-hidden="true">$ </span>
119119 <code>git clone http://example.test/demo.git</code>
120-<button type="button" class="btn copy" data-label="Copy" aria-label="copy: git clone http://example.test/demo.git" hx-on:click="navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = 'Copied'; await timeout('1500ms'); this.textContent = this.dataset.label">Copy</button>
120+<button type="button" class="btn copy" data-js data-label="Copy" aria-label="copy: git clone http://example.test/demo.git" hx-on:click="ermite.copy(this)">Copy</button>
121121 </div>
122122 </div>
123123 </section>
@@ -211,6 +211,8 @@
211211 </span>
212212 <span class="hint-live" data-state="connecting" hx-sse:connect="/-/events?topic=repo%3Ademo&amp;view=summary" hx-swap="none" :text="data.state === 'offline' ? 'live updates paused' : ''" hx-on="htmx:sse:after:connection -> data.state = 'live';
213213 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
214+ htmx:sse:error -> ermiteLive.error(event.detail.status);
215+ htmx:sse:before:connection -> ermiteLive.gate(event);
214216 home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
215217 gap -> ermiteLive.reload()">
216218 </span>
@@ -314,7 +316,7 @@
314316 <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
315317 <div class="panel-head">
316318 <label for="refs-filter" class="sr">filter branches and tags</label>
317-<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
319+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)">
318320 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
319321 <kbd>esc</kbd>
320322 </button>
@@ -353,13 +355,7 @@
353355 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
354356 <div class="panel-head">
355357 <label for="cmd-q" class="sr">go to</label>
356-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
357- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
358- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
359- let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
360- let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
361- if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
362- else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
358+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
363359 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
364360 <kbd>esc</kbd>
365361 </button>
@@ -442,67 +438,27 @@
442438 <kbd>esc</kbd> close</span>
443439 </p>
444440 </dialog>
445-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
446- if (q('dialog[open]').count) return;
447- let rows = q('[data-rows] > :not([hidden])').arr();
448- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
449- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
450- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
451- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
452- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
453- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
454- })(1);
455- k-k from:body -> (async (d) => {
456- if (q('dialog[open]').count) return;
457- let rows = q('[data-rows] > :not([hidden])').arr();
458- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
459- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
460- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
461- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
462- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
463- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
464- })(-1);
465- k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
466- k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
467- k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
468- k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
469- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
470- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
471- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
472- k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
473- k-gh from:body -> document.querySelector('.brand')?.click();
474- k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
475- k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
476- k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
477- k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
478- k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
479- k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
480- k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
481- k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
482- k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
483- k-next from:body -> {
484- let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
485- let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
486- let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
487- };
488- k-split from:body -> {
489- let el = q('[data-diff]').arr()[0]; if (!el) return;
490- el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split');
491- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
492- };
493- k-unified from:body -> {
494- let el = q('[data-diff]').arr()[0]; if (!el) return;
495- el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified');
496- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
497- };
498- k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) };
499- k-permalink from:body -> {
500- let b = document.body.dataset;
501- if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
502- history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
503- navigator.clipboard?.writeText(location.href);
504- b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
505- }">
441+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
442+ k-k from:body -> ermite.move(-1);
443+ k-top from:body -> ermite.edge(false);
444+ k-bottom from:body -> ermite.edge(true);
445+ k-open from:body -> ermite.open();
446+ k-esc from:body -> ermite.closeDialogs();
447+ k-help from:body -> ermite.dialog('panel-help');
448+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
449+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
450+ k-filter from:body -> ermite.focusFilter();
451+ k-gh from:body -> ermite.go('.brand');
452+ k-s from:body -> ermite.tab('s');
453+ k-t from:body -> ermite.tab('t');
454+ k-c from:body -> ermite.tab('c');
455+ k-r from:body -> ermite.tab('r');
456+ k-m from:body -> ermite.tab('m');
457+ k-next from:body -> ermite.nextNote();
458+ k-split from:body -> ermite.layout('split');
459+ k-unified from:body -> ermite.layout('unified');
460+ k-collapse from:body -> ermite.collapse();
461+ k-permalink from:body -> ermite.permalink()">
506462 </div>
507463 </body>
508464 </html>
M cmd/ermite/testdata/web/tree-nested.html +28 -74
@@ -47,19 +47,19 @@
4747 </ol>
4848 </nav>
4949 <div class="hdr-acts">
50-<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
50+<a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)">
5151 <span class="ico i-branch" aria-hidden="true">
5252 </span>
5353 <span class="ref-name">main</span>
5454 <span class="sha">SHA7</span>
55-</button>
56-<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
55+</a>
56+<button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd">
5757 <span class="ico i-search" aria-hidden="true">
5858 </span>
5959 <span class="search-label">Go to…</span>
6060 <kbd aria-hidden="true">ctrl k</kbd>
6161 </button>
62-<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
62+<button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button>
6363 </div>
6464 </div>
6565 <nav class="tabs" aria-label="Sections">
@@ -91,7 +91,7 @@
9191 <main id="main" tabindex="-1">
9292 <div class="filter">
9393 <label for="tree-filter" class="sr">filter</label>
94-<input id="tree-filter" type="search" data-filter placeholder="Filter this directory…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')">
94+<input id="tree-filter" type="search" data-filter="#tree-rows > tr" placeholder="Filter this directory…" autocomplete="off" hx-on:input="ermite.filter(this)">
9595 </div>
9696 <table class="list tree" aria-label="Entries">
9797 <thead>
@@ -241,7 +241,7 @@
241241 <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
242242 <div class="panel-head">
243243 <label for="refs-filter" class="sr">filter branches and tags</label>
244-<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
244+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)">
245245 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
246246 <kbd>esc</kbd>
247247 </button>
@@ -276,13 +276,7 @@
276276 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
277277 <div class="panel-head">
278278 <label for="cmd-q" class="sr">go to</label>
279-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
280- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
281- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
282- let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
283- let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
284- if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
285- else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
279+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
286280 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
287281 <kbd>esc</kbd>
288282 </button>
@@ -359,67 +353,27 @@
359353 <kbd>esc</kbd> close</span>
360354 </p>
361355 </dialog>
362-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
363- if (q('dialog[open]').count) return;
364- let rows = q('[data-rows] > :not([hidden])').arr();
365- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
366- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
367- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
368- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
369- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
370- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
371- })(1);
372- k-k from:body -> (async (d) => {
373- if (q('dialog[open]').count) return;
374- let rows = q('[data-rows] > :not([hidden])').arr();
375- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
376- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
377- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
378- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
379- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
380- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
381- })(-1);
382- k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
383- k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
384- k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
385- k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
386- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
387- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
388- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
389- k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
390- k-gh from:body -> document.querySelector('.brand')?.click();
391- k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
392- k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
393- k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
394- k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
395- k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
396- k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
397- k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
398- k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
399- k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
400- k-next from:body -> {
401- let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
402- let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
403- let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
404- };
405- k-split from:body -> {
406- let el = q('[data-diff]').arr()[0]; if (!el) return;
407- el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split');
408- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
409- };
410- k-unified from:body -> {
411- let el = q('[data-diff]').arr()[0]; if (!el) return;
412- el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified');
413- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
414- };
415- k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) };
416- k-permalink from:body -> {
417- let b = document.body.dataset;
418- if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
419- history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
420- navigator.clipboard?.writeText(location.href);
421- b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
422- }">
356+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
357+ k-k from:body -> ermite.move(-1);
358+ k-top from:body -> ermite.edge(false);
359+ k-bottom from:body -> ermite.edge(true);
360+ k-open from:body -> ermite.open();
361+ k-esc from:body -> ermite.closeDialogs();
362+ k-help from:body -> ermite.dialog('panel-help');
363+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
364+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
365+ k-filter from:body -> ermite.focusFilter();
366+ k-gh from:body -> ermite.go('.brand');
367+ k-s from:body -> ermite.tab('s');
368+ k-t from:body -> ermite.tab('t');
369+ k-c from:body -> ermite.tab('c');
370+ k-r from:body -> ermite.tab('r');
371+ k-m from:body -> ermite.tab('m');
372+ k-next from:body -> ermite.nextNote();
373+ k-split from:body -> ermite.layout('split');
374+ k-unified from:body -> ermite.layout('unified');
375+ k-collapse from:body -> ermite.collapse();
376+ k-permalink from:body -> ermite.permalink()">
423377 </div>
424378 </body>
425379 </html>
M cmd/ermite/testdata/web/tree-root.html +28 -74
@@ -41,19 +41,19 @@
4141 </ol>
4242 </nav>
4343 <div class="hdr-acts">
44-<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
44+<a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)">
4545 <span class="ico i-branch" aria-hidden="true">
4646 </span>
4747 <span class="ref-name">main</span>
4848 <span class="sha">SHA7</span>
49-</button>
50-<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
49+</a>
50+<button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd">
5151 <span class="ico i-search" aria-hidden="true">
5252 </span>
5353 <span class="search-label">Go to…</span>
5454 <kbd aria-hidden="true">ctrl k</kbd>
5555 </button>
56-<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
56+<button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button>
5757 </div>
5858 </div>
5959 <nav class="tabs" aria-label="Sections">
@@ -85,7 +85,7 @@
8585 <main id="main" tabindex="-1">
8686 <div class="filter">
8787 <label for="tree-filter" class="sr">filter</label>
88-<input id="tree-filter" type="search" data-filter placeholder="Filter this directory…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')">
88+<input id="tree-filter" type="search" data-filter="#tree-rows > tr" placeholder="Filter this directory…" autocomplete="off" hx-on:input="ermite.filter(this)">
8989 </div>
9090 <table class="list tree" aria-label="Entries">
9191 <thead>
@@ -468,7 +468,7 @@
468468 <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
469469 <div class="panel-head">
470470 <label for="refs-filter" class="sr">filter branches and tags</label>
471-<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
471+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)">
472472 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
473473 <kbd>esc</kbd>
474474 </button>
@@ -503,13 +503,7 @@
503503 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
504504 <div class="panel-head">
505505 <label for="cmd-q" class="sr">go to</label>
506-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
507- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
508- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
509- let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
510- let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
511- if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
512- else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
506+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
513507 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
514508 <kbd>esc</kbd>
515509 </button>
@@ -658,67 +652,27 @@
658652 <kbd>esc</kbd> close</span>
659653 </p>
660654 </dialog>
661-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
662- if (q('dialog[open]').count) return;
663- let rows = q('[data-rows] > :not([hidden])').arr();
664- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
665- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
666- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
667- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
668- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
669- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
670- })(1);
671- k-k from:body -> (async (d) => {
672- if (q('dialog[open]').count) return;
673- let rows = q('[data-rows] > :not([hidden])').arr();
674- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
675- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
676- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
677- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
678- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
679- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
680- })(-1);
681- k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
682- k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
683- k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
684- k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
685- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
686- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
687- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
688- k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
689- k-gh from:body -> document.querySelector('.brand')?.click();
690- k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
691- k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
692- k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
693- k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
694- k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
695- k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
696- k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
697- k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
698- k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
699- k-next from:body -> {
700- let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
701- let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
702- let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
703- };
704- k-split from:body -> {
705- let el = q('[data-diff]').arr()[0]; if (!el) return;
706- el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split');
707- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
708- };
709- k-unified from:body -> {
710- let el = q('[data-diff]').arr()[0]; if (!el) return;
711- el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified');
712- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
713- };
714- k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) };
715- k-permalink from:body -> {
716- let b = document.body.dataset;
717- if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
718- history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
719- navigator.clipboard?.writeText(location.href);
720- b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
721- }">
655+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
656+ k-k from:body -> ermite.move(-1);
657+ k-top from:body -> ermite.edge(false);
658+ k-bottom from:body -> ermite.edge(true);
659+ k-open from:body -> ermite.open();
660+ k-esc from:body -> ermite.closeDialogs();
661+ k-help from:body -> ermite.dialog('panel-help');
662+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
663+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
664+ k-filter from:body -> ermite.focusFilter();
665+ k-gh from:body -> ermite.go('.brand');
666+ k-s from:body -> ermite.tab('s');
667+ k-t from:body -> ermite.tab('t');
668+ k-c from:body -> ermite.tab('c');
669+ k-r from:body -> ermite.tab('r');
670+ k-m from:body -> ermite.tab('m');
671+ k-next from:body -> ermite.nextNote();
672+ k-split from:body -> ermite.layout('split');
673+ k-unified from:body -> ermite.layout('unified');
674+ k-collapse from:body -> ermite.collapse();
675+ k-permalink from:body -> ermite.permalink()">
722676 </div>
723677 </body>
724678 </html>
M cmd/ermite/testdata/web/tree-slash-ref.html +28 -74
@@ -44,19 +44,19 @@
4444 </ol>
4545 </nav>
4646 <div class="hdr-acts">
47-<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
47+<a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)">
4848 <span class="ico i-branch" aria-hidden="true">
4949 </span>
5050 <span class="ref-name">feature/x</span>
5151 <span class="sha">SHA7</span>
52-</button>
53-<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
52+</a>
53+<button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd">
5454 <span class="ico i-search" aria-hidden="true">
5555 </span>
5656 <span class="search-label">Go to…</span>
5757 <kbd aria-hidden="true">ctrl k</kbd>
5858 </button>
59-<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
59+<button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button>
6060 </div>
6161 </div>
6262 <nav class="tabs" aria-label="Sections">
@@ -88,7 +88,7 @@
8888 <main id="main" tabindex="-1">
8989 <div class="filter">
9090 <label for="tree-filter" class="sr">filter</label>
91-<input id="tree-filter" type="search" data-filter placeholder="Filter this directory…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')">
91+<input id="tree-filter" type="search" data-filter="#tree-rows > tr" placeholder="Filter this directory…" autocomplete="off" hx-on:input="ermite.filter(this)">
9292 </div>
9393 <table class="list tree" aria-label="Entries">
9494 <thead>
@@ -238,7 +238,7 @@
238238 <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
239239 <div class="panel-head">
240240 <label for="refs-filter" class="sr">filter branches and tags</label>
241-<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
241+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)">
242242 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
243243 <kbd>esc</kbd>
244244 </button>
@@ -273,13 +273,7 @@
273273 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
274274 <div class="panel-head">
275275 <label for="cmd-q" class="sr">go to</label>
276-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
277- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
278- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
279- let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
280- let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
281- if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
282- else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
276+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
283277 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
284278 <kbd>esc</kbd>
285279 </button>
@@ -356,67 +350,27 @@
356350 <kbd>esc</kbd> close</span>
357351 </p>
358352 </dialog>
359-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
360- if (q('dialog[open]').count) return;
361- let rows = q('[data-rows] > :not([hidden])').arr();
362- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
363- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
364- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
365- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
366- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
367- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
368- })(1);
369- k-k from:body -> (async (d) => {
370- if (q('dialog[open]').count) return;
371- let rows = q('[data-rows] > :not([hidden])').arr();
372- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
373- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
374- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
375- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
376- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
377- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
378- })(-1);
379- k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
380- k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
381- k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
382- k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
383- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
384- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
385- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
386- k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
387- k-gh from:body -> document.querySelector('.brand')?.click();
388- k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
389- k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
390- k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
391- k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
392- k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
393- k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
394- k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
395- k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
396- k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
397- k-next from:body -> {
398- let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
399- let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
400- let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
401- };
402- k-split from:body -> {
403- let el = q('[data-diff]').arr()[0]; if (!el) return;
404- el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split');
405- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
406- };
407- k-unified from:body -> {
408- let el = q('[data-diff]').arr()[0]; if (!el) return;
409- el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified');
410- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
411- };
412- k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) };
413- k-permalink from:body -> {
414- let b = document.body.dataset;
415- if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
416- history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
417- navigator.clipboard?.writeText(location.href);
418- b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
419- }">
353+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
354+ k-k from:body -> ermite.move(-1);
355+ k-top from:body -> ermite.edge(false);
356+ k-bottom from:body -> ermite.edge(true);
357+ k-open from:body -> ermite.open();
358+ k-esc from:body -> ermite.closeDialogs();
359+ k-help from:body -> ermite.dialog('panel-help');
360+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
361+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
362+ k-filter from:body -> ermite.focusFilter();
363+ k-gh from:body -> ermite.go('.brand');
364+ k-s from:body -> ermite.tab('s');
365+ k-t from:body -> ermite.tab('t');
366+ k-c from:body -> ermite.tab('c');
367+ k-r from:body -> ermite.tab('r');
368+ k-m from:body -> ermite.tab('m');
369+ k-next from:body -> ermite.nextNote();
370+ k-split from:body -> ermite.layout('split');
371+ k-unified from:body -> ermite.layout('unified');
372+ k-collapse from:body -> ermite.collapse();
373+ k-permalink from:body -> ermite.permalink()">
420374 </div>
421375 </body>
422376 </html>
M cmd/ermite/testdata/web/tree-subdir.html +28 -74
@@ -44,19 +44,19 @@
4444 </ol>
4545 </nav>
4646 <div class="hdr-acts">
47-<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
47+<a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)">
4848 <span class="ico i-branch" aria-hidden="true">
4949 </span>
5050 <span class="ref-name">main</span>
5151 <span class="sha">SHA7</span>
52-</button>
53-<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
52+</a>
53+<button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd">
5454 <span class="ico i-search" aria-hidden="true">
5555 </span>
5656 <span class="search-label">Go to…</span>
5757 <kbd aria-hidden="true">ctrl k</kbd>
5858 </button>
59-<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
59+<button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button>
6060 </div>
6161 </div>
6262 <nav class="tabs" aria-label="Sections">
@@ -88,7 +88,7 @@
8888 <main id="main" tabindex="-1">
8989 <div class="filter">
9090 <label for="tree-filter" class="sr">filter</label>
91-<input id="tree-filter" type="search" data-filter placeholder="Filter this directory…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')">
91+<input id="tree-filter" type="search" data-filter="#tree-rows > tr" placeholder="Filter this directory…" autocomplete="off" hx-on:input="ermite.filter(this)">
9292 </div>
9393 <table class="list tree" aria-label="Entries">
9494 <thead>
@@ -263,7 +263,7 @@
263263 <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
264264 <div class="panel-head">
265265 <label for="refs-filter" class="sr">filter branches and tags</label>
266-<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
266+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)">
267267 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
268268 <kbd>esc</kbd>
269269 </button>
@@ -298,13 +298,7 @@
298298 <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
299299 <div class="panel-head">
300300 <label for="cmd-q" class="sr">go to</label>
301-<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
302- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
303- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
304- let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
305- let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
306- if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
307- else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
301+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this)" hx-on:keydown="ermite.cmdKey(event)">
308302 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()">
309303 <kbd>esc</kbd>
310304 </button>
@@ -387,67 +381,27 @@
387381 <kbd>esc</kbd> close</span>
388382 </p>
389383 </dialog>
390-<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
391- if (q('dialog[open]').count) return;
392- let rows = q('[data-rows] > :not([hidden])').arr();
393- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
394- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
395- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
396- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
397- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
398- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
399- })(1);
400- k-k from:body -> (async (d) => {
401- if (q('dialog[open]').count) return;
402- let rows = q('[data-rows] > :not([hidden])').arr();
403- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
404- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
405- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
406- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
407- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
408- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
409- })(-1);
410- k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
411- k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
412- k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
413- k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
414- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
415- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
416- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
417- k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
418- k-gh from:body -> document.querySelector('.brand')?.click();
419- k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
420- k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
421- k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
422- k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
423- k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
424- k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
425- k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
426- k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
427- k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
428- k-next from:body -> {
429- let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
430- let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
431- let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
432- };
433- k-split from:body -> {
434- let el = q('[data-diff]').arr()[0]; if (!el) return;
435- el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split');
436- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
437- };
438- k-unified from:body -> {
439- let el = q('[data-diff]').arr()[0]; if (!el) return;
440- el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified');
441- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
442- };
443- k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) };
444- k-permalink from:body -> {
445- let b = document.body.dataset;
446- if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
447- history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
448- navigator.clipboard?.writeText(location.href);
449- b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
450- }">
384+<div id="keys" hidden hx-on="k-j from:body -> ermite.move(1);
385+ k-k from:body -> ermite.move(-1);
386+ k-top from:body -> ermite.edge(false);
387+ k-bottom from:body -> ermite.edge(true);
388+ k-open from:body -> ermite.open();
389+ k-esc from:body -> ermite.closeDialogs();
390+ k-help from:body -> ermite.dialog('panel-help');
391+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
392+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
393+ k-filter from:body -> ermite.focusFilter();
394+ k-gh from:body -> ermite.go('.brand');
395+ k-s from:body -> ermite.tab('s');
396+ k-t from:body -> ermite.tab('t');
397+ k-c from:body -> ermite.tab('c');
398+ k-r from:body -> ermite.tab('r');
399+ k-m from:body -> ermite.tab('m');
400+ k-next from:body -> ermite.nextNote();
401+ k-split from:body -> ermite.layout('split');
402+ k-unified from:body -> ermite.layout('unified');
403+ k-collapse from:body -> ermite.collapse();
404+ k-permalink from:body -> ermite.permalink()">
451405 </div>
452406 </body>
453407 </html>
M cmd/ermite/web_events_test.go +1 -1
@@ -162,7 +162,7 @@ func TestWebEventsRepoFragments(t *testing.T) {
162162 refs := h.sse("topic=repo:x&view=refs", "")
163163
164164 _, _, page := h.get("/x/commits/main")
165- require.Contains(t, page, `<div id="commits-head">`, "the slot of the banner")
165+ require.Contains(t, page, `<div id="commits-head" class="live-slot" role="status" data-slot>`, "the slot of the banner, a status region that stays in the page")
166166 m := regexp.MustCompile(`hx-sse:connect="([^"]+)"`).FindStringSubmatch(page)
167167 require.Len(t, m, 2, "the commits page is live")
168168 url := html.UnescapeString(m[1])
M cmd/ermite/web_mr_test.go +1 -1
@@ -511,7 +511,7 @@ func TestWebMergeRequestEvents(t *testing.T) {
511511 r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Secret <title>").ok(t)
512512 ev := list.next(t, watchWait)
513513 assert.Equal(t, "mr-notice", ev.name)
514- assert.Contains(t, ev.data, `<div id="mr-notice" class="live-banner" role="status">`)
514+ assert.Contains(t, ev.data, `<div id="mr-notice" class="live-slot" role="status" data-slot>`)
515515 assert.Contains(t, ev.data, `href="/proj/merge-requests"`)
516516 assert.NotContains(t, ev.data, "Secret")
517517
M internal/adapters/web/ASSETS.md +1 -1
@@ -8,7 +8,7 @@ content-hashed names. Nothing is fetched at runtime.
88 | `static/js/htmx.min.js` | htmx 4.0.0 (BSD Zero Clause, `static/js/htmx-LICENSE`) | `mockups/assets/htmx/htmx.min.js` of the design | `e484d9171a9db30a39c8f16e3d709d4137f3211c659f8e6125816635033d593f` |
99 | `static/js/hx-live.min.js` | htmx 4 `hx-live` extension | `mockups/assets/htmx/ext/hx-live.min.js` | `adf9b90949d57baf20dc2eaf1d1fd46c81347ac82c090a5e2912ce6b37726734` |
1010 | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` |
11-| `static/js/ermite.js` | key sequences to `k-*` events | `mockups/d/js/ermite.js` | |
11+| `static/js/ermite.js` | key sequences to `k-*` events, the functions the keys and inputs call (`window.ermite`), the live fragments (`ermiteLive`), the diff table of contents observer | `mockups/d/js/ermite.js`, extended here | |
1212 | `static/css/56-chroma.css` | syntax classes of the code view, mapped to the `--syn-*` tokens (no colour of its own) | generated by `go run scripts/chroma-css.go` from chroma's token classes | |
1313 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |
1414 | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` |
M internal/adapters/web/commits.templ +4 -5
@@ -11,19 +11,18 @@ templ Commits(l Layout, p CommitsPage) {
1111 <input
1212 id="commit-filter"
1313 type="search"
14- data-filter
14+ data-filter=".days tr"
15+ data-groups=".days section"
1516 placeholder="Filter by subject, sha or author…"
1617 autocomplete="off"
17- hx-on:input="let t = this.value.toLowerCase(); q('.days tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t));
18- q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])'));
19- let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')"
18+ hx-on:input="ermite.filter(this)"
2019 />
2120 </div>
2221 if p.Path != "" {
2322 <p class="lede dim">history of <code>{ p.Path }</code> at { p.Ref }</p>
2423 }
2524 if p.Live {
26- <div id="commits-head"></div>
25+ @LiveSlot("commits-head")
2726 }
2827 <div class="days">
2928 for _, g := range p.Days {
M internal/adapters/web/commits_templ.go +47 -47 generated file: collapsed
M internal/adapters/web/components.templ +2 -1
@@ -22,9 +22,10 @@ templ CloneBlock(c Clone) {
2222 <button
2323 type="button"
2424 class="btn copy"
25+ data-js
2526 data-label="Copy"
2627 aria-label={ "copy: " + line }
27- hx-on:click="navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = 'Copied'; await timeout('1500ms'); this.textContent = this.dataset.label"
28+ hx-on:click="ermite.copy(this)"
2829 >Copy</button>
2930 </div>
3031 }
M internal/adapters/web/components_templ.go +25 -25 generated file: collapsed
M internal/adapters/web/diff.templ +3 -4
@@ -106,8 +106,8 @@ templ viewLink(p DiffView, view, label, key, href string) {
106106 aria-current="true"
107107 }
108108 if p.Both {
109- hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))"
110- :aria-current={ "q('#diffs').attr['data-diff'] === '" + view + "' ? 'true' : false" }
109+ data-fire={ view }
110+ :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false"
111111 }
112112 >{ label }<kbd aria-hidden="true">{ key }</kbd></a>
113113 }
@@ -115,7 +115,7 @@ templ viewLink(p DiffView, view, label, key, href string) {
115115 // collapseButton folds every file of the diff, or opens them all again when
116116 // none is open; its label follows the state.
117117 templ collapseButton() {
118- <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn">
118+ <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">
119119 <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span><kbd aria-hidden="true">x</kbd>
120120 </button>
121121 }
@@ -142,7 +142,6 @@ templ FileList(files []DiffFile) {
142142 <li>
143143 <a
144144 href={ templ.SafeURL("#" + f.ID) }
145- :aria-current={ "(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === '" + f.ID + "' ? 'true' : false })()" }
146145 hx-on:click="document.getElementById(this.hash.slice(1)).open = true"
147146 >
148147 <span class="st" title={ f.Status }>{ f.Status }</span>
M internal/adapters/web/diff_templ.go +199 -212 generated file: collapsed
M internal/adapters/web/events.go +4 -1
@@ -51,7 +51,10 @@ import (
5151 // changed: the page fetches itself again (ermiteLive in ermite.js)
5252 //
5353 // The data of an event is one fragment, a top-level element with an id, one
54-// "data:" line per line of HTML; the page swaps the element that has that id.
54+// "data:" line per line of HTML; the page swaps the element that has that id
55+// (ermiteLive.swap). The slots of the banners (commits-head, mr-notice) are
56+// status regions that stay in the page: only their children are replaced, so
57+// that a screen reader announces the banner.
5558 // An event that changes nothing on the page is still sent as an id alone, so
5659 // that a reconnection resumes after it.
5760
M internal/adapters/web/fragments.go +1 -1
@@ -312,7 +312,7 @@ func (l *liveStream) banner(ctx context.Context, p *pb.Pushed) ([]fragment, erro
312312 text = "the branch was rewritten — reload"
313313 }
314314 }
315- c := CommitsSlot()
315+ c := LiveSlot("commits-head")
316316 if text != "" {
317317 c = CommitsBanner(text, repoHref(l.repo, "commits", l.ref))
318318 }
M internal/adapters/web/fragments.templ +22 -11
@@ -55,31 +55,40 @@ templ RefsKV(p RefsPage) {
5555 </dl>
5656 }
5757
58-// CommitsSlot is the place of the banner of the commits page, empty.
59-templ CommitsSlot() {
60- <div id="commits-head"></div>
58+// LiveSlot is the place of a live banner, empty: a status region that stays
59+// in the page. The live updates (ermiteLive in ermite.js) replace its children,
60+// never the element, so that a screen reader announces the new banner (a
61+// region that is inserted with its content is not announced).
62+templ LiveSlot(id string) {
63+ <div id={ id } class="live-slot" role="status" data-slot></div>
64+}
65+
66+// liveBanner is the slot with its banner: a link that reloads or opens the
67+// page. Only the children of the element reach the page that has the slot.
68+templ liveBanner(id, text, href string) {
69+ <div id={ id } class="live-slot" role="status" data-slot><a href={ templ.SafeURL(href) }>{ text }</a></div>
6170 }
6271
6372 // CommitsBanner announces new commits on the branch the page lists. It links
6473 // to the first page of the branch.
6574 templ CommitsBanner(text, href string) {
66- <div id="commits-head" class="live-banner" role="status">
67- <a href={ templ.SafeURL(href) }>{ text }</a>
68- </div>
75+ @liveBanner("commits-head", text, href)
6976 }
7077
71-// MRNotice tells a merge request page that it is out of date. The empty slot
72-// of the page has the same id, so the first notice replaces it.
78+// MRNotice tells a merge request page that it is out of date, or the list that
79+// its content changed.
7380 templ MRNotice(text, href string) {
74- <div id="mr-notice" class="live-banner" role="status">
75- <a href={ templ.SafeURL(href) }>{ text }</a>
76- </div>
81+ @liveBanner("mr-notice", text, href)
7782 }
7883
7984 // Live is the connection to the event stream, in the hint badge. It shows
8085 // only once a connection was lost: a page that never connected, or that is
8186 // live, says nothing. The events of the stream are fragments that ermiteLive
8287 // (ermite.js) swaps in the page. A page without a stream renders nothing.
88+//
89+// hx-sse retries a lost connection for ever, with a growing delay. A refusal
90+// that will not change (404: the repository is gone or hidden; 401) is handled
91+// by ermiteLive.error, which has the page fetch itself again.
8392 templ Live(l Layout) {
8493 if l.Live && l.LiveURL != "" {
8594 <span
@@ -90,6 +99,8 @@ templ Live(l Layout) {
9099 :text="data.state === 'offline' ? 'live updates paused' : ''"
91100 hx-on="htmx:sse:after:connection -> data.state = 'live';
92101 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
102+ htmx:sse:error -> ermiteLive.error(event.detail.status);
103+ htmx:sse:before:connection -> ermiteLive.gate(event);
93104 home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
94105 gap -> ermiteLive.reload()"
95106 ></span>
M internal/adapters/web/fragments_templ.go +94 -56 generated file: collapsed
A internal/adapters/web/fragments_test.go +74 -0
@@ -0,0 +1,74 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "bytes"
7+ "strings"
8+ "testing"
9+
10+ "github.com/a-h/templ"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func renderToString(t *testing.T, c templ.Component) string {
16+ t.Helper()
17+ var b bytes.Buffer
18+ require.NoError(t, c.Render(t.Context(), &b))
19+ return b.String()
20+}
21+
22+// A live banner is the content of a status region that stays in the page: the
23+// empty slot of the page and the fragment of the stream are the same element
24+// (same id, role and marker), so that ermiteLive can replace the children and
25+// leave the region where a screen reader watches it.
26+func TestLiveSlotsAreStableStatusRegions(t *testing.T) {
27+ for _, tt := range []struct {
28+ name string
29+ c templ.Component
30+ id string
31+ text string
32+ }{
33+ {"empty commits slot", LiveSlot("commits-head"), "commits-head", ""},
34+ {"empty mr slot", LiveSlot("mr-notice"), "mr-notice", ""},
35+ {"commits banner", CommitsBanner("2 new commits — reload", "/x/commits/main"), "commits-head", "2 new commits — reload"},
36+ {"mr notice", MRNotice("a comment was added — reload", "/x/merge-requests/1"), "mr-notice", "a comment was added — reload"},
37+ } {
38+ t.Run(tt.name, func(t *testing.T) {
39+ got := renderToString(t, tt.c)
40+ assert.True(t, strings.HasPrefix(got, `<div id="`+tt.id+`" class="live-slot" role="status" data-slot>`), got)
41+ if tt.text == "" {
42+ assert.True(t, strings.HasSuffix(got, "data-slot></div>"), "an empty slot has no child: %s", got)
43+ } else {
44+ assert.Contains(t, got, tt.text)
45+ }
46+ assert.True(t, strings.HasSuffix(got, "</div>"), got)
47+ })
48+ }
49+}
50+
51+// The header's refs button is a link to the refs page, which the script turns
52+// into the dialog; the filters and the buttons that need scripts are marked.
53+func TestShellDegradesWithoutScripts(t *testing.T) {
54+ l := Layout{Repo: "demo", Ref: "main", Short: "abc1234", Assets: Assets{}}
55+ got := renderToString(t, Header(l))
56+ assert.Contains(t, got, `<a class="btn ref" href="/demo/refs" data-fire="refs"`)
57+ assert.NotContains(t, got, "hx-on:click", "no handler is inlined in the header")
58+ for _, want := range []string{`class="btn search" data-js`, `class="btn icon" data-js data-fire="help"`} {
59+ assert.Contains(t, got, want)
60+ }
61+}
62+
63+// Data reaches the scripts through data-* attributes, never spliced into an
64+// attribute that is evaluated.
65+func TestDiffControlsPassDataThroughAttributes(t *testing.T) {
66+ files := renderToString(t, FileList([]DiffFile{{ID: "f1'x", Path: "a.go", Status: "M"}}))
67+ assert.NotContains(t, files, ":aria-current", "the file in view is marked by ermite.js")
68+ assert.NotContains(t, files, "f1'x'", "no id is spliced into a script")
69+
70+ link := renderToString(t, viewLink(DiffView{Both: true, View: "split"}, "split", "Split", "d", "?view=split"))
71+ assert.Contains(t, link, `data-fire="split"`)
72+ assert.Contains(t, link, `this.dataset.view`)
73+ assert.NotContains(t, link, "=== 'split'")
74+}
M internal/adapters/web/keys.templ +23 -63
@@ -3,72 +3,32 @@
33 package web
44
55 // Keys is the keyboard map. ermite.js turns keys into k-<name> events on
6-// <body>; this element (hx-on + hx-live helpers) is the only place that
7-// reacts to them.
6+// <body>; this element is the only place that binds them, each to one call of
7+// window.ermite (ermite.js), which does the work.
88 templ Keys() {
99 <div
1010 id="keys"
1111 hidden
12- hx-on="k-j from:body -> (async (d) => {
13- if (q('dialog[open]').count) return;
14- let rows = q('[data-rows] > :not([hidden])').arr();
15- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
16- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
17- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
18- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
19- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
20- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
21- })(1);
22- k-k from:body -> (async (d) => {
23- if (q('dialog[open]').count) return;
24- let rows = q('[data-rows] > :not([hidden])').arr();
25- if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
26- let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
27- let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
28- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
29- let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
30- await timeout('1500ms'); if (b.nav === mark) b.nav = ''
31- })(-1);
32- k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
33- k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
34- k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
35- k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
36- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
37- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
38- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
39- k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
40- k-gh from:body -> document.querySelector('.brand')?.click();
41- k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
42- k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
43- k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
44- k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
45- k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
46- k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
47- k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
48- k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
49- k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
50- k-next from:body -> {
51- let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
52- let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
53- let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
54- };
55- k-split from:body -> {
56- let el = q('[data-diff]').arr()[0]; if (!el) return;
57- el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split');
58- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
59- };
60- k-unified from:body -> {
61- let el = q('[data-diff]').arr()[0]; if (!el) return;
62- el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified');
63- q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)
64- };
65- k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) };
66- k-permalink from:body -> {
67- let b = document.body.dataset;
68- if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
69- history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
70- navigator.clipboard?.writeText(location.href);
71- b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
72- }"
12+ hx-on="k-j from:body -> ermite.move(1);
13+ k-k from:body -> ermite.move(-1);
14+ k-top from:body -> ermite.edge(false);
15+ k-bottom from:body -> ermite.edge(true);
16+ k-open from:body -> ermite.open();
17+ k-esc from:body -> ermite.closeDialogs();
18+ k-help from:body -> ermite.dialog('panel-help');
19+ k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');
20+ k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');
21+ k-filter from:body -> ermite.focusFilter();
22+ k-gh from:body -> ermite.go('.brand');
23+ k-s from:body -> ermite.tab('s');
24+ k-t from:body -> ermite.tab('t');
25+ k-c from:body -> ermite.tab('c');
26+ k-r from:body -> ermite.tab('r');
27+ k-m from:body -> ermite.tab('m');
28+ k-next from:body -> ermite.nextNote();
29+ k-split from:body -> ermite.layout('split');
30+ k-unified from:body -> ermite.layout('unified');
31+ k-collapse from:body -> ermite.collapse();
32+ k-permalink from:body -> ermite.permalink()"
7333 ></div>
7434 }
M internal/adapters/web/keys_templ.go +3 -3
@@ -11,8 +11,8 @@ import "github.com/a-h/templ"
1111 import templruntime "github.com/a-h/templ/runtime"
1212
1313 // Keys is the keyboard map. ermite.js turns keys into k-<name> events on
14-// <body>; this element (hx-on + hx-live helpers) is the only place that
15-// reacts to them.
14+// <body>; this element is the only place that binds them, each to one call of
15+// window.ermite (ermite.js), which does the work.
1616 func Keys() templ.Component {
1717 return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1818 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
@@ -34,7 +34,7 @@ func Keys() templ.Component {
3434 templ_7745c5c3_Var1 = templ.NopComponent
3535 }
3636 ctx = templ.ClearChildren(ctx)
37- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"keys\" hidden hx-on=\"k-j from:body -> (async (d) => {\n if (q('dialog[open]').count) return;\n let rows = q('[data-rows] > :not([hidden])').arr();\n if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));\n let i = rows.findIndex(r => r.hasAttribute('data-cursor'));\n let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];\n q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });\n let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;\n await timeout('1500ms'); if (b.nav === mark) b.nav = ''\n })(1);\n k-k from:body -> (async (d) => {\n if (q('dialog[open]').count) return;\n let rows = q('[data-rows] > :not([hidden])').arr();\n if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));\n let i = rows.findIndex(r => r.hasAttribute('data-cursor'));\n let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];\n q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });\n let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;\n await timeout('1500ms'); if (b.nav === mark) b.nav = ''\n })(-1);\n k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));\n k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));\n k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();\n k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());\n k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();\n k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());\n k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();\n k-filter from:body -> q('[data-filter]').arr()[0]?.focus();\n k-gh from:body -> document.querySelector('.brand')?.click();\n k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();\n k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();\n k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();\n k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();\n k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();\n k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();\n k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();\n k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();\n k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();\n k-next from:body -> {\n let ns = q('#diffs .cmt').arr(); if (!ns.length) return;\n let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);\n let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })\n };\n k-split from:body -> {\n let el = q('[data-diff]').arr()[0]; if (!el) return;\n el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split');\n q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)\n };\n k-unified from:body -> {\n let el = q('[data-diff]').arr()[0]; if (!el) return;\n el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified');\n q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)\n };\n k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) };\n k-permalink from:body -> {\n let b = document.body.dataset;\n if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }\n history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);\n navigator.clipboard?.writeText(location.href);\n b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''\n }\"></div>")
37+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"keys\" hidden hx-on=\"k-j from:body -> ermite.move(1);\n k-k from:body -> ermite.move(-1);\n k-top from:body -> ermite.edge(false);\n k-bottom from:body -> ermite.edge(true);\n k-open from:body -> ermite.open();\n k-esc from:body -> ermite.closeDialogs();\n k-help from:body -> ermite.dialog('panel-help');\n k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');\n k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q');\n k-filter from:body -> ermite.focusFilter();\n k-gh from:body -> ermite.go('.brand');\n k-s from:body -> ermite.tab('s');\n k-t from:body -> ermite.tab('t');\n k-c from:body -> ermite.tab('c');\n k-r from:body -> ermite.tab('r');\n k-m from:body -> ermite.tab('m');\n k-next from:body -> ermite.nextNote();\n k-split from:body -> ermite.layout('split');\n k-unified from:body -> ermite.layout('unified');\n k-collapse from:body -> ermite.collapse();\n k-permalink from:body -> ermite.permalink()\"></div>")
3838 if templ_7745c5c3_Err != nil {
3939 return templ_7745c5c3_Err
4040 }
M internal/adapters/web/mr.templ +4 -5
@@ -9,18 +9,17 @@ import "strings"
99 templ MRList(l Layout, p MRListPage) {
1010 @Shell(l, mrListRail(p)) {
1111 if p.Live {
12- <div id="mr-notice"></div>
12+ @LiveSlot("mr-notice")
1313 }
1414 <div class="filter">
1515 <label for="mr-filter" class="sr">filter</label>
1616 <input
1717 id="mr-filter"
1818 type="search"
19- data-filter
19+ data-filter=".mrs tbody tr"
2020 placeholder="Filter by title, branch, author or state…"
2121 autocomplete="off"
22- hx-on:input="let t = this.value.toLowerCase(); q('.mrs tbody tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t));
23- let first = q('.mrs tbody tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.mrs tbody tr')"
22+ hx-on:input="ermite.filter(this)"
2423 />
2524 </div>
2625 if p.Newest == "" {
@@ -140,7 +139,7 @@ templ MergeRequest(l Layout, p MRPage) {
140139 }
141140 </dl>
142141 </header>
143- <div id="mr-notice"></div>
142+ @LiveSlot("mr-notice")
144143 if len(p.Desc) > 0 {
145144 <p class="mr-desc">@userText(p.Desc)</p>
146145 }
M internal/adapters/web/mr_templ.go +202 -194 generated file: collapsed
M internal/adapters/web/overlays.templ +4 -10
@@ -37,8 +37,8 @@ templ OverlayRefs(l Layout) {
3737 type="search"
3838 placeholder="Switch branch or tag…"
3939 autocomplete="off"
40- data-pick
41- hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"
40+ data-pick="#panel-refs li"
41+ hx-on:input="ermite.filter(this)"
4242 />
4343 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"><kbd>esc</kbd></button>
4444 </div>
@@ -96,14 +96,8 @@ templ OverlayCmd(items []CmdItem) {
9696 autocomplete="off"
9797 spellcheck="false"
9898 aria-controls="cmd-list"
99- hx-on:input="let t = this.value.toLowerCase().trim();
100- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
101- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')"
102- hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
103- let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
104- let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
105- if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
106- else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"
99+ hx-on:input="ermite.cmdFilter(this)"
100+ hx-on:keydown="ermite.cmdKey(event)"
107101 />
108102 <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"><kbd>esc</kbd></button>
109103 </div>
M internal/adapters/web/overlays_templ.go +6 -6 generated file: collapsed
M internal/adapters/web/pages.templ +4 -5
@@ -10,10 +10,10 @@ templ Home(l Layout, p HomePage) {
1010 <input
1111 id="home-filter"
1212 type="search"
13- data-filter
13+ data-filter="#repo-rows > tr"
1414 placeholder="Filter repositories…"
1515 autocomplete="off"
16- hx-on:input="let t = this.value.toLowerCase(); q('#repo-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#repo-rows > tr:not([hidden])')"
16+ hx-on:input="ermite.filter(this)"
1717 />
1818 </div>
1919 <table class="list home" aria-label="Repositories">
@@ -94,11 +94,10 @@ templ Refs(l Layout, p RefsPage) {
9494 <input
9595 id="ref-filter"
9696 type="search"
97- data-filter
97+ data-filter="#ref-rows > tr"
9898 placeholder="Filter branches and tags…"
9999 autocomplete="off"
100- hx-on:input="let t = this.value.toLowerCase(); q('#ref-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t));
101- let first = q('#ref-rows > tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#ref-rows > tr')"
100+ hx-on:input="ermite.filter(this)"
102101 />
103102 </div>
104103 <table class="list refs" aria-label="Branches and tags">
M internal/adapters/web/pages_templ.go +22 -22 generated file: collapsed
M internal/adapters/web/shell.templ +7 -8
@@ -83,34 +83,33 @@ templ Header(l Layout) {
8383 }
8484 <div class="hdr-acts">
8585 if l.Ref != "" {
86- <button
87- type="button"
86+ <a
8887 class="btn ref"
89- aria-haspopup="dialog"
90- aria-controls="panel-refs"
88+ href={ templ.SafeURL(repoHref(l.Repo, "refs")) }
89+ data-fire="refs"
9190 aria-keyshortcuts="b"
9291 title="switch branch or tag (b)"
93- hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"
9492 >
9593 <span class="ico i-branch" aria-hidden="true"></span>
9694 <span class="ref-name">{ l.Ref }</span>
9795 <span class="sha">{ l.Short }</span>
98- </button>
96+ </a>
9997 }
10098 <button
10199 type="button"
102100 class="btn search"
101+ data-js
103102 aria-haspopup="dialog"
104103 aria-controls="panel-cmd"
105104 aria-keyshortcuts="Control+K :"
106105 title="go to a section, ref, file or repo (ctrl k or :)"
107- hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"
106+ data-fire="cmd"
108107 >
109108 <span class="ico i-search" aria-hidden="true"></span>
110109 <span class="search-label">Go to…</span>
111110 <kbd aria-hidden="true">ctrl k</kbd>
112111 </button>
113- <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
112+ <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button>
114113 if SignedIn(ctx) {
115114 <form class="owner" method="post" action="/-/logout">
116115 <span class="owner-badge" title="signed in as the owner">owner</span>
M internal/adapters/web/shell_templ.go +74 -61 generated file: collapsed
M internal/adapters/web/static/css/10-base.css +2 -0
@@ -70,6 +70,8 @@
7070 }
7171
7272 @layer utilities {
73+ /* what does nothing without scripts waits for ermite.js, which sets html.js */
74+ html:not(.js) [data-js], html:not(.js) .filter { display: none; }
7375 .dim { color: var(--ink-dim); }
7476 .mute { color: var(--ink-mute); }
7577 .nowrap { white-space: nowrap; }
M internal/adapters/web/static/css/20-shell.css +6 -1
@@ -169,7 +169,12 @@
169169 & > :empty { display: none; }
170170 & .hint-live { color: var(--red); }
171171 }
172- .hint:not(:has(> :not(:empty))) { display: none; }
172+ /* empty, the badge is out of sight but stays a status region (display: none
173+ would drop it from the accessibility tree and its next message would not be
174+ announced) */
175+ .hint:not(:has(> :not(:empty))) {
176+ width: 1px; height: 1px; padding: 0; border: 0; overflow: hidden; clip-path: inset(50%); box-shadow: none;
177+ }
173178
174179 @media (max-width: 1080px) {
175180 /* the rail goes under the content, in two columns while there is room */
M internal/adapters/web/static/css/80-live.css +7 -4
@@ -1,9 +1,12 @@
11 @layer components {
2- /* live updates: the banner of new commits, an empty slot takes no room */
3- #commits-head:empty { display: none; }
4- .live-banner {
2+ /* live updates: a slot is a status region that stays in the page; its banner
3+ comes and goes as its content. Empty, it is out of the way but still in the
4+ accessibility tree (display: none would drop the region, and the next banner
5+ would not be announced). */
6+ .live-slot:empty { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
7+ .live-slot:not(:empty) {
58 padding: var(--s3) var(--s4); font-weight: 700;
69 background: var(--bg-surface); border: 1px solid var(--border-strong); border-left: 3px solid var(--teal); border-radius: var(--r-s);
710 }
8- .live-banner a { color: var(--ink-hi); }
11+ .live-slot a { color: var(--ink-hi); }
912 }
M internal/adapters/web/static/js/ermite.js +279 -14
@@ -1,50 +1,297 @@
1-// ermite.js: the one thing hx-live cannot do is a global key *sequence* state machine
2-// ("g" then "h"), nor ignore keystrokes aimed at inputs before any element is focused.
3-// It only translates keys into DOM events named "k-<name>" on <body>; every reaction
4-// (cursor, panels, filters, permalink) is declared in templates/components/keys.tmpl with hx-on + hx-live.
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.
513 (() => {
14+ // Marks the page as scripted: what does nothing without scripts (the filters,
15+ // the "go to" and help buttons) is hidden until this class is set (see the
16+ // utilities of 10-base.css).
17+ document.documentElement.classList.add('js');
18+
19+ const $ = (sel, root = document) => root.querySelector(sel);
20+ const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
21+ const body = () => document.body;
22+
23+ // flash shows a message in the hint badge (body[data-flash], a status
24+ // region), then clears it unless another message came in the meantime.
25+ let flashTimer = 0;
26+ const flash = (msg, ms = 2000) => {
27+ body().dataset.flash = msg;
28+ clearTimeout(flashTimer);
29+ flashTimer = setTimeout(() => (body().dataset.flash = ''), ms);
30+ };
31+
32+ const visibleRows = () => $$('[data-rows] > :not([hidden])');
33+ // setCursor puts data-cursor on el and takes it from the others.
34+ const setCursor = (el, others) => {
35+ for (const o of others) if (o !== el) o.removeAttribute('data-cursor');
36+ el.setAttribute('data-cursor', '');
37+ };
38+ const scrollPage = (to) => {
39+ scrollTo(0, to);
40+ $('#main')?.scrollTo(0, to);
41+ };
42+
43+ const ermite = {
44+ fire(name) {
45+ body().dispatchEvent(new CustomEvent('k-' + name, { bubbles: false }));
46+ },
47+
48+ // Row cursor: j / k.
49+ move(d) {
50+ if ($('dialog[open]')) return;
51+ const rows = visibleRows();
52+ if (!rows.length) {
53+ scrollBy(0, d * 180);
54+ $('#main')?.scrollBy(0, d * 180);
55+ return;
56+ }
57+ const i = rows.findIndex((r) => r.hasAttribute('data-cursor'));
58+ const j = Math.max(0, Math.min(rows.length - 1, i + d));
59+ setCursor(rows[j], $$('[data-rows] > [data-cursor]'));
60+ rows[j].scrollIntoView({ block: 'nearest' });
61+ const mark = j + 1 + '/' + rows.length;
62+ body().dataset.nav = mark;
63+ setTimeout(() => body().dataset.nav === mark && (body().dataset.nav = ''), 1500);
64+ },
65+ // gg / G: first or last row, or the top or bottom of the page.
66+ edge(last) {
67+ const rows = visibleRows();
68+ const row = last ? rows.at(-1) : rows[0];
69+ if (row) row.scrollIntoView({ block: 'nearest' });
70+ else scrollPage(last ? 1e9 : 0);
71+ },
72+ open() {
73+ if (!$('dialog[open]')) $('[data-cursor] a')?.click();
74+ },
75+ focusFilter() {
76+ $('[data-filter]')?.focus();
77+ },
78+ go(sel) {
79+ $(sel)?.click();
80+ },
81+ tab(key) {
82+ $(`.tabs [data-key=${key}]`)?.click();
83+ },
84+
85+ // Dialogs.
86+ closeDialogs() {
87+ for (const d of $$('dialog[open]')) d.close();
88+ },
89+ dialog(id, focusId) {
90+ const d = document.getElementById(id);
91+ if (!d) return;
92+ ermite.closeDialogs();
93+ d.showModal();
94+ if (focusId) document.getElementById(focusId)?.focus();
95+ },
96+
97+ // filter hides the rows that do not contain the text of an input. The
98+ // input says which rows (data-filter, or data-pick for a list without a
99+ // cursor) and, optionally, the groups to hide when they have no row left
100+ // (data-groups). The cursor goes to the first row left.
101+ filter(input) {
102+ const sel = input.dataset.filter || input.dataset.pick;
103+ const t = input.value.toLowerCase();
104+ const rows = $$(sel);
105+ for (const r of rows) r.hidden = !r.textContent.toLowerCase().includes(t);
106+ if (input.dataset.groups) {
107+ for (const g of $$(input.dataset.groups)) g.hidden = !rows.some((r) => !r.hidden && g.contains(r));
108+ }
109+ if (input.dataset.filter) {
110+ const first = rows.find((r) => !r.hidden);
111+ for (const r of rows) r.toggleAttribute('data-cursor', r === first);
112+ }
113+ },
114+ // The "go to" palette.
115+ cmdFilter(input) {
116+ const t = input.value.toLowerCase().trim();
117+ const items = $$('#cmd-list > li');
118+ for (const li of items) li.hidden = !li.dataset.k.includes(t);
119+ const first = items.find((li) => !li.hidden);
120+ if (first) setCursor(first, items);
121+ },
122+ cmdKey(e) {
123+ const vis = $$('#cmd-list > li:not([hidden])');
124+ const i = vis.findIndex((li) => li.hasAttribute('data-cursor'));
125+ const step = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : 0;
126+ if (step) {
127+ e.preventDefault();
128+ const n = vis[Math.max(0, Math.min(vis.length - 1, i + step))];
129+ if (n) {
130+ setCursor(n, $$('#cmd-list > li'));
131+ n.scrollIntoView({ block: 'nearest' });
132+ }
133+ } else if (e.key === 'Enter') {
134+ e.preventDefault();
135+ vis[Math.max(i, 0)]?.querySelector('a')?.click();
136+ }
137+ },
138+
139+ // Diff.
140+ layout(mode) {
141+ const el = $('[data-diff]');
142+ if (!el) return;
143+ el.dataset.diff = mode;
144+ try { sessionStorage.setItem('diff', mode); } catch { /* storage refused: the layout holds for this page */ }
145+ for (const d of $$('[data-file]')) if ($('.dt', d)) d.open = true;
146+ },
147+ collapse() {
148+ const files = $$('[data-file]');
149+ const open = !files.some((d) => d.open);
150+ for (const d of files) d.open = open;
151+ },
152+ nextNote() {
153+ const notes = $$('#diffs .cmt');
154+ if (!notes.length) return;
155+ const i = notes.findIndex((n) => n.getBoundingClientRect().top > innerHeight / 3);
156+ const n = notes[i < 0 ? 0 : i];
157+ const d = n.closest('details');
158+ if (d) d.open = true;
159+ n.scrollIntoView({ block: 'center' });
160+ },
161+
162+ // y: pin the URL to the commit and copy it.
163+ async permalink() {
164+ const b = body().dataset;
165+ if (!b.sha) return flash('no ref to pin on this page');
166+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
167+ try {
168+ await navigator.clipboard.writeText(location.href);
169+ flash('permalink ' + b.sha.slice(0, 7) + ' copied');
170+ } catch {
171+ flash('permalink in the address bar (copy refused)');
172+ }
173+ },
174+
175+ // The [copy] button of a clone line: the label says "Copied" for 1.5 s,
176+ // and so does the accessible name; the hint badge (a status region)
177+ // announces it.
178+ async copy(btn) {
179+ const line = btn.closest('.clone-line')?.querySelector('code')?.textContent ?? '';
180+ btn.dataset.idleName ||= btn.getAttribute('aria-label') || '';
181+ let ok = true;
182+ try { await navigator.clipboard.writeText(line); } catch { ok = false; }
183+ btn.textContent = ok ? 'Copied' : 'Copy failed';
184+ btn.setAttribute('aria-label', (ok ? 'copied: ' : 'copy failed: ') + line);
185+ flash(ok ? 'copied' : 'copy failed', 1500);
186+ clearTimeout(btn._restore);
187+ btn._restore = setTimeout(() => {
188+ btn.textContent = btn.dataset.label;
189+ btn.setAttribute('aria-label', btn.dataset.idleName);
190+ }, 1500);
191+ },
192+ };
193+ window.ermite = ermite;
194+
195+ // 1. Keys.
6196 const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink',
7197 s: 's', t: 't', c: 'c', r: 'r', m: 'm', n: 'next', d: 'split', u: 'unified', x: 'collapse', Enter: 'open', Escape: 'esc' };
8- const goto = { h: 'gh', t: 'gt', c: 'gc', r: 'gr', m: 'gm', g: 'top' };
198+ // "g t" is the key "t": the tabs have one event each.
199+ const goto = { h: 'gh', t: 't', c: 'c', r: 'r', m: 'm', g: 'top' };
9200 let pending = 0;
10- const fire = (name) => document.body.dispatchEvent(new CustomEvent('k-' + name, { bubbles: false }));
11201 addEventListener('keydown', (e) => {
12202 const typing = e.target.closest('input, textarea, select, [contenteditable]');
13203 // ctrl k / cmd k opens the palette; in a field only cmd k, so that the
14204 // field keeps ctrl k (kill to end of line on macOS).
15- if ((e.metaKey || (e.ctrlKey && !typing)) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'k') { e.preventDefault(); pending = 0; return fire('cmd'); }
205+ if ((e.metaKey || (e.ctrlKey && !typing)) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'k') { e.preventDefault(); pending = 0; return ermite.fire('cmd'); }
16206 if (e.ctrlKey || e.metaKey || e.altKey) return;
17- if (e.key === 'Escape') { pending = 0; if (typing) e.target.blur(); return fire('esc'); }
207+ if (e.key === 'Escape') { pending = 0; if (typing) e.target.blur(); return ermite.fire('esc'); }
18208 if (typing) return;
19209 // An open dialog takes only the keys that move between dialogs: the page
20210 // behind it does not react (scripted clicks would go through inert).
21- if (document.querySelector('dialog[open]') && !['?', 'b', ':', 'f', 'Enter'].includes(e.key)) { pending = 0; return; }
211+ if ($('dialog[open]') && !['?', 'b', ':', 'f', 'Enter'].includes(e.key)) { pending = 0; return; }
22212 if (e.key === 'Enter' && e.target.closest('a, button, summary')) return;
23213 if (pending) {
24214 const name = goto[e.key];
25215 pending = 0;
26- if (name) { e.preventDefault(); fire(name); }
216+ if (name) { e.preventDefault(); ermite.fire(name); }
27217 return;
28218 }
29219 if (e.key === 'g') { pending = 1; setTimeout(() => (pending = 0), 1200); return; }
30220 const name = single[e.key];
31221 if (!name) return;
32- if (e.key !== 'Enter' || !document.querySelector('dialog[open]')) e.preventDefault();
33- fire(name);
222+ if (e.key !== 'Enter' || !$('dialog[open]')) e.preventDefault();
223+ ermite.fire(name);
224+ });
225+
226+ // 2. A [data-fire] element does what the key of that name does (the header
227+ // buttons, the diff layout links). The link to the refs page works without
228+ // scripts and is upgraded here to open the dialog; a click with a modifier
229+ // keeps its meaning (new tab).
230+ addEventListener('click', (e) => {
231+ const el = e.target.closest?.('[data-fire]');
232+ if (!el || e.button || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
233+ e.preventDefault();
234+ ermite.fire(el.dataset.fire);
34235 });
236+ for (const a of $$('a[data-fire="refs"]')) {
237+ a.setAttribute('aria-haspopup', 'dialog');
238+ a.setAttribute('aria-controls', 'panel-refs');
239+ }
240+
35241 // In the app shell the page does not scroll, <main> does: give it focus so arrows,
36242 // PageUp/PageDown and space scroll the content without a click first.
37243 if (!location.hash) document.getElementById('main')?.focus({ preventScroll: true });
244+
245+ // 4. The table of contents of a diff marks the file in view: the last file
246+ // whose top is above the first third of the window (the first file when none
247+ // is). One observer watches the files against that band; a file has passed
248+ // the line when it is in the band or wholly above it. It reports only when a
249+ // file enters or leaves the band, so a scroll costs nothing in between.
250+ const files = $$('.dfile');
251+ const links = new Map($$('.files a[href^="#"]').map((a) => [a.hash.slice(1), a]));
252+ if (files.length && links.size && 'IntersectionObserver' in window) {
253+ const passed = new Set();
254+ let current = null;
255+ const mark = () => {
256+ const f = files.findLast((x) => passed.has(x)) ?? files[0];
257+ if (f === current) return;
258+ links.get(current?.id)?.removeAttribute('aria-current');
259+ links.get(f.id)?.setAttribute('aria-current', 'true');
260+ current = f;
261+ };
262+ const io = new IntersectionObserver((entries) => {
263+ for (const e of entries) {
264+ const line = e.rootBounds ? e.rootBounds.bottom : innerHeight / 3;
265+ if (e.isIntersecting || e.boundingClientRect.top < line) passed.add(e.target);
266+ else passed.delete(e.target);
267+ }
268+ mark();
269+ }, { rootMargin: '0px 0px -67% 0px' });
270+ for (const f of files) io.observe(f);
271+ }
272+
273+ // 5. A failure of a handler or of a request (htmx logs it to the console
274+ // already) is shown, so that a key that does nothing is not a mystery.
275+ addEventListener('htmx:error', (e) => {
276+ if (e.target.closest?.('.hint-live')) return; // the stream says so itself ("live updates paused")
277+ const err = e.detail?.error;
278+ flash('something went wrong' + (err?.message ? ': ' + err.message : ''), 4000);
279+ });
38280 })();
39281
40282 // ermiteLive applies the fragments of the event stream (/-/events, see
41283 // internal/adapters/web/events.go). A fragment is a top-level element with an
42284 // id: it replaces the element of the page that has the same id. A fragment
43285 // marked data-gone removes it; a row of a repository the home page does not
44-// list yet is added at the top of the list. A gap, which says the page may
45-// have missed events or that what it shows changed, reloads the page.
286+// list yet is added at the top of the list. A slot (data-slot: the place of a
287+// banner, a persistent status region) keeps its element and only gets the
288+// children of the fragment, so that screen readers announce the change. A gap,
289+// which says the page may have missed events or that what it shows changed,
290+// reloads the page. A stream the server refuses on reconnection (the
291+// repository is gone, the session ended) makes the page reload.
46292 window.ermiteLive = (() => {
47293 let reloading = false;
294+ let stopped = false;
48295 return {
49296 swap(html) {
50297 const t = document.createElement('template');
@@ -57,6 +304,7 @@ window.ermiteLive = (() => {
57304 if (rows && el.id.startsWith('repo-')) { rows.prepend(el); window.htmx?.process(el); }
58305 continue;
59306 }
307+ if (old.hasAttribute('data-slot')) { old.replaceChildren(...el.childNodes); continue; }
60308 if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', '');
61309 if (old.hidden && old.tagName === 'TR') el.hidden = true;
62310 old.replaceWith(el);
@@ -68,5 +316,22 @@ window.ermiteLive = (() => {
68316 reloading = true;
69317 setTimeout(() => location.reload(), 300);
70318 },
319+ // error handles htmx:sse:error (detail.status is the status of a refused
320+ // reconnection). hx-sse retries for ever; a 404 or a 401 will not change by
321+ // itself, and the fetched page says what is the matter (or has no stream).
322+ // A page that reloaded for this a moment ago and still gets refused would
323+ // loop: it stops following the stream instead (see gate).
324+ error(status) {
325+ if (status !== 404 && status !== 401) return;
326+ let last = 0;
327+ try { last = +sessionStorage.getItem('ermite-live-reload') || 0; } catch { /* no storage: no guard */ }
328+ if (Date.now() - last < 15000) { stopped = true; return; }
329+ try { sessionStorage.setItem('ermite-live-reload', String(Date.now())); } catch { /* idem */ }
330+ this.reload();
331+ },
332+ // gate is htmx:sse:before:connection: once stopped, no more attempts.
333+ gate(event) {
334+ if (stopped) event.preventDefault();
335+ },
71336 };
72337 })();
M internal/adapters/web/tree.templ +2 -2
@@ -11,10 +11,10 @@ templ Tree(l Layout, p TreePage) {
1111 <input
1212 id="tree-filter"
1313 type="search"
14- data-filter
14+ data-filter="#tree-rows > tr"
1515 placeholder="Filter this directory…"
1616 autocomplete="off"
17- hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')"
17+ hx-on:input="ermite.filter(this)"
1818 />
1919 </div>
2020 <table class="list tree" aria-label="Entries">
M internal/adapters/web/tree_templ.go +1 -1 generated file: collapsed

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 44

↑↓ moveenter openesc close