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 generated file: collapsed
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
@@ -48,30 +48,30 @@ func MRList(l Layout, p MRListPage) templ.Component {
4848 }
4949 ctx = templ.InitializeContext(ctx)
5050 if p.Live {
51- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"mr-notice\"></div>")
51+ templ_7745c5c3_Err = LiveSlot("mr-notice").Render(ctx, templ_7745c5c3_Buffer)
5252 if templ_7745c5c3_Err != nil {
5353 return templ_7745c5c3_Err
5454 }
5555 }
56- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " <div class=\"filter\"><label for=\"mr-filter\" class=\"sr\">filter</label> <input id=\"mr-filter\" type=\"search\" data-filter placeholder=\"Filter by title, branch, author or state…\" autocomplete=\"off\" hx-on:input=\"let t = this.value.toLowerCase(); q('.mrs tbody tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t));\n let first = q('.mrs tbody tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.mrs tbody tr')\"></div>")
56+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, " <div class=\"filter\"><label for=\"mr-filter\" class=\"sr\">filter</label> <input id=\"mr-filter\" type=\"search\" data-filter=\".mrs tbody tr\" placeholder=\"Filter by title, branch, author or state…\" autocomplete=\"off\" hx-on:input=\"ermite.filter(this)\"></div>")
5757 if templ_7745c5c3_Err != nil {
5858 return templ_7745c5c3_Err
5959 }
6060 if p.Newest == "" {
61- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<h2 class=\"rule-h\"><span>Open</span><span class=\"count\">")
61+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<h2 class=\"rule-h\"><span>Open</span><span class=\"count\">")
6262 if templ_7745c5c3_Err != nil {
6363 return templ_7745c5c3_Err
6464 }
6565 var templ_7745c5c3_Var3 string
6666 templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Open)))
6767 if templ_7745c5c3_Err != nil {
68- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 27, Col: 78}
68+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 26, Col: 78}
6969 }
7070 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
7171 if templ_7745c5c3_Err != nil {
7272 return templ_7745c5c3_Err
7373 }
74- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</span></h2>")
74+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span></h2>")
7575 if templ_7745c5c3_Err != nil {
7676 return templ_7745c5c3_Err
7777 }
@@ -79,54 +79,54 @@ func MRList(l Layout, p MRListPage) templ.Component {
7979 if templ_7745c5c3_Err != nil {
8080 return templ_7745c5c3_Err
8181 }
82- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, " ")
82+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " ")
8383 if templ_7745c5c3_Err != nil {
8484 return templ_7745c5c3_Err
8585 }
8686 if len(p.Open) == 0 {
87- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<p class=\"lede dim\">no open merge requests.</p>")
87+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<p class=\"lede dim\">no open merge requests.</p>")
8888 if templ_7745c5c3_Err != nil {
8989 return templ_7745c5c3_Err
9090 }
9191 }
92- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, " ")
92+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " ")
9393 if templ_7745c5c3_Err != nil {
9494 return templ_7745c5c3_Err
9595 }
9696 if p.OpenNote != "" {
97- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<p class=\"note\">")
97+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<p class=\"note\">")
9898 if templ_7745c5c3_Err != nil {
9999 return templ_7745c5c3_Err
100100 }
101101 var templ_7745c5c3_Var4 string
102102 templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(p.OpenNote)
103103 if templ_7745c5c3_Err != nil {
104- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 33, Col: 32}
104+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 32, Col: 32}
105105 }
106106 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
107107 if templ_7745c5c3_Err != nil {
108108 return templ_7745c5c3_Err
109109 }
110- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</p>")
110+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</p>")
111111 if templ_7745c5c3_Err != nil {
112112 return templ_7745c5c3_Err
113113 }
114114 }
115115 }
116- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " <h2 class=\"rule-h\"><span>Merged and closed</span><span class=\"count\">")
116+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " <h2 class=\"rule-h\"><span>Merged and closed</span><span class=\"count\">")
117117 if templ_7745c5c3_Err != nil {
118118 return templ_7745c5c3_Err
119119 }
120120 var templ_7745c5c3_Var5 string
121121 templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Done)))
122122 if templ_7745c5c3_Err != nil {
123- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 36, Col: 90}
123+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 35, Col: 90}
124124 }
125125 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
126126 if templ_7745c5c3_Err != nil {
127127 return templ_7745c5c3_Err
128128 }
129- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</span></h2>")
129+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</span></h2>")
130130 if templ_7745c5c3_Err != nil {
131131 return templ_7745c5c3_Err
132132 }
@@ -134,74 +134,74 @@ func MRList(l Layout, p MRListPage) templ.Component {
134134 if templ_7745c5c3_Err != nil {
135135 return templ_7745c5c3_Err
136136 }
137- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " ")
137+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, " ")
138138 if templ_7745c5c3_Err != nil {
139139 return templ_7745c5c3_Err
140140 }
141141 if len(p.Done) == 0 {
142- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<p class=\"lede dim\">nothing merged or closed yet.</p>")
142+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<p class=\"lede dim\">nothing merged or closed yet.</p>")
143143 if templ_7745c5c3_Err != nil {
144144 return templ_7745c5c3_Err
145145 }
146146 }
147- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, " ")
147+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " ")
148148 if templ_7745c5c3_Err != nil {
149149 return templ_7745c5c3_Err
150150 }
151151 if p.Older != "" || p.Newest != "" {
152- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<nav class=\"pager\" aria-label=\"Pages\">")
152+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<nav class=\"pager\" aria-label=\"Pages\">")
153153 if templ_7745c5c3_Err != nil {
154154 return templ_7745c5c3_Err
155155 }
156156 if p.Newest != "" {
157- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<a class=\"btn\" href=\"")
157+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<a class=\"btn\" href=\"")
158158 if templ_7745c5c3_Err != nil {
159159 return templ_7745c5c3_Err
160160 }
161161 var templ_7745c5c3_Var6 templ.SafeURL
162162 templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Newest))
163163 if templ_7745c5c3_Err != nil {
164- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 44, Col: 50}
164+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 43, Col: 50}
165165 }
166166 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
167167 if templ_7745c5c3_Err != nil {
168168 return templ_7745c5c3_Err
169169 }
170- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\">← Newest</a> ")
170+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\">← Newest</a> ")
171171 if templ_7745c5c3_Err != nil {
172172 return templ_7745c5c3_Err
173173 }
174174 } else {
175- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<span class=\"btn\" aria-disabled=\"true\">← Newest</span> ")
175+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<span class=\"btn\" aria-disabled=\"true\">← Newest</span> ")
176176 if templ_7745c5c3_Err != nil {
177177 return templ_7745c5c3_Err
178178 }
179179 }
180180 if p.Older != "" {
181- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<a class=\"btn\" href=\"")
181+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<a class=\"btn\" href=\"")
182182 if templ_7745c5c3_Err != nil {
183183 return templ_7745c5c3_Err
184184 }
185185 var templ_7745c5c3_Var7 templ.SafeURL
186186 templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Older))
187187 if templ_7745c5c3_Err != nil {
188- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 49, Col: 49}
188+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 48, Col: 49}
189189 }
190190 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
191191 if templ_7745c5c3_Err != nil {
192192 return templ_7745c5c3_Err
193193 }
194- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" rel=\"next\">Older →</a>")
194+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" rel=\"next\">Older →</a>")
195195 if templ_7745c5c3_Err != nil {
196196 return templ_7745c5c3_Err
197197 }
198198 } else {
199- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<span class=\"btn\" aria-disabled=\"true\">Older →</span>")
199+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<span class=\"btn\" aria-disabled=\"true\">Older →</span>")
200200 if templ_7745c5c3_Err != nil {
201201 return templ_7745c5c3_Err
202202 }
203203 }
204- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</nav>")
204+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</nav>")
205205 if templ_7745c5c3_Err != nil {
206206 return templ_7745c5c3_Err
207207 }
@@ -238,180 +238,180 @@ func mrRows(rows []MRRow, label string) templ.Component {
238238 }
239239 ctx = templ.ClearChildren(ctx)
240240 if len(rows) > 0 {
241- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<table class=\"list mrs\" aria-label=\"")
241+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<table class=\"list mrs\" aria-label=\"")
242242 if templ_7745c5c3_Err != nil {
243243 return templ_7745c5c3_Err
244244 }
245245 var templ_7745c5c3_Var9 string
246246 templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(label)
247247 if templ_7745c5c3_Err != nil {
248- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 60, Col: 44}
248+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 59, Col: 44}
249249 }
250250 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
251251 if templ_7745c5c3_Err != nil {
252252 return templ_7745c5c3_Err
253253 }
254- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\"><tbody data-rows>")
254+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\"><tbody data-rows>")
255255 if templ_7745c5c3_Err != nil {
256256 return templ_7745c5c3_Err
257257 }
258258 for _, m := range rows {
259- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<tr")
259+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<tr")
260260 if templ_7745c5c3_Err != nil {
261261 return templ_7745c5c3_Err
262262 }
263263 if m.Cursor {
264- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " data-cursor")
264+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " data-cursor")
265265 if templ_7745c5c3_Err != nil {
266266 return templ_7745c5c3_Err
267267 }
268268 }
269- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, " data-state=\"")
269+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " data-state=\"")
270270 if templ_7745c5c3_Err != nil {
271271 return templ_7745c5c3_Err
272272 }
273273 var templ_7745c5c3_Var10 string
274274 templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.State)
275275 if templ_7745c5c3_Err != nil {
276- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 63, Col: 55}
276+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 62, Col: 55}
277277 }
278278 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
279279 if templ_7745c5c3_Err != nil {
280280 return templ_7745c5c3_Err
281281 }
282- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\"><td class=\"cur\" aria-hidden=\"true\"></td><td class=\"c-n mute\">")
282+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\"><td class=\"cur\" aria-hidden=\"true\"></td><td class=\"c-n mute\">")
283283 if templ_7745c5c3_Err != nil {
284284 return templ_7745c5c3_Err
285285 }
286286 var templ_7745c5c3_Var11 string
287287 templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(m.Number)
288288 if templ_7745c5c3_Err != nil {
289- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 65, Col: 37}
289+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 64, Col: 37}
290290 }
291291 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
292292 if templ_7745c5c3_Err != nil {
293293 return templ_7745c5c3_Err
294294 }
295- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</td><td class=\"c-state\">")
295+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</td><td class=\"c-state\">")
296296 if templ_7745c5c3_Err != nil {
297297 return templ_7745c5c3_Err
298298 }
299299 var templ_7745c5c3_Var12 string
300300 templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(m.State)
301301 if templ_7745c5c3_Err != nil {
302- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 66, Col: 35}
302+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 65, Col: 35}
303303 }
304304 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
305305 if templ_7745c5c3_Err != nil {
306306 return templ_7745c5c3_Err
307307 }
308- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</td><td class=\"c-subject\"><a href=\"")
308+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</td><td class=\"c-subject\"><a href=\"")
309309 if templ_7745c5c3_Err != nil {
310310 return templ_7745c5c3_Err
311311 }
312312 var templ_7745c5c3_Var13 templ.SafeURL
313313 templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(m.Href))
314314 if templ_7745c5c3_Err != nil {
315- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 67, Col: 59}
315+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 66, Col: 59}
316316 }
317317 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
318318 if templ_7745c5c3_Err != nil {
319319 return templ_7745c5c3_Err
320320 }
321- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\">")
321+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\">")
322322 if templ_7745c5c3_Err != nil {
323323 return templ_7745c5c3_Err
324324 }
325325 var templ_7745c5c3_Var14 string
326326 templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(m.Title)
327327 if templ_7745c5c3_Err != nil {
328- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 67, Col: 71}
328+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 66, Col: 71}
329329 }
330330 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
331331 if templ_7745c5c3_Err != nil {
332332 return templ_7745c5c3_Err
333333 }
334- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</a> <span class=\"mute\">")
334+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</a> <span class=\"mute\">")
335335 if templ_7745c5c3_Err != nil {
336336 return templ_7745c5c3_Err
337337 }
338338 var templ_7745c5c3_Var15 string
339339 templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(m.Branches)
340340 if templ_7745c5c3_Err != nil {
341- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 67, Col: 109}
341+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 66, Col: 109}
342342 }
343343 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
344344 if templ_7745c5c3_Err != nil {
345345 return templ_7745c5c3_Err
346346 }
347- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</span></td><td class=\"c-who dim\">")
347+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</span></td><td class=\"c-who dim\">")
348348 if templ_7745c5c3_Err != nil {
349349 return templ_7745c5c3_Err
350350 }
351351 var templ_7745c5c3_Var16 string
352352 templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(m.Author)
353353 if templ_7745c5c3_Err != nil {
354- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 68, Col: 38}
354+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 67, Col: 38}
355355 }
356356 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
357357 if templ_7745c5c3_Err != nil {
358358 return templ_7745c5c3_Err
359359 }
360- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</td><td class=\"c-when nowrap dim\">")
360+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</td><td class=\"c-when nowrap dim\">")
361361 if templ_7745c5c3_Err != nil {
362362 return templ_7745c5c3_Err
363363 }
364364 if m.WhenAt != "" {
365- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<time datetime=\"")
365+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<time datetime=\"")
366366 if templ_7745c5c3_Err != nil {
367367 return templ_7745c5c3_Err
368368 }
369369 var templ_7745c5c3_Var17 string
370370 templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.WhenAt)
371371 if templ_7745c5c3_Err != nil {
372- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 71, Col: 33}
372+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 70, Col: 33}
373373 }
374374 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
375375 if templ_7745c5c3_Err != nil {
376376 return templ_7745c5c3_Err
377377 }
378- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\" title=\"")
378+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" title=\"")
379379 if templ_7745c5c3_Err != nil {
380380 return templ_7745c5c3_Err
381381 }
382382 var templ_7745c5c3_Var18 string
383383 templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.WhenAt)
384384 if templ_7745c5c3_Err != nil {
385- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 71, Col: 52}
385+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 70, Col: 52}
386386 }
387387 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
388388 if templ_7745c5c3_Err != nil {
389389 return templ_7745c5c3_Err
390390 }
391- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\">")
391+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\">")
392392 if templ_7745c5c3_Err != nil {
393393 return templ_7745c5c3_Err
394394 }
395395 var templ_7745c5c3_Var19 string
396396 templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(m.When)
397397 if templ_7745c5c3_Err != nil {
398- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 71, Col: 63}
398+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 70, Col: 63}
399399 }
400400 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
401401 if templ_7745c5c3_Err != nil {
402402 return templ_7745c5c3_Err
403403 }
404- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</time>")
404+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</time>")
405405 if templ_7745c5c3_Err != nil {
406406 return templ_7745c5c3_Err
407407 }
408408 }
409- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</td></tr>")
409+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</td></tr>")
410410 if templ_7745c5c3_Err != nil {
411411 return templ_7745c5c3_Err
412412 }
413413 }
414- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</tbody></table>")
414+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</tbody></table>")
415415 if templ_7745c5c3_Err != nil {
416416 return templ_7745c5c3_Err
417417 }
@@ -441,66 +441,66 @@ func mrListRail(p MRListPage) templ.Component {
441441 templ_7745c5c3_Var20 = templ.NopComponent
442442 }
443443 ctx = templ.ClearChildren(ctx)
444- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<section aria-labelledby=\"r-states\"><h2 id=\"r-states\">States</h2><dl class=\"kv\"><dt>open</dt><dd>awaits review</dd><dt>merged</dt><dd>the target took the branch</dd><dt>closed</dt><dd>refused, branch kept</dd></dl></section><section aria-labelledby=\"r-new\"><h2 id=\"r-new\">Open one</h2>")
444+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<section aria-labelledby=\"r-states\"><h2 id=\"r-states\">States</h2><dl class=\"kv\"><dt>open</dt><dd>awaits review</dd><dt>merged</dt><dd>the target took the branch</dd><dt>closed</dt><dd>refused, branch kept</dd></dl></section><section aria-labelledby=\"r-new\"><h2 id=\"r-new\">Open one</h2>")
445445 if templ_7745c5c3_Err != nil {
446446 return templ_7745c5c3_Err
447447 }
448448 if len(p.Branches) > 0 {
449- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<p class=\"dim\">compare a branch with ")
449+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<p class=\"dim\">compare a branch with ")
450450 if templ_7745c5c3_Err != nil {
451451 return templ_7745c5c3_Err
452452 }
453453 var templ_7745c5c3_Var21 string
454454 templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(p.Default)
455455 if templ_7745c5c3_Err != nil {
456- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 96, Col: 51}
456+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 95, Col: 51}
457457 }
458458 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
459459 if templ_7745c5c3_Err != nil {
460460 return templ_7745c5c3_Err
461461 }
462- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, ":</p><nav class=\"outline\" aria-label=\"Branches\">")
462+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, ":</p><nav class=\"outline\" aria-label=\"Branches\">")
463463 if templ_7745c5c3_Err != nil {
464464 return templ_7745c5c3_Err
465465 }
466466 for _, b := range p.Branches {
467- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<a href=\"")
467+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<a href=\"")
468468 if templ_7745c5c3_Err != nil {
469469 return templ_7745c5c3_Err
470470 }
471471 var templ_7745c5c3_Var22 templ.SafeURL
472472 templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(b.Href))
473473 if templ_7745c5c3_Err != nil {
474- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 99, Col: 36}
474+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 98, Col: 36}
475475 }
476476 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
477477 if templ_7745c5c3_Err != nil {
478478 return templ_7745c5c3_Err
479479 }
480- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\">")
480+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\">")
481481 if templ_7745c5c3_Err != nil {
482482 return templ_7745c5c3_Err
483483 }
484484 var templ_7745c5c3_Var23 string
485485 templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(b.Name)
486486 if templ_7745c5c3_Err != nil {
487- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 99, Col: 47}
487+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 98, Col: 47}
488488 }
489489 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
490490 if templ_7745c5c3_Err != nil {
491491 return templ_7745c5c3_Err
492492 }
493- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</a>")
493+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</a>")
494494 if templ_7745c5c3_Err != nil {
495495 return templ_7745c5c3_Err
496496 }
497497 }
498- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</nav>")
498+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</nav>")
499499 if templ_7745c5c3_Err != nil {
500500 return templ_7745c5c3_Err
501501 }
502502 } else {
503- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<p class=\"dim\">there is no other branch to merge yet.</p>")
503+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<p class=\"dim\">there is no other branch to merge yet.</p>")
504504 if templ_7745c5c3_Err != nil {
505505 return templ_7745c5c3_Err
506506 }
@@ -509,7 +509,7 @@ func mrListRail(p MRListPage) templ.Component {
509509 if templ_7745c5c3_Err != nil {
510510 return templ_7745c5c3_Err
511511 }
512- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</section>")
512+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</section>")
513513 if templ_7745c5c3_Err != nil {
514514 return templ_7745c5c3_Err
515515 }
@@ -553,59 +553,59 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
553553 }()
554554 }
555555 ctx = templ.InitializeContext(ctx)
556- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<header class=\"show mr-h\"><h1><span class=\"mute\">")
556+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<header class=\"show mr-h\"><h1><span class=\"mute\">")
557557 if templ_7745c5c3_Err != nil {
558558 return templ_7745c5c3_Err
559559 }
560560 var templ_7745c5c3_Var26 string
561561 templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(p.Number)
562562 if templ_7745c5c3_Err != nil {
563- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 115, Col: 36}
563+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 114, Col: 36}
564564 }
565565 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
566566 if templ_7745c5c3_Err != nil {
567567 return templ_7745c5c3_Err
568568 }
569- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</span> ")
569+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</span> ")
570570 if templ_7745c5c3_Err != nil {
571571 return templ_7745c5c3_Err
572572 }
573573 var templ_7745c5c3_Var27 string
574574 templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
575575 if templ_7745c5c3_Err != nil {
576- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 115, Col: 55}
576+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 114, Col: 55}
577577 }
578578 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
579579 if templ_7745c5c3_Err != nil {
580580 return templ_7745c5c3_Err
581581 }
582- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</h1><dl class=\"kv show-kv\"><dt>state</dt><dd class=\"mr-state\" data-state=\"")
582+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</h1><dl class=\"kv show-kv\"><dt>state</dt><dd class=\"mr-state\" data-state=\"")
583583 if templ_7745c5c3_Err != nil {
584584 return templ_7745c5c3_Err
585585 }
586586 var templ_7745c5c3_Var28 string
587587 templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.State)
588588 if templ_7745c5c3_Err != nil {
589- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 118, Col: 45}
589+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 117, Col: 45}
590590 }
591591 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
592592 if templ_7745c5c3_Err != nil {
593593 return templ_7745c5c3_Err
594594 }
595- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\">")
595+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\">")
596596 if templ_7745c5c3_Err != nil {
597597 return templ_7745c5c3_Err
598598 }
599599 var templ_7745c5c3_Var29 string
600600 templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(p.State)
601601 if templ_7745c5c3_Err != nil {
602- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 119, Col: 14}
602+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 118, Col: 14}
603603 }
604604 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
605605 if templ_7745c5c3_Err != nil {
606606 return templ_7745c5c3_Err
607607 }
608- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " ")
608+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, " ")
609609 if templ_7745c5c3_Err != nil {
610610 return templ_7745c5c3_Err
611611 }
@@ -613,53 +613,53 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
613613 var templ_7745c5c3_Var30 string
614614 templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
615615 if templ_7745c5c3_Err != nil {
616- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 121, Col: 14}
616+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 120, Col: 14}
617617 }
618618 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
619619 if templ_7745c5c3_Err != nil {
620620 return templ_7745c5c3_Err
621621 }
622- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<a href=\"")
622+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<a href=\"")
623623 if templ_7745c5c3_Err != nil {
624624 return templ_7745c5c3_Err
625625 }
626626 var templ_7745c5c3_Var31 templ.SafeURL
627627 templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.MergedHref))
628628 if templ_7745c5c3_Err != nil {
629- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 121, Col: 53}
629+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 120, Col: 53}
630630 }
631631 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
632632 if templ_7745c5c3_Err != nil {
633633 return templ_7745c5c3_Err
634634 }
635- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\">")
635+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\">")
636636 if templ_7745c5c3_Err != nil {
637637 return templ_7745c5c3_Err
638638 }
639639 var templ_7745c5c3_Var32 string
640640 templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(p.MergedShort)
641641 if templ_7745c5c3_Err != nil {
642- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 121, Col: 71}
642+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 120, Col: 71}
643643 }
644644 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
645645 if templ_7745c5c3_Err != nil {
646646 return templ_7745c5c3_Err
647647 }
648- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</a>")
648+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</a>")
649649 if templ_7745c5c3_Err != nil {
650650 return templ_7745c5c3_Err
651651 }
652652 var templ_7745c5c3_Var33 string
653653 templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(" · " + p.MergeMode)
654654 if templ_7745c5c3_Err != nil {
655- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 121, Col: 99}
655+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 120, Col: 99}
656656 }
657657 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
658658 if templ_7745c5c3_Err != nil {
659659 return templ_7745c5c3_Err
660660 }
661661 }
662- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</dd><dt>branch</dt><dd>")
662+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</dd><dt>branch</dt><dd>")
663663 if templ_7745c5c3_Err != nil {
664664 return templ_7745c5c3_Err
665665 }
@@ -670,7 +670,7 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
670670 var templ_7745c5c3_Var34 string
671671 templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(" → ")
672672 if templ_7745c5c3_Err != nil {
673- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 127, Col: 14}
673+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 126, Col: 14}
674674 }
675675 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
676676 if templ_7745c5c3_Err != nil {
@@ -680,20 +680,20 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
680680 if templ_7745c5c3_Err != nil {
681681 return templ_7745c5c3_Err
682682 }
683- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</dd><dt>opened</dt><dd>")
683+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</dd><dt>opened</dt><dd>")
684684 if templ_7745c5c3_Err != nil {
685685 return templ_7745c5c3_Err
686686 }
687687 var templ_7745c5c3_Var35 string
688688 templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(p.Opened.Name)
689689 if templ_7745c5c3_Err != nil {
690- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 132, Col: 20}
690+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 131, Col: 20}
691691 }
692692 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
693693 if templ_7745c5c3_Err != nil {
694694 return templ_7745c5c3_Err
695695 }
696- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, " ")
696+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, " ")
697697 if templ_7745c5c3_Err != nil {
698698 return templ_7745c5c3_Err
699699 }
@@ -701,93 +701,101 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
701701 var templ_7745c5c3_Var36 string
702702 templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
703703 if templ_7745c5c3_Err != nil {
704- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 134, Col: 14}
704+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 133, Col: 14}
705705 }
706706 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
707707 if templ_7745c5c3_Err != nil {
708708 return templ_7745c5c3_Err
709709 }
710- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<time datetime=\"")
710+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<time datetime=\"")
711711 if templ_7745c5c3_Err != nil {
712712 return templ_7745c5c3_Err
713713 }
714714 var templ_7745c5c3_Var37 string
715715 templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Opened.WhenAt)
716716 if templ_7745c5c3_Err != nil {
717- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 134, Col: 48}
717+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 133, Col: 48}
718718 }
719719 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
720720 if templ_7745c5c3_Err != nil {
721721 return templ_7745c5c3_Err
722722 }
723- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\" title=\"")
723+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "\" title=\"")
724724 if templ_7745c5c3_Err != nil {
725725 return templ_7745c5c3_Err
726726 }
727727 var templ_7745c5c3_Var38 string
728728 templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Opened.Abs)
729729 if templ_7745c5c3_Err != nil {
730- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 134, Col: 71}
730+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 133, Col: 71}
731731 }
732732 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
733733 if templ_7745c5c3_Err != nil {
734734 return templ_7745c5c3_Err
735735 }
736- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\">")
736+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\">")
737737 if templ_7745c5c3_Err != nil {
738738 return templ_7745c5c3_Err
739739 }
740740 var templ_7745c5c3_Var39 string
741741 templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(p.Opened.When)
742742 if templ_7745c5c3_Err != nil {
743- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 134, Col: 89}
743+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 133, Col: 89}
744744 }
745745 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
746746 if templ_7745c5c3_Err != nil {
747747 return templ_7745c5c3_Err
748748 }
749- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</time>")
749+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</time>")
750750 if templ_7745c5c3_Err != nil {
751751 return templ_7745c5c3_Err
752752 }
753753 }
754- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</dd>")
754+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</dd>")
755755 if templ_7745c5c3_Err != nil {
756756 return templ_7745c5c3_Err
757757 }
758758 if p.UpdatedAt != "" {
759- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<dt>updated</dt><dd><time datetime=\"")
759+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<dt>updated</dt><dd><time datetime=\"")
760760 if templ_7745c5c3_Err != nil {
761761 return templ_7745c5c3_Err
762762 }
763763 var templ_7745c5c3_Var40 string
764764 templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.UpdatedAt)
765765 if templ_7745c5c3_Err != nil {
766- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 139, Col: 37}
766+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 138, Col: 37}
767767 }
768768 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var40)
769769 if templ_7745c5c3_Err != nil {
770770 return templ_7745c5c3_Err
771771 }
772- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\">")
772+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\">")
773773 if templ_7745c5c3_Err != nil {
774774 return templ_7745c5c3_Err
775775 }
776776 var templ_7745c5c3_Var41 string
777777 templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(p.Updated)
778778 if templ_7745c5c3_Err != nil {
779- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 139, Col: 51}
779+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 138, Col: 51}
780780 }
781781 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
782782 if templ_7745c5c3_Err != nil {
783783 return templ_7745c5c3_Err
784784 }
785- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</time></dd>")
785+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</time></dd>")
786786 if templ_7745c5c3_Err != nil {
787787 return templ_7745c5c3_Err
788788 }
789789 }
790- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</dl></header><div id=\"mr-notice\"></div>")
790+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</dl></header>")
791+ if templ_7745c5c3_Err != nil {
792+ return templ_7745c5c3_Err
793+ }
794+ templ_7745c5c3_Err = LiveSlot("mr-notice").Render(ctx, templ_7745c5c3_Buffer)
795+ if templ_7745c5c3_Err != nil {
796+ return templ_7745c5c3_Err
797+ }
798+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, " ")
791799 if templ_7745c5c3_Err != nil {
792800 return templ_7745c5c3_Err
793801 }
@@ -817,7 +825,7 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
817825 var templ_7745c5c3_Var42 templ.SafeURL
818826 templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Since.AllHref))
819827 if templ_7745c5c3_Err != nil {
820- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 150, Col: 42}
828+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 149, Col: 42}
821829 }
822830 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
823831 if templ_7745c5c3_Err != nil {
@@ -840,7 +848,7 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
840848 var templ_7745c5c3_Var43 string
841849 templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
842850 if templ_7745c5c3_Err != nil {
843- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 154, Col: 49}
851+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 153, Col: 49}
844852 }
845853 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
846854 if templ_7745c5c3_Err != nil {
@@ -853,7 +861,7 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
853861 var templ_7745c5c3_Var44 templ.SafeURL
854862 templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Since.SinceHref))
855863 if templ_7745c5c3_Err != nil {
856- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 156, Col: 44}
864+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 155, Col: 44}
857865 }
858866 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
859867 if templ_7745c5c3_Err != nil {
@@ -876,7 +884,7 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
876884 var templ_7745c5c3_Var45 string
877885 templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(p.Since.ReadShort)
878886 if templ_7745c5c3_Err != nil {
879- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 160, Col: 64}
887+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 159, Col: 64}
880888 }
881889 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
882890 if templ_7745c5c3_Err != nil {
@@ -889,7 +897,7 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
889897 var templ_7745c5c3_Var46 string
890898 templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(p.Since.HeadShort)
891899 if templ_7745c5c3_Err != nil {
892- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 160, Col: 90}
900+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 159, Col: 90}
893901 }
894902 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
895903 if templ_7745c5c3_Err != nil {
@@ -912,7 +920,7 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
912920 var templ_7745c5c3_Var47 string
913921 templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(p.SinceNote)
914922 if templ_7745c5c3_Err != nil {
915- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 164, Col: 34}
923+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 163, Col: 34}
916924 }
917925 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
918926 if templ_7745c5c3_Err != nil {
@@ -930,7 +938,7 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
930938 var templ_7745c5c3_Var48 string
931939 templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(plural(p.NFiles, "file", "files"))
932940 if templ_7745c5c3_Err != nil {
933- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 167, Col: 56}
941+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 166, Col: 56}
934942 }
935943 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
936944 if templ_7745c5c3_Err != nil {
@@ -943,7 +951,7 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
943951 var templ_7745c5c3_Var49 string
944952 templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Adds))
945953 if templ_7745c5c3_Err != nil {
946- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 167, Col: 96}
954+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 166, Col: 96}
947955 }
948956 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
949957 if templ_7745c5c3_Err != nil {
@@ -956,7 +964,7 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
956964 var templ_7745c5c3_Var50 string
957965 templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Dels))
958966 if templ_7745c5c3_Err != nil {
959- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 167, Col: 141}
967+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 166, Col: 141}
960968 }
961969 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
962970 if templ_7745c5c3_Err != nil {
@@ -1000,7 +1008,7 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
10001008 var templ_7745c5c3_Var51 string
10011009 templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)
10021010 if templ_7745c5c3_Err != nil {
1003- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 180, Col: 29}
1011+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 179, Col: 29}
10041012 }
10051013 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
10061014 if templ_7745c5c3_Err != nil {
@@ -1023,7 +1031,7 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
10231031 var templ_7745c5c3_Var52 string
10241032 templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
10251033 if templ_7745c5c3_Err != nil {
1026- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 185, Col: 22}
1034+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 184, Col: 22}
10271035 }
10281036 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var52)
10291037 if templ_7745c5c3_Err != nil {
@@ -1041,7 +1049,7 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
10411049 var templ_7745c5c3_Var53 string
10421050 templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
10431051 if templ_7745c5c3_Err != nil {
1044- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 187, Col: 22}
1052+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 186, Col: 22}
10451053 }
10461054 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var53)
10471055 if templ_7745c5c3_Err != nil {
@@ -1081,7 +1089,7 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
10811089 var templ_7745c5c3_Var54 string
10821090 templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.NUnresolved))
10831091 if templ_7745c5c3_Err != nil {
1084- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 200, Col: 97}
1092+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 199, Col: 97}
10851093 }
10861094 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
10871095 if templ_7745c5c3_Err != nil {
@@ -1094,7 +1102,7 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
10941102 var templ_7745c5c3_Var55 string
10951103 templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.NComments))
10961104 if templ_7745c5c3_Err != nil {
1097- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 200, Col: 126}
1105+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 199, Col: 126}
10981106 }
10991107 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
11001108 if templ_7745c5c3_Err != nil {
@@ -1175,7 +1183,7 @@ func branchLink(name, href string) templ.Component {
11751183 var templ_7745c5c3_Var57 templ.SafeURL
11761184 templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(href))
11771185 if templ_7745c5c3_Err != nil {
1178- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 218, Col: 31}
1186+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 217, Col: 31}
11791187 }
11801188 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))
11811189 if templ_7745c5c3_Err != nil {
@@ -1188,7 +1196,7 @@ func branchLink(name, href string) templ.Component {
11881196 var templ_7745c5c3_Var58 string
11891197 templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(name)
11901198 if templ_7745c5c3_Err != nil {
1191- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 218, Col: 40}
1199+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 217, Col: 40}
11921200 }
11931201 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58))
11941202 if templ_7745c5c3_Err != nil {
@@ -1202,7 +1210,7 @@ func branchLink(name, href string) templ.Component {
12021210 var templ_7745c5c3_Var59 string
12031211 templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(name)
12041212 if templ_7745c5c3_Err != nil {
1205- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 220, Col: 8}
1213+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 219, Col: 8}
12061214 }
12071215 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59))
12081216 if templ_7745c5c3_Err != nil {
@@ -1284,7 +1292,7 @@ func commentView(c CommentView, suffix string) templ.Component {
12841292 var templ_7745c5c3_Var62 string
12851293 templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.ResolveAttributeValue("c" + c.ID + suffix)
12861294 if templ_7745c5c3_Err != nil {
1287- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 240, Col: 42}
1295+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 239, Col: 42}
12881296 }
12891297 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var62)
12901298 if templ_7745c5c3_Err != nil {
@@ -1297,7 +1305,7 @@ func commentView(c CommentView, suffix string) templ.Component {
12971305 var templ_7745c5c3_Var63 string
12981306 templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.ResolveAttributeValue(boolText(c.Resolved))
12991307 if templ_7745c5c3_Err != nil {
1300- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 240, Col: 81}
1308+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 239, Col: 81}
13011309 }
13021310 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var63)
13031311 if templ_7745c5c3_Err != nil {
@@ -1310,7 +1318,7 @@ func commentView(c CommentView, suffix string) templ.Component {
13101318 var templ_7745c5c3_Var64 string
13111319 templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
13121320 if templ_7745c5c3_Err != nil {
1313- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 242, Col: 33}
1321+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 241, Col: 33}
13141322 }
13151323 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64))
13161324 if templ_7745c5c3_Err != nil {
@@ -1324,7 +1332,7 @@ func commentView(c CommentView, suffix string) templ.Component {
13241332 var templ_7745c5c3_Var65 string
13251333 templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
13261334 if templ_7745c5c3_Err != nil {
1327- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 244, Col: 9}
1335+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 243, Col: 9}
13281336 }
13291337 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65))
13301338 if templ_7745c5c3_Err != nil {
@@ -1337,7 +1345,7 @@ func commentView(c CommentView, suffix string) templ.Component {
13371345 var templ_7745c5c3_Var66 string
13381346 templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
13391347 if templ_7745c5c3_Err != nil {
1340- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 244, Col: 49}
1348+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 243, Col: 49}
13411349 }
13421350 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var66)
13431351 if templ_7745c5c3_Err != nil {
@@ -1350,7 +1358,7 @@ func commentView(c CommentView, suffix string) templ.Component {
13501358 var templ_7745c5c3_Var67 string
13511359 templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.Abs)
13521360 if templ_7745c5c3_Err != nil {
1353- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 244, Col: 65}
1361+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 243, Col: 65}
13541362 }
13551363 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var67)
13561364 if templ_7745c5c3_Err != nil {
@@ -1363,7 +1371,7 @@ func commentView(c CommentView, suffix string) templ.Component {
13631371 var templ_7745c5c3_Var68 string
13641372 templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
13651373 if templ_7745c5c3_Err != nil {
1366- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 244, Col: 76}
1374+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 243, Col: 76}
13671375 }
13681376 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68))
13691377 if templ_7745c5c3_Err != nil {
@@ -1378,7 +1386,7 @@ func commentView(c CommentView, suffix string) templ.Component {
13781386 var templ_7745c5c3_Var69 string
13791387 templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
13801388 if templ_7745c5c3_Err != nil {
1381- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 247, Col: 9}
1389+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 246, Col: 9}
13821390 }
13831391 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var69))
13841392 if templ_7745c5c3_Err != nil {
@@ -1393,7 +1401,7 @@ func commentView(c CommentView, suffix string) templ.Component {
13931401 var templ_7745c5c3_Var70 string
13941402 templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
13951403 if templ_7745c5c3_Err != nil {
1396- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 250, Col: 9}
1404+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 249, Col: 9}
13971405 }
13981406 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70))
13991407 if templ_7745c5c3_Err != nil {
@@ -1408,7 +1416,7 @@ func commentView(c CommentView, suffix string) templ.Component {
14081416 var templ_7745c5c3_Var71 string
14091417 templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
14101418 if templ_7745c5c3_Err != nil {
1411- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 253, Col: 9}
1419+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 252, Col: 9}
14121420 }
14131421 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71))
14141422 if templ_7745c5c3_Err != nil {
@@ -1421,7 +1429,7 @@ func commentView(c CommentView, suffix string) templ.Component {
14211429 var templ_7745c5c3_Var72 string
14221430 templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinStringErrs(c.Where)
14231431 if templ_7745c5c3_Err != nil {
1424- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 253, Col: 47}
1432+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 252, Col: 47}
14251433 }
14261434 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72))
14271435 if templ_7745c5c3_Err != nil {
@@ -1452,7 +1460,7 @@ func commentView(c CommentView, suffix string) templ.Component {
14521460 var templ_7745c5c3_Var73 templ.SafeURL
14531461 templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Resolve.Action))
14541462 if templ_7745c5c3_Err != nil {
1455- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 258, Col: 77}
1463+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 257, Col: 77}
14561464 }
14571465 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var73))
14581466 if templ_7745c5c3_Err != nil {
@@ -1465,7 +1473,7 @@ func commentView(c CommentView, suffix string) templ.Component {
14651473 var templ_7745c5c3_Var74 string
14661474 templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.ResolveAttributeValue(boolText(c.Resolve.Value))
14671475 if templ_7745c5c3_Err != nil {
1468- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 259, Col: 74}
1476+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 258, Col: 74}
14691477 }
14701478 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var74)
14711479 if templ_7745c5c3_Err != nil {
@@ -1478,7 +1486,7 @@ func commentView(c CommentView, suffix string) templ.Component {
14781486 var templ_7745c5c3_Var75 string
14791487 templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.JoinStringErrs(c.Resolve.Label)
14801488 if templ_7745c5c3_Err != nil {
1481- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 260, Col: 55}
1489+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 259, Col: 55}
14821490 }
14831491 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75))
14841492 if templ_7745c5c3_Err != nil {
@@ -1533,7 +1541,7 @@ func commentForm(f CommentForm, suffix string) templ.Component {
15331541 var templ_7745c5c3_Var77 templ.SafeURL
15341542 templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(f.Action))
15351543 if templ_7745c5c3_Err != nil {
1536- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 275, Col: 67}
1544+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 274, Col: 67}
15371545 }
15381546 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var77))
15391547 if templ_7745c5c3_Err != nil {
@@ -1546,7 +1554,7 @@ func commentForm(f CommentForm, suffix string) templ.Component {
15461554 var templ_7745c5c3_Var78 string
15471555 templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ID + suffix)
15481556 if templ_7745c5c3_Err != nil {
1549- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 276, Col: 28}
1557+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 275, Col: 28}
15501558 }
15511559 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var78)
15521560 if templ_7745c5c3_Err != nil {
@@ -1559,7 +1567,7 @@ func commentForm(f CommentForm, suffix string) templ.Component {
15591567 var templ_7745c5c3_Var79 string
15601568 templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.JoinStringErrs(f.Title)
15611569 if templ_7745c5c3_Err != nil {
1562- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 276, Col: 40}
1570+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 275, Col: 40}
15631571 }
15641572 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var79))
15651573 if templ_7745c5c3_Err != nil {
@@ -1572,7 +1580,7 @@ func commentForm(f CommentForm, suffix string) templ.Component {
15721580 var templ_7745c5c3_Var80 string
15731581 templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ID + suffix)
15741582 if templ_7745c5c3_Err != nil {
1575- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 278, Col: 21}
1583+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 277, Col: 21}
15761584 }
15771585 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var80)
15781586 if templ_7745c5c3_Err != nil {
@@ -1600,7 +1608,7 @@ func commentForm(f CommentForm, suffix string) templ.Component {
16001608 var templ_7745c5c3_Var81 string
16011609 templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path)
16021610 if templ_7745c5c3_Err != nil {
1603- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 289, Col: 50}
1611+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 288, Col: 50}
16041612 }
16051613 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var81)
16061614 if templ_7745c5c3_Err != nil {
@@ -1613,7 +1621,7 @@ func commentForm(f CommentForm, suffix string) templ.Component {
16131621 var templ_7745c5c3_Var82 string
16141622 templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Line)
16151623 if templ_7745c5c3_Err != nil {
1616- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 290, Col: 50}
1624+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 289, Col: 50}
16171625 }
16181626 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var82)
16191627 if templ_7745c5c3_Err != nil {
@@ -1626,7 +1634,7 @@ func commentForm(f CommentForm, suffix string) templ.Component {
16261634 var templ_7745c5c3_Var83 string
16271635 templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Side)
16281636 if templ_7745c5c3_Err != nil {
1629- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 291, Col: 50}
1637+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 290, Col: 50}
16301638 }
16311639 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var83)
16321640 if templ_7745c5c3_Err != nil {
@@ -1649,7 +1657,7 @@ func commentForm(f CommentForm, suffix string) templ.Component {
16491657 var templ_7745c5c3_Var84 templ.SafeURL
16501658 templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(f.Cancel))
16511659 if templ_7745c5c3_Err != nil {
1652- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 296, Col: 49}
1660+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 295, Col: 49}
16531661 }
16541662 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var84))
16551663 if templ_7745c5c3_Err != nil {
@@ -1702,7 +1710,7 @@ func mrRail(p MRPage) templ.Component {
17021710 var templ_7745c5c3_Var86 string
17031711 templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(strings.Join(p.Modes, ", "))
17041712 if templ_7745c5c3_Err != nil {
1705- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 315, Col: 36}
1713+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 314, Col: 36}
17061714 }
17071715 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86))
17081716 if templ_7745c5c3_Err != nil {
@@ -1730,7 +1738,7 @@ func mrRail(p MRPage) templ.Component {
17301738 var templ_7745c5c3_Var87 string
17311739 templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Commits)))
17321740 if templ_7745c5c3_Err != nil {
1733- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 327, Col: 71}
1741+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 326, Col: 71}
17341742 }
17351743 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var87))
17361744 if templ_7745c5c3_Err != nil {
@@ -1758,7 +1766,7 @@ func mrRail(p MRPage) templ.Component {
17581766 var templ_7745c5c3_Var88 templ.SafeURL
17591767 templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
17601768 if templ_7745c5c3_Err != nil {
1761- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 331, Col: 36}
1769+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 330, Col: 36}
17621770 }
17631771 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var88))
17641772 if templ_7745c5c3_Err != nil {
@@ -1771,7 +1779,7 @@ func mrRail(p MRPage) templ.Component {
17711779 var templ_7745c5c3_Var89 string
17721780 templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
17731781 if templ_7745c5c3_Err != nil {
1774- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 331, Col: 48}
1782+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 330, Col: 48}
17751783 }
17761784 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89))
17771785 if templ_7745c5c3_Err != nil {
@@ -1784,7 +1792,7 @@ func mrRail(p MRPage) templ.Component {
17841792 var templ_7745c5c3_Var90 string
17851793 templ_7745c5c3_Var90, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
17861794 if templ_7745c5c3_Err != nil {
1787- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 332, Col: 32}
1795+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 331, Col: 32}
17881796 }
17891797 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var90))
17901798 if templ_7745c5c3_Err != nil {
@@ -1797,7 +1805,7 @@ func mrRail(p MRPage) templ.Component {
17971805 var templ_7745c5c3_Var91 string
17981806 templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
17991807 if templ_7745c5c3_Err != nil {
1800- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 334, Col: 16}
1808+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 333, Col: 16}
18011809 }
18021810 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var91))
18031811 if templ_7745c5c3_Err != nil {
@@ -1811,7 +1819,7 @@ func mrRail(p MRPage) templ.Component {
18111819 var templ_7745c5c3_Var92 string
18121820 templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
18131821 if templ_7745c5c3_Err != nil {
1814- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 336, Col: 15}
1822+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 335, Col: 15}
18151823 }
18161824 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var92))
18171825 if templ_7745c5c3_Err != nil {
@@ -1824,7 +1832,7 @@ func mrRail(p MRPage) templ.Component {
18241832 var templ_7745c5c3_Var93 string
18251833 templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
18261834 if templ_7745c5c3_Err != nil {
1827- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 336, Col: 42}
1835+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 335, Col: 42}
18281836 }
18291837 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var93)
18301838 if templ_7745c5c3_Err != nil {
@@ -1837,7 +1845,7 @@ func mrRail(p MRPage) templ.Component {
18371845 var templ_7745c5c3_Var94 string
18381846 templ_7745c5c3_Var94, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
18391847 if templ_7745c5c3_Err != nil {
1840- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 336, Col: 53}
1848+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 335, Col: 53}
18411849 }
18421850 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var94))
18431851 if templ_7745c5c3_Err != nil {
@@ -1852,7 +1860,7 @@ func mrRail(p MRPage) templ.Component {
18521860 var templ_7745c5c3_Var95 string
18531861 templ_7745c5c3_Var95, templ_7745c5c3_Err = templ.JoinStringErrs(" · new")
18541862 if templ_7745c5c3_Err != nil {
1855- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 339, Col: 18}
1863+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 338, Col: 18}
18561864 }
18571865 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var95))
18581866 if templ_7745c5c3_Err != nil {
@@ -1882,7 +1890,7 @@ func mrRail(p MRPage) templ.Component {
18821890 var templ_7745c5c3_Var96 string
18831891 templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.JoinStringErrs(p.CommitsNote)
18841892 if templ_7745c5c3_Err != nil {
1885- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 349, Col: 34}
1893+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 348, Col: 34}
18861894 }
18871895 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var96))
18881896 if templ_7745c5c3_Err != nil {
@@ -1900,7 +1908,7 @@ func mrRail(p MRPage) templ.Component {
19001908 var templ_7745c5c3_Var97 string
19011909 templ_7745c5c3_Var97, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))
19021910 if templ_7745c5c3_Err != nil {
1903- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 353, Col: 65}
1911+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 352, Col: 65}
19041912 }
19051913 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var97))
19061914 if templ_7745c5c3_Err != nil {
@@ -1959,7 +1967,7 @@ func mergeBox(p MRPage) templ.Component {
19591967 var templ_7745c5c3_Var99 string
19601968 templ_7745c5c3_Var99, templ_7745c5c3_Err = templ.JoinStringErrs(p.HeadShort)
19611969 if templ_7745c5c3_Err != nil {
1962- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 370, Col: 18}
1970+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 369, Col: 18}
19631971 }
19641972 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var99))
19651973 if templ_7745c5c3_Err != nil {
@@ -1973,7 +1981,7 @@ func mergeBox(p MRPage) templ.Component {
19731981 var templ_7745c5c3_Var100 string
19741982 templ_7745c5c3_Var100, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
19751983 if templ_7745c5c3_Err != nil {
1976- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 374, Col: 17}
1984+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 373, Col: 17}
19771985 }
19781986 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var100))
19791987 if templ_7745c5c3_Err != nil {
@@ -1991,7 +1999,7 @@ func mergeBox(p MRPage) templ.Component {
19911999 var templ_7745c5c3_Var101 templ.SafeURL
19922000 templ_7745c5c3_Var101, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.MergeAction))
19932001 if templ_7745c5c3_Err != nil {
1994- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 377, Col: 79}
2002+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 376, Col: 79}
19952003 }
19962004 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var101))
19972005 if templ_7745c5c3_Err != nil {
@@ -2004,7 +2012,7 @@ func mergeBox(p MRPage) templ.Component {
20042012 var templ_7745c5c3_Var102 string
20052013 templ_7745c5c3_Var102, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.Head)
20062014 if templ_7745c5c3_Err != nil {
2007- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 378, Col: 58}
2015+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 377, Col: 58}
20082016 }
20092017 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var102)
20102018 if templ_7745c5c3_Err != nil {
@@ -2022,7 +2030,7 @@ func mergeBox(p MRPage) templ.Component {
20222030 var templ_7745c5c3_Var103 string
20232031 templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.Value)
20242032 if templ_7745c5c3_Err != nil {
2025- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 382, Col: 29}
2033+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 381, Col: 29}
20262034 }
20272035 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var103)
20282036 if templ_7745c5c3_Err != nil {
@@ -2035,7 +2043,7 @@ func mergeBox(p MRPage) templ.Component {
20352043 var templ_7745c5c3_Var104 string
20362044 templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.JoinStringErrs(m.Label)
20372045 if templ_7745c5c3_Err != nil {
2038- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 382, Col: 41}
2046+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 381, Col: 41}
20392047 }
20402048 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var104))
20412049 if templ_7745c5c3_Err != nil {
@@ -2058,7 +2066,7 @@ func mergeBox(p MRPage) templ.Component {
20582066 var templ_7745c5c3_Var105 string
20592067 templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
20602068 if templ_7745c5c3_Err != nil {
2061- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 386, Col: 101}
2069+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 385, Col: 101}
20622070 }
20632071 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var105))
20642072 if templ_7745c5c3_Err != nil {
@@ -2086,7 +2094,7 @@ func mergeBox(p MRPage) templ.Component {
20862094 var templ_7745c5c3_Var106 templ.SafeURL
20872095 templ_7745c5c3_Var106, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.ReadAction))
20882096 if templ_7745c5c3_Err != nil {
2089- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 395, Col: 64}
2097+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 394, Col: 64}
20902098 }
20912099 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var106))
20922100 if templ_7745c5c3_Err != nil {
@@ -2099,7 +2107,7 @@ func mergeBox(p MRPage) templ.Component {
20992107 var templ_7745c5c3_Var107 string
21002108 templ_7745c5c3_Var107, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.Head)
21012109 if templ_7745c5c3_Err != nil {
2102- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 396, Col: 59}
2110+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 395, Col: 59}
21032111 }
21042112 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var107)
21052113 if templ_7745c5c3_Err != nil {
@@ -2117,7 +2125,7 @@ func mergeBox(p MRPage) templ.Component {
21172125 var templ_7745c5c3_Var108 templ.SafeURL
21182126 templ_7745c5c3_Var108, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.CloseAction))
21192127 if templ_7745c5c3_Err != nil {
2120- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 400, Col: 64}
2128+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 399, Col: 64}
21212129 }
21222130 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var108))
21232131 if templ_7745c5c3_Err != nil {
@@ -2187,7 +2195,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
21872195 var templ_7745c5c3_Var111 string
21882196 templ_7745c5c3_Var111, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
21892197 if templ_7745c5c3_Err != nil {
2190- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 416, Col: 17}
2198+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 415, Col: 17}
21912199 }
21922200 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var111))
21932201 if templ_7745c5c3_Err != nil {
@@ -2200,7 +2208,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
22002208 var templ_7745c5c3_Var112 string
22012209 templ_7745c5c3_Var112, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
22022210 if templ_7745c5c3_Err != nil {
2203- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 416, Col: 34}
2211+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 415, Col: 34}
22042212 }
22052213 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var112))
22062214 if templ_7745c5c3_Err != nil {
@@ -2213,7 +2221,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
22132221 var templ_7745c5c3_Var113 templ.SafeURL
22142222 templ_7745c5c3_Var113, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.SourceHref))
22152223 if templ_7745c5c3_Err != nil {
2216- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 419, Col: 45}
2224+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 418, Col: 45}
22172225 }
22182226 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var113))
22192227 if templ_7745c5c3_Err != nil {
@@ -2226,7 +2234,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
22262234 var templ_7745c5c3_Var114 string
22272235 templ_7745c5c3_Var114, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
22282236 if templ_7745c5c3_Err != nil {
2229- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 419, Col: 58}
2237+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 418, Col: 58}
22302238 }
22312239 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var114))
22322240 if templ_7745c5c3_Err != nil {
@@ -2239,7 +2247,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
22392247 var templ_7745c5c3_Var115 templ.SafeURL
22402248 templ_7745c5c3_Var115, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.TargetHref))
22412249 if templ_7745c5c3_Err != nil {
2242- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 421, Col: 45}
2250+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 420, Col: 45}
22432251 }
22442252 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var115))
22452253 if templ_7745c5c3_Err != nil {
@@ -2252,7 +2260,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
22522260 var templ_7745c5c3_Var116 string
22532261 templ_7745c5c3_Var116, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
22542262 if templ_7745c5c3_Err != nil {
2255- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 421, Col: 58}
2263+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 420, Col: 58}
22562264 }
22572265 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var116))
22582266 if templ_7745c5c3_Err != nil {
@@ -2265,7 +2273,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
22652273 var templ_7745c5c3_Var117 string
22662274 templ_7745c5c3_Var117, templ_7745c5c3_Err = templ.JoinStringErrs(p.AheadText)
22672275 if templ_7745c5c3_Err != nil {
2268- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 423, Col: 21}
2276+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 422, Col: 21}
22692277 }
22702278 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var117))
22712279 if templ_7745c5c3_Err != nil {
@@ -2283,7 +2291,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
22832291 var templ_7745c5c3_Var118 string
22842292 templ_7745c5c3_Var118, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)
22852293 if templ_7745c5c3_Err != nil {
2286- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 427, Col: 29}
2294+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 426, Col: 29}
22872295 }
22882296 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var118))
22892297 if templ_7745c5c3_Err != nil {
@@ -2306,7 +2314,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
23062314 var templ_7745c5c3_Var119 templ.SafeURL
23072315 templ_7745c5c3_Var119, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Existing.Href))
23082316 if templ_7745c5c3_Err != nil {
2309- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 430, Col: 114}
2317+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 429, Col: 114}
23102318 }
23112319 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var119))
23122320 if templ_7745c5c3_Err != nil {
@@ -2319,7 +2327,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
23192327 var templ_7745c5c3_Var120 string
23202328 templ_7745c5c3_Var120, templ_7745c5c3_Err = templ.JoinStringErrs(p.Existing.Label)
23212329 if templ_7745c5c3_Err != nil {
2322- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 430, Col: 135}
2330+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 429, Col: 135}
23232331 }
23242332 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var120))
23252333 if templ_7745c5c3_Err != nil {
@@ -2342,7 +2350,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
23422350 var templ_7745c5c3_Var121 templ.SafeURL
23432351 templ_7745c5c3_Var121, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Create.Action))
23442352 if templ_7745c5c3_Err != nil {
2345- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 433, Col: 83}
2353+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 432, Col: 83}
23462354 }
23472355 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var121))
23482356 if templ_7745c5c3_Err != nil {
@@ -2355,7 +2363,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
23552363 var templ_7745c5c3_Var122 string
23562364 templ_7745c5c3_Var122, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Source)
23572365 if templ_7745c5c3_Err != nil {
2358- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 434, Col: 62}
2366+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 433, Col: 62}
23592367 }
23602368 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var122)
23612369 if templ_7745c5c3_Err != nil {
@@ -2368,7 +2376,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
23682376 var templ_7745c5c3_Var123 string
23692377 templ_7745c5c3_Var123, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Target)
23702378 if templ_7745c5c3_Err != nil {
2371- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 435, Col: 62}
2379+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 434, Col: 62}
23722380 }
23732381 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var123)
23742382 if templ_7745c5c3_Err != nil {
@@ -2381,7 +2389,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
23812389 var templ_7745c5c3_Var124 string
23822390 templ_7745c5c3_Var124, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Title)
23832391 if templ_7745c5c3_Err != nil {
2384- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 437, Col: 97}
2392+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 436, Col: 97}
23852393 }
23862394 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var124)
23872395 if templ_7745c5c3_Err != nil {
@@ -2404,7 +2412,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
24042412 var templ_7745c5c3_Var125 string
24052413 templ_7745c5c3_Var125, templ_7745c5c3_Err = templ.JoinStringErrs(plural(p.NFiles, "file", "files"))
24062414 if templ_7745c5c3_Err != nil {
2407- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 447, Col: 57}
2415+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 446, Col: 57}
24082416 }
24092417 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var125))
24102418 if templ_7745c5c3_Err != nil {
@@ -2417,7 +2425,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
24172425 var templ_7745c5c3_Var126 string
24182426 templ_7745c5c3_Var126, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Adds))
24192427 if templ_7745c5c3_Err != nil {
2420- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 447, Col: 97}
2428+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 446, Col: 97}
24212429 }
24222430 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var126))
24232431 if templ_7745c5c3_Err != nil {
@@ -2430,7 +2438,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
24302438 var templ_7745c5c3_Var127 string
24312439 templ_7745c5c3_Var127, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Dels))
24322440 if templ_7745c5c3_Err != nil {
2433- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 447, Col: 142}
2441+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 446, Col: 142}
24342442 }
24352443 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var127))
24362444 if templ_7745c5c3_Err != nil {
@@ -2474,7 +2482,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
24742482 var templ_7745c5c3_Var128 string
24752483 templ_7745c5c3_Var128, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
24762484 if templ_7745c5c3_Err != nil {
2477- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 462, Col: 23}
2485+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 461, Col: 23}
24782486 }
24792487 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var128)
24802488 if templ_7745c5c3_Err != nil {
@@ -2492,7 +2500,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
24922500 var templ_7745c5c3_Var129 string
24932501 templ_7745c5c3_Var129, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
24942502 if templ_7745c5c3_Err != nil {
2495- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 464, Col: 23}
2503+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 463, Col: 23}
24962504 }
24972505 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var129)
24982506 if templ_7745c5c3_Err != nil {
@@ -2562,7 +2570,7 @@ func compareRail(p ComparePage) templ.Component {
25622570 var templ_7745c5c3_Var131 string
25632571 templ_7745c5c3_Var131, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Ahead)))
25642572 if templ_7745c5c3_Err != nil {
2565- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 480, Col: 67}
2573+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 479, Col: 67}
25662574 }
25672575 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var131))
25682576 if templ_7745c5c3_Err != nil {
@@ -2580,7 +2588,7 @@ func compareRail(p ComparePage) templ.Component {
25802588 var templ_7745c5c3_Var132 templ.SafeURL
25812589 templ_7745c5c3_Var132, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
25822590 if templ_7745c5c3_Err != nil {
2583- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 484, Col: 37}
2591+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 483, Col: 37}
25842592 }
25852593 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var132))
25862594 if templ_7745c5c3_Err != nil {
@@ -2593,7 +2601,7 @@ func compareRail(p ComparePage) templ.Component {
25932601 var templ_7745c5c3_Var133 string
25942602 templ_7745c5c3_Var133, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
25952603 if templ_7745c5c3_Err != nil {
2596- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 484, Col: 49}
2604+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 483, Col: 49}
25972605 }
25982606 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var133))
25992607 if templ_7745c5c3_Err != nil {
@@ -2606,7 +2614,7 @@ func compareRail(p ComparePage) templ.Component {
26062614 var templ_7745c5c3_Var134 string
26072615 templ_7745c5c3_Var134, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
26082616 if templ_7745c5c3_Err != nil {
2609- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 485, Col: 32}
2617+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 484, Col: 32}
26102618 }
26112619 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var134))
26122620 if templ_7745c5c3_Err != nil {
@@ -2619,7 +2627,7 @@ func compareRail(p ComparePage) templ.Component {
26192627 var templ_7745c5c3_Var135 string
26202628 templ_7745c5c3_Var135, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
26212629 if templ_7745c5c3_Err != nil {
2622- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 487, Col: 16}
2630+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 486, Col: 16}
26232631 }
26242632 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var135))
26252633 if templ_7745c5c3_Err != nil {
@@ -2633,7 +2641,7 @@ func compareRail(p ComparePage) templ.Component {
26332641 var templ_7745c5c3_Var136 string
26342642 templ_7745c5c3_Var136, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
26352643 if templ_7745c5c3_Err != nil {
2636- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 489, Col: 15}
2644+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 488, Col: 15}
26372645 }
26382646 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var136))
26392647 if templ_7745c5c3_Err != nil {
@@ -2646,7 +2654,7 @@ func compareRail(p ComparePage) templ.Component {
26462654 var templ_7745c5c3_Var137 string
26472655 templ_7745c5c3_Var137, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
26482656 if templ_7745c5c3_Err != nil {
2649- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 489, Col: 42}
2657+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 488, Col: 42}
26502658 }
26512659 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var137)
26522660 if templ_7745c5c3_Err != nil {
@@ -2659,7 +2667,7 @@ func compareRail(p ComparePage) templ.Component {
26592667 var templ_7745c5c3_Var138 string
26602668 templ_7745c5c3_Var138, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
26612669 if templ_7745c5c3_Err != nil {
2662- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 489, Col: 53}
2670+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 488, Col: 53}
26632671 }
26642672 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var138))
26652673 if templ_7745c5c3_Err != nil {
@@ -2687,7 +2695,7 @@ func compareRail(p ComparePage) templ.Component {
26872695 var templ_7745c5c3_Var139 string
26882696 templ_7745c5c3_Var139, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
26892697 if templ_7745c5c3_Err != nil {
2690- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 496, Col: 47}
2698+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 495, Col: 47}
26912699 }
26922700 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var139))
26932701 if templ_7745c5c3_Err != nil {
@@ -2710,7 +2718,7 @@ func compareRail(p ComparePage) templ.Component {
27102718 var templ_7745c5c3_Var140 string
27112719 templ_7745c5c3_Var140, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))
27122720 if templ_7745c5c3_Err != nil {
2713- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 501, Col: 66}
2721+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 500, Col: 66}
27142722 }
27152723 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var140))
27162724 if templ_7745c5c3_Err != nil {
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