detached dad3569

feat(web): add commit history pages

/<repo>/commits/<ref>[/<path>]: the commits of a ref, a tag or a commit id
grouped by day, with an older link that carries the API page token, the
path filter (404 for a path that never existed), refs with a slash resolved
by the longest prefix, and the refs of the page in the rail. Invisible
characters in names and subjects show as <U+XXXX> markers.
commit
dad3569cfc8c62b2aa45376df8791db673ac93df
author
brice · · 2026-10-10 13:03 UTC
parent
25ce556
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +3353 -0
A cmd/ermite/testdata/web/commits-page2.html +340 -0
@@ -0,0 +1,340 @@
1+<!doctype html>
2+<html lang="en">
3+<head>
4+<meta charset="utf-8">
5+<meta name="viewport" content="width=device-width, initial-scale=1">
6+<meta name="color-scheme" content="light dark">
7+<title>commits · long · example.test</title>
8+<link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
9+<script src="/-/static/js/htmx.min.HASH.js" defer>
10+</script>
11+<script src="/-/static/js/hx-live.min.HASH.js" defer>
12+</script>
13+<script src="/-/static/js/hx-sse.min.HASH.js" defer>
14+</script>
15+<script src="/-/static/js/ermite.HASH.js" defer>
16+</script>
17+</head>
18+<body data-ref="main" data-sha="SHA40" data-sel="">
19+<a class="skip" href="#main">skip to content</a>
20+<header class="bar top">
21+<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
22+<span class="fade" aria-hidden="true">
23+</span>
24+<nav class="crumbs" aria-label="Path">
25+<ol>
26+<li>
27+<a href="/long">long</a>
28+</li>
29+<li>
30+<a href="/long/commits/main">commits</a>
31+</li>
32+<li>
33+<a href="/long/commits/main" aria-current="page">main</a>
34+</li>
35+</ol>
36+</nav>
37+<nav class="secs" aria-label="Sections">
38+<ul>
39+<li>
40+<a href="/long" data-key="s" aria-keyshortcuts="g s">summary</a>
41+</li>
42+<li>
43+<a href="/long/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a>
44+</li>
45+<li>
46+<a href="/long/commits/main" data-key="c" aria-keyshortcuts="g c" aria-current="page">commits</a>
47+</li>
48+<li>
49+<a href="/long/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
50+</li>
51+</ul>
52+</nav>
53+<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
54+</button>
55+</header>
56+<div class="work has-rail">
57+<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
58+<div class="filter">
59+<label for="commit-filter">filter&gt;</label>
60+<input id="commit-filter" type="search" data-filter placeholder="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));
61+ q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])'));
62+ let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')">
63+</div>
64+<div class="days" id="commits-head">
65+<section aria-label="2026-03-01">
66+<h2 class="rule-h">
67+<span>2026-03-01</span>
68+</h2>
69+<table class="list commits" aria-label="Commits">
70+<tbody data-rows>
71+<tr id="c-SHA7" data-cursor>
72+<td class="cur" aria-hidden="true">
73+</td>
74+<td class="c-sha mute">SHA7</td>
75+<td class="c-subject">
76+<a href="/long/commit/SHA40">commit number 1</a>
77+</td>
78+<td class="c-who dim">T</td>
79+<td class="c-when nowrap dim">
80+<time datetime="STAMP" title="STAMP">AGO</time>
81+</td>
82+</tr>
83+<tr id="c-SHA7">
84+<td class="cur" aria-hidden="true">
85+</td>
86+<td class="c-sha mute">SHA7</td>
87+<td class="c-subject">
88+<a href="/long/commit/SHA40">commit number 0</a>
89+</td>
90+<td class="c-who dim">T</td>
91+<td class="c-when nowrap dim">
92+<time datetime="STAMP" title="STAMP">AGO</time>
93+</td>
94+</tr>
95+</tbody>
96+</table>
97+</section>
98+</div>
99+<nav class="pager" aria-label="Pages">
100+<a href="/long/commits/main">← newest</a>
101+<span class="mute">older →</span>
102+</nav>
103+</main>
104+<aside class="rail" aria-label="Context">
105+<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
106+<summary>history · refs on this page</summary>
107+<section aria-labelledby="r-hist">
108+<h2 id="r-hist">history</h2>
109+<dl class="kv">
110+<dt>ref</dt>
111+<dd>main</dd>
112+<dt>on page</dt>
113+<dd>2</dd>
114+</dl>
115+</section>
116+<section aria-labelledby="r-refs">
117+<h2 id="r-refs">refs on this page</h2>
118+<p class="dim">no tag or branch head here</p>
119+</section>
120+</details>
121+</aside>
122+</div>
123+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
124+<div class="panel-head">
125+<span>keys</span>
126+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
127+</div>
128+<dl class="keys">
129+<div>
130+<dt>
131+<kbd>j</kbd>
132+<kbd>k</kbd>
133+</dt>
134+<dd>move cursor down / up</dd>
135+</div>
136+<div>
137+<dt>
138+<kbd>enter</kbd>
139+</dt>
140+<dd>open row under cursor</dd>
141+</div>
142+<div>
143+<dt>
144+<kbd>g</kbd>
145+<kbd>g</kbd> / <kbd>G</kbd>
146+</dt>
147+<dd>first / last row</dd>
148+</div>
149+<div>
150+<dt>
151+<kbd>/</kbd>
152+</dt>
153+<dd>focus the filter</dd>
154+</div>
155+<div>
156+<dt>
157+<kbd>g h</kbd>
158+<kbd>g t</kbd>
159+</dt>
160+<dd>go home / tree</dd>
161+</div>
162+<div>
163+<dt>
164+<kbd>g c</kbd>
165+<kbd>g r</kbd>
166+</dt>
167+<dd>go commits / refs</dd>
168+</div>
169+<div>
170+<dt>
171+<kbd>b</kbd>
172+</dt>
173+<dd>switch ref (branches, tags)</dd>
174+</div>
175+<div>
176+<dt>
177+<kbd>:</kbd>
178+<kbd>f</kbd>
179+</dt>
180+<dd>go to: section, ref, file, repo</dd>
181+</div>
182+<div>
183+<dt>
184+<kbd>y</kbd>
185+</dt>
186+<dd>permalink: ref becomes the full sha</dd>
187+</div>
188+<div>
189+<dt>
190+<kbd>s</kbd>
191+<kbd>t</kbd>
192+<kbd>c</kbd>
193+<kbd>r</kbd>
194+</dt>
195+<dd>repo sections (when shown)</dd>
196+</div>
197+<div>
198+<dt>
199+<kbd>d</kbd>
200+<kbd>u</kbd>
201+<kbd>x</kbd>
202+</dt>
203+<dd>commit: split / unified / collapse all</dd>
204+</div>
205+<div>
206+<dt>
207+<kbd>esc</kbd>
208+</dt>
209+<dd>close panel</dd>
210+</div>
211+</dl>
212+</dialog>
213+<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
214+<div class="panel-head">
215+<label for="refs-filter">ref&gt;</label>
216+<input id="refs-filter" type="search" placeholder="filter branches and tags" 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))">
217+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
218+</div>
219+<div class="panel-cols" data-picklist>
220+<section aria-label="Branches">
221+<h2>branches <span class="mute">1</span>
222+</h2>
223+<ul>
224+<li>
225+<a href="/long/tree/main" aria-current="true">
226+<span class="mark" aria-hidden="true">●</span>main</a>
227+</li>
228+</ul>
229+</section>
230+<section aria-label="Tags">
231+<h2>tags <span class="mute">0</span>
232+</h2>
233+<ul>
234+</ul>
235+</section>
236+</div>
237+</dialog>
238+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
239+<div class="panel-head">
240+<label for="cmd-q" class="hi">:</label>
241+<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
242+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
243+ 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();
244+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
245+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
246+ 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' })) }
247+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
248+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
249+</div>
250+<ul id="cmd-list" class="cmd-list">
251+<li data-k="go home" data-cursor>
252+<a href="/" tabindex="-1">
253+<span class="k">go</span>home</a>
254+</li>
255+<li data-k="section summary">
256+<a href="/long" tabindex="-1">
257+<span class="k">section</span>summary</a>
258+</li>
259+<li data-k="section tree">
260+<a href="/long/tree/main" tabindex="-1">
261+<span class="k">section</span>tree</a>
262+</li>
263+<li data-k="section commits">
264+<a href="/long/commits/main" tabindex="-1">
265+<span class="k">section</span>commits</a>
266+</li>
267+<li data-k="section refs">
268+<a href="/long/refs" tabindex="-1">
269+<span class="k">section</span>refs</a>
270+</li>
271+<li data-k="branch main">
272+<a href="/long/tree/main" tabindex="-1">
273+<span class="k">branch</span>main</a>
274+</li>
275+<li data-k="repo diffs">
276+<a href="/diffs" tabindex="-1">
277+<span class="k">repo</span>diffs</a>
278+</li>
279+</ul>
280+</dialog>
281+<footer class="bar status" aria-label="Status">
282+<span class="mode">commits</span>
283+<span class="st-ctx">main · 2 commits</span>
284+<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
285+<span class="hi">:</span> go to…</button>
286+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
287+</span>
288+<span class="st-hints">j/k move · enter open · / filter · ? help</span>
289+<span class="st-src">
290+<span class="st-ver">ermite devel · </span>
291+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
292+</span>
293+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
294+<span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/2</span>
295+</footer>
296+<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
297+ if (q('dialog[open]').count) return;
298+ let rows = q('[data-rows] > :not([hidden])').arr();
299+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
300+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
301+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
302+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
303+ })(1);
304+ k-k from:body -> ((d) => {
305+ if (q('dialog[open]').count) return;
306+ let rows = q('[data-rows] > :not([hidden])').arr();
307+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
308+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
309+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
310+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
311+ })(-1);
312+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
313+ 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));
314+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
315+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
316+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
317+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
318+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
319+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
320+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
321+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
322+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
323+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
324+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
325+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
326+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
327+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
328+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
329+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
330+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
331+ k-permalink from:body -> {
332+ let b = document.body.dataset;
333+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
334+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
335+ navigator.clipboard?.writeText(location.href);
336+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
337+ }">
338+</div>
339+</body>
340+</html>
@@ -0,0 +1,340 @@
1+<!doctype html>
2+<html lang="en">
3+<head>
4+<meta charset="utf-8">
5+<meta name="viewport" content="width=device-width, initial-scale=1">
6+<meta name="color-scheme" content="light dark">
7+<title>commits · long · example.test</title>
8+<link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
9+<script src="/-/static/js/htmx.min.HASH.js" defer>
10+</script>
11+<script src="/-/static/js/hx-live.min.HASH.js" defer>
12+</script>
13+<script src="/-/static/js/hx-sse.min.HASH.js" defer>
14+</script>
15+<script src="/-/static/js/ermite.HASH.js" defer>
16+</script>
17+</head>
18+<body data-ref="main" data-sha="SHA40" data-sel="">
19+<a class="skip" href="#main">skip to content</a>
20+<header class="bar top">
21+<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
22+<span class="fade" aria-hidden="true">
23+</span>
24+<nav class="crumbs" aria-label="Path">
25+<ol>
26+<li>
27+<a href="/long">long</a>
28+</li>
29+<li>
30+<a href="/long/commits/main">commits</a>
31+</li>
32+<li>
33+<a href="/long/commits/main" aria-current="page">main</a>
34+</li>
35+</ol>
36+</nav>
37+<nav class="secs" aria-label="Sections">
38+<ul>
39+<li>
40+<a href="/long" data-key="s" aria-keyshortcuts="g s">summary</a>
41+</li>
42+<li>
43+<a href="/long/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a>
44+</li>
45+<li>
46+<a href="/long/commits/main" data-key="c" aria-keyshortcuts="g c" aria-current="page">commits</a>
47+</li>
48+<li>
49+<a href="/long/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
50+</li>
51+</ul>
52+</nav>
53+<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
54+</button>
55+</header>
56+<div class="work has-rail">
57+<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
58+<div class="filter">
59+<label for="commit-filter">filter&gt;</label>
60+<input id="commit-filter" type="search" data-filter placeholder="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));
61+ q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])'));
62+ let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')">
63+</div>
64+<div class="days" id="commits-head">
65+<section aria-label="2026-03-01">
66+<h2 class="rule-h">
67+<span>2026-03-01</span>
68+</h2>
69+<table class="list commits" aria-label="Commits">
70+<tbody data-rows>
71+<tr id="c-SHA7" data-cursor>
72+<td class="cur" aria-hidden="true">
73+</td>
74+<td class="c-sha mute">SHA7</td>
75+<td class="c-subject">
76+<a href="/long/commit/SHA40">commit number 1</a>
77+</td>
78+<td class="c-who dim">T</td>
79+<td class="c-when nowrap dim">
80+<time datetime="STAMP" title="STAMP">AGO</time>
81+</td>
82+</tr>
83+<tr id="c-SHA7">
84+<td class="cur" aria-hidden="true">
85+</td>
86+<td class="c-sha mute">SHA7</td>
87+<td class="c-subject">
88+<a href="/long/commit/SHA40">commit number 0</a>
89+</td>
90+<td class="c-who dim">T</td>
91+<td class="c-when nowrap dim">
92+<time datetime="STAMP" title="STAMP">AGO</time>
93+</td>
94+</tr>
95+</tbody>
96+</table>
97+</section>
98+</div>
99+<nav class="pager" aria-label="Pages">
100+<a href="/long/commits/main">← newest</a>
101+<span class="mute">older →</span>
102+</nav>
103+</main>
104+<aside class="rail" aria-label="Context">
105+<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
106+<summary>history · refs on this page</summary>
107+<section aria-labelledby="r-hist">
108+<h2 id="r-hist">history</h2>
109+<dl class="kv">
110+<dt>ref</dt>
111+<dd>main</dd>
112+<dt>on page</dt>
113+<dd>2</dd>
114+</dl>
115+</section>
116+<section aria-labelledby="r-refs">
117+<h2 id="r-refs">refs on this page</h2>
118+<p class="dim">no tag or branch head here</p>
119+</section>
120+</details>
121+</aside>
122+</div>
123+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
124+<div class="panel-head">
125+<span>keys</span>
126+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
127+</div>
128+<dl class="keys">
129+<div>
130+<dt>
131+<kbd>j</kbd>
132+<kbd>k</kbd>
133+</dt>
134+<dd>move cursor down / up</dd>
135+</div>
136+<div>
137+<dt>
138+<kbd>enter</kbd>
139+</dt>
140+<dd>open row under cursor</dd>
141+</div>
142+<div>
143+<dt>
144+<kbd>g</kbd>
145+<kbd>g</kbd> / <kbd>G</kbd>
146+</dt>
147+<dd>first / last row</dd>
148+</div>
149+<div>
150+<dt>
151+<kbd>/</kbd>
152+</dt>
153+<dd>focus the filter</dd>
154+</div>
155+<div>
156+<dt>
157+<kbd>g h</kbd>
158+<kbd>g t</kbd>
159+</dt>
160+<dd>go home / tree</dd>
161+</div>
162+<div>
163+<dt>
164+<kbd>g c</kbd>
165+<kbd>g r</kbd>
166+</dt>
167+<dd>go commits / refs</dd>
168+</div>
169+<div>
170+<dt>
171+<kbd>b</kbd>
172+</dt>
173+<dd>switch ref (branches, tags)</dd>
174+</div>
175+<div>
176+<dt>
177+<kbd>:</kbd>
178+<kbd>f</kbd>
179+</dt>
180+<dd>go to: section, ref, file, repo</dd>
181+</div>
182+<div>
183+<dt>
184+<kbd>y</kbd>
185+</dt>
186+<dd>permalink: ref becomes the full sha</dd>
187+</div>
188+<div>
189+<dt>
190+<kbd>s</kbd>
191+<kbd>t</kbd>
192+<kbd>c</kbd>
193+<kbd>r</kbd>
194+</dt>
195+<dd>repo sections (when shown)</dd>
196+</div>
197+<div>
198+<dt>
199+<kbd>d</kbd>
200+<kbd>u</kbd>
201+<kbd>x</kbd>
202+</dt>
203+<dd>commit: split / unified / collapse all</dd>
204+</div>
205+<div>
206+<dt>
207+<kbd>esc</kbd>
208+</dt>
209+<dd>close panel</dd>
210+</div>
211+</dl>
212+</dialog>
213+<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
214+<div class="panel-head">
215+<label for="refs-filter">ref&gt;</label>
216+<input id="refs-filter" type="search" placeholder="filter branches and tags" 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))">
217+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
218+</div>
219+<div class="panel-cols" data-picklist>
220+<section aria-label="Branches">
221+<h2>branches <span class="mute">1</span>
222+</h2>
223+<ul>
224+<li>
225+<a href="/long/tree/main" aria-current="true">
226+<span class="mark" aria-hidden="true">●</span>main</a>
227+</li>
228+</ul>
229+</section>
230+<section aria-label="Tags">
231+<h2>tags <span class="mute">0</span>
232+</h2>
233+<ul>
234+</ul>
235+</section>
236+</div>
237+</dialog>
238+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
239+<div class="panel-head">
240+<label for="cmd-q" class="hi">:</label>
241+<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
242+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
243+ 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();
244+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
245+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
246+ 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' })) }
247+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
248+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
249+</div>
250+<ul id="cmd-list" class="cmd-list">
251+<li data-k="go home" data-cursor>
252+<a href="/" tabindex="-1">
253+<span class="k">go</span>home</a>
254+</li>
255+<li data-k="section summary">
256+<a href="/long" tabindex="-1">
257+<span class="k">section</span>summary</a>
258+</li>
259+<li data-k="section tree">
260+<a href="/long/tree/main" tabindex="-1">
261+<span class="k">section</span>tree</a>
262+</li>
263+<li data-k="section commits">
264+<a href="/long/commits/main" tabindex="-1">
265+<span class="k">section</span>commits</a>
266+</li>
267+<li data-k="section refs">
268+<a href="/long/refs" tabindex="-1">
269+<span class="k">section</span>refs</a>
270+</li>
271+<li data-k="branch main">
272+<a href="/long/tree/main" tabindex="-1">
273+<span class="k">branch</span>main</a>
274+</li>
275+<li data-k="repo diffs">
276+<a href="/diffs" tabindex="-1">
277+<span class="k">repo</span>diffs</a>
278+</li>
279+</ul>
280+</dialog>
281+<footer class="bar status" aria-label="Status">
282+<span class="mode">commits</span>
283+<span class="st-ctx">main · 2 commits</span>
284+<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
285+<span class="hi">:</span> go to…</button>
286+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
287+</span>
288+<span class="st-hints">j/k move · enter open · / filter · ? help</span>
289+<span class="st-src">
290+<span class="st-ver">ermite devel · </span>
291+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
292+</span>
293+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
294+<span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/2</span>
295+</footer>
296+<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
297+ if (q('dialog[open]').count) return;
298+ let rows = q('[data-rows] > :not([hidden])').arr();
299+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
300+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
301+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
302+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
303+ })(1);
304+ k-k from:body -> ((d) => {
305+ if (q('dialog[open]').count) return;
306+ let rows = q('[data-rows] > :not([hidden])').arr();
307+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
308+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
309+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
310+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
311+ })(-1);
312+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
313+ 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));
314+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
315+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
316+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
317+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
318+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
319+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
320+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
321+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
322+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
323+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
324+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
325+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
326+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
327+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
328+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
329+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
330+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
331+ k-permalink from:body -> {
332+ let b = document.body.dataset;
333+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
334+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
335+ navigator.clipboard?.writeText(location.href);
336+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
337+ }">
338+</div>
339+</body>
340+</html>
A cmd/ermite/testdata/web/commits-path.html +355 -0
@@ -0,0 +1,355 @@
1+<!doctype html>
2+<html lang="en">
3+<head>
4+<meta charset="utf-8">
5+<meta name="viewport" content="width=device-width, initial-scale=1">
6+<meta name="color-scheme" content="light dark">
7+<title>history of a.txt · long · example.test</title>
8+<link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
9+<script src="/-/static/js/htmx.min.HASH.js" defer>
10+</script>
11+<script src="/-/static/js/hx-live.min.HASH.js" defer>
12+</script>
13+<script src="/-/static/js/hx-sse.min.HASH.js" defer>
14+</script>
15+<script src="/-/static/js/ermite.HASH.js" defer>
16+</script>
17+</head>
18+<body data-ref="main" data-sha="SHA40" data-sel="">
19+<a class="skip" href="#main">skip to content</a>
20+<header class="bar top">
21+<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
22+<span class="fade" aria-hidden="true">
23+</span>
24+<nav class="crumbs" aria-label="Path">
25+<ol>
26+<li>
27+<a href="/long">long</a>
28+</li>
29+<li>
30+<a href="/long/commits/main">commits</a>
31+</li>
32+<li>
33+<a href="/long/commits/main">main</a>
34+</li>
35+<li>
36+<span aria-current="page">a.txt</span>
37+</li>
38+</ol>
39+</nav>
40+<nav class="secs" aria-label="Sections">
41+<ul>
42+<li>
43+<a href="/long" data-key="s" aria-keyshortcuts="g s">summary</a>
44+</li>
45+<li>
46+<a href="/long/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a>
47+</li>
48+<li>
49+<a href="/long/commits/main" data-key="c" aria-keyshortcuts="g c" aria-current="page">commits</a>
50+</li>
51+<li>
52+<a href="/long/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
53+</li>
54+</ul>
55+</nav>
56+<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
57+</button>
58+</header>
59+<div class="work has-rail">
60+<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
61+<div class="filter">
62+<label for="commit-filter">filter&gt;</label>
63+<input id="commit-filter" type="search" data-filter placeholder="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));
64+ q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])'));
65+ let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')">
66+</div>
67+<p class="lede dim">history of <code>a.txt</code> at main</p>
68+<div class="days" id="commits-head">
69+<section aria-label="2026-03-16">
70+<h2 class="rule-h">
71+<span>2026-03-16</span>
72+</h2>
73+<table class="list commits" aria-label="Commits">
74+<tbody data-rows>
75+<tr id="c-SHA7" data-cursor>
76+<td class="cur" aria-hidden="true">
77+</td>
78+<td class="c-sha mute">SHA7</td>
79+<td class="c-subject">
80+<a href="/long/commit/SHA40">commit number 30</a>
81+</td>
82+<td class="c-who dim">T</td>
83+<td class="c-when nowrap dim">
84+<time datetime="STAMP" title="STAMP">AGO</time>
85+</td>
86+</tr>
87+</tbody>
88+</table>
89+</section>
90+<section aria-label="2026-03-02">
91+<h2 class="rule-h">
92+<span>2026-03-02</span>
93+</h2>
94+<table class="list commits" aria-label="Commits">
95+<tbody data-rows>
96+<tr id="c-SHA7">
97+<td class="cur" aria-hidden="true">
98+</td>
99+<td class="c-sha mute">SHA7</td>
100+<td class="c-subject">
101+<a href="/long/commit/SHA40">commit number 3</a>
102+</td>
103+<td class="c-who dim">T</td>
104+<td class="c-when nowrap dim">
105+<time datetime="STAMP" title="STAMP">AGO</time>
106+</td>
107+</tr>
108+</tbody>
109+</table>
110+</section>
111+</div>
112+</main>
113+<aside class="rail" aria-label="Context">
114+<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
115+<summary>history · refs on this page</summary>
116+<section aria-labelledby="r-hist">
117+<h2 id="r-hist">history</h2>
118+<dl class="kv">
119+<dt>ref</dt>
120+<dd>main</dd>
121+<dt>on page</dt>
122+<dd>2</dd>
123+<dt>path</dt>
124+<dd>a.txt</dd>
125+<dt>file</dt>
126+<dd>
127+<a href="/long/blob/main/a.txt">open</a>
128+</dd>
129+</dl>
130+</section>
131+<section aria-labelledby="r-refs">
132+<h2 id="r-refs">refs on this page</h2>
133+<p class="dim">no tag or branch head here</p>
134+</section>
135+</details>
136+</aside>
137+</div>
138+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
139+<div class="panel-head">
140+<span>keys</span>
141+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
142+</div>
143+<dl class="keys">
144+<div>
145+<dt>
146+<kbd>j</kbd>
147+<kbd>k</kbd>
148+</dt>
149+<dd>move cursor down / up</dd>
150+</div>
151+<div>
152+<dt>
153+<kbd>enter</kbd>
154+</dt>
155+<dd>open row under cursor</dd>
156+</div>
157+<div>
158+<dt>
159+<kbd>g</kbd>
160+<kbd>g</kbd> / <kbd>G</kbd>
161+</dt>
162+<dd>first / last row</dd>
163+</div>
164+<div>
165+<dt>
166+<kbd>/</kbd>
167+</dt>
168+<dd>focus the filter</dd>
169+</div>
170+<div>
171+<dt>
172+<kbd>g h</kbd>
173+<kbd>g t</kbd>
174+</dt>
175+<dd>go home / tree</dd>
176+</div>
177+<div>
178+<dt>
179+<kbd>g c</kbd>
180+<kbd>g r</kbd>
181+</dt>
182+<dd>go commits / refs</dd>
183+</div>
184+<div>
185+<dt>
186+<kbd>b</kbd>
187+</dt>
188+<dd>switch ref (branches, tags)</dd>
189+</div>
190+<div>
191+<dt>
192+<kbd>:</kbd>
193+<kbd>f</kbd>
194+</dt>
195+<dd>go to: section, ref, file, repo</dd>
196+</div>
197+<div>
198+<dt>
199+<kbd>y</kbd>
200+</dt>
201+<dd>permalink: ref becomes the full sha</dd>
202+</div>
203+<div>
204+<dt>
205+<kbd>s</kbd>
206+<kbd>t</kbd>
207+<kbd>c</kbd>
208+<kbd>r</kbd>
209+</dt>
210+<dd>repo sections (when shown)</dd>
211+</div>
212+<div>
213+<dt>
214+<kbd>d</kbd>
215+<kbd>u</kbd>
216+<kbd>x</kbd>
217+</dt>
218+<dd>commit: split / unified / collapse all</dd>
219+</div>
220+<div>
221+<dt>
222+<kbd>esc</kbd>
223+</dt>
224+<dd>close panel</dd>
225+</div>
226+</dl>
227+</dialog>
228+<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
229+<div class="panel-head">
230+<label for="refs-filter">ref&gt;</label>
231+<input id="refs-filter" type="search" placeholder="filter branches and tags" 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+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
233+</div>
234+<div class="panel-cols" data-picklist>
235+<section aria-label="Branches">
236+<h2>branches <span class="mute">1</span>
237+</h2>
238+<ul>
239+<li>
240+<a href="/long/tree/main" aria-current="true">
241+<span class="mark" aria-hidden="true">●</span>main</a>
242+</li>
243+</ul>
244+</section>
245+<section aria-label="Tags">
246+<h2>tags <span class="mute">0</span>
247+</h2>
248+<ul>
249+</ul>
250+</section>
251+</div>
252+</dialog>
253+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
254+<div class="panel-head">
255+<label for="cmd-q" class="hi">:</label>
256+<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
257+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
258+ 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();
259+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
260+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
261+ 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' })) }
262+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
263+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
264+</div>
265+<ul id="cmd-list" class="cmd-list">
266+<li data-k="go home" data-cursor>
267+<a href="/" tabindex="-1">
268+<span class="k">go</span>home</a>
269+</li>
270+<li data-k="section summary">
271+<a href="/long" tabindex="-1">
272+<span class="k">section</span>summary</a>
273+</li>
274+<li data-k="section tree">
275+<a href="/long/tree/main" tabindex="-1">
276+<span class="k">section</span>tree</a>
277+</li>
278+<li data-k="section commits">
279+<a href="/long/commits/main" tabindex="-1">
280+<span class="k">section</span>commits</a>
281+</li>
282+<li data-k="section refs">
283+<a href="/long/refs" tabindex="-1">
284+<span class="k">section</span>refs</a>
285+</li>
286+<li data-k="branch main">
287+<a href="/long/tree/main" tabindex="-1">
288+<span class="k">branch</span>main</a>
289+</li>
290+<li data-k="repo diffs">
291+<a href="/diffs" tabindex="-1">
292+<span class="k">repo</span>diffs</a>
293+</li>
294+</ul>
295+</dialog>
296+<footer class="bar status" aria-label="Status">
297+<span class="mode">commits</span>
298+<span class="st-ctx">main · history of a.txt · 2 commits</span>
299+<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
300+<span class="hi">:</span> go to…</button>
301+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
302+</span>
303+<span class="st-hints">j/k move · enter open · / filter · ? help</span>
304+<span class="st-src">
305+<span class="st-ver">ermite devel · </span>
306+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
307+</span>
308+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
309+<span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/2</span>
310+</footer>
311+<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
312+ if (q('dialog[open]').count) return;
313+ let rows = q('[data-rows] > :not([hidden])').arr();
314+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
315+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
316+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
317+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
318+ })(1);
319+ k-k from:body -> ((d) => {
320+ if (q('dialog[open]').count) return;
321+ let rows = q('[data-rows] > :not([hidden])').arr();
322+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
323+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
324+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
325+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
326+ })(-1);
327+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
328+ 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));
329+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
330+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
331+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
332+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
333+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
334+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
335+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
336+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
337+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
338+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
339+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
340+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
341+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
342+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
343+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
344+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
345+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
346+ k-permalink from:body -> {
347+ let b = document.body.dataset;
348+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
349+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
350+ navigator.clipboard?.writeText(location.href);
351+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
352+ }">
353+</div>
354+</body>
355+</html>
@@ -0,0 +1,355 @@
1+<!doctype html>
2+<html lang="en">
3+<head>
4+<meta charset="utf-8">
5+<meta name="viewport" content="width=device-width, initial-scale=1">
6+<meta name="color-scheme" content="light dark">
7+<title>history of a.txt · long · example.test</title>
8+<link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
9+<script src="/-/static/js/htmx.min.HASH.js" defer>
10+</script>
11+<script src="/-/static/js/hx-live.min.HASH.js" defer>
12+</script>
13+<script src="/-/static/js/hx-sse.min.HASH.js" defer>
14+</script>
15+<script src="/-/static/js/ermite.HASH.js" defer>
16+</script>
17+</head>
18+<body data-ref="main" data-sha="SHA40" data-sel="">
19+<a class="skip" href="#main">skip to content</a>
20+<header class="bar top">
21+<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
22+<span class="fade" aria-hidden="true">
23+</span>
24+<nav class="crumbs" aria-label="Path">
25+<ol>
26+<li>
27+<a href="/long">long</a>
28+</li>
29+<li>
30+<a href="/long/commits/main">commits</a>
31+</li>
32+<li>
33+<a href="/long/commits/main">main</a>
34+</li>
35+<li>
36+<span aria-current="page">a.txt</span>
37+</li>
38+</ol>
39+</nav>
40+<nav class="secs" aria-label="Sections">
41+<ul>
42+<li>
43+<a href="/long" data-key="s" aria-keyshortcuts="g s">summary</a>
44+</li>
45+<li>
46+<a href="/long/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a>
47+</li>
48+<li>
49+<a href="/long/commits/main" data-key="c" aria-keyshortcuts="g c" aria-current="page">commits</a>
50+</li>
51+<li>
52+<a href="/long/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
53+</li>
54+</ul>
55+</nav>
56+<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
57+</button>
58+</header>
59+<div class="work has-rail">
60+<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
61+<div class="filter">
62+<label for="commit-filter">filter&gt;</label>
63+<input id="commit-filter" type="search" data-filter placeholder="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));
64+ q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])'));
65+ let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')">
66+</div>
67+<p class="lede dim">history of <code>a.txt</code> at main</p>
68+<div class="days" id="commits-head">
69+<section aria-label="2026-03-16">
70+<h2 class="rule-h">
71+<span>2026-03-16</span>
72+</h2>
73+<table class="list commits" aria-label="Commits">
74+<tbody data-rows>
75+<tr id="c-SHA7" data-cursor>
76+<td class="cur" aria-hidden="true">
77+</td>
78+<td class="c-sha mute">SHA7</td>
79+<td class="c-subject">
80+<a href="/long/commit/SHA40">commit number 30</a>
81+</td>
82+<td class="c-who dim">T</td>
83+<td class="c-when nowrap dim">
84+<time datetime="STAMP" title="STAMP">AGO</time>
85+</td>
86+</tr>
87+</tbody>
88+</table>
89+</section>
90+<section aria-label="2026-03-02">
91+<h2 class="rule-h">
92+<span>2026-03-02</span>
93+</h2>
94+<table class="list commits" aria-label="Commits">
95+<tbody data-rows>
96+<tr id="c-SHA7">
97+<td class="cur" aria-hidden="true">
98+</td>
99+<td class="c-sha mute">SHA7</td>
100+<td class="c-subject">
101+<a href="/long/commit/SHA40">commit number 3</a>
102+</td>
103+<td class="c-who dim">T</td>
104+<td class="c-when nowrap dim">
105+<time datetime="STAMP" title="STAMP">AGO</time>
106+</td>
107+</tr>
108+</tbody>
109+</table>
110+</section>
111+</div>
112+</main>
113+<aside class="rail" aria-label="Context">
114+<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
115+<summary>history · refs on this page</summary>
116+<section aria-labelledby="r-hist">
117+<h2 id="r-hist">history</h2>
118+<dl class="kv">
119+<dt>ref</dt>
120+<dd>main</dd>
121+<dt>on page</dt>
122+<dd>2</dd>
123+<dt>path</dt>
124+<dd>a.txt</dd>
125+<dt>file</dt>
126+<dd>
127+<a href="/long/blob/main/a.txt">open</a>
128+</dd>
129+</dl>
130+</section>
131+<section aria-labelledby="r-refs">
132+<h2 id="r-refs">refs on this page</h2>
133+<p class="dim">no tag or branch head here</p>
134+</section>
135+</details>
136+</aside>
137+</div>
138+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
139+<div class="panel-head">
140+<span>keys</span>
141+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
142+</div>
143+<dl class="keys">
144+<div>
145+<dt>
146+<kbd>j</kbd>
147+<kbd>k</kbd>
148+</dt>
149+<dd>move cursor down / up</dd>
150+</div>
151+<div>
152+<dt>
153+<kbd>enter</kbd>
154+</dt>
155+<dd>open row under cursor</dd>
156+</div>
157+<div>
158+<dt>
159+<kbd>g</kbd>
160+<kbd>g</kbd> / <kbd>G</kbd>
161+</dt>
162+<dd>first / last row</dd>
163+</div>
164+<div>
165+<dt>
166+<kbd>/</kbd>
167+</dt>
168+<dd>focus the filter</dd>
169+</div>
170+<div>
171+<dt>
172+<kbd>g h</kbd>
173+<kbd>g t</kbd>
174+</dt>
175+<dd>go home / tree</dd>
176+</div>
177+<div>
178+<dt>
179+<kbd>g c</kbd>
180+<kbd>g r</kbd>
181+</dt>
182+<dd>go commits / refs</dd>
183+</div>
184+<div>
185+<dt>
186+<kbd>b</kbd>
187+</dt>
188+<dd>switch ref (branches, tags)</dd>
189+</div>
190+<div>
191+<dt>
192+<kbd>:</kbd>
193+<kbd>f</kbd>
194+</dt>
195+<dd>go to: section, ref, file, repo</dd>
196+</div>
197+<div>
198+<dt>
199+<kbd>y</kbd>
200+</dt>
201+<dd>permalink: ref becomes the full sha</dd>
202+</div>
203+<div>
204+<dt>
205+<kbd>s</kbd>
206+<kbd>t</kbd>
207+<kbd>c</kbd>
208+<kbd>r</kbd>
209+</dt>
210+<dd>repo sections (when shown)</dd>
211+</div>
212+<div>
213+<dt>
214+<kbd>d</kbd>
215+<kbd>u</kbd>
216+<kbd>x</kbd>
217+</dt>
218+<dd>commit: split / unified / collapse all</dd>
219+</div>
220+<div>
221+<dt>
222+<kbd>esc</kbd>
223+</dt>
224+<dd>close panel</dd>
225+</div>
226+</dl>
227+</dialog>
228+<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
229+<div class="panel-head">
230+<label for="refs-filter">ref&gt;</label>
231+<input id="refs-filter" type="search" placeholder="filter branches and tags" 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+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
233+</div>
234+<div class="panel-cols" data-picklist>
235+<section aria-label="Branches">
236+<h2>branches <span class="mute">1</span>
237+</h2>
238+<ul>
239+<li>
240+<a href="/long/tree/main" aria-current="true">
241+<span class="mark" aria-hidden="true">●</span>main</a>
242+</li>
243+</ul>
244+</section>
245+<section aria-label="Tags">
246+<h2>tags <span class="mute">0</span>
247+</h2>
248+<ul>
249+</ul>
250+</section>
251+</div>
252+</dialog>
253+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
254+<div class="panel-head">
255+<label for="cmd-q" class="hi">:</label>
256+<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
257+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
258+ 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();
259+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
260+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
261+ 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' })) }
262+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
263+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
264+</div>
265+<ul id="cmd-list" class="cmd-list">
266+<li data-k="go home" data-cursor>
267+<a href="/" tabindex="-1">
268+<span class="k">go</span>home</a>
269+</li>
270+<li data-k="section summary">
271+<a href="/long" tabindex="-1">
272+<span class="k">section</span>summary</a>
273+</li>
274+<li data-k="section tree">
275+<a href="/long/tree/main" tabindex="-1">
276+<span class="k">section</span>tree</a>
277+</li>
278+<li data-k="section commits">
279+<a href="/long/commits/main" tabindex="-1">
280+<span class="k">section</span>commits</a>
281+</li>
282+<li data-k="section refs">
283+<a href="/long/refs" tabindex="-1">
284+<span class="k">section</span>refs</a>
285+</li>
286+<li data-k="branch main">
287+<a href="/long/tree/main" tabindex="-1">
288+<span class="k">branch</span>main</a>
289+</li>
290+<li data-k="repo diffs">
291+<a href="/diffs" tabindex="-1">
292+<span class="k">repo</span>diffs</a>
293+</li>
294+</ul>
295+</dialog>
296+<footer class="bar status" aria-label="Status">
297+<span class="mode">commits</span>
298+<span class="st-ctx">main · history of a.txt · 2 commits</span>
299+<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
300+<span class="hi">:</span> go to…</button>
301+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
302+</span>
303+<span class="st-hints">j/k move · enter open · / filter · ? help</span>
304+<span class="st-src">
305+<span class="st-ver">ermite devel · </span>
306+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
307+</span>
308+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
309+<span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/2</span>
310+</footer>
311+<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
312+ if (q('dialog[open]').count) return;
313+ let rows = q('[data-rows] > :not([hidden])').arr();
314+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
315+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
316+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
317+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
318+ })(1);
319+ k-k from:body -> ((d) => {
320+ if (q('dialog[open]').count) return;
321+ let rows = q('[data-rows] > :not([hidden])').arr();
322+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
323+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
324+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
325+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
326+ })(-1);
327+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
328+ 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));
329+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
330+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
331+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
332+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
333+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
334+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
335+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
336+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
337+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
338+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
339+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
340+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
341+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
342+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
343+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
344+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
345+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
346+ k-permalink from:body -> {
347+ let b = document.body.dataset;
348+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
349+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
350+ navigator.clipboard?.writeText(location.href);
351+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
352+ }">
353+</div>
354+</body>
355+</html>
A cmd/ermite/testdata/web/commits-root.html +1135 -0
@@ -0,0 +1,1135 @@
1+<!doctype html>
2+<html lang="en">
3+<head>
4+<meta charset="utf-8">
5+<meta name="viewport" content="width=device-width, initial-scale=1">
6+<meta name="color-scheme" content="light dark">
7+<title>commits · long · example.test</title>
8+<link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
9+<script src="/-/static/js/htmx.min.HASH.js" defer>
10+</script>
11+<script src="/-/static/js/hx-live.min.HASH.js" defer>
12+</script>
13+<script src="/-/static/js/hx-sse.min.HASH.js" defer>
14+</script>
15+<script src="/-/static/js/ermite.HASH.js" defer>
16+</script>
17+</head>
18+<body data-ref="main" data-sha="SHA40" data-sel="">
19+<a class="skip" href="#main">skip to content</a>
20+<header class="bar top">
21+<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
22+<span class="fade" aria-hidden="true">
23+</span>
24+<nav class="crumbs" aria-label="Path">
25+<ol>
26+<li>
27+<a href="/long">long</a>
28+</li>
29+<li>
30+<a href="/long/commits/main">commits</a>
31+</li>
32+<li>
33+<a href="/long/commits/main" aria-current="page">main</a>
34+</li>
35+</ol>
36+</nav>
37+<nav class="secs" aria-label="Sections">
38+<ul>
39+<li>
40+<a href="/long" data-key="s" aria-keyshortcuts="g s">summary</a>
41+</li>
42+<li>
43+<a href="/long/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a>
44+</li>
45+<li>
46+<a href="/long/commits/main" data-key="c" aria-keyshortcuts="g c" aria-current="page">commits</a>
47+</li>
48+<li>
49+<a href="/long/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
50+</li>
51+</ul>
52+</nav>
53+<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
54+</button>
55+</header>
56+<div class="work has-rail">
57+<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
58+<div class="filter">
59+<label for="commit-filter">filter&gt;</label>
60+<input id="commit-filter" type="search" data-filter placeholder="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));
61+ q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])'));
62+ let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')">
63+</div>
64+<div class="days" id="commits-head">
65+<section aria-label="2026-03-26">
66+<h2 class="rule-h">
67+<span>2026-03-26</span>
68+</h2>
69+<table class="list commits" aria-label="Commits">
70+<tbody data-rows>
71+<tr id="c-SHA7" data-cursor>
72+<td class="cur" aria-hidden="true">
73+</td>
74+<td class="c-sha mute">SHA7</td>
75+<td class="c-subject">
76+<a href="/long/commit/SHA40">commit number 51</a>
77+<span class="tagged">[main]</span>
78+</td>
79+<td class="c-who dim">T</td>
80+<td class="c-when nowrap dim">
81+<time datetime="STAMP" title="STAMP">AGO</time>
82+</td>
83+</tr>
84+<tr id="c-SHA7">
85+<td class="cur" aria-hidden="true">
86+</td>
87+<td class="c-sha mute">SHA7</td>
88+<td class="c-subject">
89+<a href="/long/commit/SHA40">commit number 50</a>
90+</td>
91+<td class="c-who dim">T</td>
92+<td class="c-when nowrap dim">
93+<time datetime="STAMP" title="STAMP">AGO</time>
94+</td>
95+</tr>
96+</tbody>
97+</table>
98+</section>
99+<section aria-label="2026-03-25">
100+<h2 class="rule-h">
101+<span>2026-03-25</span>
102+</h2>
103+<table class="list commits" aria-label="Commits">
104+<tbody data-rows>
105+<tr id="c-SHA7">
106+<td class="cur" aria-hidden="true">
107+</td>
108+<td class="c-sha mute">SHA7</td>
109+<td class="c-subject">
110+<a href="/long/commit/SHA40">commit number 49</a>
111+</td>
112+<td class="c-who dim">T</td>
113+<td class="c-when nowrap dim">
114+<time datetime="STAMP" title="STAMP">AGO</time>
115+</td>
116+</tr>
117+<tr id="c-SHA7">
118+<td class="cur" aria-hidden="true">
119+</td>
120+<td class="c-sha mute">SHA7</td>
121+<td class="c-subject">
122+<a href="/long/commit/SHA40">commit number 48</a>
123+</td>
124+<td class="c-who dim">T</td>
125+<td class="c-when nowrap dim">
126+<time datetime="STAMP" title="STAMP">AGO</time>
127+</td>
128+</tr>
129+</tbody>
130+</table>
131+</section>
132+<section aria-label="2026-03-24">
133+<h2 class="rule-h">
134+<span>2026-03-24</span>
135+</h2>
136+<table class="list commits" aria-label="Commits">
137+<tbody data-rows>
138+<tr id="c-SHA7">
139+<td class="cur" aria-hidden="true">
140+</td>
141+<td class="c-sha mute">SHA7</td>
142+<td class="c-subject">
143+<a href="/long/commit/SHA40">commit number 47</a>
144+</td>
145+<td class="c-who dim">T</td>
146+<td class="c-when nowrap dim">
147+<time datetime="STAMP" title="STAMP">AGO</time>
148+</td>
149+</tr>
150+<tr id="c-SHA7">
151+<td class="cur" aria-hidden="true">
152+</td>
153+<td class="c-sha mute">SHA7</td>
154+<td class="c-subject">
155+<a href="/long/commit/SHA40">commit number 46</a>
156+</td>
157+<td class="c-who dim">T</td>
158+<td class="c-when nowrap dim">
159+<time datetime="STAMP" title="STAMP">AGO</time>
160+</td>
161+</tr>
162+</tbody>
163+</table>
164+</section>
165+<section aria-label="2026-03-23">
166+<h2 class="rule-h">
167+<span>2026-03-23</span>
168+</h2>
169+<table class="list commits" aria-label="Commits">
170+<tbody data-rows>
171+<tr id="c-SHA7">
172+<td class="cur" aria-hidden="true">
173+</td>
174+<td class="c-sha mute">SHA7</td>
175+<td class="c-subject">
176+<a href="/long/commit/SHA40">commit number 45</a>
177+</td>
178+<td class="c-who dim">T</td>
179+<td class="c-when nowrap dim">
180+<time datetime="STAMP" title="STAMP">AGO</time>
181+</td>
182+</tr>
183+<tr id="c-SHA7">
184+<td class="cur" aria-hidden="true">
185+</td>
186+<td class="c-sha mute">SHA7</td>
187+<td class="c-subject">
188+<a href="/long/commit/SHA40">commit number 44</a>
189+</td>
190+<td class="c-who dim">T</td>
191+<td class="c-when nowrap dim">
192+<time datetime="STAMP" title="STAMP">AGO</time>
193+</td>
194+</tr>
195+</tbody>
196+</table>
197+</section>
198+<section aria-label="2026-03-22">
199+<h2 class="rule-h">
200+<span>2026-03-22</span>
201+</h2>
202+<table class="list commits" aria-label="Commits">
203+<tbody data-rows>
204+<tr id="c-SHA7">
205+<td class="cur" aria-hidden="true">
206+</td>
207+<td class="c-sha mute">SHA7</td>
208+<td class="c-subject">
209+<a href="/long/commit/SHA40">commit number 43</a>
210+</td>
211+<td class="c-who dim">T</td>
212+<td class="c-when nowrap dim">
213+<time datetime="STAMP" title="STAMP">AGO</time>
214+</td>
215+</tr>
216+<tr id="c-SHA7">
217+<td class="cur" aria-hidden="true">
218+</td>
219+<td class="c-sha mute">SHA7</td>
220+<td class="c-subject">
221+<a href="/long/commit/SHA40">commit number 42</a>
222+</td>
223+<td class="c-who dim">T</td>
224+<td class="c-when nowrap dim">
225+<time datetime="STAMP" title="STAMP">AGO</time>
226+</td>
227+</tr>
228+</tbody>
229+</table>
230+</section>
231+<section aria-label="2026-03-21">
232+<h2 class="rule-h">
233+<span>2026-03-21</span>
234+</h2>
235+<table class="list commits" aria-label="Commits">
236+<tbody data-rows>
237+<tr id="c-SHA7">
238+<td class="cur" aria-hidden="true">
239+</td>
240+<td class="c-sha mute">SHA7</td>
241+<td class="c-subject">
242+<a href="/long/commit/SHA40">commit number 41</a>
243+</td>
244+<td class="c-who dim">T</td>
245+<td class="c-when nowrap dim">
246+<time datetime="STAMP" title="STAMP">AGO</time>
247+</td>
248+</tr>
249+<tr id="c-SHA7">
250+<td class="cur" aria-hidden="true">
251+</td>
252+<td class="c-sha mute">SHA7</td>
253+<td class="c-subject">
254+<a href="/long/commit/SHA40">commit number 40</a>
255+</td>
256+<td class="c-who dim">T</td>
257+<td class="c-when nowrap dim">
258+<time datetime="STAMP" title="STAMP">AGO</time>
259+</td>
260+</tr>
261+</tbody>
262+</table>
263+</section>
264+<section aria-label="2026-03-20">
265+<h2 class="rule-h">
266+<span>2026-03-20</span>
267+</h2>
268+<table class="list commits" aria-label="Commits">
269+<tbody data-rows>
270+<tr id="c-SHA7">
271+<td class="cur" aria-hidden="true">
272+</td>
273+<td class="c-sha mute">SHA7</td>
274+<td class="c-subject">
275+<a href="/long/commit/SHA40">commit number 39</a>
276+</td>
277+<td class="c-who dim">T</td>
278+<td class="c-when nowrap dim">
279+<time datetime="STAMP" title="STAMP">AGO</time>
280+</td>
281+</tr>
282+<tr id="c-SHA7">
283+<td class="cur" aria-hidden="true">
284+</td>
285+<td class="c-sha mute">SHA7</td>
286+<td class="c-subject">
287+<a href="/long/commit/SHA40">commit number 38</a>
288+</td>
289+<td class="c-who dim">T</td>
290+<td class="c-when nowrap dim">
291+<time datetime="STAMP" title="STAMP">AGO</time>
292+</td>
293+</tr>
294+</tbody>
295+</table>
296+</section>
297+<section aria-label="2026-03-19">
298+<h2 class="rule-h">
299+<span>2026-03-19</span>
300+</h2>
301+<table class="list commits" aria-label="Commits">
302+<tbody data-rows>
303+<tr id="c-SHA7">
304+<td class="cur" aria-hidden="true">
305+</td>
306+<td class="c-sha mute">SHA7</td>
307+<td class="c-subject">
308+<a href="/long/commit/SHA40">commit number 37</a>
309+</td>
310+<td class="c-who dim">T</td>
311+<td class="c-when nowrap dim">
312+<time datetime="STAMP" title="STAMP">AGO</time>
313+</td>
314+</tr>
315+<tr id="c-SHA7">
316+<td class="cur" aria-hidden="true">
317+</td>
318+<td class="c-sha mute">SHA7</td>
319+<td class="c-subject">
320+<a href="/long/commit/SHA40">commit number 36</a>
321+</td>
322+<td class="c-who dim">T</td>
323+<td class="c-when nowrap dim">
324+<time datetime="STAMP" title="STAMP">AGO</time>
325+</td>
326+</tr>
327+</tbody>
328+</table>
329+</section>
330+<section aria-label="2026-03-18">
331+<h2 class="rule-h">
332+<span>2026-03-18</span>
333+</h2>
334+<table class="list commits" aria-label="Commits">
335+<tbody data-rows>
336+<tr id="c-SHA7">
337+<td class="cur" aria-hidden="true">
338+</td>
339+<td class="c-sha mute">SHA7</td>
340+<td class="c-subject">
341+<a href="/long/commit/SHA40">commit number 35</a>
342+</td>
343+<td class="c-who dim">T</td>
344+<td class="c-when nowrap dim">
345+<time datetime="STAMP" title="STAMP">AGO</time>
346+</td>
347+</tr>
348+<tr id="c-SHA7">
349+<td class="cur" aria-hidden="true">
350+</td>
351+<td class="c-sha mute">SHA7</td>
352+<td class="c-subject">
353+<a href="/long/commit/SHA40">commit number 34</a>
354+</td>
355+<td class="c-who dim">T</td>
356+<td class="c-when nowrap dim">
357+<time datetime="STAMP" title="STAMP">AGO</time>
358+</td>
359+</tr>
360+</tbody>
361+</table>
362+</section>
363+<section aria-label="2026-03-17">
364+<h2 class="rule-h">
365+<span>2026-03-17</span>
366+</h2>
367+<table class="list commits" aria-label="Commits">
368+<tbody data-rows>
369+<tr id="c-SHA7">
370+<td class="cur" aria-hidden="true">
371+</td>
372+<td class="c-sha mute">SHA7</td>
373+<td class="c-subject">
374+<a href="/long/commit/SHA40">commit number 33</a>
375+</td>
376+<td class="c-who dim">T</td>
377+<td class="c-when nowrap dim">
378+<time datetime="STAMP" title="STAMP">AGO</time>
379+</td>
380+</tr>
381+<tr id="c-SHA7">
382+<td class="cur" aria-hidden="true">
383+</td>
384+<td class="c-sha mute">SHA7</td>
385+<td class="c-subject">
386+<a href="/long/commit/SHA40">commit number 32</a>
387+</td>
388+<td class="c-who dim">T</td>
389+<td class="c-when nowrap dim">
390+<time datetime="STAMP" title="STAMP">AGO</time>
391+</td>
392+</tr>
393+</tbody>
394+</table>
395+</section>
396+<section aria-label="2026-03-16">
397+<h2 class="rule-h">
398+<span>2026-03-16</span>
399+</h2>
400+<table class="list commits" aria-label="Commits">
401+<tbody data-rows>
402+<tr id="c-SHA7">
403+<td class="cur" aria-hidden="true">
404+</td>
405+<td class="c-sha mute">SHA7</td>
406+<td class="c-subject">
407+<a href="/long/commit/SHA40">commit number 31</a>
408+</td>
409+<td class="c-who dim">T</td>
410+<td class="c-when nowrap dim">
411+<time datetime="STAMP" title="STAMP">AGO</time>
412+</td>
413+</tr>
414+<tr id="c-SHA7">
415+<td class="cur" aria-hidden="true">
416+</td>
417+<td class="c-sha mute">SHA7</td>
418+<td class="c-subject">
419+<a href="/long/commit/SHA40">commit number 30</a>
420+</td>
421+<td class="c-who dim">T</td>
422+<td class="c-when nowrap dim">
423+<time datetime="STAMP" title="STAMP">AGO</time>
424+</td>
425+</tr>
426+</tbody>
427+</table>
428+</section>
429+<section aria-label="2026-03-15">
430+<h2 class="rule-h">
431+<span>2026-03-15</span>
432+</h2>
433+<table class="list commits" aria-label="Commits">
434+<tbody data-rows>
435+<tr id="c-SHA7">
436+<td class="cur" aria-hidden="true">
437+</td>
438+<td class="c-sha mute">SHA7</td>
439+<td class="c-subject">
440+<a href="/long/commit/SHA40">commit number 29</a>
441+</td>
442+<td class="c-who dim">T</td>
443+<td class="c-when nowrap dim">
444+<time datetime="STAMP" title="STAMP">AGO</time>
445+</td>
446+</tr>
447+<tr id="c-SHA7">
448+<td class="cur" aria-hidden="true">
449+</td>
450+<td class="c-sha mute">SHA7</td>
451+<td class="c-subject">
452+<a href="/long/commit/SHA40">commit number 28</a>
453+</td>
454+<td class="c-who dim">T</td>
455+<td class="c-when nowrap dim">
456+<time datetime="STAMP" title="STAMP">AGO</time>
457+</td>
458+</tr>
459+</tbody>
460+</table>
461+</section>
462+<section aria-label="2026-03-14">
463+<h2 class="rule-h">
464+<span>2026-03-14</span>
465+</h2>
466+<table class="list commits" aria-label="Commits">
467+<tbody data-rows>
468+<tr id="c-SHA7">
469+<td class="cur" aria-hidden="true">
470+</td>
471+<td class="c-sha mute">SHA7</td>
472+<td class="c-subject">
473+<a href="/long/commit/SHA40">commit number 27</a>
474+</td>
475+<td class="c-who dim">T</td>
476+<td class="c-when nowrap dim">
477+<time datetime="STAMP" title="STAMP">AGO</time>
478+</td>
479+</tr>
480+<tr id="c-SHA7">
481+<td class="cur" aria-hidden="true">
482+</td>
483+<td class="c-sha mute">SHA7</td>
484+<td class="c-subject">
485+<a href="/long/commit/SHA40">commit number 26</a>
486+</td>
487+<td class="c-who dim">T</td>
488+<td class="c-when nowrap dim">
489+<time datetime="STAMP" title="STAMP">AGO</time>
490+</td>
491+</tr>
492+</tbody>
493+</table>
494+</section>
495+<section aria-label="2026-03-13">
496+<h2 class="rule-h">
497+<span>2026-03-13</span>
498+</h2>
499+<table class="list commits" aria-label="Commits">
500+<tbody data-rows>
501+<tr id="c-SHA7">
502+<td class="cur" aria-hidden="true">
503+</td>
504+<td class="c-sha mute">SHA7</td>
505+<td class="c-subject">
506+<a href="/long/commit/SHA40">commit number 25</a>
507+</td>
508+<td class="c-who dim">T</td>
509+<td class="c-when nowrap dim">
510+<time datetime="STAMP" title="STAMP">AGO</time>
511+</td>
512+</tr>
513+<tr id="c-SHA7">
514+<td class="cur" aria-hidden="true">
515+</td>
516+<td class="c-sha mute">SHA7</td>
517+<td class="c-subject">
518+<a href="/long/commit/SHA40">commit number 24</a>
519+</td>
520+<td class="c-who dim">T</td>
521+<td class="c-when nowrap dim">
522+<time datetime="STAMP" title="STAMP">AGO</time>
523+</td>
524+</tr>
525+</tbody>
526+</table>
527+</section>
528+<section aria-label="2026-03-12">
529+<h2 class="rule-h">
530+<span>2026-03-12</span>
531+</h2>
532+<table class="list commits" aria-label="Commits">
533+<tbody data-rows>
534+<tr id="c-SHA7">
535+<td class="cur" aria-hidden="true">
536+</td>
537+<td class="c-sha mute">SHA7</td>
538+<td class="c-subject">
539+<a href="/long/commit/SHA40">commit number 23</a>
540+</td>
541+<td class="c-who dim">T</td>
542+<td class="c-when nowrap dim">
543+<time datetime="STAMP" title="STAMP">AGO</time>
544+</td>
545+</tr>
546+<tr id="c-SHA7">
547+<td class="cur" aria-hidden="true">
548+</td>
549+<td class="c-sha mute">SHA7</td>
550+<td class="c-subject">
551+<a href="/long/commit/SHA40">commit number 22</a>
552+</td>
553+<td class="c-who dim">T</td>
554+<td class="c-when nowrap dim">
555+<time datetime="STAMP" title="STAMP">AGO</time>
556+</td>
557+</tr>
558+</tbody>
559+</table>
560+</section>
561+<section aria-label="2026-03-11">
562+<h2 class="rule-h">
563+<span>2026-03-11</span>
564+</h2>
565+<table class="list commits" aria-label="Commits">
566+<tbody data-rows>
567+<tr id="c-SHA7">
568+<td class="cur" aria-hidden="true">
569+</td>
570+<td class="c-sha mute">SHA7</td>
571+<td class="c-subject">
572+<a href="/long/commit/SHA40">commit number 21</a>
573+</td>
574+<td class="c-who dim">T</td>
575+<td class="c-when nowrap dim">
576+<time datetime="STAMP" title="STAMP">AGO</time>
577+</td>
578+</tr>
579+<tr id="c-SHA7">
580+<td class="cur" aria-hidden="true">
581+</td>
582+<td class="c-sha mute">SHA7</td>
583+<td class="c-subject">
584+<a href="/long/commit/SHA40">commit number 20</a>
585+</td>
586+<td class="c-who dim">T</td>
587+<td class="c-when nowrap dim">
588+<time datetime="STAMP" title="STAMP">AGO</time>
589+</td>
590+</tr>
591+</tbody>
592+</table>
593+</section>
594+<section aria-label="2026-03-10">
595+<h2 class="rule-h">
596+<span>2026-03-10</span>
597+</h2>
598+<table class="list commits" aria-label="Commits">
599+<tbody data-rows>
600+<tr id="c-SHA7">
601+<td class="cur" aria-hidden="true">
602+</td>
603+<td class="c-sha mute">SHA7</td>
604+<td class="c-subject">
605+<a href="/long/commit/SHA40">commit number 19</a>
606+</td>
607+<td class="c-who dim">T</td>
608+<td class="c-when nowrap dim">
609+<time datetime="STAMP" title="STAMP">AGO</time>
610+</td>
611+</tr>
612+<tr id="c-SHA7">
613+<td class="cur" aria-hidden="true">
614+</td>
615+<td class="c-sha mute">SHA7</td>
616+<td class="c-subject">
617+<a href="/long/commit/SHA40">commit number 18</a>
618+</td>
619+<td class="c-who dim">T</td>
620+<td class="c-when nowrap dim">
621+<time datetime="STAMP" title="STAMP">AGO</time>
622+</td>
623+</tr>
624+</tbody>
625+</table>
626+</section>
627+<section aria-label="2026-03-09">
628+<h2 class="rule-h">
629+<span>2026-03-09</span>
630+</h2>
631+<table class="list commits" aria-label="Commits">
632+<tbody data-rows>
633+<tr id="c-SHA7">
634+<td class="cur" aria-hidden="true">
635+</td>
636+<td class="c-sha mute">SHA7</td>
637+<td class="c-subject">
638+<a href="/long/commit/SHA40">commit number 17</a>
639+</td>
640+<td class="c-who dim">T</td>
641+<td class="c-when nowrap dim">
642+<time datetime="STAMP" title="STAMP">AGO</time>
643+</td>
644+</tr>
645+<tr id="c-SHA7">
646+<td class="cur" aria-hidden="true">
647+</td>
648+<td class="c-sha mute">SHA7</td>
649+<td class="c-subject">
650+<a href="/long/commit/SHA40">commit number 16</a>
651+</td>
652+<td class="c-who dim">T</td>
653+<td class="c-when nowrap dim">
654+<time datetime="STAMP" title="STAMP">AGO</time>
655+</td>
656+</tr>
657+</tbody>
658+</table>
659+</section>
660+<section aria-label="2026-03-08">
661+<h2 class="rule-h">
662+<span>2026-03-08</span>
663+</h2>
664+<table class="list commits" aria-label="Commits">
665+<tbody data-rows>
666+<tr id="c-SHA7">
667+<td class="cur" aria-hidden="true">
668+</td>
669+<td class="c-sha mute">SHA7</td>
670+<td class="c-subject">
671+<a href="/long/commit/SHA40">commit number 15</a>
672+</td>
673+<td class="c-who dim">T</td>
674+<td class="c-when nowrap dim">
675+<time datetime="STAMP" title="STAMP">AGO</time>
676+</td>
677+</tr>
678+<tr id="c-SHA7">
679+<td class="cur" aria-hidden="true">
680+</td>
681+<td class="c-sha mute">SHA7</td>
682+<td class="c-subject">
683+<a href="/long/commit/SHA40">commit number 14</a>
684+</td>
685+<td class="c-who dim">T</td>
686+<td class="c-when nowrap dim">
687+<time datetime="STAMP" title="STAMP">AGO</time>
688+</td>
689+</tr>
690+</tbody>
691+</table>
692+</section>
693+<section aria-label="2026-03-07">
694+<h2 class="rule-h">
695+<span>2026-03-07</span>
696+</h2>
697+<table class="list commits" aria-label="Commits">
698+<tbody data-rows>
699+<tr id="c-SHA7">
700+<td class="cur" aria-hidden="true">
701+</td>
702+<td class="c-sha mute">SHA7</td>
703+<td class="c-subject">
704+<a href="/long/commit/SHA40">commit number 13</a>
705+</td>
706+<td class="c-who dim">T</td>
707+<td class="c-when nowrap dim">
708+<time datetime="STAMP" title="STAMP">AGO</time>
709+</td>
710+</tr>
711+<tr id="c-SHA7">
712+<td class="cur" aria-hidden="true">
713+</td>
714+<td class="c-sha mute">SHA7</td>
715+<td class="c-subject">
716+<a href="/long/commit/SHA40">commit number 12</a>
717+</td>
718+<td class="c-who dim">T</td>
719+<td class="c-when nowrap dim">
720+<time datetime="STAMP" title="STAMP">AGO</time>
721+</td>
722+</tr>
723+</tbody>
724+</table>
725+</section>
726+<section aria-label="2026-03-06">
727+<h2 class="rule-h">
728+<span>2026-03-06</span>
729+</h2>
730+<table class="list commits" aria-label="Commits">
731+<tbody data-rows>
732+<tr id="c-SHA7">
733+<td class="cur" aria-hidden="true">
734+</td>
735+<td class="c-sha mute">SHA7</td>
736+<td class="c-subject">
737+<a href="/long/commit/SHA40">commit number 11</a>
738+</td>
739+<td class="c-who dim">T</td>
740+<td class="c-when nowrap dim">
741+<time datetime="STAMP" title="STAMP">AGO</time>
742+</td>
743+</tr>
744+<tr id="c-SHA7">
745+<td class="cur" aria-hidden="true">
746+</td>
747+<td class="c-sha mute">SHA7</td>
748+<td class="c-subject">
749+<a href="/long/commit/SHA40">commit number 10</a>
750+</td>
751+<td class="c-who dim">T</td>
752+<td class="c-when nowrap dim">
753+<time datetime="STAMP" title="STAMP">AGO</time>
754+</td>
755+</tr>
756+</tbody>
757+</table>
758+</section>
759+<section aria-label="2026-03-05">
760+<h2 class="rule-h">
761+<span>2026-03-05</span>
762+</h2>
763+<table class="list commits" aria-label="Commits">
764+<tbody data-rows>
765+<tr id="c-SHA7">
766+<td class="cur" aria-hidden="true">
767+</td>
768+<td class="c-sha mute">SHA7</td>
769+<td class="c-subject">
770+<a href="/long/commit/SHA40">commit number 9</a>
771+</td>
772+<td class="c-who dim">T</td>
773+<td class="c-when nowrap dim">
774+<time datetime="STAMP" title="STAMP">AGO</time>
775+</td>
776+</tr>
777+<tr id="c-SHA7">
778+<td class="cur" aria-hidden="true">
779+</td>
780+<td class="c-sha mute">SHA7</td>
781+<td class="c-subject">
782+<a href="/long/commit/SHA40">commit number 8</a>
783+</td>
784+<td class="c-who dim">T</td>
785+<td class="c-when nowrap dim">
786+<time datetime="STAMP" title="STAMP">AGO</time>
787+</td>
788+</tr>
789+</tbody>
790+</table>
791+</section>
792+<section aria-label="2026-03-04">
793+<h2 class="rule-h">
794+<span>2026-03-04</span>
795+</h2>
796+<table class="list commits" aria-label="Commits">
797+<tbody data-rows>
798+<tr id="c-SHA7">
799+<td class="cur" aria-hidden="true">
800+</td>
801+<td class="c-sha mute">SHA7</td>
802+<td class="c-subject">
803+<a href="/long/commit/SHA40">commit number 7</a>
804+</td>
805+<td class="c-who dim">T</td>
806+<td class="c-when nowrap dim">
807+<time datetime="STAMP" title="STAMP">AGO</time>
808+</td>
809+</tr>
810+<tr id="c-SHA7">
811+<td class="cur" aria-hidden="true">
812+</td>
813+<td class="c-sha mute">SHA7</td>
814+<td class="c-subject">
815+<a href="/long/commit/SHA40">commit number 6</a>
816+</td>
817+<td class="c-who dim">T</td>
818+<td class="c-when nowrap dim">
819+<time datetime="STAMP" title="STAMP">AGO</time>
820+</td>
821+</tr>
822+</tbody>
823+</table>
824+</section>
825+<section aria-label="2026-03-03">
826+<h2 class="rule-h">
827+<span>2026-03-03</span>
828+</h2>
829+<table class="list commits" aria-label="Commits">
830+<tbody data-rows>
831+<tr id="c-SHA7">
832+<td class="cur" aria-hidden="true">
833+</td>
834+<td class="c-sha mute">SHA7</td>
835+<td class="c-subject">
836+<a href="/long/commit/SHA40">commit number 5</a>
837+</td>
838+<td class="c-who dim">T</td>
839+<td class="c-when nowrap dim">
840+<time datetime="STAMP" title="STAMP">AGO</time>
841+</td>
842+</tr>
843+<tr id="c-SHA7">
844+<td class="cur" aria-hidden="true">
845+</td>
846+<td class="c-sha mute">SHA7</td>
847+<td class="c-subject">
848+<a href="/long/commit/SHA40">commit number 4</a>
849+</td>
850+<td class="c-who dim">T</td>
851+<td class="c-when nowrap dim">
852+<time datetime="STAMP" title="STAMP">AGO</time>
853+</td>
854+</tr>
855+</tbody>
856+</table>
857+</section>
858+<section aria-label="2026-03-02">
859+<h2 class="rule-h">
860+<span>2026-03-02</span>
861+</h2>
862+<table class="list commits" aria-label="Commits">
863+<tbody data-rows>
864+<tr id="c-SHA7">
865+<td class="cur" aria-hidden="true">
866+</td>
867+<td class="c-sha mute">SHA7</td>
868+<td class="c-subject">
869+<a href="/long/commit/SHA40">commit number 3</a>
870+</td>
871+<td class="c-who dim">T</td>
872+<td class="c-when nowrap dim">
873+<time datetime="STAMP" title="STAMP">AGO</time>
874+</td>
875+</tr>
876+<tr id="c-SHA7">
877+<td class="cur" aria-hidden="true">
878+</td>
879+<td class="c-sha mute">SHA7</td>
880+<td class="c-subject">
881+<a href="/long/commit/SHA40">commit number 2</a>
882+</td>
883+<td class="c-who dim">T</td>
884+<td class="c-when nowrap dim">
885+<time datetime="STAMP" title="STAMP">AGO</time>
886+</td>
887+</tr>
888+</tbody>
889+</table>
890+</section>
891+</div>
892+<nav class="pager" aria-label="Pages">
893+<span class="mute">← newest</span>
894+<a href="/long/commits/main?page=YzE6MzMzZGFjMWY5YmY4MjIzMmQxOTAzNGVlZTliZmI2NzFmM2QxMWEzNDo1MDplM2IwYzQ0Mjk4ZmM" rel="next">older →</a>
895+</nav>
896+</main>
897+<aside class="rail" aria-label="Context">
898+<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
899+<summary>history · refs on this page</summary>
900+<section aria-labelledby="r-hist">
901+<h2 id="r-hist">history</h2>
902+<dl class="kv">
903+<dt>ref</dt>
904+<dd>main</dd>
905+<dt>on page</dt>
906+<dd>50</dd>
907+</dl>
908+</section>
909+<section aria-labelledby="r-refs">
910+<h2 id="r-refs">refs on this page</h2>
911+<nav class="outline" aria-label="Refs on this page">
912+<a href="#c-SHA7">branch main</a>
913+</nav>
914+</section>
915+</details>
916+</aside>
917+</div>
918+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
919+<div class="panel-head">
920+<span>keys</span>
921+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
922+</div>
923+<dl class="keys">
924+<div>
925+<dt>
926+<kbd>j</kbd>
927+<kbd>k</kbd>
928+</dt>
929+<dd>move cursor down / up</dd>
930+</div>
931+<div>
932+<dt>
933+<kbd>enter</kbd>
934+</dt>
935+<dd>open row under cursor</dd>
936+</div>
937+<div>
938+<dt>
939+<kbd>g</kbd>
940+<kbd>g</kbd> / <kbd>G</kbd>
941+</dt>
942+<dd>first / last row</dd>
943+</div>
944+<div>
945+<dt>
946+<kbd>/</kbd>
947+</dt>
948+<dd>focus the filter</dd>
949+</div>
950+<div>
951+<dt>
952+<kbd>g h</kbd>
953+<kbd>g t</kbd>
954+</dt>
955+<dd>go home / tree</dd>
956+</div>
957+<div>
958+<dt>
959+<kbd>g c</kbd>
960+<kbd>g r</kbd>
961+</dt>
962+<dd>go commits / refs</dd>
963+</div>
964+<div>
965+<dt>
966+<kbd>b</kbd>
967+</dt>
968+<dd>switch ref (branches, tags)</dd>
969+</div>
970+<div>
971+<dt>
972+<kbd>:</kbd>
973+<kbd>f</kbd>
974+</dt>
975+<dd>go to: section, ref, file, repo</dd>
976+</div>
977+<div>
978+<dt>
979+<kbd>y</kbd>
980+</dt>
981+<dd>permalink: ref becomes the full sha</dd>
982+</div>
983+<div>
984+<dt>
985+<kbd>s</kbd>
986+<kbd>t</kbd>
987+<kbd>c</kbd>
988+<kbd>r</kbd>
989+</dt>
990+<dd>repo sections (when shown)</dd>
991+</div>
992+<div>
993+<dt>
994+<kbd>d</kbd>
995+<kbd>u</kbd>
996+<kbd>x</kbd>
997+</dt>
998+<dd>commit: split / unified / collapse all</dd>
999+</div>
1000+<div>
1001+<dt>
1002+<kbd>esc</kbd>
1003+</dt>
1004+<dd>close panel</dd>
1005+</div>
1006+</dl>
1007+</dialog>
1008+<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
1009+<div class="panel-head">
1010+<label for="refs-filter">ref&gt;</label>
1011+<input id="refs-filter" type="search" placeholder="filter branches and tags" 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))">
1012+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
1013+</div>
1014+<div class="panel-cols" data-picklist>
1015+<section aria-label="Branches">
1016+<h2>branches <span class="mute">1</span>
1017+</h2>
1018+<ul>
1019+<li>
1020+<a href="/long/tree/main" aria-current="true">
1021+<span class="mark" aria-hidden="true">●</span>main</a>
1022+</li>
1023+</ul>
1024+</section>
1025+<section aria-label="Tags">
1026+<h2>tags <span class="mute">0</span>
1027+</h2>
1028+<ul>
1029+</ul>
1030+</section>
1031+</div>
1032+</dialog>
1033+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
1034+<div class="panel-head">
1035+<label for="cmd-q" class="hi">:</label>
1036+<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
1037+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
1038+ 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();
1039+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
1040+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
1041+ 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' })) }
1042+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
1043+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
1044+</div>
1045+<ul id="cmd-list" class="cmd-list">
1046+<li data-k="go home" data-cursor>
1047+<a href="/" tabindex="-1">
1048+<span class="k">go</span>home</a>
1049+</li>
1050+<li data-k="section summary">
1051+<a href="/long" tabindex="-1">
1052+<span class="k">section</span>summary</a>
1053+</li>
1054+<li data-k="section tree">
1055+<a href="/long/tree/main" tabindex="-1">
1056+<span class="k">section</span>tree</a>
1057+</li>
1058+<li data-k="section commits">
1059+<a href="/long/commits/main" tabindex="-1">
1060+<span class="k">section</span>commits</a>
1061+</li>
1062+<li data-k="section refs">
1063+<a href="/long/refs" tabindex="-1">
1064+<span class="k">section</span>refs</a>
1065+</li>
1066+<li data-k="branch main">
1067+<a href="/long/tree/main" tabindex="-1">
1068+<span class="k">branch</span>main</a>
1069+</li>
1070+<li data-k="repo diffs">
1071+<a href="/diffs" tabindex="-1">
1072+<span class="k">repo</span>diffs</a>
1073+</li>
1074+</ul>
1075+</dialog>
1076+<footer class="bar status" aria-label="Status">
1077+<span class="mode">commits</span>
1078+<span class="st-ctx">main · 50 commits · more</span>
1079+<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
1080+<span class="hi">:</span> go to…</button>
1081+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
1082+</span>
1083+<span class="st-hints">j/k move · enter open · / filter · ? help</span>
1084+<span class="st-src">
1085+<span class="st-ver">ermite devel · </span>
1086+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
1087+</span>
1088+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
1089+<span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/50</span>
1090+</footer>
1091+<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
1092+ if (q('dialog[open]').count) return;
1093+ let rows = q('[data-rows] > :not([hidden])').arr();
1094+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
1095+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
1096+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
1097+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
1098+ })(1);
1099+ k-k from:body -> ((d) => {
1100+ if (q('dialog[open]').count) return;
1101+ let rows = q('[data-rows] > :not([hidden])').arr();
1102+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
1103+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
1104+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
1105+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
1106+ })(-1);
1107+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
1108+ 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));
1109+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
1110+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
1111+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
1112+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
1113+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
1114+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
1115+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
1116+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
1117+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
1118+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
1119+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
1120+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
1121+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
1122+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
1123+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
1124+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
1125+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
1126+ k-permalink from:body -> {
1127+ let b = document.body.dataset;
1128+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
1129+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
1130+ navigator.clipboard?.writeText(location.href);
1131+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
1132+ }">
1133+</div>
1134+</body>
1135+</html>
@@ -0,0 +1,1135 @@
1+<!doctype html>
2+<html lang="en">
3+<head>
4+<meta charset="utf-8">
5+<meta name="viewport" content="width=device-width, initial-scale=1">
6+<meta name="color-scheme" content="light dark">
7+<title>commits · long · example.test</title>
8+<link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
9+<script src="/-/static/js/htmx.min.HASH.js" defer>
10+</script>
11+<script src="/-/static/js/hx-live.min.HASH.js" defer>
12+</script>
13+<script src="/-/static/js/hx-sse.min.HASH.js" defer>
14+</script>
15+<script src="/-/static/js/ermite.HASH.js" defer>
16+</script>
17+</head>
18+<body data-ref="main" data-sha="SHA40" data-sel="">
19+<a class="skip" href="#main">skip to content</a>
20+<header class="bar top">
21+<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
22+<span class="fade" aria-hidden="true">
23+</span>
24+<nav class="crumbs" aria-label="Path">
25+<ol>
26+<li>
27+<a href="/long">long</a>
28+</li>
29+<li>
30+<a href="/long/commits/main">commits</a>
31+</li>
32+<li>
33+<a href="/long/commits/main" aria-current="page">main</a>
34+</li>
35+</ol>
36+</nav>
37+<nav class="secs" aria-label="Sections">
38+<ul>
39+<li>
40+<a href="/long" data-key="s" aria-keyshortcuts="g s">summary</a>
41+</li>
42+<li>
43+<a href="/long/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a>
44+</li>
45+<li>
46+<a href="/long/commits/main" data-key="c" aria-keyshortcuts="g c" aria-current="page">commits</a>
47+</li>
48+<li>
49+<a href="/long/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
50+</li>
51+</ul>
52+</nav>
53+<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
54+</button>
55+</header>
56+<div class="work has-rail">
57+<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
58+<div class="filter">
59+<label for="commit-filter">filter&gt;</label>
60+<input id="commit-filter" type="search" data-filter placeholder="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));
61+ q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])'));
62+ let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')">
63+</div>
64+<div class="days" id="commits-head">
65+<section aria-label="2026-03-26">
66+<h2 class="rule-h">
67+<span>2026-03-26</span>
68+</h2>
69+<table class="list commits" aria-label="Commits">
70+<tbody data-rows>
71+<tr id="c-SHA7" data-cursor>
72+<td class="cur" aria-hidden="true">
73+</td>
74+<td class="c-sha mute">SHA7</td>
75+<td class="c-subject">
76+<a href="/long/commit/SHA40">commit number 51</a>
77+<span class="tagged">[main]</span>
78+</td>
79+<td class="c-who dim">T</td>
80+<td class="c-when nowrap dim">
81+<time datetime="STAMP" title="STAMP">AGO</time>
82+</td>
83+</tr>
84+<tr id="c-SHA7">
85+<td class="cur" aria-hidden="true">
86+</td>
87+<td class="c-sha mute">SHA7</td>
88+<td class="c-subject">
89+<a href="/long/commit/SHA40">commit number 50</a>
90+</td>
91+<td class="c-who dim">T</td>
92+<td class="c-when nowrap dim">
93+<time datetime="STAMP" title="STAMP">AGO</time>
94+</td>
95+</tr>
96+</tbody>
97+</table>
98+</section>
99+<section aria-label="2026-03-25">
100+<h2 class="rule-h">
101+<span>2026-03-25</span>
102+</h2>
103+<table class="list commits" aria-label="Commits">
104+<tbody data-rows>
105+<tr id="c-SHA7">
106+<td class="cur" aria-hidden="true">
107+</td>
108+<td class="c-sha mute">SHA7</td>
109+<td class="c-subject">
110+<a href="/long/commit/SHA40">commit number 49</a>
111+</td>
112+<td class="c-who dim">T</td>
113+<td class="c-when nowrap dim">
114+<time datetime="STAMP" title="STAMP">AGO</time>
115+</td>
116+</tr>
117+<tr id="c-SHA7">
118+<td class="cur" aria-hidden="true">
119+</td>
120+<td class="c-sha mute">SHA7</td>
121+<td class="c-subject">
122+<a href="/long/commit/SHA40">commit number 48</a>
123+</td>
124+<td class="c-who dim">T</td>
125+<td class="c-when nowrap dim">
126+<time datetime="STAMP" title="STAMP">AGO</time>
127+</td>
128+</tr>
129+</tbody>
130+</table>
131+</section>
132+<section aria-label="2026-03-24">
133+<h2 class="rule-h">
134+<span>2026-03-24</span>
135+</h2>
136+<table class="list commits" aria-label="Commits">
137+<tbody data-rows>
138+<tr id="c-SHA7">
139+<td class="cur" aria-hidden="true">
140+</td>
141+<td class="c-sha mute">SHA7</td>
142+<td class="c-subject">
143+<a href="/long/commit/SHA40">commit number 47</a>
144+</td>
145+<td class="c-who dim">T</td>
146+<td class="c-when nowrap dim">
147+<time datetime="STAMP" title="STAMP">AGO</time>
148+</td>
149+</tr>
150+<tr id="c-SHA7">
151+<td class="cur" aria-hidden="true">
152+</td>
153+<td class="c-sha mute">SHA7</td>
154+<td class="c-subject">
155+<a href="/long/commit/SHA40">commit number 46</a>
156+</td>
157+<td class="c-who dim">T</td>
158+<td class="c-when nowrap dim">
159+<time datetime="STAMP" title="STAMP">AGO</time>
160+</td>
161+</tr>
162+</tbody>
163+</table>
164+</section>
165+<section aria-label="2026-03-23">
166+<h2 class="rule-h">
167+<span>2026-03-23</span>
168+</h2>
169+<table class="list commits" aria-label="Commits">
170+<tbody data-rows>
171+<tr id="c-SHA7">
172+<td class="cur" aria-hidden="true">
173+</td>
174+<td class="c-sha mute">SHA7</td>
175+<td class="c-subject">
176+<a href="/long/commit/SHA40">commit number 45</a>
177+</td>
178+<td class="c-who dim">T</td>
179+<td class="c-when nowrap dim">
180+<time datetime="STAMP" title="STAMP">AGO</time>
181+</td>
182+</tr>
183+<tr id="c-SHA7">
184+<td class="cur" aria-hidden="true">
185+</td>
186+<td class="c-sha mute">SHA7</td>
187+<td class="c-subject">
188+<a href="/long/commit/SHA40">commit number 44</a>
189+</td>
190+<td class="c-who dim">T</td>
191+<td class="c-when nowrap dim">
192+<time datetime="STAMP" title="STAMP">AGO</time>
193+</td>
194+</tr>
195+</tbody>
196+</table>
197+</section>
198+<section aria-label="2026-03-22">
199+<h2 class="rule-h">
200+<span>2026-03-22</span>
201+</h2>
202+<table class="list commits" aria-label="Commits">
203+<tbody data-rows>
204+<tr id="c-SHA7">
205+<td class="cur" aria-hidden="true">
206+</td>
207+<td class="c-sha mute">SHA7</td>
208+<td class="c-subject">
209+<a href="/long/commit/SHA40">commit number 43</a>
210+</td>
211+<td class="c-who dim">T</td>
212+<td class="c-when nowrap dim">
213+<time datetime="STAMP" title="STAMP">AGO</time>
214+</td>
215+</tr>
216+<tr id="c-SHA7">
217+<td class="cur" aria-hidden="true">
218+</td>
219+<td class="c-sha mute">SHA7</td>
220+<td class="c-subject">
221+<a href="/long/commit/SHA40">commit number 42</a>
222+</td>
223+<td class="c-who dim">T</td>
224+<td class="c-when nowrap dim">
225+<time datetime="STAMP" title="STAMP">AGO</time>
226+</td>
227+</tr>
228+</tbody>
229+</table>
230+</section>
231+<section aria-label="2026-03-21">
232+<h2 class="rule-h">
233+<span>2026-03-21</span>
234+</h2>
235+<table class="list commits" aria-label="Commits">
236+<tbody data-rows>
237+<tr id="c-SHA7">
238+<td class="cur" aria-hidden="true">
239+</td>
240+<td class="c-sha mute">SHA7</td>
241+<td class="c-subject">
242+<a href="/long/commit/SHA40">commit number 41</a>
243+</td>
244+<td class="c-who dim">T</td>
245+<td class="c-when nowrap dim">
246+<time datetime="STAMP" title="STAMP">AGO</time>
247+</td>
248+</tr>
249+<tr id="c-SHA7">
250+<td class="cur" aria-hidden="true">
251+</td>
252+<td class="c-sha mute">SHA7</td>
253+<td class="c-subject">
254+<a href="/long/commit/SHA40">commit number 40</a>
255+</td>
256+<td class="c-who dim">T</td>
257+<td class="c-when nowrap dim">
258+<time datetime="STAMP" title="STAMP">AGO</time>
259+</td>
260+</tr>
261+</tbody>
262+</table>
263+</section>
264+<section aria-label="2026-03-20">
265+<h2 class="rule-h">
266+<span>2026-03-20</span>
267+</h2>
268+<table class="list commits" aria-label="Commits">
269+<tbody data-rows>
270+<tr id="c-SHA7">
271+<td class="cur" aria-hidden="true">
272+</td>
273+<td class="c-sha mute">SHA7</td>
274+<td class="c-subject">
275+<a href="/long/commit/SHA40">commit number 39</a>
276+</td>
277+<td class="c-who dim">T</td>
278+<td class="c-when nowrap dim">
279+<time datetime="STAMP" title="STAMP">AGO</time>
280+</td>
281+</tr>
282+<tr id="c-SHA7">
283+<td class="cur" aria-hidden="true">
284+</td>
285+<td class="c-sha mute">SHA7</td>
286+<td class="c-subject">
287+<a href="/long/commit/SHA40">commit number 38</a>
288+</td>
289+<td class="c-who dim">T</td>
290+<td class="c-when nowrap dim">
291+<time datetime="STAMP" title="STAMP">AGO</time>
292+</td>
293+</tr>
294+</tbody>
295+</table>
296+</section>
297+<section aria-label="2026-03-19">
298+<h2 class="rule-h">
299+<span>2026-03-19</span>
300+</h2>
301+<table class="list commits" aria-label="Commits">
302+<tbody data-rows>
303+<tr id="c-SHA7">
304+<td class="cur" aria-hidden="true">
305+</td>
306+<td class="c-sha mute">SHA7</td>
307+<td class="c-subject">
308+<a href="/long/commit/SHA40">commit number 37</a>
309+</td>
310+<td class="c-who dim">T</td>
311+<td class="c-when nowrap dim">
312+<time datetime="STAMP" title="STAMP">AGO</time>
313+</td>
314+</tr>
315+<tr id="c-SHA7">
316+<td class="cur" aria-hidden="true">
317+</td>
318+<td class="c-sha mute">SHA7</td>
319+<td class="c-subject">
320+<a href="/long/commit/SHA40">commit number 36</a>
321+</td>
322+<td class="c-who dim">T</td>
323+<td class="c-when nowrap dim">
324+<time datetime="STAMP" title="STAMP">AGO</time>
325+</td>
326+</tr>
327+</tbody>
328+</table>
329+</section>
330+<section aria-label="2026-03-18">
331+<h2 class="rule-h">
332+<span>2026-03-18</span>
333+</h2>
334+<table class="list commits" aria-label="Commits">
335+<tbody data-rows>
336+<tr id="c-SHA7">
337+<td class="cur" aria-hidden="true">
338+</td>
339+<td class="c-sha mute">SHA7</td>
340+<td class="c-subject">
341+<a href="/long/commit/SHA40">commit number 35</a>
342+</td>
343+<td class="c-who dim">T</td>
344+<td class="c-when nowrap dim">
345+<time datetime="STAMP" title="STAMP">AGO</time>
346+</td>
347+</tr>
348+<tr id="c-SHA7">
349+<td class="cur" aria-hidden="true">
350+</td>
351+<td class="c-sha mute">SHA7</td>
352+<td class="c-subject">
353+<a href="/long/commit/SHA40">commit number 34</a>
354+</td>
355+<td class="c-who dim">T</td>
356+<td class="c-when nowrap dim">
357+<time datetime="STAMP" title="STAMP">AGO</time>
358+</td>
359+</tr>
360+</tbody>
361+</table>
362+</section>
363+<section aria-label="2026-03-17">
364+<h2 class="rule-h">
365+<span>2026-03-17</span>
366+</h2>
367+<table class="list commits" aria-label="Commits">
368+<tbody data-rows>
369+<tr id="c-SHA7">
370+<td class="cur" aria-hidden="true">
371+</td>
372+<td class="c-sha mute">SHA7</td>
373+<td class="c-subject">
374+<a href="/long/commit/SHA40">commit number 33</a>
375+</td>
376+<td class="c-who dim">T</td>
377+<td class="c-when nowrap dim">
378+<time datetime="STAMP" title="STAMP">AGO</time>
379+</td>
380+</tr>
381+<tr id="c-SHA7">
382+<td class="cur" aria-hidden="true">
383+</td>
384+<td class="c-sha mute">SHA7</td>
385+<td class="c-subject">
386+<a href="/long/commit/SHA40">commit number 32</a>
387+</td>
388+<td class="c-who dim">T</td>
389+<td class="c-when nowrap dim">
390+<time datetime="STAMP" title="STAMP">AGO</time>
391+</td>
392+</tr>
393+</tbody>
394+</table>
395+</section>
396+<section aria-label="2026-03-16">
397+<h2 class="rule-h">
398+<span>2026-03-16</span>
399+</h2>
400+<table class="list commits" aria-label="Commits">
401+<tbody data-rows>
402+<tr id="c-SHA7">
403+<td class="cur" aria-hidden="true">
404+</td>
405+<td class="c-sha mute">SHA7</td>
406+<td class="c-subject">
407+<a href="/long/commit/SHA40">commit number 31</a>
408+</td>
409+<td class="c-who dim">T</td>
410+<td class="c-when nowrap dim">
411+<time datetime="STAMP" title="STAMP">AGO</time>
412+</td>
413+</tr>
414+<tr id="c-SHA7">
415+<td class="cur" aria-hidden="true">
416+</td>
417+<td class="c-sha mute">SHA7</td>
418+<td class="c-subject">
419+<a href="/long/commit/SHA40">commit number 30</a>
420+</td>
421+<td class="c-who dim">T</td>
422+<td class="c-when nowrap dim">
423+<time datetime="STAMP" title="STAMP">AGO</time>
424+</td>
425+</tr>
426+</tbody>
427+</table>
428+</section>
429+<section aria-label="2026-03-15">
430+<h2 class="rule-h">
431+<span>2026-03-15</span>
432+</h2>
433+<table class="list commits" aria-label="Commits">
434+<tbody data-rows>
435+<tr id="c-SHA7">
436+<td class="cur" aria-hidden="true">
437+</td>
438+<td class="c-sha mute">SHA7</td>
439+<td class="c-subject">
440+<a href="/long/commit/SHA40">commit number 29</a>
441+</td>
442+<td class="c-who dim">T</td>
443+<td class="c-when nowrap dim">
444+<time datetime="STAMP" title="STAMP">AGO</time>
445+</td>
446+</tr>
447+<tr id="c-SHA7">
448+<td class="cur" aria-hidden="true">
449+</td>
450+<td class="c-sha mute">SHA7</td>
451+<td class="c-subject">
452+<a href="/long/commit/SHA40">commit number 28</a>
453+</td>
454+<td class="c-who dim">T</td>
455+<td class="c-when nowrap dim">
456+<time datetime="STAMP" title="STAMP">AGO</time>
457+</td>
458+</tr>
459+</tbody>
460+</table>
461+</section>
462+<section aria-label="2026-03-14">
463+<h2 class="rule-h">
464+<span>2026-03-14</span>
465+</h2>
466+<table class="list commits" aria-label="Commits">
467+<tbody data-rows>
468+<tr id="c-SHA7">
469+<td class="cur" aria-hidden="true">
470+</td>
471+<td class="c-sha mute">SHA7</td>
472+<td class="c-subject">
473+<a href="/long/commit/SHA40">commit number 27</a>
474+</td>
475+<td class="c-who dim">T</td>
476+<td class="c-when nowrap dim">
477+<time datetime="STAMP" title="STAMP">AGO</time>
478+</td>
479+</tr>
480+<tr id="c-SHA7">
481+<td class="cur" aria-hidden="true">
482+</td>
483+<td class="c-sha mute">SHA7</td>
484+<td class="c-subject">
485+<a href="/long/commit/SHA40">commit number 26</a>
486+</td>
487+<td class="c-who dim">T</td>
488+<td class="c-when nowrap dim">
489+<time datetime="STAMP" title="STAMP">AGO</time>
490+</td>
491+</tr>
492+</tbody>
493+</table>
494+</section>
495+<section aria-label="2026-03-13">
496+<h2 class="rule-h">
497+<span>2026-03-13</span>
498+</h2>
499+<table class="list commits" aria-label="Commits">
500+<tbody data-rows>
501+<tr id="c-SHA7">
502+<td class="cur" aria-hidden="true">
503+</td>
504+<td class="c-sha mute">SHA7</td>
505+<td class="c-subject">
506+<a href="/long/commit/SHA40">commit number 25</a>
507+</td>
508+<td class="c-who dim">T</td>
509+<td class="c-when nowrap dim">
510+<time datetime="STAMP" title="STAMP">AGO</time>
511+</td>
512+</tr>
513+<tr id="c-SHA7">
514+<td class="cur" aria-hidden="true">
515+</td>
516+<td class="c-sha mute">SHA7</td>
517+<td class="c-subject">
518+<a href="/long/commit/SHA40">commit number 24</a>
519+</td>
520+<td class="c-who dim">T</td>
521+<td class="c-when nowrap dim">
522+<time datetime="STAMP" title="STAMP">AGO</time>
523+</td>
524+</tr>
525+</tbody>
526+</table>
527+</section>
528+<section aria-label="2026-03-12">
529+<h2 class="rule-h">
530+<span>2026-03-12</span>
531+</h2>
532+<table class="list commits" aria-label="Commits">
533+<tbody data-rows>
534+<tr id="c-SHA7">
535+<td class="cur" aria-hidden="true">
536+</td>
537+<td class="c-sha mute">SHA7</td>
538+<td class="c-subject">
539+<a href="/long/commit/SHA40">commit number 23</a>
540+</td>
541+<td class="c-who dim">T</td>
542+<td class="c-when nowrap dim">
543+<time datetime="STAMP" title="STAMP">AGO</time>
544+</td>
545+</tr>
546+<tr id="c-SHA7">
547+<td class="cur" aria-hidden="true">
548+</td>
549+<td class="c-sha mute">SHA7</td>
550+<td class="c-subject">
551+<a href="/long/commit/SHA40">commit number 22</a>
552+</td>
553+<td class="c-who dim">T</td>
554+<td class="c-when nowrap dim">
555+<time datetime="STAMP" title="STAMP">AGO</time>
556+</td>
557+</tr>
558+</tbody>
559+</table>
560+</section>
561+<section aria-label="2026-03-11">
562+<h2 class="rule-h">
563+<span>2026-03-11</span>
564+</h2>
565+<table class="list commits" aria-label="Commits">
566+<tbody data-rows>
567+<tr id="c-SHA7">
568+<td class="cur" aria-hidden="true">
569+</td>
570+<td class="c-sha mute">SHA7</td>
571+<td class="c-subject">
572+<a href="/long/commit/SHA40">commit number 21</a>
573+</td>
574+<td class="c-who dim">T</td>
575+<td class="c-when nowrap dim">
576+<time datetime="STAMP" title="STAMP">AGO</time>
577+</td>
578+</tr>
579+<tr id="c-SHA7">
580+<td class="cur" aria-hidden="true">
581+</td>
582+<td class="c-sha mute">SHA7</td>
583+<td class="c-subject">
584+<a href="/long/commit/SHA40">commit number 20</a>
585+</td>
586+<td class="c-who dim">T</td>
587+<td class="c-when nowrap dim">
588+<time datetime="STAMP" title="STAMP">AGO</time>
589+</td>
590+</tr>
591+</tbody>
592+</table>
593+</section>
594+<section aria-label="2026-03-10">
595+<h2 class="rule-h">
596+<span>2026-03-10</span>
597+</h2>
598+<table class="list commits" aria-label="Commits">
599+<tbody data-rows>
600+<tr id="c-SHA7">
601+<td class="cur" aria-hidden="true">
602+</td>
603+<td class="c-sha mute">SHA7</td>
604+<td class="c-subject">
605+<a href="/long/commit/SHA40">commit number 19</a>
606+</td>
607+<td class="c-who dim">T</td>
608+<td class="c-when nowrap dim">
609+<time datetime="STAMP" title="STAMP">AGO</time>
610+</td>
611+</tr>
612+<tr id="c-SHA7">
613+<td class="cur" aria-hidden="true">
614+</td>
615+<td class="c-sha mute">SHA7</td>
616+<td class="c-subject">
617+<a href="/long/commit/SHA40">commit number 18</a>
618+</td>
619+<td class="c-who dim">T</td>
620+<td class="c-when nowrap dim">
621+<time datetime="STAMP" title="STAMP">AGO</time>
622+</td>
623+</tr>
624+</tbody>
625+</table>
626+</section>
627+<section aria-label="2026-03-09">
628+<h2 class="rule-h">
629+<span>2026-03-09</span>
630+</h2>
631+<table class="list commits" aria-label="Commits">
632+<tbody data-rows>
633+<tr id="c-SHA7">
634+<td class="cur" aria-hidden="true">
635+</td>
636+<td class="c-sha mute">SHA7</td>
637+<td class="c-subject">
638+<a href="/long/commit/SHA40">commit number 17</a>
639+</td>
640+<td class="c-who dim">T</td>
641+<td class="c-when nowrap dim">
642+<time datetime="STAMP" title="STAMP">AGO</time>
643+</td>
644+</tr>
645+<tr id="c-SHA7">
646+<td class="cur" aria-hidden="true">
647+</td>
648+<td class="c-sha mute">SHA7</td>
649+<td class="c-subject">
650+<a href="/long/commit/SHA40">commit number 16</a>
651+</td>
652+<td class="c-who dim">T</td>
653+<td class="c-when nowrap dim">
654+<time datetime="STAMP" title="STAMP">AGO</time>
655+</td>
656+</tr>
657+</tbody>
658+</table>
659+</section>
660+<section aria-label="2026-03-08">
661+<h2 class="rule-h">
662+<span>2026-03-08</span>
663+</h2>
664+<table class="list commits" aria-label="Commits">
665+<tbody data-rows>
666+<tr id="c-SHA7">
667+<td class="cur" aria-hidden="true">
668+</td>
669+<td class="c-sha mute">SHA7</td>
670+<td class="c-subject">
671+<a href="/long/commit/SHA40">commit number 15</a>
672+</td>
673+<td class="c-who dim">T</td>
674+<td class="c-when nowrap dim">
675+<time datetime="STAMP" title="STAMP">AGO</time>
676+</td>
677+</tr>
678+<tr id="c-SHA7">
679+<td class="cur" aria-hidden="true">
680+</td>
681+<td class="c-sha mute">SHA7</td>
682+<td class="c-subject">
683+<a href="/long/commit/SHA40">commit number 14</a>
684+</td>
685+<td class="c-who dim">T</td>
686+<td class="c-when nowrap dim">
687+<time datetime="STAMP" title="STAMP">AGO</time>
688+</td>
689+</tr>
690+</tbody>
691+</table>
692+</section>
693+<section aria-label="2026-03-07">
694+<h2 class="rule-h">
695+<span>2026-03-07</span>
696+</h2>
697+<table class="list commits" aria-label="Commits">
698+<tbody data-rows>
699+<tr id="c-SHA7">
700+<td class="cur" aria-hidden="true">
701+</td>
702+<td class="c-sha mute">SHA7</td>
703+<td class="c-subject">
704+<a href="/long/commit/SHA40">commit number 13</a>
705+</td>
706+<td class="c-who dim">T</td>
707+<td class="c-when nowrap dim">
708+<time datetime="STAMP" title="STAMP">AGO</time>
709+</td>
710+</tr>
711+<tr id="c-SHA7">
712+<td class="cur" aria-hidden="true">
713+</td>
714+<td class="c-sha mute">SHA7</td>
715+<td class="c-subject">
716+<a href="/long/commit/SHA40">commit number 12</a>
717+</td>
718+<td class="c-who dim">T</td>
719+<td class="c-when nowrap dim">
720+<time datetime="STAMP" title="STAMP">AGO</time>
721+</td>
722+</tr>
723+</tbody>
724+</table>
725+</section>
726+<section aria-label="2026-03-06">
727+<h2 class="rule-h">
728+<span>2026-03-06</span>
729+</h2>
730+<table class="list commits" aria-label="Commits">
731+<tbody data-rows>
732+<tr id="c-SHA7">
733+<td class="cur" aria-hidden="true">
734+</td>
735+<td class="c-sha mute">SHA7</td>
736+<td class="c-subject">
737+<a href="/long/commit/SHA40">commit number 11</a>
738+</td>
739+<td class="c-who dim">T</td>
740+<td class="c-when nowrap dim">
741+<time datetime="STAMP" title="STAMP">AGO</time>
742+</td>
743+</tr>
744+<tr id="c-SHA7">
745+<td class="cur" aria-hidden="true">
746+</td>
747+<td class="c-sha mute">SHA7</td>
748+<td class="c-subject">
749+<a href="/long/commit/SHA40">commit number 10</a>
750+</td>
751+<td class="c-who dim">T</td>
752+<td class="c-when nowrap dim">
753+<time datetime="STAMP" title="STAMP">AGO</time>
754+</td>
755+</tr>
756+</tbody>
757+</table>
758+</section>
759+<section aria-label="2026-03-05">
760+<h2 class="rule-h">
761+<span>2026-03-05</span>
762+</h2>
763+<table class="list commits" aria-label="Commits">
764+<tbody data-rows>
765+<tr id="c-SHA7">
766+<td class="cur" aria-hidden="true">
767+</td>
768+<td class="c-sha mute">SHA7</td>
769+<td class="c-subject">
770+<a href="/long/commit/SHA40">commit number 9</a>
771+</td>
772+<td class="c-who dim">T</td>
773+<td class="c-when nowrap dim">
774+<time datetime="STAMP" title="STAMP">AGO</time>
775+</td>
776+</tr>
777+<tr id="c-SHA7">
778+<td class="cur" aria-hidden="true">
779+</td>
780+<td class="c-sha mute">SHA7</td>
781+<td class="c-subject">
782+<a href="/long/commit/SHA40">commit number 8</a>
783+</td>
784+<td class="c-who dim">T</td>
785+<td class="c-when nowrap dim">
786+<time datetime="STAMP" title="STAMP">AGO</time>
787+</td>
788+</tr>
789+</tbody>
790+</table>
791+</section>
792+<section aria-label="2026-03-04">
793+<h2 class="rule-h">
794+<span>2026-03-04</span>
795+</h2>
796+<table class="list commits" aria-label="Commits">
797+<tbody data-rows>
798+<tr id="c-SHA7">
799+<td class="cur" aria-hidden="true">
800+</td>
801+<td class="c-sha mute">SHA7</td>
802+<td class="c-subject">
803+<a href="/long/commit/SHA40">commit number 7</a>
804+</td>
805+<td class="c-who dim">T</td>
806+<td class="c-when nowrap dim">
807+<time datetime="STAMP" title="STAMP">AGO</time>
808+</td>
809+</tr>
810+<tr id="c-SHA7">
811+<td class="cur" aria-hidden="true">
812+</td>
813+<td class="c-sha mute">SHA7</td>
814+<td class="c-subject">
815+<a href="/long/commit/SHA40">commit number 6</a>
816+</td>
817+<td class="c-who dim">T</td>
818+<td class="c-when nowrap dim">
819+<time datetime="STAMP" title="STAMP">AGO</time>
820+</td>
821+</tr>
822+</tbody>
823+</table>
824+</section>
825+<section aria-label="2026-03-03">
826+<h2 class="rule-h">
827+<span>2026-03-03</span>
828+</h2>
829+<table class="list commits" aria-label="Commits">
830+<tbody data-rows>
831+<tr id="c-SHA7">
832+<td class="cur" aria-hidden="true">
833+</td>
834+<td class="c-sha mute">SHA7</td>
835+<td class="c-subject">
836+<a href="/long/commit/SHA40">commit number 5</a>
837+</td>
838+<td class="c-who dim">T</td>
839+<td class="c-when nowrap dim">
840+<time datetime="STAMP" title="STAMP">AGO</time>
841+</td>
842+</tr>
843+<tr id="c-SHA7">
844+<td class="cur" aria-hidden="true">
845+</td>
846+<td class="c-sha mute">SHA7</td>
847+<td class="c-subject">
848+<a href="/long/commit/SHA40">commit number 4</a>
849+</td>
850+<td class="c-who dim">T</td>
851+<td class="c-when nowrap dim">
852+<time datetime="STAMP" title="STAMP">AGO</time>
853+</td>
854+</tr>
855+</tbody>
856+</table>
857+</section>
858+<section aria-label="2026-03-02">
859+<h2 class="rule-h">
860+<span>2026-03-02</span>
861+</h2>
862+<table class="list commits" aria-label="Commits">
863+<tbody data-rows>
864+<tr id="c-SHA7">
865+<td class="cur" aria-hidden="true">
866+</td>
867+<td class="c-sha mute">SHA7</td>
868+<td class="c-subject">
869+<a href="/long/commit/SHA40">commit number 3</a>
870+</td>
871+<td class="c-who dim">T</td>
872+<td class="c-when nowrap dim">
873+<time datetime="STAMP" title="STAMP">AGO</time>
874+</td>
875+</tr>
876+<tr id="c-SHA7">
877+<td class="cur" aria-hidden="true">
878+</td>
879+<td class="c-sha mute">SHA7</td>
880+<td class="c-subject">
881+<a href="/long/commit/SHA40">commit number 2</a>
882+</td>
883+<td class="c-who dim">T</td>
884+<td class="c-when nowrap dim">
885+<time datetime="STAMP" title="STAMP">AGO</time>
886+</td>
887+</tr>
888+</tbody>
889+</table>
890+</section>
891+</div>
892+<nav class="pager" aria-label="Pages">
893+<span class="mute">← newest</span>
894+<a href="/long/commits/main?page=YzE6MzMzZGFjMWY5YmY4MjIzMmQxOTAzNGVlZTliZmI2NzFmM2QxMWEzNDo1MDplM2IwYzQ0Mjk4ZmM" rel="next">older →</a>
895+</nav>
896+</main>
897+<aside class="rail" aria-label="Context">
898+<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
899+<summary>history · refs on this page</summary>
900+<section aria-labelledby="r-hist">
901+<h2 id="r-hist">history</h2>
902+<dl class="kv">
903+<dt>ref</dt>
904+<dd>main</dd>
905+<dt>on page</dt>
906+<dd>50</dd>
907+</dl>
908+</section>
909+<section aria-labelledby="r-refs">
910+<h2 id="r-refs">refs on this page</h2>
911+<nav class="outline" aria-label="Refs on this page">
912+<a href="#c-SHA7">branch main</a>
913+</nav>
914+</section>
915+</details>
916+</aside>
917+</div>
918+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
919+<div class="panel-head">
920+<span>keys</span>
921+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
922+</div>
923+<dl class="keys">
924+<div>
925+<dt>
926+<kbd>j</kbd>
927+<kbd>k</kbd>
928+</dt>
929+<dd>move cursor down / up</dd>
930+</div>
931+<div>
932+<dt>
933+<kbd>enter</kbd>
934+</dt>
935+<dd>open row under cursor</dd>
936+</div>
937+<div>
938+<dt>
939+<kbd>g</kbd>
940+<kbd>g</kbd> / <kbd>G</kbd>
941+</dt>
942+<dd>first / last row</dd>
943+</div>
944+<div>
945+<dt>
946+<kbd>/</kbd>
947+</dt>
948+<dd>focus the filter</dd>
949+</div>
950+<div>
951+<dt>
952+<kbd>g h</kbd>
953+<kbd>g t</kbd>
954+</dt>
955+<dd>go home / tree</dd>
956+</div>
957+<div>
958+<dt>
959+<kbd>g c</kbd>
960+<kbd>g r</kbd>
961+</dt>
962+<dd>go commits / refs</dd>
963+</div>
964+<div>
965+<dt>
966+<kbd>b</kbd>
967+</dt>
968+<dd>switch ref (branches, tags)</dd>
969+</div>
970+<div>
971+<dt>
972+<kbd>:</kbd>
973+<kbd>f</kbd>
974+</dt>
975+<dd>go to: section, ref, file, repo</dd>
976+</div>
977+<div>
978+<dt>
979+<kbd>y</kbd>
980+</dt>
981+<dd>permalink: ref becomes the full sha</dd>
982+</div>
983+<div>
984+<dt>
985+<kbd>s</kbd>
986+<kbd>t</kbd>
987+<kbd>c</kbd>
988+<kbd>r</kbd>
989+</dt>
990+<dd>repo sections (when shown)</dd>
991+</div>
992+<div>
993+<dt>
994+<kbd>d</kbd>
995+<kbd>u</kbd>
996+<kbd>x</kbd>
997+</dt>
998+<dd>commit: split / unified / collapse all</dd>
999+</div>
1000+<div>
1001+<dt>
1002+<kbd>esc</kbd>
1003+</dt>
1004+<dd>close panel</dd>
1005+</div>
1006+</dl>
1007+</dialog>
1008+<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
1009+<div class="panel-head">
1010+<label for="refs-filter">ref&gt;</label>
1011+<input id="refs-filter" type="search" placeholder="filter branches and tags" 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))">
1012+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
1013+</div>
1014+<div class="panel-cols" data-picklist>
1015+<section aria-label="Branches">
1016+<h2>branches <span class="mute">1</span>
1017+</h2>
1018+<ul>
1019+<li>
1020+<a href="/long/tree/main" aria-current="true">
1021+<span class="mark" aria-hidden="true">●</span>main</a>
1022+</li>
1023+</ul>
1024+</section>
1025+<section aria-label="Tags">
1026+<h2>tags <span class="mute">0</span>
1027+</h2>
1028+<ul>
1029+</ul>
1030+</section>
1031+</div>
1032+</dialog>
1033+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
1034+<div class="panel-head">
1035+<label for="cmd-q" class="hi">:</label>
1036+<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
1037+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
1038+ 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();
1039+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
1040+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
1041+ 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' })) }
1042+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
1043+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
1044+</div>
1045+<ul id="cmd-list" class="cmd-list">
1046+<li data-k="go home" data-cursor>
1047+<a href="/" tabindex="-1">
1048+<span class="k">go</span>home</a>
1049+</li>
1050+<li data-k="section summary">
1051+<a href="/long" tabindex="-1">
1052+<span class="k">section</span>summary</a>
1053+</li>
1054+<li data-k="section tree">
1055+<a href="/long/tree/main" tabindex="-1">
1056+<span class="k">section</span>tree</a>
1057+</li>
1058+<li data-k="section commits">
1059+<a href="/long/commits/main" tabindex="-1">
1060+<span class="k">section</span>commits</a>
1061+</li>
1062+<li data-k="section refs">
1063+<a href="/long/refs" tabindex="-1">
1064+<span class="k">section</span>refs</a>
1065+</li>
1066+<li data-k="branch main">
1067+<a href="/long/tree/main" tabindex="-1">
1068+<span class="k">branch</span>main</a>
1069+</li>
1070+<li data-k="repo diffs">
1071+<a href="/diffs" tabindex="-1">
1072+<span class="k">repo</span>diffs</a>
1073+</li>
1074+</ul>
1075+</dialog>
1076+<footer class="bar status" aria-label="Status">
1077+<span class="mode">commits</span>
1078+<span class="st-ctx">main · 50 commits · more</span>
1079+<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
1080+<span class="hi">:</span> go to…</button>
1081+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
1082+</span>
1083+<span class="st-hints">j/k move · enter open · / filter · ? help</span>
1084+<span class="st-src">
1085+<span class="st-ver">ermite devel · </span>
1086+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
1087+</span>
1088+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
1089+<span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/50</span>
1090+</footer>
1091+<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
1092+ if (q('dialog[open]').count) return;
1093+ let rows = q('[data-rows] > :not([hidden])').arr();
1094+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
1095+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
1096+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
1097+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
1098+ })(1);
1099+ k-k from:body -> ((d) => {
1100+ if (q('dialog[open]').count) return;
1101+ let rows = q('[data-rows] > :not([hidden])').arr();
1102+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
1103+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
1104+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
1105+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
1106+ })(-1);
1107+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
1108+ 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));
1109+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
1110+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
1111+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
1112+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
1113+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
1114+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
1115+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
1116+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
1117+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
1118+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
1119+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
1120+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
1121+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
1122+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
1123+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
1124+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
1125+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
1126+ k-permalink from:body -> {
1127+ let b = document.body.dataset;
1128+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
1129+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
1130+ navigator.clipboard?.writeText(location.href);
1131+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
1132+ }">
1133+</div>
1134+</body>
1135+</html>
A cmd/ermite/testdata/web/commits-small.html +495 -0
@@ -0,0 +1,495 @@
1+<!doctype html>
2+<html lang="en">
3+<head>
4+<meta charset="utf-8">
5+<meta name="viewport" content="width=device-width, initial-scale=1">
6+<meta name="color-scheme" content="light dark">
7+<title>commits · diffs · example.test</title>
8+<link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
9+<script src="/-/static/js/htmx.min.HASH.js" defer>
10+</script>
11+<script src="/-/static/js/hx-live.min.HASH.js" defer>
12+</script>
13+<script src="/-/static/js/hx-sse.min.HASH.js" defer>
14+</script>
15+<script src="/-/static/js/ermite.HASH.js" defer>
16+</script>
17+</head>
18+<body data-ref="main" data-sha="SHA40" data-sel="">
19+<a class="skip" href="#main">skip to content</a>
20+<header class="bar top">
21+<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
22+<span class="fade" aria-hidden="true">
23+</span>
24+<nav class="crumbs" aria-label="Path">
25+<ol>
26+<li>
27+<a href="/diffs">diffs</a>
28+</li>
29+<li>
30+<a href="/diffs/commits/main">commits</a>
31+</li>
32+<li>
33+<a href="/diffs/commits/main" aria-current="page">main</a>
34+</li>
35+</ol>
36+</nav>
37+<nav class="secs" aria-label="Sections">
38+<ul>
39+<li>
40+<a href="/diffs" data-key="s" aria-keyshortcuts="g s">summary</a>
41+</li>
42+<li>
43+<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a>
44+</li>
45+<li>
46+<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="g c" aria-current="page">commits</a>
47+</li>
48+<li>
49+<a href="/diffs/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
50+</li>
51+</ul>
52+</nav>
53+<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
54+</button>
55+</header>
56+<div class="work has-rail">
57+<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
58+<div class="filter">
59+<label for="commit-filter">filter&gt;</label>
60+<input id="commit-filter" type="search" data-filter placeholder="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));
61+ q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])'));
62+ let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')">
63+</div>
64+<div class="days" id="commits-head">
65+<section aria-label="2026-02-08">
66+<h2 class="rule-h">
67+<span>2026-02-08</span>
68+</h2>
69+<table class="list commits" aria-label="Commits">
70+<tbody data-rows>
71+<tr id="c-SHA7" data-cursor>
72+<td class="cur" aria-hidden="true">
73+</td>
74+<td class="c-sha mute">SHA7</td>
75+<td class="c-subject">
76+<a href="/diffs/commit/SHA40">add a very large file</a>
77+<span class="tagged">[main]</span>
78+</td>
79+<td class="c-who dim">T</td>
80+<td class="c-when nowrap dim">
81+<time datetime="STAMP" title="STAMP">AGO</time>
82+</td>
83+</tr>
84+</tbody>
85+</table>
86+</section>
87+<section aria-label="2026-02-07">
88+<h2 class="rule-h">
89+<span>2026-02-07</span>
90+</h2>
91+<table class="list commits" aria-label="Commits">
92+<tbody data-rows>
93+<tr id="c-SHA7">
94+<td class="cur" aria-hidden="true">
95+</td>
96+<td class="c-sha mute">SHA7</td>
97+<td class="c-subject">
98+<a href="/diffs/commit/SHA40">add evil.go</a>
99+</td>
100+<td class="c-who dim">T</td>
101+<td class="c-when nowrap dim">
102+<time datetime="STAMP" title="STAMP">AGO</time>
103+</td>
104+</tr>
105+</tbody>
106+</table>
107+</section>
108+<section aria-label="2026-02-06">
109+<h2 class="rule-h">
110+<span>2026-02-06</span>
111+</h2>
112+<table class="list commits" aria-label="Commits">
113+<tbody data-rows>
114+<tr id="c-SHA7">
115+<td class="cur" aria-hidden="true">
116+</td>
117+<td class="c-sha mute">SHA7</td>
118+<td class="c-subject">
119+<a href="/diffs/commit/SHA40">merge the side branch</a>
120+<span class="tagged">(merge)</span>
121+</td>
122+<td class="c-who dim">T</td>
123+<td class="c-when nowrap dim">
124+<time datetime="STAMP" title="STAMP">AGO</time>
125+</td>
126+</tr>
127+</tbody>
128+</table>
129+</section>
130+<section aria-label="2026-02-05">
131+<h2 class="rule-h">
132+<span>2026-02-05</span>
133+</h2>
134+<table class="list commits" aria-label="Commits">
135+<tbody data-rows>
136+<tr id="c-SHA7">
137+<td class="cur" aria-hidden="true">
138+</td>
139+<td class="c-sha mute">SHA7</td>
140+<td class="c-subject">
141+<a href="/diffs/commit/SHA40">work on the side</a>
142+<span class="tagged">[side]</span>
143+</td>
144+<td class="c-who dim">T</td>
145+<td class="c-when nowrap dim">
146+<time datetime="STAMP" title="STAMP">AGO</time>
147+</td>
148+</tr>
149+</tbody>
150+</table>
151+</section>
152+<section aria-label="2026-02-04">
153+<h2 class="rule-h">
154+<span>2026-02-04</span>
155+</h2>
156+<table class="list commits" aria-label="Commits">
157+<tbody data-rows>
158+<tr id="c-SHA7">
159+<td class="cur" aria-hidden="true">
160+</td>
161+<td class="c-sha mute">SHA7</td>
162+<td class="c-subject">
163+<a href="/diffs/commit/SHA40">change a binary file</a>
164+</td>
165+<td class="c-who dim">T</td>
166+<td class="c-when nowrap dim">
167+<time datetime="STAMP" title="STAMP">AGO</time>
168+</td>
169+</tr>
170+</tbody>
171+</table>
172+</section>
173+<section aria-label="2026-02-03">
174+<h2 class="rule-h">
175+<span>2026-02-03</span>
176+</h2>
177+<table class="list commits" aria-label="Commits">
178+<tbody data-rows>
179+<tr id="c-SHA7">
180+<td class="cur" aria-hidden="true">
181+</td>
182+<td class="c-sha mute">SHA7</td>
183+<td class="c-subject">
184+<a href="/diffs/commit/SHA40">rename a file and delete another</a>
185+</td>
186+<td class="c-who dim">T</td>
187+<td class="c-when nowrap dim">
188+<time datetime="STAMP" title="STAMP">AGO</time>
189+</td>
190+</tr>
191+</tbody>
192+</table>
193+</section>
194+<section aria-label="2026-02-02">
195+<h2 class="rule-h">
196+<span>2026-02-02</span>
197+</h2>
198+<table class="list commits" aria-label="Commits">
199+<tbody data-rows>
200+<tr id="c-SHA7">
201+<td class="cur" aria-hidden="true">
202+</td>
203+<td class="c-sha mute">SHA7</td>
204+<td class="c-subject">
205+<a href="/diffs/commit/SHA40">modify a file and make a script executable</a>
206+<span class="tagged">[tag: v1.0.0]</span>
207+</td>
208+<td class="c-who dim">T</td>
209+<td class="c-when nowrap dim">
210+<time datetime="STAMP" title="STAMP">AGO</time>
211+</td>
212+</tr>
213+</tbody>
214+</table>
215+</section>
216+<section aria-label="2026-02-01">
217+<h2 class="rule-h">
218+<span>2026-02-01</span>
219+</h2>
220+<table class="list commits" aria-label="Commits">
221+<tbody data-rows>
222+<tr id="c-SHA7">
223+<td class="cur" aria-hidden="true">
224+</td>
225+<td class="c-sha mute">SHA7</td>
226+<td class="c-subject">
227+<a href="/diffs/commit/SHA40">root: the first files</a>
228+</td>
229+<td class="c-who dim">T</td>
230+<td class="c-when nowrap dim">
231+<time datetime="STAMP" title="STAMP">AGO</time>
232+</td>
233+</tr>
234+</tbody>
235+</table>
236+</section>
237+</div>
238+</main>
239+<aside class="rail" aria-label="Context">
240+<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
241+<summary>history · refs on this page</summary>
242+<section aria-labelledby="r-hist">
243+<h2 id="r-hist">history</h2>
244+<dl class="kv">
245+<dt>ref</dt>
246+<dd>main</dd>
247+<dt>on page</dt>
248+<dd>8</dd>
249+</dl>
250+</section>
251+<section aria-labelledby="r-refs">
252+<h2 id="r-refs">refs on this page</h2>
253+<nav class="outline" aria-label="Refs on this page">
254+<a href="#c-SHA7">branch main</a>
255+<a href="#c-SHA7">branch side</a>
256+<a href="#c-SHA7">tag v1.0.0</a>
257+</nav>
258+</section>
259+</details>
260+</aside>
261+</div>
262+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
263+<div class="panel-head">
264+<span>keys</span>
265+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
266+</div>
267+<dl class="keys">
268+<div>
269+<dt>
270+<kbd>j</kbd>
271+<kbd>k</kbd>
272+</dt>
273+<dd>move cursor down / up</dd>
274+</div>
275+<div>
276+<dt>
277+<kbd>enter</kbd>
278+</dt>
279+<dd>open row under cursor</dd>
280+</div>
281+<div>
282+<dt>
283+<kbd>g</kbd>
284+<kbd>g</kbd> / <kbd>G</kbd>
285+</dt>
286+<dd>first / last row</dd>
287+</div>
288+<div>
289+<dt>
290+<kbd>/</kbd>
291+</dt>
292+<dd>focus the filter</dd>
293+</div>
294+<div>
295+<dt>
296+<kbd>g h</kbd>
297+<kbd>g t</kbd>
298+</dt>
299+<dd>go home / tree</dd>
300+</div>
301+<div>
302+<dt>
303+<kbd>g c</kbd>
304+<kbd>g r</kbd>
305+</dt>
306+<dd>go commits / refs</dd>
307+</div>
308+<div>
309+<dt>
310+<kbd>b</kbd>
311+</dt>
312+<dd>switch ref (branches, tags)</dd>
313+</div>
314+<div>
315+<dt>
316+<kbd>:</kbd>
317+<kbd>f</kbd>
318+</dt>
319+<dd>go to: section, ref, file, repo</dd>
320+</div>
321+<div>
322+<dt>
323+<kbd>y</kbd>
324+</dt>
325+<dd>permalink: ref becomes the full sha</dd>
326+</div>
327+<div>
328+<dt>
329+<kbd>s</kbd>
330+<kbd>t</kbd>
331+<kbd>c</kbd>
332+<kbd>r</kbd>
333+</dt>
334+<dd>repo sections (when shown)</dd>
335+</div>
336+<div>
337+<dt>
338+<kbd>d</kbd>
339+<kbd>u</kbd>
340+<kbd>x</kbd>
341+</dt>
342+<dd>commit: split / unified / collapse all</dd>
343+</div>
344+<div>
345+<dt>
346+<kbd>esc</kbd>
347+</dt>
348+<dd>close panel</dd>
349+</div>
350+</dl>
351+</dialog>
352+<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
353+<div class="panel-head">
354+<label for="refs-filter">ref&gt;</label>
355+<input id="refs-filter" type="search" placeholder="filter branches and tags" 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))">
356+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
357+</div>
358+<div class="panel-cols" data-picklist>
359+<section aria-label="Branches">
360+<h2>branches <span class="mute">2</span>
361+</h2>
362+<ul>
363+<li>
364+<a href="/diffs/tree/main" aria-current="true">
365+<span class="mark" aria-hidden="true">●</span>main</a>
366+</li>
367+<li>
368+<a href="/diffs/tree/side">
369+<span class="mark" aria-hidden="true">&nbsp;</span>side</a>
370+</li>
371+</ul>
372+</section>
373+<section aria-label="Tags">
374+<h2>tags <span class="mute">1</span>
375+</h2>
376+<ul>
377+<li>
378+<a href="/diffs/tree/v1.0.0">
379+<span class="mark" aria-hidden="true">&nbsp;</span>v1.0.0</a>
380+</li>
381+</ul>
382+</section>
383+</div>
384+</dialog>
385+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
386+<div class="panel-head">
387+<label for="cmd-q" class="hi">:</label>
388+<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
389+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
390+ 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();
391+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
392+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
393+ 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' })) }
394+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
395+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
396+</div>
397+<ul id="cmd-list" class="cmd-list">
398+<li data-k="go home" data-cursor>
399+<a href="/" tabindex="-1">
400+<span class="k">go</span>home</a>
401+</li>
402+<li data-k="section summary">
403+<a href="/diffs" tabindex="-1">
404+<span class="k">section</span>summary</a>
405+</li>
406+<li data-k="section tree">
407+<a href="/diffs/tree/main" tabindex="-1">
408+<span class="k">section</span>tree</a>
409+</li>
410+<li data-k="section commits">
411+<a href="/diffs/commits/main" tabindex="-1">
412+<span class="k">section</span>commits</a>
413+</li>
414+<li data-k="section refs">
415+<a href="/diffs/refs" tabindex="-1">
416+<span class="k">section</span>refs</a>
417+</li>
418+<li data-k="branch main">
419+<a href="/diffs/tree/main" tabindex="-1">
420+<span class="k">branch</span>main</a>
421+</li>
422+<li data-k="branch side">
423+<a href="/diffs/tree/side" tabindex="-1">
424+<span class="k">branch</span>side</a>
425+</li>
426+<li data-k="tag v1.0.0">
427+<a href="/diffs/tree/v1.0.0" tabindex="-1">
428+<span class="k">tag</span>v1.0.0</a>
429+</li>
430+<li data-k="repo long">
431+<a href="/long" tabindex="-1">
432+<span class="k">repo</span>long</a>
433+</li>
434+</ul>
435+</dialog>
436+<footer class="bar status" aria-label="Status">
437+<span class="mode">commits</span>
438+<span class="st-ctx">main · 8 commits</span>
439+<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
440+<span class="hi">:</span> go to…</button>
441+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
442+</span>
443+<span class="st-hints">j/k move · enter open · / filter · ? help</span>
444+<span class="st-src">
445+<span class="st-ver">ermite devel · </span>
446+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
447+</span>
448+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
449+<span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/8</span>
450+</footer>
451+<div id="keys" hidden hx-on="k-j from:body -> ((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 n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
457+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
458+ })(1);
459+ k-k from:body -> ((d) => {
460+ if (q('dialog[open]').count) return;
461+ let rows = q('[data-rows] > :not([hidden])').arr();
462+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
463+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
464+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
465+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
466+ })(-1);
467+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
468+ 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));
469+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
470+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
471+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
472+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
473+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
474+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
475+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
476+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
477+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
478+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
479+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
480+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
481+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
482+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
483+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
484+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
485+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
486+ k-permalink from:body -> {
487+ let b = document.body.dataset;
488+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
489+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
490+ navigator.clipboard?.writeText(location.href);
491+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
492+ }">
493+</div>
494+</body>
495+</html>
@@ -0,0 +1,495 @@
1+<!doctype html>
2+<html lang="en">
3+<head>
4+<meta charset="utf-8">
5+<meta name="viewport" content="width=device-width, initial-scale=1">
6+<meta name="color-scheme" content="light dark">
7+<title>commits · diffs · example.test</title>
8+<link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
9+<script src="/-/static/js/htmx.min.HASH.js" defer>
10+</script>
11+<script src="/-/static/js/hx-live.min.HASH.js" defer>
12+</script>
13+<script src="/-/static/js/hx-sse.min.HASH.js" defer>
14+</script>
15+<script src="/-/static/js/ermite.HASH.js" defer>
16+</script>
17+</head>
18+<body data-ref="main" data-sha="SHA40" data-sel="">
19+<a class="skip" href="#main">skip to content</a>
20+<header class="bar top">
21+<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
22+<span class="fade" aria-hidden="true">
23+</span>
24+<nav class="crumbs" aria-label="Path">
25+<ol>
26+<li>
27+<a href="/diffs">diffs</a>
28+</li>
29+<li>
30+<a href="/diffs/commits/main">commits</a>
31+</li>
32+<li>
33+<a href="/diffs/commits/main" aria-current="page">main</a>
34+</li>
35+</ol>
36+</nav>
37+<nav class="secs" aria-label="Sections">
38+<ul>
39+<li>
40+<a href="/diffs" data-key="s" aria-keyshortcuts="g s">summary</a>
41+</li>
42+<li>
43+<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a>
44+</li>
45+<li>
46+<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="g c" aria-current="page">commits</a>
47+</li>
48+<li>
49+<a href="/diffs/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
50+</li>
51+</ul>
52+</nav>
53+<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
54+</button>
55+</header>
56+<div class="work has-rail">
57+<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
58+<div class="filter">
59+<label for="commit-filter">filter&gt;</label>
60+<input id="commit-filter" type="search" data-filter placeholder="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));
61+ q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])'));
62+ let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')">
63+</div>
64+<div class="days" id="commits-head">
65+<section aria-label="2026-02-08">
66+<h2 class="rule-h">
67+<span>2026-02-08</span>
68+</h2>
69+<table class="list commits" aria-label="Commits">
70+<tbody data-rows>
71+<tr id="c-SHA7" data-cursor>
72+<td class="cur" aria-hidden="true">
73+</td>
74+<td class="c-sha mute">SHA7</td>
75+<td class="c-subject">
76+<a href="/diffs/commit/SHA40">add a very large file</a>
77+<span class="tagged">[main]</span>
78+</td>
79+<td class="c-who dim">T</td>
80+<td class="c-when nowrap dim">
81+<time datetime="STAMP" title="STAMP">AGO</time>
82+</td>
83+</tr>
84+</tbody>
85+</table>
86+</section>
87+<section aria-label="2026-02-07">
88+<h2 class="rule-h">
89+<span>2026-02-07</span>
90+</h2>
91+<table class="list commits" aria-label="Commits">
92+<tbody data-rows>
93+<tr id="c-SHA7">
94+<td class="cur" aria-hidden="true">
95+</td>
96+<td class="c-sha mute">SHA7</td>
97+<td class="c-subject">
98+<a href="/diffs/commit/SHA40">add evil.go</a>
99+</td>
100+<td class="c-who dim">T</td>
101+<td class="c-when nowrap dim">
102+<time datetime="STAMP" title="STAMP">AGO</time>
103+</td>
104+</tr>
105+</tbody>
106+</table>
107+</section>
108+<section aria-label="2026-02-06">
109+<h2 class="rule-h">
110+<span>2026-02-06</span>
111+</h2>
112+<table class="list commits" aria-label="Commits">
113+<tbody data-rows>
114+<tr id="c-SHA7">
115+<td class="cur" aria-hidden="true">
116+</td>
117+<td class="c-sha mute">SHA7</td>
118+<td class="c-subject">
119+<a href="/diffs/commit/SHA40">merge the side branch</a>
120+<span class="tagged">(merge)</span>
121+</td>
122+<td class="c-who dim">T</td>
123+<td class="c-when nowrap dim">
124+<time datetime="STAMP" title="STAMP">AGO</time>
125+</td>
126+</tr>
127+</tbody>
128+</table>
129+</section>
130+<section aria-label="2026-02-05">
131+<h2 class="rule-h">
132+<span>2026-02-05</span>
133+</h2>
134+<table class="list commits" aria-label="Commits">
135+<tbody data-rows>
136+<tr id="c-SHA7">
137+<td class="cur" aria-hidden="true">
138+</td>
139+<td class="c-sha mute">SHA7</td>
140+<td class="c-subject">
141+<a href="/diffs/commit/SHA40">work on the side</a>
142+<span class="tagged">[side]</span>
143+</td>
144+<td class="c-who dim">T</td>
145+<td class="c-when nowrap dim">
146+<time datetime="STAMP" title="STAMP">AGO</time>
147+</td>
148+</tr>
149+</tbody>
150+</table>
151+</section>
152+<section aria-label="2026-02-04">
153+<h2 class="rule-h">
154+<span>2026-02-04</span>
155+</h2>
156+<table class="list commits" aria-label="Commits">
157+<tbody data-rows>
158+<tr id="c-SHA7">
159+<td class="cur" aria-hidden="true">
160+</td>
161+<td class="c-sha mute">SHA7</td>
162+<td class="c-subject">
163+<a href="/diffs/commit/SHA40">change a binary file</a>
164+</td>
165+<td class="c-who dim">T</td>
166+<td class="c-when nowrap dim">
167+<time datetime="STAMP" title="STAMP">AGO</time>
168+</td>
169+</tr>
170+</tbody>
171+</table>
172+</section>
173+<section aria-label="2026-02-03">
174+<h2 class="rule-h">
175+<span>2026-02-03</span>
176+</h2>
177+<table class="list commits" aria-label="Commits">
178+<tbody data-rows>
179+<tr id="c-SHA7">
180+<td class="cur" aria-hidden="true">
181+</td>
182+<td class="c-sha mute">SHA7</td>
183+<td class="c-subject">
184+<a href="/diffs/commit/SHA40">rename a file and delete another</a>
185+</td>
186+<td class="c-who dim">T</td>
187+<td class="c-when nowrap dim">
188+<time datetime="STAMP" title="STAMP">AGO</time>
189+</td>
190+</tr>
191+</tbody>
192+</table>
193+</section>
194+<section aria-label="2026-02-02">
195+<h2 class="rule-h">
196+<span>2026-02-02</span>
197+</h2>
198+<table class="list commits" aria-label="Commits">
199+<tbody data-rows>
200+<tr id="c-SHA7">
201+<td class="cur" aria-hidden="true">
202+</td>
203+<td class="c-sha mute">SHA7</td>
204+<td class="c-subject">
205+<a href="/diffs/commit/SHA40">modify a file and make a script executable</a>
206+<span class="tagged">[tag: v1.0.0]</span>
207+</td>
208+<td class="c-who dim">T</td>
209+<td class="c-when nowrap dim">
210+<time datetime="STAMP" title="STAMP">AGO</time>
211+</td>
212+</tr>
213+</tbody>
214+</table>
215+</section>
216+<section aria-label="2026-02-01">
217+<h2 class="rule-h">
218+<span>2026-02-01</span>
219+</h2>
220+<table class="list commits" aria-label="Commits">
221+<tbody data-rows>
222+<tr id="c-SHA7">
223+<td class="cur" aria-hidden="true">
224+</td>
225+<td class="c-sha mute">SHA7</td>
226+<td class="c-subject">
227+<a href="/diffs/commit/SHA40">root: the first files</a>
228+</td>
229+<td class="c-who dim">T</td>
230+<td class="c-when nowrap dim">
231+<time datetime="STAMP" title="STAMP">AGO</time>
232+</td>
233+</tr>
234+</tbody>
235+</table>
236+</section>
237+</div>
238+</main>
239+<aside class="rail" aria-label="Context">
240+<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
241+<summary>history · refs on this page</summary>
242+<section aria-labelledby="r-hist">
243+<h2 id="r-hist">history</h2>
244+<dl class="kv">
245+<dt>ref</dt>
246+<dd>main</dd>
247+<dt>on page</dt>
248+<dd>8</dd>
249+</dl>
250+</section>
251+<section aria-labelledby="r-refs">
252+<h2 id="r-refs">refs on this page</h2>
253+<nav class="outline" aria-label="Refs on this page">
254+<a href="#c-SHA7">branch main</a>
255+<a href="#c-SHA7">branch side</a>
256+<a href="#c-SHA7">tag v1.0.0</a>
257+</nav>
258+</section>
259+</details>
260+</aside>
261+</div>
262+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
263+<div class="panel-head">
264+<span>keys</span>
265+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
266+</div>
267+<dl class="keys">
268+<div>
269+<dt>
270+<kbd>j</kbd>
271+<kbd>k</kbd>
272+</dt>
273+<dd>move cursor down / up</dd>
274+</div>
275+<div>
276+<dt>
277+<kbd>enter</kbd>
278+</dt>
279+<dd>open row under cursor</dd>
280+</div>
281+<div>
282+<dt>
283+<kbd>g</kbd>
284+<kbd>g</kbd> / <kbd>G</kbd>
285+</dt>
286+<dd>first / last row</dd>
287+</div>
288+<div>
289+<dt>
290+<kbd>/</kbd>
291+</dt>
292+<dd>focus the filter</dd>
293+</div>
294+<div>
295+<dt>
296+<kbd>g h</kbd>
297+<kbd>g t</kbd>
298+</dt>
299+<dd>go home / tree</dd>
300+</div>
301+<div>
302+<dt>
303+<kbd>g c</kbd>
304+<kbd>g r</kbd>
305+</dt>
306+<dd>go commits / refs</dd>
307+</div>
308+<div>
309+<dt>
310+<kbd>b</kbd>
311+</dt>
312+<dd>switch ref (branches, tags)</dd>
313+</div>
314+<div>
315+<dt>
316+<kbd>:</kbd>
317+<kbd>f</kbd>
318+</dt>
319+<dd>go to: section, ref, file, repo</dd>
320+</div>
321+<div>
322+<dt>
323+<kbd>y</kbd>
324+</dt>
325+<dd>permalink: ref becomes the full sha</dd>
326+</div>
327+<div>
328+<dt>
329+<kbd>s</kbd>
330+<kbd>t</kbd>
331+<kbd>c</kbd>
332+<kbd>r</kbd>
333+</dt>
334+<dd>repo sections (when shown)</dd>
335+</div>
336+<div>
337+<dt>
338+<kbd>d</kbd>
339+<kbd>u</kbd>
340+<kbd>x</kbd>
341+</dt>
342+<dd>commit: split / unified / collapse all</dd>
343+</div>
344+<div>
345+<dt>
346+<kbd>esc</kbd>
347+</dt>
348+<dd>close panel</dd>
349+</div>
350+</dl>
351+</dialog>
352+<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
353+<div class="panel-head">
354+<label for="refs-filter">ref&gt;</label>
355+<input id="refs-filter" type="search" placeholder="filter branches and tags" 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))">
356+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
357+</div>
358+<div class="panel-cols" data-picklist>
359+<section aria-label="Branches">
360+<h2>branches <span class="mute">2</span>
361+</h2>
362+<ul>
363+<li>
364+<a href="/diffs/tree/main" aria-current="true">
365+<span class="mark" aria-hidden="true">●</span>main</a>
366+</li>
367+<li>
368+<a href="/diffs/tree/side">
369+<span class="mark" aria-hidden="true">&nbsp;</span>side</a>
370+</li>
371+</ul>
372+</section>
373+<section aria-label="Tags">
374+<h2>tags <span class="mute">1</span>
375+</h2>
376+<ul>
377+<li>
378+<a href="/diffs/tree/v1.0.0">
379+<span class="mark" aria-hidden="true">&nbsp;</span>v1.0.0</a>
380+</li>
381+</ul>
382+</section>
383+</div>
384+</dialog>
385+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
386+<div class="panel-head">
387+<label for="cmd-q" class="hi">:</label>
388+<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
389+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
390+ 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();
391+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
392+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
393+ 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' })) }
394+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
395+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
396+</div>
397+<ul id="cmd-list" class="cmd-list">
398+<li data-k="go home" data-cursor>
399+<a href="/" tabindex="-1">
400+<span class="k">go</span>home</a>
401+</li>
402+<li data-k="section summary">
403+<a href="/diffs" tabindex="-1">
404+<span class="k">section</span>summary</a>
405+</li>
406+<li data-k="section tree">
407+<a href="/diffs/tree/main" tabindex="-1">
408+<span class="k">section</span>tree</a>
409+</li>
410+<li data-k="section commits">
411+<a href="/diffs/commits/main" tabindex="-1">
412+<span class="k">section</span>commits</a>
413+</li>
414+<li data-k="section refs">
415+<a href="/diffs/refs" tabindex="-1">
416+<span class="k">section</span>refs</a>
417+</li>
418+<li data-k="branch main">
419+<a href="/diffs/tree/main" tabindex="-1">
420+<span class="k">branch</span>main</a>
421+</li>
422+<li data-k="branch side">
423+<a href="/diffs/tree/side" tabindex="-1">
424+<span class="k">branch</span>side</a>
425+</li>
426+<li data-k="tag v1.0.0">
427+<a href="/diffs/tree/v1.0.0" tabindex="-1">
428+<span class="k">tag</span>v1.0.0</a>
429+</li>
430+<li data-k="repo long">
431+<a href="/long" tabindex="-1">
432+<span class="k">repo</span>long</a>
433+</li>
434+</ul>
435+</dialog>
436+<footer class="bar status" aria-label="Status">
437+<span class="mode">commits</span>
438+<span class="st-ctx">main · 8 commits</span>
439+<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
440+<span class="hi">:</span> go to…</button>
441+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
442+</span>
443+<span class="st-hints">j/k move · enter open · / filter · ? help</span>
444+<span class="st-src">
445+<span class="st-ver">ermite devel · </span>
446+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
447+</span>
448+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
449+<span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/8</span>
450+</footer>
451+<div id="keys" hidden hx-on="k-j from:body -> ((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 n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
457+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
458+ })(1);
459+ k-k from:body -> ((d) => {
460+ if (q('dialog[open]').count) return;
461+ let rows = q('[data-rows] > :not([hidden])').arr();
462+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
463+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
464+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
465+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
466+ })(-1);
467+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
468+ 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));
469+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
470+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
471+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
472+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
473+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
474+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
475+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
476+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
477+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
478+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
479+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
480+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
481+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
482+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
483+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
484+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
485+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
486+ k-permalink from:body -> {
487+ let b = document.body.dataset;
488+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
489+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
490+ navigator.clipboard?.writeText(location.href);
491+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
492+ }">
493+</div>
494+</body>
495+</html>
A cmd/ermite/web_commits_test.go +224 -0
@@ -0,0 +1,224 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "regexp"
10+ "strings"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+
17+ "bnema.dev/ermite/internal/app"
18+ "bnema.dev/ermite/internal/domain"
19+)
20+
21+// diffFixture is the repository of the commit pages: one commit for each
22+// kind of change a diff shows.
23+type diffFixture struct {
24+ root, modify, rename, binary, merge, trojan, big string
25+}
26+
27+// createRepo creates a public repository for the pages.
28+func (h *harness) createRepo(name string, vis domain.Visibility) {
29+ h.t.Helper()
30+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name(name), Description: "Diffs", Visibility: vis})
31+ require.NoError(h.t, err)
32+}
33+
34+// commitAt commits what is staged at a fixed date and returns the id.
35+func (h *harness) commitAt(wt, date, msg string) string {
36+ h.t.Helper()
37+ env := []string{"GIT_AUTHOR_DATE=" + date, "GIT_COMMITTER_DATE=" + date}
38+ out, err := h.gitEnv(env, wt, "commit", "-q", "--allow-empty", "-m", msg)
39+ require.NoError(h.t, err, out)
40+ return strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD"))
41+}
42+
43+func (h *harness) put(wt, name string, data []byte, mode os.FileMode) {
44+ h.t.Helper()
45+ full := filepath.Join(wt, name)
46+ require.NoError(h.t, os.MkdirAll(filepath.Dir(full), 0o755))
47+ require.NoError(h.t, os.WriteFile(full, data, mode))
48+ require.NoError(h.t, os.Chmod(full, mode))
49+ h.mustGit(wt, "add", name)
50+}
51+
52+// diffRepo builds the fixture and pushes it as a public repository.
53+func (h *harness) diffRepo(name string) diffFixture {
54+ h.t.Helper()
55+ wt := h.t.TempDir()
56+ h.mustGit(wt, "init", "-q", "--initial-branch=main")
57+ var f diffFixture
58+
59+ h.put(wt, "a.txt", []byte("one\ntwo\nthree\nfour\nfive\n"), 0o644)
60+ h.put(wt, "old.txt", []byte("keep me\nkeep me too\nand me\n"), 0o644)
61+ h.put(wt, "run.sh", []byte("#!/bin/sh\necho hi\n"), 0o644)
62+ h.put(wt, "gone.txt", []byte("bye\n"), 0o644)
63+ h.put(wt, "logo.bin", []byte{0x89, 'P', 'N', 'G', 0, 1, 2, 3}, 0o644)
64+ f.root = h.commitAt(wt, "2026-02-01T10:00:00Z", "root: the first files")
65+
66+ h.put(wt, "a.txt", []byte("one\n2\nthree\nfour\nfive\nsix\n"), 0o644)
67+ h.put(wt, "run.sh", []byte("#!/bin/sh\necho hi\n"), 0o755)
68+ f.modify = h.commitAt(wt, "2026-02-02T10:00:00Z", "modify a file and make a script executable\n\nThe body has a second paragraph.\n\nAnd <b>html</b> & \"quotes\".")
69+
70+ h.mustGit(wt, "mv", "old.txt", "docs-old.txt")
71+ h.mustGit(wt, "rm", "-q", "gone.txt")
72+ f.rename = h.commitAt(wt, "2026-02-03T10:00:00Z", "rename a file and delete another")
73+
74+ h.put(wt, "logo.bin", []byte{0x89, 'P', 'N', 'G', 0, 9, 9, 9, 9}, 0o644)
75+ f.binary = h.commitAt(wt, "2026-02-04T10:00:00Z", "change a binary file")
76+
77+ // A side branch from the root, merged with a merge commit.
78+ h.mustGit(wt, "checkout", "-q", "-b", "side", f.root)
79+ h.put(wt, "side.txt", []byte("from the side\n"), 0o644)
80+ h.commitAt(wt, "2026-02-05T10:00:00Z", "work on the side")
81+ h.mustGit(wt, "checkout", "-q", "main")
82+ env := []string{"GIT_AUTHOR_DATE=2026-02-06T10:00:00Z", "GIT_COMMITTER_DATE=2026-02-06T10:00:00Z"}
83+ out, err := h.gitEnv(env, wt, "merge", "-q", "--no-ff", "-m", "merge the side branch", "side")
84+ require.NoError(h.t, err, out)
85+ f.merge = strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD"))
86+
87+ h.put(wt, "evil.go", []byte("package evil\n\nfunc admin(user string) bool {\n\tif user == \"user\u202e \u2066// check\u2069 \u2066\" {\n\t\treturn true // z\u200bw\n\t}\n\treturn false\n}\n"), 0o644)
88+ f.trojan = h.commitAt(wt, "2026-02-07T10:00:00Z", "add evil.go\n\nlooks \u202efine\u202c")
89+
90+ var big strings.Builder
91+ for i := range 2600 {
92+ fmt.Fprintf(&big, "line %d\n", i)
93+ }
94+ h.put(wt, "big.txt", []byte(big.String()), 0o644)
95+ f.big = h.commitAt(wt, "2026-02-08T10:00:00Z", "add a very large file")
96+
97+ h.mustGit(wt, "tag", "v1.0.0", f.modify)
98+ h.createRepo(name, domain.VisibilityPublic)
99+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/"+name+".git"), "main", "side", "--tags")
100+ return f
101+}
102+
103+// longRepo is a repository with more commits than a page lists: 52 on main,
104+// one of which adds a.txt and one changes it.
105+func (h *harness) longRepo(name string) {
106+ h.t.Helper()
107+ wt := h.t.TempDir()
108+ h.mustGit(wt, "init", "-q", "--initial-branch=main")
109+ base := time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC)
110+ for i := range 52 {
111+ switch i {
112+ case 3:
113+ h.put(wt, "a.txt", []byte("first\n"), 0o644)
114+ case 30:
115+ h.put(wt, "a.txt", []byte("second\n"), 0o644)
116+ default:
117+ h.put(wt, "counter.txt", []byte(fmt.Sprintf("%d\n", i)), 0o644)
118+ }
119+ h.commitAt(wt, base.Add(time.Duration(i)*12*time.Hour).Format(time.RFC3339), fmt.Sprintf("commit number %d", i))
120+ }
121+ h.createRepo(name, domain.VisibilityPublic)
122+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/"+name+".git"), "main")
123+}
124+
125+var reOlder = regexp.MustCompile(`<a href="([^"]+)" rel="next">`)
126+
127+// unescape undoes the HTML escaping of an attribute value.
128+func unescape(s string) string { return strings.ReplaceAll(s, "&amp;", "&") }
129+
130+// AC1: the history pages render for the fixtures with the structure of the
131+// mockup (golden HTML, normalised).
132+func TestWebCommitsGolden(t *testing.T) {
133+ h := newHarness(t, harnessOpts{})
134+ h.diffRepo("diffs")
135+ h.longRepo("long")
136+
137+ // The second page is reached by the link of the first, as a visitor would.
138+ _, _, first := h.get("/long/commits/main")
139+ m := reOlder.FindStringSubmatch(first)
140+ require.Len(t, m, 2, "the first page links to the older commits")
141+ second := unescape(m[1])
142+ assert.True(t, strings.HasPrefix(second, "/long/commits/main?page="), second)
143+
144+ for _, tt := range []struct{ name, path string }{
145+ {"commits-root", "/long/commits/main"},
146+ {"commits-page2", second},
147+ {"commits-path", "/long/commits/main/a.txt"},
148+ {"commits-small", "/diffs/commits"},
149+ } {
150+ t.Run(tt.name, func(t *testing.T) {
151+ status, hdr, body := h.get(tt.path)
152+ require.Equal(t, 200, status)
153+ assert.Equal(t, "no-cache", hdr.Get("Cache-Control"))
154+ assertNoInline(t, body)
155+ golden(t, tt.name, normalise(body))
156+ })
157+ }
158+}
159+
160+// The behaviours of the history pages, asserted on their own.
161+func TestWebCommits(t *testing.T) {
162+ h := newHarness(t, harnessOpts{})
163+ f := h.diffRepo("diffs")
164+ h.longRepo("long")
165+ get := func(path string, status int) string {
166+ t.Helper()
167+ st, _, body := h.get(path)
168+ require.Equal(t, status, st, path)
169+ return body
170+ }
171+
172+ t.Run("history", func(t *testing.T) {
173+ first := get("/long/commits/main", 200)
174+ assert.Equal(t, 50, strings.Count(first, `class="c-sha mute"`), "a page lists 50 commits")
175+ assert.Contains(t, first, "commit number 51")
176+ assert.NotContains(t, first, "commit number 1<", "the oldest are on the next page")
177+ assert.Contains(t, first, "<span class=\"mute\">← newest</span>")
178+ second := unescape(reOlder.FindStringSubmatch(first)[1])
179+ page2 := get(second, 200)
180+ assert.Equal(t, 2, strings.Count(page2, `class="c-sha mute"`))
181+ assert.Contains(t, page2, `href="/long/commits/main">← newest</a>`)
182+ assert.Contains(t, page2, "<span class=\"mute\">older →</span>")
183+ get("/long/commits/main?page=garbage", 404)
184+ })
185+
186+ t.Run("path filter", func(t *testing.T) {
187+ body := get("/long/commits/main/a.txt", 200)
188+ assert.Equal(t, 2, strings.Count(body, `class="c-sha mute"`))
189+ assert.Contains(t, body, "history of <code>a.txt</code> at main")
190+ assert.Contains(t, body, `href="/long/blob/main/a.txt"`, "the way to the file")
191+ get("/long/commits/main/never/existed.txt", 404)
192+ })
193+
194+ t.Run("refs", func(t *testing.T) {
195+ // A tag, a branch, a commit id, and a ref with a slash.
196+ assert.Contains(t, get("/diffs/commits/v1.0.0", 200), "modify a file")
197+ assert.Contains(t, get("/diffs/commits/side", 200), "work on the side")
198+ assert.Contains(t, get("/diffs/commits/"+f.modify[:10], 200), "modify a file")
199+ assert.Contains(t, get("/diffs/commits/main", 200), `[tag: v1.0.0]`)
200+ assert.Contains(t, get("/diffs/commits/main", 200), `(merge)`)
201+ get("/diffs/commits/nope", 404)
202+ get("/diffs/commits/mai", 404)
203+ get("/nope/commits/main", 404)
204+ })
205+}
206+
207+// AC2: the history pages of a private repository are the 404 page of a
208+// repository that does not exist.
209+func TestWebCommitsPrivate(t *testing.T) {
210+ h := newHarness(t, harnessOpts{})
211+ wt := h.workTree("main", "secret.txt")
212+ h.createRepo("hidden-one", domain.VisibilityPrivate)
213+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/hidden-one.git"), "main")
214+
215+ _, _, missing := h.get("/never-existed")
216+ for _, p := range []string{"/hidden-one/commits/main", "/hidden-one/commits", "/hidden-one/commits/main/secret.txt"} {
217+ st, hdr, body := h.get(p)
218+ assert.Equal(t, 404, st, p)
219+ assert.Equal(t, "no-cache", hdr.Get("Cache-Control"), p)
220+ assert.NotContains(t, body, "hidden-one", p)
221+ assert.NotContains(t, body, "secret.txt", p)
222+ assert.Equal(t, normalise(missing), normalise(body), p)
223+ }
224+}
@@ -0,0 +1,224 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "regexp"
10+ "strings"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+
17+ "bnema.dev/ermite/internal/app"
18+ "bnema.dev/ermite/internal/domain"
19+)
20+
21+// diffFixture is the repository of the commit pages: one commit for each
22+// kind of change a diff shows.
23+type diffFixture struct {
24+ root, modify, rename, binary, merge, trojan, big string
25+}
26+
27+// createRepo creates a public repository for the pages.
28+func (h *harness) createRepo(name string, vis domain.Visibility) {
29+ h.t.Helper()
30+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name(name), Description: "Diffs", Visibility: vis})
31+ require.NoError(h.t, err)
32+}
33+
34+// commitAt commits what is staged at a fixed date and returns the id.
35+func (h *harness) commitAt(wt, date, msg string) string {
36+ h.t.Helper()
37+ env := []string{"GIT_AUTHOR_DATE=" + date, "GIT_COMMITTER_DATE=" + date}
38+ out, err := h.gitEnv(env, wt, "commit", "-q", "--allow-empty", "-m", msg)
39+ require.NoError(h.t, err, out)
40+ return strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD"))
41+}
42+
43+func (h *harness) put(wt, name string, data []byte, mode os.FileMode) {
44+ h.t.Helper()
45+ full := filepath.Join(wt, name)
46+ require.NoError(h.t, os.MkdirAll(filepath.Dir(full), 0o755))
47+ require.NoError(h.t, os.WriteFile(full, data, mode))
48+ require.NoError(h.t, os.Chmod(full, mode))
49+ h.mustGit(wt, "add", name)
50+}
51+
52+// diffRepo builds the fixture and pushes it as a public repository.
53+func (h *harness) diffRepo(name string) diffFixture {
54+ h.t.Helper()
55+ wt := h.t.TempDir()
56+ h.mustGit(wt, "init", "-q", "--initial-branch=main")
57+ var f diffFixture
58+
59+ h.put(wt, "a.txt", []byte("one\ntwo\nthree\nfour\nfive\n"), 0o644)
60+ h.put(wt, "old.txt", []byte("keep me\nkeep me too\nand me\n"), 0o644)
61+ h.put(wt, "run.sh", []byte("#!/bin/sh\necho hi\n"), 0o644)
62+ h.put(wt, "gone.txt", []byte("bye\n"), 0o644)
63+ h.put(wt, "logo.bin", []byte{0x89, 'P', 'N', 'G', 0, 1, 2, 3}, 0o644)
64+ f.root = h.commitAt(wt, "2026-02-01T10:00:00Z", "root: the first files")
65+
66+ h.put(wt, "a.txt", []byte("one\n2\nthree\nfour\nfive\nsix\n"), 0o644)
67+ h.put(wt, "run.sh", []byte("#!/bin/sh\necho hi\n"), 0o755)
68+ f.modify = h.commitAt(wt, "2026-02-02T10:00:00Z", "modify a file and make a script executable\n\nThe body has a second paragraph.\n\nAnd <b>html</b> & \"quotes\".")
69+
70+ h.mustGit(wt, "mv", "old.txt", "docs-old.txt")
71+ h.mustGit(wt, "rm", "-q", "gone.txt")
72+ f.rename = h.commitAt(wt, "2026-02-03T10:00:00Z", "rename a file and delete another")
73+
74+ h.put(wt, "logo.bin", []byte{0x89, 'P', 'N', 'G', 0, 9, 9, 9, 9}, 0o644)
75+ f.binary = h.commitAt(wt, "2026-02-04T10:00:00Z", "change a binary file")
76+
77+ // A side branch from the root, merged with a merge commit.
78+ h.mustGit(wt, "checkout", "-q", "-b", "side", f.root)
79+ h.put(wt, "side.txt", []byte("from the side\n"), 0o644)
80+ h.commitAt(wt, "2026-02-05T10:00:00Z", "work on the side")
81+ h.mustGit(wt, "checkout", "-q", "main")
82+ env := []string{"GIT_AUTHOR_DATE=2026-02-06T10:00:00Z", "GIT_COMMITTER_DATE=2026-02-06T10:00:00Z"}
83+ out, err := h.gitEnv(env, wt, "merge", "-q", "--no-ff", "-m", "merge the side branch", "side")
84+ require.NoError(h.t, err, out)
85+ f.merge = strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD"))
86+
87+ h.put(wt, "evil.go", []byte("package evil\n\nfunc admin(user string) bool {\n\tif user == \"user\u202e \u2066// check\u2069 \u2066\" {\n\t\treturn true // z\u200bw\n\t}\n\treturn false\n}\n"), 0o644)
88+ f.trojan = h.commitAt(wt, "2026-02-07T10:00:00Z", "add evil.go\n\nlooks \u202efine\u202c")
89+
90+ var big strings.Builder
91+ for i := range 2600 {
92+ fmt.Fprintf(&big, "line %d\n", i)
93+ }
94+ h.put(wt, "big.txt", []byte(big.String()), 0o644)
95+ f.big = h.commitAt(wt, "2026-02-08T10:00:00Z", "add a very large file")
96+
97+ h.mustGit(wt, "tag", "v1.0.0", f.modify)
98+ h.createRepo(name, domain.VisibilityPublic)
99+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/"+name+".git"), "main", "side", "--tags")
100+ return f
101+}
102+
103+// longRepo is a repository with more commits than a page lists: 52 on main,
104+// one of which adds a.txt and one changes it.
105+func (h *harness) longRepo(name string) {
106+ h.t.Helper()
107+ wt := h.t.TempDir()
108+ h.mustGit(wt, "init", "-q", "--initial-branch=main")
109+ base := time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC)
110+ for i := range 52 {
111+ switch i {
112+ case 3:
113+ h.put(wt, "a.txt", []byte("first\n"), 0o644)
114+ case 30:
115+ h.put(wt, "a.txt", []byte("second\n"), 0o644)
116+ default:
117+ h.put(wt, "counter.txt", []byte(fmt.Sprintf("%d\n", i)), 0o644)
118+ }
119+ h.commitAt(wt, base.Add(time.Duration(i)*12*time.Hour).Format(time.RFC3339), fmt.Sprintf("commit number %d", i))
120+ }
121+ h.createRepo(name, domain.VisibilityPublic)
122+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/"+name+".git"), "main")
123+}
124+
125+var reOlder = regexp.MustCompile(`<a href="([^"]+)" rel="next">`)
126+
127+// unescape undoes the HTML escaping of an attribute value.
128+func unescape(s string) string { return strings.ReplaceAll(s, "&amp;", "&") }
129+
130+// AC1: the history pages render for the fixtures with the structure of the
131+// mockup (golden HTML, normalised).
132+func TestWebCommitsGolden(t *testing.T) {
133+ h := newHarness(t, harnessOpts{})
134+ h.diffRepo("diffs")
135+ h.longRepo("long")
136+
137+ // The second page is reached by the link of the first, as a visitor would.
138+ _, _, first := h.get("/long/commits/main")
139+ m := reOlder.FindStringSubmatch(first)
140+ require.Len(t, m, 2, "the first page links to the older commits")
141+ second := unescape(m[1])
142+ assert.True(t, strings.HasPrefix(second, "/long/commits/main?page="), second)
143+
144+ for _, tt := range []struct{ name, path string }{
145+ {"commits-root", "/long/commits/main"},
146+ {"commits-page2", second},
147+ {"commits-path", "/long/commits/main/a.txt"},
148+ {"commits-small", "/diffs/commits"},
149+ } {
150+ t.Run(tt.name, func(t *testing.T) {
151+ status, hdr, body := h.get(tt.path)
152+ require.Equal(t, 200, status)
153+ assert.Equal(t, "no-cache", hdr.Get("Cache-Control"))
154+ assertNoInline(t, body)
155+ golden(t, tt.name, normalise(body))
156+ })
157+ }
158+}
159+
160+// The behaviours of the history pages, asserted on their own.
161+func TestWebCommits(t *testing.T) {
162+ h := newHarness(t, harnessOpts{})
163+ f := h.diffRepo("diffs")
164+ h.longRepo("long")
165+ get := func(path string, status int) string {
166+ t.Helper()
167+ st, _, body := h.get(path)
168+ require.Equal(t, status, st, path)
169+ return body
170+ }
171+
172+ t.Run("history", func(t *testing.T) {
173+ first := get("/long/commits/main", 200)
174+ assert.Equal(t, 50, strings.Count(first, `class="c-sha mute"`), "a page lists 50 commits")
175+ assert.Contains(t, first, "commit number 51")
176+ assert.NotContains(t, first, "commit number 1<", "the oldest are on the next page")
177+ assert.Contains(t, first, "<span class=\"mute\">← newest</span>")
178+ second := unescape(reOlder.FindStringSubmatch(first)[1])
179+ page2 := get(second, 200)
180+ assert.Equal(t, 2, strings.Count(page2, `class="c-sha mute"`))
181+ assert.Contains(t, page2, `href="/long/commits/main">← newest</a>`)
182+ assert.Contains(t, page2, "<span class=\"mute\">older →</span>")
183+ get("/long/commits/main?page=garbage", 404)
184+ })
185+
186+ t.Run("path filter", func(t *testing.T) {
187+ body := get("/long/commits/main/a.txt", 200)
188+ assert.Equal(t, 2, strings.Count(body, `class="c-sha mute"`))
189+ assert.Contains(t, body, "history of <code>a.txt</code> at main")
190+ assert.Contains(t, body, `href="/long/blob/main/a.txt"`, "the way to the file")
191+ get("/long/commits/main/never/existed.txt", 404)
192+ })
193+
194+ t.Run("refs", func(t *testing.T) {
195+ // A tag, a branch, a commit id, and a ref with a slash.
196+ assert.Contains(t, get("/diffs/commits/v1.0.0", 200), "modify a file")
197+ assert.Contains(t, get("/diffs/commits/side", 200), "work on the side")
198+ assert.Contains(t, get("/diffs/commits/"+f.modify[:10], 200), "modify a file")
199+ assert.Contains(t, get("/diffs/commits/main", 200), `[tag: v1.0.0]`)
200+ assert.Contains(t, get("/diffs/commits/main", 200), `(merge)`)
201+ get("/diffs/commits/nope", 404)
202+ get("/diffs/commits/mai", 404)
203+ get("/nope/commits/main", 404)
204+ })
205+}
206+
207+// AC2: the history pages of a private repository are the 404 page of a
208+// repository that does not exist.
209+func TestWebCommitsPrivate(t *testing.T) {
210+ h := newHarness(t, harnessOpts{})
211+ wt := h.workTree("main", "secret.txt")
212+ h.createRepo("hidden-one", domain.VisibilityPrivate)
213+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/hidden-one.git"), "main")
214+
215+ _, _, missing := h.get("/never-existed")
216+ for _, p := range []string{"/hidden-one/commits/main", "/hidden-one/commits", "/hidden-one/commits/main/secret.txt"} {
217+ st, hdr, body := h.get(p)
218+ assert.Equal(t, 404, st, p)
219+ assert.Equal(t, "no-cache", hdr.Get("Cache-Control"), p)
220+ assert.NotContains(t, body, "hidden-one", p)
221+ assert.NotContains(t, body, "secret.txt", p)
222+ assert.Equal(t, normalise(missing), normalise(body), p)
223+ }
224+}
A internal/adapters/web/commits.go +238 -0
@@ -0,0 +1,238 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "net/http"
8+ "net/url"
9+ "path"
10+ "strings"
11+
12+ "connectrpc.com/connect"
13+ "github.com/a-h/templ"
14+
15+ pb "bnema.dev/ermite-api/gen/ermite"
16+)
17+
18+// commitsPerPage is how many commits one page of history lists.
19+const commitsPerPage = 50
20+
21+// DayGroup is the commits of one day (UTC, by committer date).
22+type DayGroup struct {
23+ Day string
24+ Rows []CommitRow
25+}
26+
27+// CommitsPage is the history of a ref, or of a path at a ref.
28+type CommitsPage struct {
29+ Ref string
30+ // Path is the path the history is limited to, ready to display; "" for
31+ // the whole history.
32+ Path string
33+ Days []DayGroup
34+ Count int
35+ // Older is the link to the next page, "" on the last page. Newest is the
36+ // link back to the first page, "" on the first page: page tokens only go
37+ // forward.
38+ Older, Newest string
39+ // Open links to the file or directory at this ref, when it exists there.
40+ Open *Link
41+ // Refs are the branch heads and tags among the commits of this page.
42+ Refs []Link
43+}
44+
45+// revSel is what the URL of a history page selects.
46+type revSel struct {
47+ rev string // what the API is asked for: a commit id
48+ name string // what the page shows
49+ path string // the path limit, "" for none
50+ // detached is true when a commit id, not a ref, was named.
51+ detached bool
52+}
53+
54+// isCommitID reports whether s can be a commit id or an abbreviation of one:
55+// 7 to 40 lowercase hexadecimal digits.
56+func isCommitID(s string) bool {
57+ if len(s) < 7 || len(s) > 40 {
58+ return false
59+ }
60+ for _, c := range []byte(s) {
61+ if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
62+ return false
63+ }
64+ }
65+ return true
66+}
67+
68+// selectRev reads "<ref>[/<path>]" or "<commit>[/<path>]". The ref is the
69+// longest prefix that names a branch or a tag; an empty rest selects the head.
70+func (d *repoData) selectRev(rest string) (revSel, bool) {
71+ rest = strings.Trim(rest, "/")
72+ if rest == "" {
73+ if d.head == nil {
74+ return revSel{}, false
75+ }
76+ return revSel{rev: d.head.GetCommitId(), name: d.head.GetShortName()}, true
77+ }
78+ all := append(append([]*pb.Ref(nil), d.branches...), d.tags...)
79+ if ref, p, ok := resolveRef(all, rest); ok && ref.GetCommitId() != "" {
80+ return revSel{rev: ref.GetCommitId(), name: ref.GetShortName(), path: p}, true
81+ }
82+ first, p, _ := strings.Cut(rest, "/")
83+ if isCommitID(first) {
84+ return revSel{rev: first, name: first, path: p, detached: true}, true
85+ }
86+ return revSel{}, false
87+}
88+
89+// commits lists the history of a ref, newest first, a page at a time, with
90+// an optional path to limit it to. The page token of the API travels in the
91+// "page" query parameter.
92+func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component, int, error) {
93+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
94+ if err != nil {
95+ return nil, 0, err
96+ }
97+ sel, ok := d.selectRev(r.PathValue("rest"))
98+ if !ok {
99+ return nil, 0, errNotFound
100+ }
101+ token := r.URL.Query().Get("page")
102+ res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{
103+ Repo: d.name(), Revision: sel.rev, Path: sel.path, PageSize: commitsPerPage, PageToken: token,
104+ }))
105+ if err != nil {
106+ return nil, 0, err
107+ }
108+ cs := res.Msg.GetCommits()
109+ if len(cs) == 0 && token == "" && sel.path != "" {
110+ // Git answers an empty history for a path that never existed.
111+ return nil, 0, errNotFound
112+ }
113+
114+ p := CommitsPage{Ref: sel.name, Path: visibleText(sel.path), Count: len(cs)}
115+ base := repoHref(d.name(), "commits", sel.name, sel.path)
116+ if t := res.Msg.GetNextPageToken(); t != "" {
117+ p.Older = base + "?page=" + url.QueryEscape(t)
118+ }
119+ if token != "" {
120+ p.Newest = base
121+ }
122+ s.fillDays(&p, d, cs)
123+ if sel.path != "" && token == "" {
124+ p.Open = s.pathLink(ctx, d, sel)
125+ }
126+
127+ l := s.repoLayout(ctx, d, "commits")
128+ s.setRef(&l, sel)
129+ l.Crumbs = commitsCrumbs(d, sel)
130+ if sel.path == "" {
131+ l.Title = "commits · " + d.name() + " · " + s.instance
132+ } else {
133+ l.Title = "history of " + visibleText(sel.path) + " · " + d.name() + " · " + s.instance
134+ }
135+ what := plural(len(cs), "commit", "commits")
136+ if p.Older != "" {
137+ what += " · more"
138+ }
139+ if p.Path != "" {
140+ what = "history of " + p.Path + " · " + what
141+ }
142+ l.Status = Status{
143+ Mode: "commits", Context: sel.name + " · " + what,
144+ Hints: "j/k move · enter open · / filter · ? help", Cursor: true, Count: len(cs),
145+ }
146+ l.HasRail, l.RailSum = true, "history · refs on this page"
147+ return Commits(l, p), http.StatusOK, nil
148+}
149+
150+// setRef points the ref switcher of the layout at what the page shows.
151+func (s *Server) setRef(l *Layout, sel revSel) {
152+ if sel.detached {
153+ l.Ref, l.RefPath, l.Short, l.SHA = "detached", "", short(sel.rev), ""
154+ if len(sel.rev) == 40 {
155+ l.SHA = sel.rev
156+ }
157+ return
158+ }
159+ l.Ref, l.RefPath, l.SHA, l.Short = sel.name, sel.name, sel.rev, short(sel.rev)
160+}
161+
162+// commitsCrumbs is the path in the top bar: repo, commits, ref, then the
163+// directories of the path, each of which links to its tree.
164+func commitsCrumbs(d *repoData, sel revSel) []Crumb {
165+ home := repoHref(d.name(), "commits", sel.name)
166+ cs := []Crumb{
167+ {Label: d.name(), Href: repoHref(d.name())},
168+ {Label: "commits", Href: home},
169+ {Label: sel.name, Href: home, Current: sel.path == ""},
170+ }
171+ if sel.path == "" {
172+ return cs
173+ }
174+ segs := strings.Split(sel.path, "/")
175+ for i, seg := range segs {
176+ last := i == len(segs)-1
177+ c := Crumb{Label: visibleText(seg), Current: last}
178+ if !last {
179+ c.Href = repoHref(d.name(), "tree", sel.name, strings.Join(segs[:i+1], "/"))
180+ }
181+ cs = append(cs, c)
182+ }
183+ return cs
184+}
185+
186+// fillDays groups the commits of a page by day and lists the refs among them.
187+func (s *Server) fillDays(p *CommitsPage, d *repoData, cs []*pb.Commit) {
188+ now := s.cfg.Now()
189+ branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags)
190+ for i, c := range cs {
191+ row := CommitRow{
192+ ID: "c-" + short(c.GetId()), Short: short(c.GetId()), Subject: visibleText(c.GetSummary()),
193+ CHref: repoHref(d.name(), "commit", c.GetId()),
194+ Author: visibleText(c.GetAuthor().GetName()), Merge: len(c.GetParentIds()) > 1,
195+ Branches: branchesAt[c.GetId()], Tags: tagsAt[c.GetId()], Cursor: i == 0,
196+ }
197+ day := "unknown date"
198+ if t := c.GetCommitter().GetTime(); t.IsValid() {
199+ row.When, row.WhenAt = ago(now, t.AsTime()), rfc3339(t.AsTime())
200+ day = t.AsTime().UTC().Format("2006-01-02")
201+ }
202+ if n := len(p.Days); n == 0 || p.Days[n-1].Day != day {
203+ p.Days = append(p.Days, DayGroup{Day: day})
204+ }
205+ g := &p.Days[len(p.Days)-1]
206+ g.Rows = append(g.Rows, row)
207+ for _, t := range row.Tags {
208+ p.Refs = append(p.Refs, Link{Label: "tag " + t, Href: "#" + row.ID})
209+ }
210+ for _, b := range row.Branches {
211+ p.Refs = append(p.Refs, Link{Label: "branch " + b, Href: "#" + row.ID})
212+ }
213+ }
214+}
215+
216+// pathLink finds what the path of a history is at the ref: a directory
217+// (tree) or a file (blob). It is nil when the path is not there, a deleted
218+// file for one, or when the listing fails: the link is a convenience.
219+func (s *Server) pathLink(ctx context.Context, d *repoData, sel revSel) *Link {
220+ dir := path.Dir(sel.path)
221+ if dir == "." {
222+ dir = ""
223+ }
224+ tree, err := s.cfg.Git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{Repo: d.name(), Revision: sel.rev, Path: dir}))
225+ if err != nil {
226+ return nil
227+ }
228+ for _, e := range tree.Msg.GetEntries() {
229+ if e.GetPath() != sel.path {
230+ continue
231+ }
232+ if e.GetKind() == pb.EntryKind_ENTRY_KIND_DIRECTORY {
233+ return &Link{Label: "open", Href: repoHref(d.name(), "tree", sel.name, sel.path)}
234+ }
235+ return &Link{Label: "open", Href: repoHref(d.name(), "blob", sel.name, sel.path)}
236+ }
237+ return nil
238+}
@@ -0,0 +1,238 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "net/http"
8+ "net/url"
9+ "path"
10+ "strings"
11+
12+ "connectrpc.com/connect"
13+ "github.com/a-h/templ"
14+
15+ pb "bnema.dev/ermite-api/gen/ermite"
16+)
17+
18+// commitsPerPage is how many commits one page of history lists.
19+const commitsPerPage = 50
20+
21+// DayGroup is the commits of one day (UTC, by committer date).
22+type DayGroup struct {
23+ Day string
24+ Rows []CommitRow
25+}
26+
27+// CommitsPage is the history of a ref, or of a path at a ref.
28+type CommitsPage struct {
29+ Ref string
30+ // Path is the path the history is limited to, ready to display; "" for
31+ // the whole history.
32+ Path string
33+ Days []DayGroup
34+ Count int
35+ // Older is the link to the next page, "" on the last page. Newest is the
36+ // link back to the first page, "" on the first page: page tokens only go
37+ // forward.
38+ Older, Newest string
39+ // Open links to the file or directory at this ref, when it exists there.
40+ Open *Link
41+ // Refs are the branch heads and tags among the commits of this page.
42+ Refs []Link
43+}
44+
45+// revSel is what the URL of a history page selects.
46+type revSel struct {
47+ rev string // what the API is asked for: a commit id
48+ name string // what the page shows
49+ path string // the path limit, "" for none
50+ // detached is true when a commit id, not a ref, was named.
51+ detached bool
52+}
53+
54+// isCommitID reports whether s can be a commit id or an abbreviation of one:
55+// 7 to 40 lowercase hexadecimal digits.
56+func isCommitID(s string) bool {
57+ if len(s) < 7 || len(s) > 40 {
58+ return false
59+ }
60+ for _, c := range []byte(s) {
61+ if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
62+ return false
63+ }
64+ }
65+ return true
66+}
67+
68+// selectRev reads "<ref>[/<path>]" or "<commit>[/<path>]". The ref is the
69+// longest prefix that names a branch or a tag; an empty rest selects the head.
70+func (d *repoData) selectRev(rest string) (revSel, bool) {
71+ rest = strings.Trim(rest, "/")
72+ if rest == "" {
73+ if d.head == nil {
74+ return revSel{}, false
75+ }
76+ return revSel{rev: d.head.GetCommitId(), name: d.head.GetShortName()}, true
77+ }
78+ all := append(append([]*pb.Ref(nil), d.branches...), d.tags...)
79+ if ref, p, ok := resolveRef(all, rest); ok && ref.GetCommitId() != "" {
80+ return revSel{rev: ref.GetCommitId(), name: ref.GetShortName(), path: p}, true
81+ }
82+ first, p, _ := strings.Cut(rest, "/")
83+ if isCommitID(first) {
84+ return revSel{rev: first, name: first, path: p, detached: true}, true
85+ }
86+ return revSel{}, false
87+}
88+
89+// commits lists the history of a ref, newest first, a page at a time, with
90+// an optional path to limit it to. The page token of the API travels in the
91+// "page" query parameter.
92+func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component, int, error) {
93+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
94+ if err != nil {
95+ return nil, 0, err
96+ }
97+ sel, ok := d.selectRev(r.PathValue("rest"))
98+ if !ok {
99+ return nil, 0, errNotFound
100+ }
101+ token := r.URL.Query().Get("page")
102+ res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{
103+ Repo: d.name(), Revision: sel.rev, Path: sel.path, PageSize: commitsPerPage, PageToken: token,
104+ }))
105+ if err != nil {
106+ return nil, 0, err
107+ }
108+ cs := res.Msg.GetCommits()
109+ if len(cs) == 0 && token == "" && sel.path != "" {
110+ // Git answers an empty history for a path that never existed.
111+ return nil, 0, errNotFound
112+ }
113+
114+ p := CommitsPage{Ref: sel.name, Path: visibleText(sel.path), Count: len(cs)}
115+ base := repoHref(d.name(), "commits", sel.name, sel.path)
116+ if t := res.Msg.GetNextPageToken(); t != "" {
117+ p.Older = base + "?page=" + url.QueryEscape(t)
118+ }
119+ if token != "" {
120+ p.Newest = base
121+ }
122+ s.fillDays(&p, d, cs)
123+ if sel.path != "" && token == "" {
124+ p.Open = s.pathLink(ctx, d, sel)
125+ }
126+
127+ l := s.repoLayout(ctx, d, "commits")
128+ s.setRef(&l, sel)
129+ l.Crumbs = commitsCrumbs(d, sel)
130+ if sel.path == "" {
131+ l.Title = "commits · " + d.name() + " · " + s.instance
132+ } else {
133+ l.Title = "history of " + visibleText(sel.path) + " · " + d.name() + " · " + s.instance
134+ }
135+ what := plural(len(cs), "commit", "commits")
136+ if p.Older != "" {
137+ what += " · more"
138+ }
139+ if p.Path != "" {
140+ what = "history of " + p.Path + " · " + what
141+ }
142+ l.Status = Status{
143+ Mode: "commits", Context: sel.name + " · " + what,
144+ Hints: "j/k move · enter open · / filter · ? help", Cursor: true, Count: len(cs),
145+ }
146+ l.HasRail, l.RailSum = true, "history · refs on this page"
147+ return Commits(l, p), http.StatusOK, nil
148+}
149+
150+// setRef points the ref switcher of the layout at what the page shows.
151+func (s *Server) setRef(l *Layout, sel revSel) {
152+ if sel.detached {
153+ l.Ref, l.RefPath, l.Short, l.SHA = "detached", "", short(sel.rev), ""
154+ if len(sel.rev) == 40 {
155+ l.SHA = sel.rev
156+ }
157+ return
158+ }
159+ l.Ref, l.RefPath, l.SHA, l.Short = sel.name, sel.name, sel.rev, short(sel.rev)
160+}
161+
162+// commitsCrumbs is the path in the top bar: repo, commits, ref, then the
163+// directories of the path, each of which links to its tree.
164+func commitsCrumbs(d *repoData, sel revSel) []Crumb {
165+ home := repoHref(d.name(), "commits", sel.name)
166+ cs := []Crumb{
167+ {Label: d.name(), Href: repoHref(d.name())},
168+ {Label: "commits", Href: home},
169+ {Label: sel.name, Href: home, Current: sel.path == ""},
170+ }
171+ if sel.path == "" {
172+ return cs
173+ }
174+ segs := strings.Split(sel.path, "/")
175+ for i, seg := range segs {
176+ last := i == len(segs)-1
177+ c := Crumb{Label: visibleText(seg), Current: last}
178+ if !last {
179+ c.Href = repoHref(d.name(), "tree", sel.name, strings.Join(segs[:i+1], "/"))
180+ }
181+ cs = append(cs, c)
182+ }
183+ return cs
184+}
185+
186+// fillDays groups the commits of a page by day and lists the refs among them.
187+func (s *Server) fillDays(p *CommitsPage, d *repoData, cs []*pb.Commit) {
188+ now := s.cfg.Now()
189+ branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags)
190+ for i, c := range cs {
191+ row := CommitRow{
192+ ID: "c-" + short(c.GetId()), Short: short(c.GetId()), Subject: visibleText(c.GetSummary()),
193+ CHref: repoHref(d.name(), "commit", c.GetId()),
194+ Author: visibleText(c.GetAuthor().GetName()), Merge: len(c.GetParentIds()) > 1,
195+ Branches: branchesAt[c.GetId()], Tags: tagsAt[c.GetId()], Cursor: i == 0,
196+ }
197+ day := "unknown date"
198+ if t := c.GetCommitter().GetTime(); t.IsValid() {
199+ row.When, row.WhenAt = ago(now, t.AsTime()), rfc3339(t.AsTime())
200+ day = t.AsTime().UTC().Format("2006-01-02")
201+ }
202+ if n := len(p.Days); n == 0 || p.Days[n-1].Day != day {
203+ p.Days = append(p.Days, DayGroup{Day: day})
204+ }
205+ g := &p.Days[len(p.Days)-1]
206+ g.Rows = append(g.Rows, row)
207+ for _, t := range row.Tags {
208+ p.Refs = append(p.Refs, Link{Label: "tag " + t, Href: "#" + row.ID})
209+ }
210+ for _, b := range row.Branches {
211+ p.Refs = append(p.Refs, Link{Label: "branch " + b, Href: "#" + row.ID})
212+ }
213+ }
214+}
215+
216+// pathLink finds what the path of a history is at the ref: a directory
217+// (tree) or a file (blob). It is nil when the path is not there, a deleted
218+// file for one, or when the listing fails: the link is a convenience.
219+func (s *Server) pathLink(ctx context.Context, d *repoData, sel revSel) *Link {
220+ dir := path.Dir(sel.path)
221+ if dir == "." {
222+ dir = ""
223+ }
224+ tree, err := s.cfg.Git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{Repo: d.name(), Revision: sel.rev, Path: dir}))
225+ if err != nil {
226+ return nil
227+ }
228+ for _, e := range tree.Msg.GetEntries() {
229+ if e.GetPath() != sel.path {
230+ continue
231+ }
232+ if e.GetKind() == pb.EntryKind_ENTRY_KIND_DIRECTORY {
233+ return &Link{Label: "open", Href: repoHref(d.name(), "tree", sel.name, sel.path)}
234+ }
235+ return &Link{Label: "open", Href: repoHref(d.name(), "blob", sel.name, sel.path)}
236+ }
237+ return nil
238+}
A internal/adapters/web/commits.templ +83 -0
@@ -0,0 +1,83 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+// Commits is the history of a ref, or of a path at a ref: the commits of the
6+// page grouped by day, and the way to the older ones.
7+templ Commits(l Layout, p CommitsPage) {
8+ @Shell(l, commitsRail(p)) {
9+ <div class="filter">
10+ <label for="commit-filter">filter&gt;</label>
11+ <input
12+ id="commit-filter"
13+ type="search"
14+ data-filter
15+ placeholder="subject, sha or author ( / )"
16+ 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')"
20+ />
21+ </div>
22+ if p.Path != "" {
23+ <p class="lede dim">history of <code>{ p.Path }</code> at { p.Ref }</p>
24+ }
25+ <div class="days" id="commits-head">
26+ for _, g := range p.Days {
27+ <section aria-label={ g.Day }>
28+ <h2 class="rule-h"><span>{ g.Day }</span></h2>
29+ @CommitRows(g.Rows)
30+ </section>
31+ }
32+ </div>
33+ if len(p.Days) == 0 {
34+ <p class="lede dim">no commits.</p>
35+ }
36+ if p.Older != "" || p.Newest != "" {
37+ <nav class="pager" aria-label="Pages">
38+ if p.Newest != "" {
39+ <a href={ templ.SafeURL(p.Newest) }>← newest</a>
40+ } else {
41+ <span class="mute">← newest</span>
42+ }
43+ if p.Older != "" {
44+ <a href={ templ.SafeURL(p.Older) } rel="next">older →</a>
45+ } else {
46+ <span class="mute">older →</span>
47+ }
48+ </nav>
49+ }
50+ }
51+}
52+
53+templ commitsRail(p CommitsPage) {
54+ <section aria-labelledby="r-hist">
55+ <h2 id="r-hist">history</h2>
56+ <dl class="kv">
57+ <dt>ref</dt>
58+ <dd>{ p.Ref }</dd>
59+ <dt>on page</dt>
60+ <dd>{ itoa(p.Count) }</dd>
61+ if p.Path != "" {
62+ <dt>path</dt>
63+ <dd>{ p.Path }</dd>
64+ }
65+ if p.Open != nil {
66+ <dt>file</dt>
67+ <dd><a href={ templ.SafeURL(p.Open.Href) }>{ p.Open.Label }</a></dd>
68+ }
69+ </dl>
70+ </section>
71+ <section aria-labelledby="r-refs">
72+ <h2 id="r-refs">refs on this page</h2>
73+ if len(p.Refs) > 0 {
74+ <nav class="outline" aria-label="Refs on this page">
75+ for _, r := range p.Refs {
76+ <a href={ templ.SafeURL(r.Href) }>{ r.Label }</a>
77+ }
78+ </nav>
79+ } else {
80+ <p class="dim">no tag or branch head here</p>
81+ }
82+ </section>
83+}
@@ -0,0 +1,83 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+// Commits is the history of a ref, or of a path at a ref: the commits of the
6+// page grouped by day, and the way to the older ones.
7+templ Commits(l Layout, p CommitsPage) {
8+ @Shell(l, commitsRail(p)) {
9+ <div class="filter">
10+ <label for="commit-filter">filter&gt;</label>
11+ <input
12+ id="commit-filter"
13+ type="search"
14+ data-filter
15+ placeholder="subject, sha or author ( / )"
16+ 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')"
20+ />
21+ </div>
22+ if p.Path != "" {
23+ <p class="lede dim">history of <code>{ p.Path }</code> at { p.Ref }</p>
24+ }
25+ <div class="days" id="commits-head">
26+ for _, g := range p.Days {
27+ <section aria-label={ g.Day }>
28+ <h2 class="rule-h"><span>{ g.Day }</span></h2>
29+ @CommitRows(g.Rows)
30+ </section>
31+ }
32+ </div>
33+ if len(p.Days) == 0 {
34+ <p class="lede dim">no commits.</p>
35+ }
36+ if p.Older != "" || p.Newest != "" {
37+ <nav class="pager" aria-label="Pages">
38+ if p.Newest != "" {
39+ <a href={ templ.SafeURL(p.Newest) }>← newest</a>
40+ } else {
41+ <span class="mute">← newest</span>
42+ }
43+ if p.Older != "" {
44+ <a href={ templ.SafeURL(p.Older) } rel="next">older →</a>
45+ } else {
46+ <span class="mute">older →</span>
47+ }
48+ </nav>
49+ }
50+ }
51+}
52+
53+templ commitsRail(p CommitsPage) {
54+ <section aria-labelledby="r-hist">
55+ <h2 id="r-hist">history</h2>
56+ <dl class="kv">
57+ <dt>ref</dt>
58+ <dd>{ p.Ref }</dd>
59+ <dt>on page</dt>
60+ <dd>{ itoa(p.Count) }</dd>
61+ if p.Path != "" {
62+ <dt>path</dt>
63+ <dd>{ p.Path }</dd>
64+ }
65+ if p.Open != nil {
66+ <dt>file</dt>
67+ <dd><a href={ templ.SafeURL(p.Open.Href) }>{ p.Open.Label }</a></dd>
68+ }
69+ </dl>
70+ </section>
71+ <section aria-labelledby="r-refs">
72+ <h2 id="r-refs">refs on this page</h2>
73+ if len(p.Refs) > 0 {
74+ <nav class="outline" aria-label="Refs on this page">
75+ for _, r := range p.Refs {
76+ <a href={ templ.SafeURL(r.Href) }>{ r.Label }</a>
77+ }
78+ </nav>
79+ } else {
80+ <p class="dim">no tag or branch head here</p>
81+ }
82+ </section>
83+}
A internal/adapters/web/commits_templ.go +371 -0 generated file: collapsed
A internal/adapters/web/commits_test.go +50 -0
@@ -0,0 +1,50 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+
10+ pb "bnema.dev/ermite-api/gen/ermite"
11+)
12+
13+func TestIsCommitID(t *testing.T) {
14+ for _, s := range []string{"abcdef0", "0123456789abcdef0123456789abcdef01234567"} {
15+ assert.True(t, isCommitID(s), s)
16+ }
17+ for _, s := range []string{"", "abcdef", "ABCDEF0", "main", "abcdefg", "0123456789abcdef0123456789abcdef012345678"} {
18+ assert.False(t, isCommitID(s), s)
19+ }
20+}
21+
22+func TestSelectRev(t *testing.T) {
23+ b, tag := pb.RefKind_REF_KIND_BRANCH, pb.RefKind_REF_KIND_TAG
24+ d := &repoData{branches: []*pb.Ref{ref(b, "main"), ref(b, "feature/x")}, tags: []*pb.Ref{ref(tag, "v1")}}
25+ d.head = d.branches[0]
26+ tests := []struct {
27+ in string
28+ want revSel
29+ wantOK bool
30+ describe string
31+ }{
32+ {"", revSel{rev: "c-main", name: "main"}, true, "head"},
33+ {"main", revSel{rev: "c-main", name: "main"}, true, "branch"},
34+ {"main/docs/a.md", revSel{rev: "c-main", name: "main", path: "docs/a.md"}, true, "path"},
35+ {"feature/x/src", revSel{rev: "c-feature/x", name: "feature/x", path: "src"}, true, "ref with a slash"},
36+ {"v1", revSel{rev: "c-v1", name: "v1"}, true, "tag"},
37+ {"abcdef0123/x.go", revSel{rev: "abcdef0123", name: "abcdef0123", path: "x.go", detached: true}, true, "commit id"},
38+ {"nope", revSel{}, false, "unknown"},
39+ {"feature", revSel{}, false, "a prefix of a ref is not a ref"},
40+ }
41+ for _, tt := range tests {
42+ got, ok := d.selectRev(tt.in)
43+ assert.Equal(t, tt.wantOK, ok, tt.describe)
44+ if ok {
45+ assert.Equal(t, tt.want, got, tt.describe)
46+ }
47+ }
48+ _, ok := (&repoData{}).selectRev("")
49+ assert.False(t, ok, "an empty repository has no history")
50+}
@@ -0,0 +1,50 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+
10+ pb "bnema.dev/ermite-api/gen/ermite"
11+)
12+
13+func TestIsCommitID(t *testing.T) {
14+ for _, s := range []string{"abcdef0", "0123456789abcdef0123456789abcdef01234567"} {
15+ assert.True(t, isCommitID(s), s)
16+ }
17+ for _, s := range []string{"", "abcdef", "ABCDEF0", "main", "abcdefg", "0123456789abcdef0123456789abcdef012345678"} {
18+ assert.False(t, isCommitID(s), s)
19+ }
20+}
21+
22+func TestSelectRev(t *testing.T) {
23+ b, tag := pb.RefKind_REF_KIND_BRANCH, pb.RefKind_REF_KIND_TAG
24+ d := &repoData{branches: []*pb.Ref{ref(b, "main"), ref(b, "feature/x")}, tags: []*pb.Ref{ref(tag, "v1")}}
25+ d.head = d.branches[0]
26+ tests := []struct {
27+ in string
28+ want revSel
29+ wantOK bool
30+ describe string
31+ }{
32+ {"", revSel{rev: "c-main", name: "main"}, true, "head"},
33+ {"main", revSel{rev: "c-main", name: "main"}, true, "branch"},
34+ {"main/docs/a.md", revSel{rev: "c-main", name: "main", path: "docs/a.md"}, true, "path"},
35+ {"feature/x/src", revSel{rev: "c-feature/x", name: "feature/x", path: "src"}, true, "ref with a slash"},
36+ {"v1", revSel{rev: "c-v1", name: "v1"}, true, "tag"},
37+ {"abcdef0123/x.go", revSel{rev: "abcdef0123", name: "abcdef0123", path: "x.go", detached: true}, true, "commit id"},
38+ {"nope", revSel{}, false, "unknown"},
39+ {"feature", revSel{}, false, "a prefix of a ref is not a ref"},
40+ }
41+ for _, tt := range tests {
42+ got, ok := d.selectRev(tt.in)
43+ assert.Equal(t, tt.wantOK, ok, tt.describe)
44+ if ok {
45+ assert.Equal(t, tt.want, got, tt.describe)
46+ }
47+ }
48+ _, ok := (&repoData{}).selectRev("")
49+ assert.False(t, ok, "an empty repository has no history")
50+}
A internal/adapters/web/text.go +44 -0
@@ -0,0 +1,44 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "fmt"
7+ "strings"
8+ "unicode"
9+)
10+
11+// invisible reports whether r is a character that changes how code reads
12+// without being seen: the bidirectional controls and the other format
13+// characters (Cf: zero-width, joiners, tags, BOM…), the control characters,
14+// the line and paragraph separators, and the blank Hangul fillers. A tab is
15+// not one, nor is the line break of a commit message.
16+func invisible(r rune) bool {
17+ switch r {
18+ case '\t', '\n':
19+ return false
20+ case 0x115F, 0x1160, 0x3164, 0xFFA0: // Hangul fillers render blank
21+ return true
22+ }
23+ return unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r)
24+}
25+
26+// marker is the visible form of an invisible character.
27+func marker(r rune) string { return fmt.Sprintf("<U+%04X>", r) }
28+
29+// visibleText is s with every invisible character replaced by its marker,
30+// for the places that show text as a plain string: paths, names, subjects.
31+func visibleText(s string) string {
32+ if !strings.ContainsFunc(s, invisible) {
33+ return s
34+ }
35+ var b strings.Builder
36+ for _, r := range s {
37+ if invisible(r) {
38+ b.WriteString(marker(r))
39+ } else {
40+ b.WriteRune(r)
41+ }
42+ }
43+ return b.String()
44+}
@@ -0,0 +1,44 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "fmt"
7+ "strings"
8+ "unicode"
9+)
10+
11+// invisible reports whether r is a character that changes how code reads
12+// without being seen: the bidirectional controls and the other format
13+// characters (Cf: zero-width, joiners, tags, BOM…), the control characters,
14+// the line and paragraph separators, and the blank Hangul fillers. A tab is
15+// not one, nor is the line break of a commit message.
16+func invisible(r rune) bool {
17+ switch r {
18+ case '\t', '\n':
19+ return false
20+ case 0x115F, 0x1160, 0x3164, 0xFFA0: // Hangul fillers render blank
21+ return true
22+ }
23+ return unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r)
24+}
25+
26+// marker is the visible form of an invisible character.
27+func marker(r rune) string { return fmt.Sprintf("<U+%04X>", r) }
28+
29+// visibleText is s with every invisible character replaced by its marker,
30+// for the places that show text as a plain string: paths, names, subjects.
31+func visibleText(s string) string {
32+ if !strings.ContainsFunc(s, invisible) {
33+ return s
34+ }
35+ var b strings.Builder
36+ for _, r := range s {
37+ if invisible(r) {
38+ b.WriteString(marker(r))
39+ } else {
40+ b.WriteRune(r)
41+ }
42+ }
43+ return b.String()
44+}
A internal/adapters/web/text_test.go +16 -0
@@ -0,0 +1,16 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+)
10+
11+// Invisible characters in names and subjects show as markers.
12+func TestVisibleText(t *testing.T) {
13+ assert.Equal(t, "a<U+202E>b.go", visibleText("a\u202eb.go"))
14+ assert.Equal(t, "plain π\t“é”", visibleText("plain π\t“é”"))
15+ assert.Equal(t, "a<U+0000>b<U+200B>", visibleText("a\x00b\u200b"))
16+}
@@ -0,0 +1,16 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+)
10+
11+// Invisible characters in names and subjects show as markers.
12+func TestVisibleText(t *testing.T) {
13+ assert.Equal(t, "a<U+202E>b.go", visibleText("a\u202eb.go"))
14+ assert.Equal(t, "plain π\t“é”", visibleText("plain π\t“é”"))
15+ assert.Equal(t, "a<U+0000>b<U+200B>", visibleText("a\x00b\u200b"))
16+}
M internal/adapters/web/web.go +2 -0
@@ -98,6 +98,8 @@ func (s *Server) routes() *http.ServeMux {
9898 mux.HandleFunc("GET /{repo}", s.page(s.summary))
9999 mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary))
100100 mux.HandleFunc("GET /{repo}/refs", s.page(s.refs))
101+ mux.HandleFunc("GET /{repo}/commits", s.page(s.commits))
102+ mux.HandleFunc("GET /{repo}/commits/{rest...}", s.page(s.commits))
101103 mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound))
102104 return mux
103105 }
@@ -98,6 +98,8 @@ func (s *Server) routes() *http.ServeMux {
98 mux.HandleFunc("GET /{repo}", s.page(s.summary))98 mux.HandleFunc("GET /{repo}", s.page(s.summary))
99 mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary))99 mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary))
100 mux.HandleFunc("GET /{repo}/refs", s.page(s.refs))100 mux.HandleFunc("GET /{repo}/refs", s.page(s.refs))
101+ mux.HandleFunc("GET /{repo}/commits", s.page(s.commits))
102+ mux.HandleFunc("GET /{repo}/commits/{rest...}", s.page(s.commits))
101 mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound))103 mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound))
102 return mux104 return mux
103 }105 }

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 45

↑↓ moveenter openesc close