detached 4f18354

feat(web): add commit page with unified and split diffs

/<repo>/commit/<sha> (full or abbreviated id): message, author and
committer, parent links, refs, totals, the file list in the rail and the
diff of each file in a <details>. Unified is the default; ?view=split
selects the split layout on the server, and the toggle links work without
JavaScript (with it, both layouts are in the page and d/u/x flip them).
Truncated and binary files are collapsed under a note, renames, copies and
mode changes are noted, an incomplete diff says so. Invisible characters
(bidi controls, zero-width, controls) show as <U+XXXX> markers; every
changed line keeps its +/- marker.
commit
4f18354aa685a080ab722965d5166ff76f311fee
author
brice · · 2026-10-10 13:03 UTC
parent
dad3569
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
16 files · +6129 -0
A cmd/ermite/testdata/web/commit-big.html +352 -0
@@ -0,0 +1,352 @@
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>SHA7 add a very large file · 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="" 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">commit</a>
31+</li>
32+<li>
33+<span aria-current="page">SHA7</span>
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+<header class="show">
59+<h1>add a very large file</h1>
60+<dl class="kv show-kv">
61+<dt>commit</dt>
62+<dd>SHA40</dd>
63+<dt>author</dt>
64+<dd>T · <time datetime="STAMP" title="STAMP">AGO</time> · STAMP</dd>
65+<dt>parent</dt>
66+<dd>
67+<a href="/diffs/commit/SHA40">SHA7</a>
68+</dd>
69+<dt>refs</dt>
70+<dd>[main] </dd>
71+</dl>
72+</header>
73+<div class="diff-tools" role="toolbar" aria-label="Diff view">
74+<span class="dim">1 file · <span class="plus">+2600</span>
75+<span class="minus">-0</span>
76+</span>
77+<span class="grow">
78+</span>
79+<a href="/diffs/commit/SHA40?view=unified" class="vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;unified&#39; ? &#39;true&#39; : false">[u]nified</a>
80+<a href="/diffs/commit/SHA40?view=split" class="vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;split&#39; ? &#39;true&#39; : false">[d] split</a>
81+<button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))">[x] collapse all</button>
82+</div>
83+<p class="notice">this diff is incomplete: 1 file is too large to show. the totals above count everything; clone the repository to read the rest.</p>
84+<div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
85+<details class="dfile" id="f1" data-file>
86+<summary>
87+<span class="st">A</span>
88+<span class="p">big.txt</span>
89+<span class="plus">+2600</span>
90+<span class="minus">-0</span>
91+<span class="note">diff too large to show (2600 lines changed): collapsed</span>
92+</summary>
93+<p class="note pad">diff too large to show (2600 lines changed): collapsed</p>
94+</details>
95+</div>
96+</main>
97+<aside class="rail" aria-label="Context">
98+<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
99+<summary>1 changed file</summary>
100+<section aria-labelledby="r-files">
101+<h2 id="r-files">files <span>1</span>
102+</h2>
103+<ol class="files" aria-label="Changed files">
104+<li>
105+<a href="#f1" :aria-current="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f1&#39; ? &#39;true&#39; : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
106+<span class="st" title="A">A</span>
107+<span class="p" title="big.txt">big.txt</span>
108+<span class="n">
109+<span class="plus">+2600</span>
110+<span class="minus">-0</span>
111+</span>
112+<span class="b" aria-hidden="true">
113+<span class="plus">++++++++++</span>
114+<span class="minus">
115+</span>
116+</span>
117+</a>
118+</li>
119+</ol>
120+</section>
121+</details>
122+</aside>
123+</div>
124+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
125+<div class="panel-head">
126+<span>keys</span>
127+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
128+</div>
129+<dl class="keys">
130+<div>
131+<dt>
132+<kbd>j</kbd>
133+<kbd>k</kbd>
134+</dt>
135+<dd>move cursor down / up</dd>
136+</div>
137+<div>
138+<dt>
139+<kbd>enter</kbd>
140+</dt>
141+<dd>open row under cursor</dd>
142+</div>
143+<div>
144+<dt>
145+<kbd>g</kbd>
146+<kbd>g</kbd> / <kbd>G</kbd>
147+</dt>
148+<dd>first / last row</dd>
149+</div>
150+<div>
151+<dt>
152+<kbd>/</kbd>
153+</dt>
154+<dd>focus the filter</dd>
155+</div>
156+<div>
157+<dt>
158+<kbd>g h</kbd>
159+<kbd>g t</kbd>
160+</dt>
161+<dd>go home / tree</dd>
162+</div>
163+<div>
164+<dt>
165+<kbd>g c</kbd>
166+<kbd>g r</kbd>
167+</dt>
168+<dd>go commits / refs</dd>
169+</div>
170+<div>
171+<dt>
172+<kbd>b</kbd>
173+</dt>
174+<dd>switch ref (branches, tags)</dd>
175+</div>
176+<div>
177+<dt>
178+<kbd>:</kbd>
179+<kbd>f</kbd>
180+</dt>
181+<dd>go to: section, ref, file, repo</dd>
182+</div>
183+<div>
184+<dt>
185+<kbd>y</kbd>
186+</dt>
187+<dd>permalink: ref becomes the full sha</dd>
188+</div>
189+<div>
190+<dt>
191+<kbd>s</kbd>
192+<kbd>t</kbd>
193+<kbd>c</kbd>
194+<kbd>r</kbd>
195+</dt>
196+<dd>repo sections (when shown)</dd>
197+</div>
198+<div>
199+<dt>
200+<kbd>d</kbd>
201+<kbd>u</kbd>
202+<kbd>x</kbd>
203+</dt>
204+<dd>commit: split / unified / collapse all</dd>
205+</div>
206+<div>
207+<dt>
208+<kbd>esc</kbd>
209+</dt>
210+<dd>close panel</dd>
211+</div>
212+</dl>
213+</dialog>
214+<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
215+<div class="panel-head">
216+<label for="refs-filter">ref&gt;</label>
217+<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))">
218+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
219+</div>
220+<div class="panel-cols" data-picklist>
221+<section aria-label="Branches">
222+<h2>branches <span class="mute">2</span>
223+</h2>
224+<ul>
225+<li>
226+<a href="/diffs/tree/main" aria-current="true">
227+<span class="mark" aria-hidden="true">●</span>main</a>
228+</li>
229+<li>
230+<a href="/diffs/tree/side">
231+<span class="mark" aria-hidden="true">&nbsp;</span>side</a>
232+</li>
233+</ul>
234+</section>
235+<section aria-label="Tags">
236+<h2>tags <span class="mute">1</span>
237+</h2>
238+<ul>
239+<li>
240+<a href="/diffs/tree/v1.0.0">
241+<span class="mark" aria-hidden="true">&nbsp;</span>v1.0.0</a>
242+</li>
243+</ul>
244+</section>
245+</div>
246+</dialog>
247+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
248+<div class="panel-head">
249+<label for="cmd-q" class="hi">:</label>
250+<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();
251+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
252+ 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();
253+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
254+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
255+ 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' })) }
256+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
257+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
258+</div>
259+<ul id="cmd-list" class="cmd-list">
260+<li data-k="go home" data-cursor>
261+<a href="/" tabindex="-1">
262+<span class="k">go</span>home</a>
263+</li>
264+<li data-k="section summary">
265+<a href="/diffs" tabindex="-1">
266+<span class="k">section</span>summary</a>
267+</li>
268+<li data-k="section tree">
269+<a href="/diffs/tree/main" tabindex="-1">
270+<span class="k">section</span>tree</a>
271+</li>
272+<li data-k="section commits">
273+<a href="/diffs/commits/main" tabindex="-1">
274+<span class="k">section</span>commits</a>
275+</li>
276+<li data-k="section refs">
277+<a href="/diffs/refs" tabindex="-1">
278+<span class="k">section</span>refs</a>
279+</li>
280+<li data-k="branch main">
281+<a href="/diffs/tree/main" tabindex="-1">
282+<span class="k">branch</span>main</a>
283+</li>
284+<li data-k="branch side">
285+<a href="/diffs/tree/side" tabindex="-1">
286+<span class="k">branch</span>side</a>
287+</li>
288+<li data-k="tag v1.0.0">
289+<a href="/diffs/tree/v1.0.0" tabindex="-1">
290+<span class="k">tag</span>v1.0.0</a>
291+</li>
292+</ul>
293+</dialog>
294+<footer class="bar status" aria-label="Status">
295+<span class="mode">commit</span>
296+<span class="st-ctx">SHA7 · 1 file · +2600 -0</span>
297+<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'))">
298+<span class="hi">:</span> go to…</button>
299+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
300+</span>
301+<span class="st-hints">d split · u unified · x collapse all · ? help</span>
302+<span class="st-src">
303+<span class="st-ver">ermite devel · </span>
304+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
305+</span>
306+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
307+</footer>
308+<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
309+ if (q('dialog[open]').count) return;
310+ let rows = q('[data-rows] > :not([hidden])').arr();
311+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
312+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
313+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
314+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
315+ })(1);
316+ k-k from:body -> ((d) => {
317+ if (q('dialog[open]').count) return;
318+ let rows = q('[data-rows] > :not([hidden])').arr();
319+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
320+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
321+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
322+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
323+ })(-1);
324+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
325+ 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));
326+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
327+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
328+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
329+ 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());
330+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
331+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
332+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
333+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
334+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
335+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
336+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
337+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
338+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
339+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
340+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
341+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
342+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
343+ k-permalink from:body -> {
344+ let b = document.body.dataset;
345+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
346+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
347+ navigator.clipboard?.writeText(location.href);
348+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
349+ }">
350+</div>
351+</body>
352+</html>
A cmd/ermite/testdata/web/commit-binary.html +349 -0
@@ -0,0 +1,349 @@
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>SHA7 change a binary file · 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="" 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">commit</a>
31+</li>
32+<li>
33+<span aria-current="page">SHA7</span>
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+<header class="show">
59+<h1>change a binary file</h1>
60+<dl class="kv show-kv">
61+<dt>commit</dt>
62+<dd>SHA40</dd>
63+<dt>author</dt>
64+<dd>T · <time datetime="STAMP" title="STAMP">AGO</time> · STAMP</dd>
65+<dt>parent</dt>
66+<dd>
67+<a href="/diffs/commit/SHA40">SHA7</a>
68+</dd>
69+</dl>
70+</header>
71+<div class="diff-tools" role="toolbar" aria-label="Diff view">
72+<span class="dim">1 file · <span class="plus">+0</span>
73+<span class="minus">-0</span>
74+</span>
75+<span class="grow">
76+</span>
77+<a href="/diffs/commit/SHA40?view=unified" class="vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;unified&#39; ? &#39;true&#39; : false">[u]nified</a>
78+<a href="/diffs/commit/SHA40?view=split" class="vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;split&#39; ? &#39;true&#39; : false">[d] split</a>
79+<button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))">[x] collapse all</button>
80+</div>
81+<div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
82+<details class="dfile" id="f1" data-file>
83+<summary>
84+<span class="st">M</span>
85+<span class="p">logo.bin</span>
86+<span class="plus">+0</span>
87+<span class="minus">-0</span>
88+<span class="note">binary file: no text diff</span>
89+</summary>
90+<p class="note pad">binary file: no text diff</p>
91+</details>
92+</div>
93+</main>
94+<aside class="rail" aria-label="Context">
95+<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
96+<summary>1 changed file</summary>
97+<section aria-labelledby="r-files">
98+<h2 id="r-files">files <span>1</span>
99+</h2>
100+<ol class="files" aria-label="Changed files">
101+<li>
102+<a href="#f1" :aria-current="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f1&#39; ? &#39;true&#39; : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
103+<span class="st" title="M">M</span>
104+<span class="p" title="logo.bin">logo.bin</span>
105+<span class="n">
106+<span class="plus">+0</span>
107+<span class="minus">-0</span>
108+</span>
109+<span class="b" aria-hidden="true">
110+<span class="plus">
111+</span>
112+<span class="minus">
113+</span>bin</span>
114+</a>
115+</li>
116+</ol>
117+</section>
118+</details>
119+</aside>
120+</div>
121+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
122+<div class="panel-head">
123+<span>keys</span>
124+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
125+</div>
126+<dl class="keys">
127+<div>
128+<dt>
129+<kbd>j</kbd>
130+<kbd>k</kbd>
131+</dt>
132+<dd>move cursor down / up</dd>
133+</div>
134+<div>
135+<dt>
136+<kbd>enter</kbd>
137+</dt>
138+<dd>open row under cursor</dd>
139+</div>
140+<div>
141+<dt>
142+<kbd>g</kbd>
143+<kbd>g</kbd> / <kbd>G</kbd>
144+</dt>
145+<dd>first / last row</dd>
146+</div>
147+<div>
148+<dt>
149+<kbd>/</kbd>
150+</dt>
151+<dd>focus the filter</dd>
152+</div>
153+<div>
154+<dt>
155+<kbd>g h</kbd>
156+<kbd>g t</kbd>
157+</dt>
158+<dd>go home / tree</dd>
159+</div>
160+<div>
161+<dt>
162+<kbd>g c</kbd>
163+<kbd>g r</kbd>
164+</dt>
165+<dd>go commits / refs</dd>
166+</div>
167+<div>
168+<dt>
169+<kbd>b</kbd>
170+</dt>
171+<dd>switch ref (branches, tags)</dd>
172+</div>
173+<div>
174+<dt>
175+<kbd>:</kbd>
176+<kbd>f</kbd>
177+</dt>
178+<dd>go to: section, ref, file, repo</dd>
179+</div>
180+<div>
181+<dt>
182+<kbd>y</kbd>
183+</dt>
184+<dd>permalink: ref becomes the full sha</dd>
185+</div>
186+<div>
187+<dt>
188+<kbd>s</kbd>
189+<kbd>t</kbd>
190+<kbd>c</kbd>
191+<kbd>r</kbd>
192+</dt>
193+<dd>repo sections (when shown)</dd>
194+</div>
195+<div>
196+<dt>
197+<kbd>d</kbd>
198+<kbd>u</kbd>
199+<kbd>x</kbd>
200+</dt>
201+<dd>commit: split / unified / collapse all</dd>
202+</div>
203+<div>
204+<dt>
205+<kbd>esc</kbd>
206+</dt>
207+<dd>close panel</dd>
208+</div>
209+</dl>
210+</dialog>
211+<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
212+<div class="panel-head">
213+<label for="refs-filter">ref&gt;</label>
214+<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))">
215+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
216+</div>
217+<div class="panel-cols" data-picklist>
218+<section aria-label="Branches">
219+<h2>branches <span class="mute">2</span>
220+</h2>
221+<ul>
222+<li>
223+<a href="/diffs/tree/main" aria-current="true">
224+<span class="mark" aria-hidden="true">●</span>main</a>
225+</li>
226+<li>
227+<a href="/diffs/tree/side">
228+<span class="mark" aria-hidden="true">&nbsp;</span>side</a>
229+</li>
230+</ul>
231+</section>
232+<section aria-label="Tags">
233+<h2>tags <span class="mute">1</span>
234+</h2>
235+<ul>
236+<li>
237+<a href="/diffs/tree/v1.0.0">
238+<span class="mark" aria-hidden="true">&nbsp;</span>v1.0.0</a>
239+</li>
240+</ul>
241+</section>
242+</div>
243+</dialog>
244+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
245+<div class="panel-head">
246+<label for="cmd-q" class="hi">:</label>
247+<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();
248+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
249+ 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();
250+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
251+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
252+ 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' })) }
253+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
254+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
255+</div>
256+<ul id="cmd-list" class="cmd-list">
257+<li data-k="go home" data-cursor>
258+<a href="/" tabindex="-1">
259+<span class="k">go</span>home</a>
260+</li>
261+<li data-k="section summary">
262+<a href="/diffs" tabindex="-1">
263+<span class="k">section</span>summary</a>
264+</li>
265+<li data-k="section tree">
266+<a href="/diffs/tree/main" tabindex="-1">
267+<span class="k">section</span>tree</a>
268+</li>
269+<li data-k="section commits">
270+<a href="/diffs/commits/main" tabindex="-1">
271+<span class="k">section</span>commits</a>
272+</li>
273+<li data-k="section refs">
274+<a href="/diffs/refs" tabindex="-1">
275+<span class="k">section</span>refs</a>
276+</li>
277+<li data-k="branch main">
278+<a href="/diffs/tree/main" tabindex="-1">
279+<span class="k">branch</span>main</a>
280+</li>
281+<li data-k="branch side">
282+<a href="/diffs/tree/side" tabindex="-1">
283+<span class="k">branch</span>side</a>
284+</li>
285+<li data-k="tag v1.0.0">
286+<a href="/diffs/tree/v1.0.0" tabindex="-1">
287+<span class="k">tag</span>v1.0.0</a>
288+</li>
289+</ul>
290+</dialog>
291+<footer class="bar status" aria-label="Status">
292+<span class="mode">commit</span>
293+<span class="st-ctx">SHA7 · 1 file · +0 -0</span>
294+<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'))">
295+<span class="hi">:</span> go to…</button>
296+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
297+</span>
298+<span class="st-hints">d split · u unified · x collapse all · ? help</span>
299+<span class="st-src">
300+<span class="st-ver">ermite devel · </span>
301+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
302+</span>
303+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
304+</footer>
305+<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
306+ if (q('dialog[open]').count) return;
307+ let rows = q('[data-rows] > :not([hidden])').arr();
308+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
309+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
310+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
311+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
312+ })(1);
313+ k-k from:body -> ((d) => {
314+ if (q('dialog[open]').count) return;
315+ let rows = q('[data-rows] > :not([hidden])').arr();
316+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
317+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
318+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
319+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
320+ })(-1);
321+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
322+ 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));
323+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
324+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
325+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
326+ 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());
327+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
328+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
329+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
330+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
331+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
332+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
333+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
334+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
335+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
336+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
337+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
338+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
339+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
340+ k-permalink from:body -> {
341+ let b = document.body.dataset;
342+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
343+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
344+ navigator.clipboard?.writeText(location.href);
345+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
346+ }">
347+</div>
348+</body>
349+</html>
A cmd/ermite/testdata/web/commit-merge.html +380 -0
@@ -0,0 +1,380 @@
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>SHA7 merge the side branch · 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="" 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">commit</a>
31+</li>
32+<li>
33+<span aria-current="page">SHA7</span>
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+<header class="show">
59+<h1>merge the side branch</h1>
60+<dl class="kv show-kv">
61+<dt>commit</dt>
62+<dd>SHA40</dd>
63+<dt>author</dt>
64+<dd>T · <time datetime="STAMP" title="STAMP">AGO</time> · STAMP</dd>
65+<dt>merge of</dt>
66+<dd>
67+<a href="/diffs/commit/SHA40">SHA7</a> + <a href="/diffs/commit/SHA40">SHA7</a> · diff against the first parent</dd>
68+</dl>
69+</header>
70+<div class="diff-tools" role="toolbar" aria-label="Diff view">
71+<span class="dim">1 file · <span class="plus">+1</span>
72+<span class="minus">-0</span>
73+</span>
74+<span class="grow">
75+</span>
76+<a href="/diffs/commit/SHA40?view=unified" class="vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;unified&#39; ? &#39;true&#39; : false">[u]nified</a>
77+<a href="/diffs/commit/SHA40?view=split" class="vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;split&#39; ? &#39;true&#39; : false">[d] split</a>
78+<button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))">[x] collapse all</button>
79+</div>
80+<div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
81+<details class="dfile" id="f1" data-file open>
82+<summary>
83+<span class="st">A</span>
84+<span class="p">side.txt</span>
85+<span class="plus">+1</span>
86+<span class="minus">-0</span>
87+</summary>
88+<div class="dscroll">
89+<table class="dt uni" aria-label="side.txt, unified">
90+<tbody>
91+<tr class="hunk">
92+<td colspan="4">@@ -0,0 +1,1 @@</td>
93+</tr>
94+<tr class="add">
95+<td class="ln">
96+</td>
97+<td class="ln">1</td>
98+<td class="m">+</td>
99+<td class="tx">from the side</td>
100+</tr>
101+</tbody>
102+</table>
103+<table class="dt spl" aria-label="side.txt, split">
104+<tbody>
105+<tr class="hunk">
106+<td colspan="6">@@ -0,0 +1,1 @@</td>
107+</tr>
108+<tr>
109+<td class="ln none">
110+</td>
111+<td class="m none">
112+</td>
113+<td class="tx none">
114+</td>
115+<td class="ln add">1</td>
116+<td class="m add">+</td>
117+<td class="tx add">from the side</td>
118+</tr>
119+</tbody>
120+</table>
121+</div>
122+</details>
123+</div>
124+</main>
125+<aside class="rail" aria-label="Context">
126+<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
127+<summary>1 changed file</summary>
128+<section aria-labelledby="r-files">
129+<h2 id="r-files">files <span>1</span>
130+</h2>
131+<ol class="files" aria-label="Changed files">
132+<li>
133+<a href="#f1" :aria-current="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f1&#39; ? &#39;true&#39; : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
134+<span class="st" title="A">A</span>
135+<span class="p" title="side.txt">side.txt</span>
136+<span class="n">
137+<span class="plus">+1</span>
138+<span class="minus">-0</span>
139+</span>
140+<span class="b" aria-hidden="true">
141+<span class="plus">++++++++++</span>
142+<span class="minus">
143+</span>
144+</span>
145+</a>
146+</li>
147+</ol>
148+</section>
149+</details>
150+</aside>
151+</div>
152+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
153+<div class="panel-head">
154+<span>keys</span>
155+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
156+</div>
157+<dl class="keys">
158+<div>
159+<dt>
160+<kbd>j</kbd>
161+<kbd>k</kbd>
162+</dt>
163+<dd>move cursor down / up</dd>
164+</div>
165+<div>
166+<dt>
167+<kbd>enter</kbd>
168+</dt>
169+<dd>open row under cursor</dd>
170+</div>
171+<div>
172+<dt>
173+<kbd>g</kbd>
174+<kbd>g</kbd> / <kbd>G</kbd>
175+</dt>
176+<dd>first / last row</dd>
177+</div>
178+<div>
179+<dt>
180+<kbd>/</kbd>
181+</dt>
182+<dd>focus the filter</dd>
183+</div>
184+<div>
185+<dt>
186+<kbd>g h</kbd>
187+<kbd>g t</kbd>
188+</dt>
189+<dd>go home / tree</dd>
190+</div>
191+<div>
192+<dt>
193+<kbd>g c</kbd>
194+<kbd>g r</kbd>
195+</dt>
196+<dd>go commits / refs</dd>
197+</div>
198+<div>
199+<dt>
200+<kbd>b</kbd>
201+</dt>
202+<dd>switch ref (branches, tags)</dd>
203+</div>
204+<div>
205+<dt>
206+<kbd>:</kbd>
207+<kbd>f</kbd>
208+</dt>
209+<dd>go to: section, ref, file, repo</dd>
210+</div>
211+<div>
212+<dt>
213+<kbd>y</kbd>
214+</dt>
215+<dd>permalink: ref becomes the full sha</dd>
216+</div>
217+<div>
218+<dt>
219+<kbd>s</kbd>
220+<kbd>t</kbd>
221+<kbd>c</kbd>
222+<kbd>r</kbd>
223+</dt>
224+<dd>repo sections (when shown)</dd>
225+</div>
226+<div>
227+<dt>
228+<kbd>d</kbd>
229+<kbd>u</kbd>
230+<kbd>x</kbd>
231+</dt>
232+<dd>commit: split / unified / collapse all</dd>
233+</div>
234+<div>
235+<dt>
236+<kbd>esc</kbd>
237+</dt>
238+<dd>close panel</dd>
239+</div>
240+</dl>
241+</dialog>
242+<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
243+<div class="panel-head">
244+<label for="refs-filter">ref&gt;</label>
245+<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))">
246+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
247+</div>
248+<div class="panel-cols" data-picklist>
249+<section aria-label="Branches">
250+<h2>branches <span class="mute">2</span>
251+</h2>
252+<ul>
253+<li>
254+<a href="/diffs/tree/main" aria-current="true">
255+<span class="mark" aria-hidden="true">●</span>main</a>
256+</li>
257+<li>
258+<a href="/diffs/tree/side">
259+<span class="mark" aria-hidden="true">&nbsp;</span>side</a>
260+</li>
261+</ul>
262+</section>
263+<section aria-label="Tags">
264+<h2>tags <span class="mute">1</span>
265+</h2>
266+<ul>
267+<li>
268+<a href="/diffs/tree/v1.0.0">
269+<span class="mark" aria-hidden="true">&nbsp;</span>v1.0.0</a>
270+</li>
271+</ul>
272+</section>
273+</div>
274+</dialog>
275+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
276+<div class="panel-head">
277+<label for="cmd-q" class="hi">:</label>
278+<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();
279+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
280+ 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();
281+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
282+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
283+ 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' })) }
284+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
285+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
286+</div>
287+<ul id="cmd-list" class="cmd-list">
288+<li data-k="go home" data-cursor>
289+<a href="/" tabindex="-1">
290+<span class="k">go</span>home</a>
291+</li>
292+<li data-k="section summary">
293+<a href="/diffs" tabindex="-1">
294+<span class="k">section</span>summary</a>
295+</li>
296+<li data-k="section tree">
297+<a href="/diffs/tree/main" tabindex="-1">
298+<span class="k">section</span>tree</a>
299+</li>
300+<li data-k="section commits">
301+<a href="/diffs/commits/main" tabindex="-1">
302+<span class="k">section</span>commits</a>
303+</li>
304+<li data-k="section refs">
305+<a href="/diffs/refs" tabindex="-1">
306+<span class="k">section</span>refs</a>
307+</li>
308+<li data-k="branch main">
309+<a href="/diffs/tree/main" tabindex="-1">
310+<span class="k">branch</span>main</a>
311+</li>
312+<li data-k="branch side">
313+<a href="/diffs/tree/side" tabindex="-1">
314+<span class="k">branch</span>side</a>
315+</li>
316+<li data-k="tag v1.0.0">
317+<a href="/diffs/tree/v1.0.0" tabindex="-1">
318+<span class="k">tag</span>v1.0.0</a>
319+</li>
320+</ul>
321+</dialog>
322+<footer class="bar status" aria-label="Status">
323+<span class="mode">commit</span>
324+<span class="st-ctx">SHA7 · 1 file · +1 -0</span>
325+<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'))">
326+<span class="hi">:</span> go to…</button>
327+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
328+</span>
329+<span class="st-hints">d split · u unified · x collapse all · ? help</span>
330+<span class="st-src">
331+<span class="st-ver">ermite devel · </span>
332+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
333+</span>
334+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
335+</footer>
336+<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
337+ if (q('dialog[open]').count) return;
338+ let rows = q('[data-rows] > :not([hidden])').arr();
339+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
340+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
341+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
342+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
343+ })(1);
344+ k-k from:body -> ((d) => {
345+ if (q('dialog[open]').count) return;
346+ let rows = q('[data-rows] > :not([hidden])').arr();
347+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
348+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
349+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
350+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
351+ })(-1);
352+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
353+ 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));
354+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
355+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
356+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
357+ 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());
358+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
359+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
360+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
361+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
362+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
363+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
364+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
365+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
366+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
367+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
368+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
369+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
370+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
371+ k-permalink from:body -> {
372+ let b = document.body.dataset;
373+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
374+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
375+ navigator.clipboard?.writeText(location.href);
376+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
377+ }">
378+</div>
379+</body>
380+</html>
A cmd/ermite/testdata/web/commit-modify-split.html +499 -0
@@ -0,0 +1,499 @@
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>SHA7 modify a file and make a script executable · 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="" 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">commit</a>
31+</li>
32+<li>
33+<span aria-current="page">SHA7</span>
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+<header class="show">
59+<h1>modify a file and make a script executable</h1>
60+<pre class="msg">The body has a second paragraph.
61+And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
62+<dl class="kv show-kv">
63+<dt>commit</dt>
64+<dd>SHA40</dd>
65+<dt>author</dt>
66+<dd>T · <time datetime="STAMP" title="STAMP">AGO</time> · STAMP</dd>
67+<dt>parent</dt>
68+<dd>
69+<a href="/diffs/commit/SHA40">SHA7</a>
70+</dd>
71+<dt>refs</dt>
72+<dd>[tag: v1.0.0] </dd>
73+</dl>
74+</header>
75+<div class="diff-tools" role="toolbar" aria-label="Diff view">
76+<span class="dim">2 files · <span class="plus">+2</span>
77+<span class="minus">-1</span>
78+</span>
79+<span class="grow">
80+</span>
81+<a href="/diffs/commit/SHA40?view=unified" class="vbtn" data-view="unified" aria-keyshortcuts="u" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;unified&#39; ? &#39;true&#39; : false">[u]nified</a>
82+<a href="/diffs/commit/SHA40?view=split" class="vbtn" data-view="split" aria-keyshortcuts="d" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;split&#39; ? &#39;true&#39; : false">[d] split</a>
83+<button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))">[x] collapse all</button>
84+</div>
85+<div id="diffs" data-diff="split">
86+<details class="dfile" id="f1" data-file open>
87+<summary>
88+<span class="st">M</span>
89+<span class="p">a.txt</span>
90+<span class="plus">+2</span>
91+<span class="minus">-1</span>
92+</summary>
93+<div class="dscroll">
94+<table class="dt uni" aria-label="a.txt, unified">
95+<tbody>
96+<tr class="hunk">
97+<td colspan="4">@@ -1,5 +1,6 @@</td>
98+</tr>
99+<tr class="ctx">
100+<td class="ln">1</td>
101+<td class="ln">1</td>
102+<td class="m">
103+</td>
104+<td class="tx">one</td>
105+</tr>
106+<tr class="del">
107+<td class="ln">2</td>
108+<td class="ln">
109+</td>
110+<td class="m">-</td>
111+<td class="tx">two</td>
112+</tr>
113+<tr class="add">
114+<td class="ln">
115+</td>
116+<td class="ln">2</td>
117+<td class="m">+</td>
118+<td class="tx">2</td>
119+</tr>
120+<tr class="ctx">
121+<td class="ln">3</td>
122+<td class="ln">3</td>
123+<td class="m">
124+</td>
125+<td class="tx">three</td>
126+</tr>
127+<tr class="ctx">
128+<td class="ln">4</td>
129+<td class="ln">4</td>
130+<td class="m">
131+</td>
132+<td class="tx">four</td>
133+</tr>
134+<tr class="ctx">
135+<td class="ln">5</td>
136+<td class="ln">5</td>
137+<td class="m">
138+</td>
139+<td class="tx">five</td>
140+</tr>
141+<tr class="add">
142+<td class="ln">
143+</td>
144+<td class="ln">6</td>
145+<td class="m">+</td>
146+<td class="tx">six</td>
147+</tr>
148+</tbody>
149+</table>
150+<table class="dt spl" aria-label="a.txt, split">
151+<tbody>
152+<tr class="hunk">
153+<td colspan="6">@@ -1,5 +1,6 @@</td>
154+</tr>
155+<tr>
156+<td class="ln ctx">1</td>
157+<td class="m ctx">
158+</td>
159+<td class="tx ctx">one</td>
160+<td class="ln ctx">1</td>
161+<td class="m ctx">
162+</td>
163+<td class="tx ctx">one</td>
164+</tr>
165+<tr>
166+<td class="ln del">2</td>
167+<td class="m del">-</td>
168+<td class="tx del">two</td>
169+<td class="ln add">2</td>
170+<td class="m add">+</td>
171+<td class="tx add">2</td>
172+</tr>
173+<tr>
174+<td class="ln ctx">3</td>
175+<td class="m ctx">
176+</td>
177+<td class="tx ctx">three</td>
178+<td class="ln ctx">3</td>
179+<td class="m ctx">
180+</td>
181+<td class="tx ctx">three</td>
182+</tr>
183+<tr>
184+<td class="ln ctx">4</td>
185+<td class="m ctx">
186+</td>
187+<td class="tx ctx">four</td>
188+<td class="ln ctx">4</td>
189+<td class="m ctx">
190+</td>
191+<td class="tx ctx">four</td>
192+</tr>
193+<tr>
194+<td class="ln ctx">5</td>
195+<td class="m ctx">
196+</td>
197+<td class="tx ctx">five</td>
198+<td class="ln ctx">5</td>
199+<td class="m ctx">
200+</td>
201+<td class="tx ctx">five</td>
202+</tr>
203+<tr>
204+<td class="ln none">
205+</td>
206+<td class="m none">
207+</td>
208+<td class="tx none">
209+</td>
210+<td class="ln add">6</td>
211+<td class="m add">+</td>
212+<td class="tx add">six</td>
213+</tr>
214+</tbody>
215+</table>
216+</div>
217+</details>
218+<details class="dfile" id="f2" data-file>
219+<summary>
220+<span class="st">M</span>
221+<span class="p">run.sh</span>
222+<span class="plus">+0</span>
223+<span class="minus">-0</span>
224+<span class="note">mode 100644 → 100755</span>
225+</summary>
226+<p class="note pad">mode 100644 → 100755</p>
227+</details>
228+</div>
229+</main>
230+<aside class="rail" aria-label="Context">
231+<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
232+<summary>2 changed files</summary>
233+<section aria-labelledby="r-files">
234+<h2 id="r-files">files <span>2</span>
235+</h2>
236+<ol class="files" aria-label="Changed files">
237+<li>
238+<a href="#f1" :aria-current="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f1&#39; ? &#39;true&#39; : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
239+<span class="st" title="M">M</span>
240+<span class="p" title="a.txt">a.txt</span>
241+<span class="n">
242+<span class="plus">+2</span>
243+<span class="minus">-1</span>
244+</span>
245+<span class="b" aria-hidden="true">
246+<span class="plus">+++++++</span>
247+<span class="minus">---</span>
248+</span>
249+</a>
250+</li>
251+<li>
252+<a href="#f2" :aria-current="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f2&#39; ? &#39;true&#39; : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
253+<span class="st" title="M">M</span>
254+<span class="p" title="run.sh">run.sh</span>
255+<span class="n">
256+<span class="plus">+0</span>
257+<span class="minus">-0</span>
258+</span>
259+<span class="b" aria-hidden="true">
260+<span class="plus">
261+</span>
262+<span class="minus">
263+</span>··········</span>
264+</a>
265+</li>
266+</ol>
267+</section>
268+</details>
269+</aside>
270+</div>
271+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
272+<div class="panel-head">
273+<span>keys</span>
274+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
275+</div>
276+<dl class="keys">
277+<div>
278+<dt>
279+<kbd>j</kbd>
280+<kbd>k</kbd>
281+</dt>
282+<dd>move cursor down / up</dd>
283+</div>
284+<div>
285+<dt>
286+<kbd>enter</kbd>
287+</dt>
288+<dd>open row under cursor</dd>
289+</div>
290+<div>
291+<dt>
292+<kbd>g</kbd>
293+<kbd>g</kbd> / <kbd>G</kbd>
294+</dt>
295+<dd>first / last row</dd>
296+</div>
297+<div>
298+<dt>
299+<kbd>/</kbd>
300+</dt>
301+<dd>focus the filter</dd>
302+</div>
303+<div>
304+<dt>
305+<kbd>g h</kbd>
306+<kbd>g t</kbd>
307+</dt>
308+<dd>go home / tree</dd>
309+</div>
310+<div>
311+<dt>
312+<kbd>g c</kbd>
313+<kbd>g r</kbd>
314+</dt>
315+<dd>go commits / refs</dd>
316+</div>
317+<div>
318+<dt>
319+<kbd>b</kbd>
320+</dt>
321+<dd>switch ref (branches, tags)</dd>
322+</div>
323+<div>
324+<dt>
325+<kbd>:</kbd>
326+<kbd>f</kbd>
327+</dt>
328+<dd>go to: section, ref, file, repo</dd>
329+</div>
330+<div>
331+<dt>
332+<kbd>y</kbd>
333+</dt>
334+<dd>permalink: ref becomes the full sha</dd>
335+</div>
336+<div>
337+<dt>
338+<kbd>s</kbd>
339+<kbd>t</kbd>
340+<kbd>c</kbd>
341+<kbd>r</kbd>
342+</dt>
343+<dd>repo sections (when shown)</dd>
344+</div>
345+<div>
346+<dt>
347+<kbd>d</kbd>
348+<kbd>u</kbd>
349+<kbd>x</kbd>
350+</dt>
351+<dd>commit: split / unified / collapse all</dd>
352+</div>
353+<div>
354+<dt>
355+<kbd>esc</kbd>
356+</dt>
357+<dd>close panel</dd>
358+</div>
359+</dl>
360+</dialog>
361+<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
362+<div class="panel-head">
363+<label for="refs-filter">ref&gt;</label>
364+<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))">
365+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
366+</div>
367+<div class="panel-cols" data-picklist>
368+<section aria-label="Branches">
369+<h2>branches <span class="mute">2</span>
370+</h2>
371+<ul>
372+<li>
373+<a href="/diffs/tree/main" aria-current="true">
374+<span class="mark" aria-hidden="true">●</span>main</a>
375+</li>
376+<li>
377+<a href="/diffs/tree/side">
378+<span class="mark" aria-hidden="true">&nbsp;</span>side</a>
379+</li>
380+</ul>
381+</section>
382+<section aria-label="Tags">
383+<h2>tags <span class="mute">1</span>
384+</h2>
385+<ul>
386+<li>
387+<a href="/diffs/tree/v1.0.0">
388+<span class="mark" aria-hidden="true">&nbsp;</span>v1.0.0</a>
389+</li>
390+</ul>
391+</section>
392+</div>
393+</dialog>
394+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
395+<div class="panel-head">
396+<label for="cmd-q" class="hi">:</label>
397+<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();
398+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
399+ 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();
400+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
401+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
402+ 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' })) }
403+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
404+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
405+</div>
406+<ul id="cmd-list" class="cmd-list">
407+<li data-k="go home" data-cursor>
408+<a href="/" tabindex="-1">
409+<span class="k">go</span>home</a>
410+</li>
411+<li data-k="section summary">
412+<a href="/diffs" tabindex="-1">
413+<span class="k">section</span>summary</a>
414+</li>
415+<li data-k="section tree">
416+<a href="/diffs/tree/main" tabindex="-1">
417+<span class="k">section</span>tree</a>
418+</li>
419+<li data-k="section commits">
420+<a href="/diffs/commits/main" tabindex="-1">
421+<span class="k">section</span>commits</a>
422+</li>
423+<li data-k="section refs">
424+<a href="/diffs/refs" tabindex="-1">
425+<span class="k">section</span>refs</a>
426+</li>
427+<li data-k="branch main">
428+<a href="/diffs/tree/main" tabindex="-1">
429+<span class="k">branch</span>main</a>
430+</li>
431+<li data-k="branch side">
432+<a href="/diffs/tree/side" tabindex="-1">
433+<span class="k">branch</span>side</a>
434+</li>
435+<li data-k="tag v1.0.0">
436+<a href="/diffs/tree/v1.0.0" tabindex="-1">
437+<span class="k">tag</span>v1.0.0</a>
438+</li>
439+</ul>
440+</dialog>
441+<footer class="bar status" aria-label="Status">
442+<span class="mode">commit</span>
443+<span class="st-ctx">SHA7 · 2 files · +2 -1</span>
444+<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'))">
445+<span class="hi">:</span> go to…</button>
446+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
447+</span>
448+<span class="st-hints">d split · u unified · x collapse all · ? help</span>
449+<span class="st-src">
450+<span class="st-ver">ermite devel · </span>
451+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
452+</span>
453+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
454+</footer>
455+<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
456+ if (q('dialog[open]').count) return;
457+ let rows = q('[data-rows] > :not([hidden])').arr();
458+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
459+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
460+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
461+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
462+ })(1);
463+ k-k from:body -> ((d) => {
464+ if (q('dialog[open]').count) return;
465+ let rows = q('[data-rows] > :not([hidden])').arr();
466+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
467+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
468+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
469+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
470+ })(-1);
471+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
472+ 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));
473+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
474+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
475+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
476+ 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());
477+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
478+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
479+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
480+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
481+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
482+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
483+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
484+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
485+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
486+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
487+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
488+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
489+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
490+ k-permalink from:body -> {
491+ let b = document.body.dataset;
492+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
493+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
494+ navigator.clipboard?.writeText(location.href);
495+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
496+ }">
497+</div>
498+</body>
499+</html>
A cmd/ermite/testdata/web/commit-modify.html +499 -0
@@ -0,0 +1,499 @@
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>SHA7 modify a file and make a script executable · 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="" 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">commit</a>
31+</li>
32+<li>
33+<span aria-current="page">SHA7</span>
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+<header class="show">
59+<h1>modify a file and make a script executable</h1>
60+<pre class="msg">The body has a second paragraph.
61+And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
62+<dl class="kv show-kv">
63+<dt>commit</dt>
64+<dd>SHA40</dd>
65+<dt>author</dt>
66+<dd>T · <time datetime="STAMP" title="STAMP">AGO</time> · STAMP</dd>
67+<dt>parent</dt>
68+<dd>
69+<a href="/diffs/commit/SHA40">SHA7</a>
70+</dd>
71+<dt>refs</dt>
72+<dd>[tag: v1.0.0] </dd>
73+</dl>
74+</header>
75+<div class="diff-tools" role="toolbar" aria-label="Diff view">
76+<span class="dim">2 files · <span class="plus">+2</span>
77+<span class="minus">-1</span>
78+</span>
79+<span class="grow">
80+</span>
81+<a href="/diffs/commit/SHA40?view=unified" class="vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;unified&#39; ? &#39;true&#39; : false">[u]nified</a>
82+<a href="/diffs/commit/SHA40?view=split" class="vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;split&#39; ? &#39;true&#39; : false">[d] split</a>
83+<button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))">[x] collapse all</button>
84+</div>
85+<div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
86+<details class="dfile" id="f1" data-file open>
87+<summary>
88+<span class="st">M</span>
89+<span class="p">a.txt</span>
90+<span class="plus">+2</span>
91+<span class="minus">-1</span>
92+</summary>
93+<div class="dscroll">
94+<table class="dt uni" aria-label="a.txt, unified">
95+<tbody>
96+<tr class="hunk">
97+<td colspan="4">@@ -1,5 +1,6 @@</td>
98+</tr>
99+<tr class="ctx">
100+<td class="ln">1</td>
101+<td class="ln">1</td>
102+<td class="m">
103+</td>
104+<td class="tx">one</td>
105+</tr>
106+<tr class="del">
107+<td class="ln">2</td>
108+<td class="ln">
109+</td>
110+<td class="m">-</td>
111+<td class="tx">two</td>
112+</tr>
113+<tr class="add">
114+<td class="ln">
115+</td>
116+<td class="ln">2</td>
117+<td class="m">+</td>
118+<td class="tx">2</td>
119+</tr>
120+<tr class="ctx">
121+<td class="ln">3</td>
122+<td class="ln">3</td>
123+<td class="m">
124+</td>
125+<td class="tx">three</td>
126+</tr>
127+<tr class="ctx">
128+<td class="ln">4</td>
129+<td class="ln">4</td>
130+<td class="m">
131+</td>
132+<td class="tx">four</td>
133+</tr>
134+<tr class="ctx">
135+<td class="ln">5</td>
136+<td class="ln">5</td>
137+<td class="m">
138+</td>
139+<td class="tx">five</td>
140+</tr>
141+<tr class="add">
142+<td class="ln">
143+</td>
144+<td class="ln">6</td>
145+<td class="m">+</td>
146+<td class="tx">six</td>
147+</tr>
148+</tbody>
149+</table>
150+<table class="dt spl" aria-label="a.txt, split">
151+<tbody>
152+<tr class="hunk">
153+<td colspan="6">@@ -1,5 +1,6 @@</td>
154+</tr>
155+<tr>
156+<td class="ln ctx">1</td>
157+<td class="m ctx">
158+</td>
159+<td class="tx ctx">one</td>
160+<td class="ln ctx">1</td>
161+<td class="m ctx">
162+</td>
163+<td class="tx ctx">one</td>
164+</tr>
165+<tr>
166+<td class="ln del">2</td>
167+<td class="m del">-</td>
168+<td class="tx del">two</td>
169+<td class="ln add">2</td>
170+<td class="m add">+</td>
171+<td class="tx add">2</td>
172+</tr>
173+<tr>
174+<td class="ln ctx">3</td>
175+<td class="m ctx">
176+</td>
177+<td class="tx ctx">three</td>
178+<td class="ln ctx">3</td>
179+<td class="m ctx">
180+</td>
181+<td class="tx ctx">three</td>
182+</tr>
183+<tr>
184+<td class="ln ctx">4</td>
185+<td class="m ctx">
186+</td>
187+<td class="tx ctx">four</td>
188+<td class="ln ctx">4</td>
189+<td class="m ctx">
190+</td>
191+<td class="tx ctx">four</td>
192+</tr>
193+<tr>
194+<td class="ln ctx">5</td>
195+<td class="m ctx">
196+</td>
197+<td class="tx ctx">five</td>
198+<td class="ln ctx">5</td>
199+<td class="m ctx">
200+</td>
201+<td class="tx ctx">five</td>
202+</tr>
203+<tr>
204+<td class="ln none">
205+</td>
206+<td class="m none">
207+</td>
208+<td class="tx none">
209+</td>
210+<td class="ln add">6</td>
211+<td class="m add">+</td>
212+<td class="tx add">six</td>
213+</tr>
214+</tbody>
215+</table>
216+</div>
217+</details>
218+<details class="dfile" id="f2" data-file>
219+<summary>
220+<span class="st">M</span>
221+<span class="p">run.sh</span>
222+<span class="plus">+0</span>
223+<span class="minus">-0</span>
224+<span class="note">mode 100644 → 100755</span>
225+</summary>
226+<p class="note pad">mode 100644 → 100755</p>
227+</details>
228+</div>
229+</main>
230+<aside class="rail" aria-label="Context">
231+<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
232+<summary>2 changed files</summary>
233+<section aria-labelledby="r-files">
234+<h2 id="r-files">files <span>2</span>
235+</h2>
236+<ol class="files" aria-label="Changed files">
237+<li>
238+<a href="#f1" :aria-current="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f1&#39; ? &#39;true&#39; : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
239+<span class="st" title="M">M</span>
240+<span class="p" title="a.txt">a.txt</span>
241+<span class="n">
242+<span class="plus">+2</span>
243+<span class="minus">-1</span>
244+</span>
245+<span class="b" aria-hidden="true">
246+<span class="plus">+++++++</span>
247+<span class="minus">---</span>
248+</span>
249+</a>
250+</li>
251+<li>
252+<a href="#f2" :aria-current="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f2&#39; ? &#39;true&#39; : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
253+<span class="st" title="M">M</span>
254+<span class="p" title="run.sh">run.sh</span>
255+<span class="n">
256+<span class="plus">+0</span>
257+<span class="minus">-0</span>
258+</span>
259+<span class="b" aria-hidden="true">
260+<span class="plus">
261+</span>
262+<span class="minus">
263+</span>··········</span>
264+</a>
265+</li>
266+</ol>
267+</section>
268+</details>
269+</aside>
270+</div>
271+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
272+<div class="panel-head">
273+<span>keys</span>
274+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
275+</div>
276+<dl class="keys">
277+<div>
278+<dt>
279+<kbd>j</kbd>
280+<kbd>k</kbd>
281+</dt>
282+<dd>move cursor down / up</dd>
283+</div>
284+<div>
285+<dt>
286+<kbd>enter</kbd>
287+</dt>
288+<dd>open row under cursor</dd>
289+</div>
290+<div>
291+<dt>
292+<kbd>g</kbd>
293+<kbd>g</kbd> / <kbd>G</kbd>
294+</dt>
295+<dd>first / last row</dd>
296+</div>
297+<div>
298+<dt>
299+<kbd>/</kbd>
300+</dt>
301+<dd>focus the filter</dd>
302+</div>
303+<div>
304+<dt>
305+<kbd>g h</kbd>
306+<kbd>g t</kbd>
307+</dt>
308+<dd>go home / tree</dd>
309+</div>
310+<div>
311+<dt>
312+<kbd>g c</kbd>
313+<kbd>g r</kbd>
314+</dt>
315+<dd>go commits / refs</dd>
316+</div>
317+<div>
318+<dt>
319+<kbd>b</kbd>
320+</dt>
321+<dd>switch ref (branches, tags)</dd>
322+</div>
323+<div>
324+<dt>
325+<kbd>:</kbd>
326+<kbd>f</kbd>
327+</dt>
328+<dd>go to: section, ref, file, repo</dd>
329+</div>
330+<div>
331+<dt>
332+<kbd>y</kbd>
333+</dt>
334+<dd>permalink: ref becomes the full sha</dd>
335+</div>
336+<div>
337+<dt>
338+<kbd>s</kbd>
339+<kbd>t</kbd>
340+<kbd>c</kbd>
341+<kbd>r</kbd>
342+</dt>
343+<dd>repo sections (when shown)</dd>
344+</div>
345+<div>
346+<dt>
347+<kbd>d</kbd>
348+<kbd>u</kbd>
349+<kbd>x</kbd>
350+</dt>
351+<dd>commit: split / unified / collapse all</dd>
352+</div>
353+<div>
354+<dt>
355+<kbd>esc</kbd>
356+</dt>
357+<dd>close panel</dd>
358+</div>
359+</dl>
360+</dialog>
361+<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
362+<div class="panel-head">
363+<label for="refs-filter">ref&gt;</label>
364+<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))">
365+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
366+</div>
367+<div class="panel-cols" data-picklist>
368+<section aria-label="Branches">
369+<h2>branches <span class="mute">2</span>
370+</h2>
371+<ul>
372+<li>
373+<a href="/diffs/tree/main" aria-current="true">
374+<span class="mark" aria-hidden="true">●</span>main</a>
375+</li>
376+<li>
377+<a href="/diffs/tree/side">
378+<span class="mark" aria-hidden="true">&nbsp;</span>side</a>
379+</li>
380+</ul>
381+</section>
382+<section aria-label="Tags">
383+<h2>tags <span class="mute">1</span>
384+</h2>
385+<ul>
386+<li>
387+<a href="/diffs/tree/v1.0.0">
388+<span class="mark" aria-hidden="true">&nbsp;</span>v1.0.0</a>
389+</li>
390+</ul>
391+</section>
392+</div>
393+</dialog>
394+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
395+<div class="panel-head">
396+<label for="cmd-q" class="hi">:</label>
397+<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();
398+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
399+ 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();
400+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
401+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
402+ 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' })) }
403+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
404+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
405+</div>
406+<ul id="cmd-list" class="cmd-list">
407+<li data-k="go home" data-cursor>
408+<a href="/" tabindex="-1">
409+<span class="k">go</span>home</a>
410+</li>
411+<li data-k="section summary">
412+<a href="/diffs" tabindex="-1">
413+<span class="k">section</span>summary</a>
414+</li>
415+<li data-k="section tree">
416+<a href="/diffs/tree/main" tabindex="-1">
417+<span class="k">section</span>tree</a>
418+</li>
419+<li data-k="section commits">
420+<a href="/diffs/commits/main" tabindex="-1">
421+<span class="k">section</span>commits</a>
422+</li>
423+<li data-k="section refs">
424+<a href="/diffs/refs" tabindex="-1">
425+<span class="k">section</span>refs</a>
426+</li>
427+<li data-k="branch main">
428+<a href="/diffs/tree/main" tabindex="-1">
429+<span class="k">branch</span>main</a>
430+</li>
431+<li data-k="branch side">
432+<a href="/diffs/tree/side" tabindex="-1">
433+<span class="k">branch</span>side</a>
434+</li>
435+<li data-k="tag v1.0.0">
436+<a href="/diffs/tree/v1.0.0" tabindex="-1">
437+<span class="k">tag</span>v1.0.0</a>
438+</li>
439+</ul>
440+</dialog>
441+<footer class="bar status" aria-label="Status">
442+<span class="mode">commit</span>
443+<span class="st-ctx">SHA7 · 2 files · +2 -1</span>
444+<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'))">
445+<span class="hi">:</span> go to…</button>
446+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
447+</span>
448+<span class="st-hints">d split · u unified · x collapse all · ? help</span>
449+<span class="st-src">
450+<span class="st-ver">ermite devel · </span>
451+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
452+</span>
453+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
454+</footer>
455+<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
456+ if (q('dialog[open]').count) return;
457+ let rows = q('[data-rows] > :not([hidden])').arr();
458+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
459+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
460+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
461+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
462+ })(1);
463+ k-k from:body -> ((d) => {
464+ if (q('dialog[open]').count) return;
465+ let rows = q('[data-rows] > :not([hidden])').arr();
466+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
467+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
468+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
469+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
470+ })(-1);
471+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
472+ 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));
473+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
474+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
475+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
476+ 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());
477+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
478+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
479+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
480+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
481+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
482+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
483+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
484+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
485+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
486+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
487+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
488+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
489+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
490+ k-permalink from:body -> {
491+ let b = document.body.dataset;
492+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
493+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
494+ navigator.clipboard?.writeText(location.href);
495+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
496+ }">
497+</div>
498+</body>
499+</html>
A cmd/ermite/testdata/web/commit-rename.html +406 -0
@@ -0,0 +1,406 @@
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>SHA7 rename a file and delete another · 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="" 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">commit</a>
31+</li>
32+<li>
33+<span aria-current="page">SHA7</span>
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+<header class="show">
59+<h1>rename a file and delete another</h1>
60+<dl class="kv show-kv">
61+<dt>commit</dt>
62+<dd>SHA40</dd>
63+<dt>author</dt>
64+<dd>T · <time datetime="STAMP" title="STAMP">AGO</time> · STAMP</dd>
65+<dt>parent</dt>
66+<dd>
67+<a href="/diffs/commit/SHA40">SHA7</a>
68+</dd>
69+</dl>
70+</header>
71+<div class="diff-tools" role="toolbar" aria-label="Diff view">
72+<span class="dim">2 files · <span class="plus">+0</span>
73+<span class="minus">-1</span>
74+</span>
75+<span class="grow">
76+</span>
77+<a href="/diffs/commit/SHA40?view=unified" class="vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;unified&#39; ? &#39;true&#39; : false">[u]nified</a>
78+<a href="/diffs/commit/SHA40?view=split" class="vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;split&#39; ? &#39;true&#39; : false">[d] split</a>
79+<button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))">[x] collapse all</button>
80+</div>
81+<div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
82+<details class="dfile" id="f1" data-file>
83+<summary>
84+<span class="st">R</span>
85+<span class="p">old.txt → docs-old.txt</span>
86+<span class="plus">+0</span>
87+<span class="minus">-0</span>
88+<span class="note">renamed, 100% similar</span>
89+</summary>
90+<p class="note pad">renamed, 100% similar</p>
91+</details>
92+<details class="dfile" id="f2" data-file open>
93+<summary>
94+<span class="st">D</span>
95+<span class="p">gone.txt</span>
96+<span class="plus">+0</span>
97+<span class="minus">-1</span>
98+</summary>
99+<div class="dscroll">
100+<table class="dt uni" aria-label="gone.txt, unified">
101+<tbody>
102+<tr class="hunk">
103+<td colspan="4">@@ -1,1 +0,0 @@</td>
104+</tr>
105+<tr class="del">
106+<td class="ln">1</td>
107+<td class="ln">
108+</td>
109+<td class="m">-</td>
110+<td class="tx">bye</td>
111+</tr>
112+</tbody>
113+</table>
114+<table class="dt spl" aria-label="gone.txt, split">
115+<tbody>
116+<tr class="hunk">
117+<td colspan="6">@@ -1,1 +0,0 @@</td>
118+</tr>
119+<tr>
120+<td class="ln del">1</td>
121+<td class="m del">-</td>
122+<td class="tx del">bye</td>
123+<td class="ln none">
124+</td>
125+<td class="m none">
126+</td>
127+<td class="tx none">
128+</td>
129+</tr>
130+</tbody>
131+</table>
132+</div>
133+</details>
134+</div>
135+</main>
136+<aside class="rail" aria-label="Context">
137+<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
138+<summary>2 changed files</summary>
139+<section aria-labelledby="r-files">
140+<h2 id="r-files">files <span>2</span>
141+</h2>
142+<ol class="files" aria-label="Changed files">
143+<li>
144+<a href="#f1" :aria-current="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f1&#39; ? &#39;true&#39; : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
145+<span class="st" title="R">R</span>
146+<span class="p" title="old.txt → docs-old.txt">old.txt → docs-old.txt</span>
147+<span class="n">
148+<span class="plus">+0</span>
149+<span class="minus">-0</span>
150+</span>
151+<span class="b" aria-hidden="true">
152+<span class="plus">
153+</span>
154+<span class="minus">
155+</span>··········</span>
156+</a>
157+</li>
158+<li>
159+<a href="#f2" :aria-current="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f2&#39; ? &#39;true&#39; : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
160+<span class="st" title="D">D</span>
161+<span class="p" title="gone.txt">gone.txt</span>
162+<span class="n">
163+<span class="plus">+0</span>
164+<span class="minus">-1</span>
165+</span>
166+<span class="b" aria-hidden="true">
167+<span class="plus">
168+</span>
169+<span class="minus">----------</span>
170+</span>
171+</a>
172+</li>
173+</ol>
174+</section>
175+</details>
176+</aside>
177+</div>
178+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
179+<div class="panel-head">
180+<span>keys</span>
181+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
182+</div>
183+<dl class="keys">
184+<div>
185+<dt>
186+<kbd>j</kbd>
187+<kbd>k</kbd>
188+</dt>
189+<dd>move cursor down / up</dd>
190+</div>
191+<div>
192+<dt>
193+<kbd>enter</kbd>
194+</dt>
195+<dd>open row under cursor</dd>
196+</div>
197+<div>
198+<dt>
199+<kbd>g</kbd>
200+<kbd>g</kbd> / <kbd>G</kbd>
201+</dt>
202+<dd>first / last row</dd>
203+</div>
204+<div>
205+<dt>
206+<kbd>/</kbd>
207+</dt>
208+<dd>focus the filter</dd>
209+</div>
210+<div>
211+<dt>
212+<kbd>g h</kbd>
213+<kbd>g t</kbd>
214+</dt>
215+<dd>go home / tree</dd>
216+</div>
217+<div>
218+<dt>
219+<kbd>g c</kbd>
220+<kbd>g r</kbd>
221+</dt>
222+<dd>go commits / refs</dd>
223+</div>
224+<div>
225+<dt>
226+<kbd>b</kbd>
227+</dt>
228+<dd>switch ref (branches, tags)</dd>
229+</div>
230+<div>
231+<dt>
232+<kbd>:</kbd>
233+<kbd>f</kbd>
234+</dt>
235+<dd>go to: section, ref, file, repo</dd>
236+</div>
237+<div>
238+<dt>
239+<kbd>y</kbd>
240+</dt>
241+<dd>permalink: ref becomes the full sha</dd>
242+</div>
243+<div>
244+<dt>
245+<kbd>s</kbd>
246+<kbd>t</kbd>
247+<kbd>c</kbd>
248+<kbd>r</kbd>
249+</dt>
250+<dd>repo sections (when shown)</dd>
251+</div>
252+<div>
253+<dt>
254+<kbd>d</kbd>
255+<kbd>u</kbd>
256+<kbd>x</kbd>
257+</dt>
258+<dd>commit: split / unified / collapse all</dd>
259+</div>
260+<div>
261+<dt>
262+<kbd>esc</kbd>
263+</dt>
264+<dd>close panel</dd>
265+</div>
266+</dl>
267+</dialog>
268+<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
269+<div class="panel-head">
270+<label for="refs-filter">ref&gt;</label>
271+<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))">
272+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
273+</div>
274+<div class="panel-cols" data-picklist>
275+<section aria-label="Branches">
276+<h2>branches <span class="mute">2</span>
277+</h2>
278+<ul>
279+<li>
280+<a href="/diffs/tree/main" aria-current="true">
281+<span class="mark" aria-hidden="true">●</span>main</a>
282+</li>
283+<li>
284+<a href="/diffs/tree/side">
285+<span class="mark" aria-hidden="true">&nbsp;</span>side</a>
286+</li>
287+</ul>
288+</section>
289+<section aria-label="Tags">
290+<h2>tags <span class="mute">1</span>
291+</h2>
292+<ul>
293+<li>
294+<a href="/diffs/tree/v1.0.0">
295+<span class="mark" aria-hidden="true">&nbsp;</span>v1.0.0</a>
296+</li>
297+</ul>
298+</section>
299+</div>
300+</dialog>
301+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
302+<div class="panel-head">
303+<label for="cmd-q" class="hi">:</label>
304+<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();
305+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
306+ let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
307+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
308+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
309+ if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
310+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
311+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
312+</div>
313+<ul id="cmd-list" class="cmd-list">
314+<li data-k="go home" data-cursor>
315+<a href="/" tabindex="-1">
316+<span class="k">go</span>home</a>
317+</li>
318+<li data-k="section summary">
319+<a href="/diffs" tabindex="-1">
320+<span class="k">section</span>summary</a>
321+</li>
322+<li data-k="section tree">
323+<a href="/diffs/tree/main" tabindex="-1">
324+<span class="k">section</span>tree</a>
325+</li>
326+<li data-k="section commits">
327+<a href="/diffs/commits/main" tabindex="-1">
328+<span class="k">section</span>commits</a>
329+</li>
330+<li data-k="section refs">
331+<a href="/diffs/refs" tabindex="-1">
332+<span class="k">section</span>refs</a>
333+</li>
334+<li data-k="branch main">
335+<a href="/diffs/tree/main" tabindex="-1">
336+<span class="k">branch</span>main</a>
337+</li>
338+<li data-k="branch side">
339+<a href="/diffs/tree/side" tabindex="-1">
340+<span class="k">branch</span>side</a>
341+</li>
342+<li data-k="tag v1.0.0">
343+<a href="/diffs/tree/v1.0.0" tabindex="-1">
344+<span class="k">tag</span>v1.0.0</a>
345+</li>
346+</ul>
347+</dialog>
348+<footer class="bar status" aria-label="Status">
349+<span class="mode">commit</span>
350+<span class="st-ctx">SHA7 · 2 files · +0 -1</span>
351+<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'))">
352+<span class="hi">:</span> go to…</button>
353+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
354+</span>
355+<span class="st-hints">d split · u unified · x collapse all · ? help</span>
356+<span class="st-src">
357+<span class="st-ver">ermite devel · </span>
358+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
359+</span>
360+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
361+</footer>
362+<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
363+ if (q('dialog[open]').count) return;
364+ let rows = q('[data-rows] > :not([hidden])').arr();
365+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
366+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
367+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
368+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
369+ })(1);
370+ k-k from:body -> ((d) => {
371+ if (q('dialog[open]').count) return;
372+ let rows = q('[data-rows] > :not([hidden])').arr();
373+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
374+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
375+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
376+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
377+ })(-1);
378+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
379+ 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));
380+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
381+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
382+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
383+ 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());
384+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
385+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
386+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
387+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
388+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
389+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
390+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
391+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
392+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
393+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
394+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
395+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
396+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
397+ k-permalink from:body -> {
398+ let b = document.body.dataset;
399+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
400+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
401+ navigator.clipboard?.writeText(location.href);
402+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
403+ }">
404+</div>
405+</body>
406+</html>
A cmd/ermite/testdata/web/commit-root.html +698 -0
@@ -0,0 +1,698 @@
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>SHA7 root: the first files · 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="" 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">commit</a>
31+</li>
32+<li>
33+<span aria-current="page">SHA7</span>
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+<header class="show">
59+<h1>root: the first files</h1>
60+<dl class="kv show-kv">
61+<dt>commit</dt>
62+<dd>SHA40</dd>
63+<dt>author</dt>
64+<dd>T · <time datetime="STAMP" title="STAMP">AGO</time> · STAMP</dd>
65+<dt>parent</dt>
66+<dd>none (root commit) </dd>
67+</dl>
68+</header>
69+<div class="diff-tools" role="toolbar" aria-label="Diff view">
70+<span class="dim">5 files · <span class="plus">+11</span>
71+<span class="minus">-0</span>
72+</span>
73+<span class="grow">
74+</span>
75+<a href="/diffs/commit/SHA40?view=unified" class="vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;unified&#39; ? &#39;true&#39; : false">[u]nified</a>
76+<a href="/diffs/commit/SHA40?view=split" class="vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;split&#39; ? &#39;true&#39; : false">[d] split</a>
77+<button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))">[x] collapse all</button>
78+</div>
79+<div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
80+<details class="dfile" id="f1" data-file open>
81+<summary>
82+<span class="st">A</span>
83+<span class="p">a.txt</span>
84+<span class="plus">+5</span>
85+<span class="minus">-0</span>
86+</summary>
87+<div class="dscroll">
88+<table class="dt uni" aria-label="a.txt, unified">
89+<tbody>
90+<tr class="hunk">
91+<td colspan="4">@@ -0,0 +1,5 @@</td>
92+</tr>
93+<tr class="add">
94+<td class="ln">
95+</td>
96+<td class="ln">1</td>
97+<td class="m">+</td>
98+<td class="tx">one</td>
99+</tr>
100+<tr class="add">
101+<td class="ln">
102+</td>
103+<td class="ln">2</td>
104+<td class="m">+</td>
105+<td class="tx">two</td>
106+</tr>
107+<tr class="add">
108+<td class="ln">
109+</td>
110+<td class="ln">3</td>
111+<td class="m">+</td>
112+<td class="tx">three</td>
113+</tr>
114+<tr class="add">
115+<td class="ln">
116+</td>
117+<td class="ln">4</td>
118+<td class="m">+</td>
119+<td class="tx">four</td>
120+</tr>
121+<tr class="add">
122+<td class="ln">
123+</td>
124+<td class="ln">5</td>
125+<td class="m">+</td>
126+<td class="tx">five</td>
127+</tr>
128+</tbody>
129+</table>
130+<table class="dt spl" aria-label="a.txt, split">
131+<tbody>
132+<tr class="hunk">
133+<td colspan="6">@@ -0,0 +1,5 @@</td>
134+</tr>
135+<tr>
136+<td class="ln none">
137+</td>
138+<td class="m none">
139+</td>
140+<td class="tx none">
141+</td>
142+<td class="ln add">1</td>
143+<td class="m add">+</td>
144+<td class="tx add">one</td>
145+</tr>
146+<tr>
147+<td class="ln none">
148+</td>
149+<td class="m none">
150+</td>
151+<td class="tx none">
152+</td>
153+<td class="ln add">2</td>
154+<td class="m add">+</td>
155+<td class="tx add">two</td>
156+</tr>
157+<tr>
158+<td class="ln none">
159+</td>
160+<td class="m none">
161+</td>
162+<td class="tx none">
163+</td>
164+<td class="ln add">3</td>
165+<td class="m add">+</td>
166+<td class="tx add">three</td>
167+</tr>
168+<tr>
169+<td class="ln none">
170+</td>
171+<td class="m none">
172+</td>
173+<td class="tx none">
174+</td>
175+<td class="ln add">4</td>
176+<td class="m add">+</td>
177+<td class="tx add">four</td>
178+</tr>
179+<tr>
180+<td class="ln none">
181+</td>
182+<td class="m none">
183+</td>
184+<td class="tx none">
185+</td>
186+<td class="ln add">5</td>
187+<td class="m add">+</td>
188+<td class="tx add">five</td>
189+</tr>
190+</tbody>
191+</table>
192+</div>
193+</details>
194+<details class="dfile" id="f2" data-file open>
195+<summary>
196+<span class="st">A</span>
197+<span class="p">gone.txt</span>
198+<span class="plus">+1</span>
199+<span class="minus">-0</span>
200+</summary>
201+<div class="dscroll">
202+<table class="dt uni" aria-label="gone.txt, unified">
203+<tbody>
204+<tr class="hunk">
205+<td colspan="4">@@ -0,0 +1,1 @@</td>
206+</tr>
207+<tr class="add">
208+<td class="ln">
209+</td>
210+<td class="ln">1</td>
211+<td class="m">+</td>
212+<td class="tx">bye</td>
213+</tr>
214+</tbody>
215+</table>
216+<table class="dt spl" aria-label="gone.txt, split">
217+<tbody>
218+<tr class="hunk">
219+<td colspan="6">@@ -0,0 +1,1 @@</td>
220+</tr>
221+<tr>
222+<td class="ln none">
223+</td>
224+<td class="m none">
225+</td>
226+<td class="tx none">
227+</td>
228+<td class="ln add">1</td>
229+<td class="m add">+</td>
230+<td class="tx add">bye</td>
231+</tr>
232+</tbody>
233+</table>
234+</div>
235+</details>
236+<details class="dfile" id="f3" data-file>
237+<summary>
238+<span class="st">A</span>
239+<span class="p">logo.bin</span>
240+<span class="plus">+0</span>
241+<span class="minus">-0</span>
242+<span class="note">binary file: no text diff</span>
243+</summary>
244+<p class="note pad">binary file: no text diff</p>
245+</details>
246+<details class="dfile" id="f4" data-file open>
247+<summary>
248+<span class="st">A</span>
249+<span class="p">old.txt</span>
250+<span class="plus">+3</span>
251+<span class="minus">-0</span>
252+</summary>
253+<div class="dscroll">
254+<table class="dt uni" aria-label="old.txt, unified">
255+<tbody>
256+<tr class="hunk">
257+<td colspan="4">@@ -0,0 +1,3 @@</td>
258+</tr>
259+<tr class="add">
260+<td class="ln">
261+</td>
262+<td class="ln">1</td>
263+<td class="m">+</td>
264+<td class="tx">keep me</td>
265+</tr>
266+<tr class="add">
267+<td class="ln">
268+</td>
269+<td class="ln">2</td>
270+<td class="m">+</td>
271+<td class="tx">keep me too</td>
272+</tr>
273+<tr class="add">
274+<td class="ln">
275+</td>
276+<td class="ln">3</td>
277+<td class="m">+</td>
278+<td class="tx">and me</td>
279+</tr>
280+</tbody>
281+</table>
282+<table class="dt spl" aria-label="old.txt, split">
283+<tbody>
284+<tr class="hunk">
285+<td colspan="6">@@ -0,0 +1,3 @@</td>
286+</tr>
287+<tr>
288+<td class="ln none">
289+</td>
290+<td class="m none">
291+</td>
292+<td class="tx none">
293+</td>
294+<td class="ln add">1</td>
295+<td class="m add">+</td>
296+<td class="tx add">keep me</td>
297+</tr>
298+<tr>
299+<td class="ln none">
300+</td>
301+<td class="m none">
302+</td>
303+<td class="tx none">
304+</td>
305+<td class="ln add">2</td>
306+<td class="m add">+</td>
307+<td class="tx add">keep me too</td>
308+</tr>
309+<tr>
310+<td class="ln none">
311+</td>
312+<td class="m none">
313+</td>
314+<td class="tx none">
315+</td>
316+<td class="ln add">3</td>
317+<td class="m add">+</td>
318+<td class="tx add">and me</td>
319+</tr>
320+</tbody>
321+</table>
322+</div>
323+</details>
324+<details class="dfile" id="f5" data-file open>
325+<summary>
326+<span class="st">A</span>
327+<span class="p">run.sh</span>
328+<span class="plus">+2</span>
329+<span class="minus">-0</span>
330+</summary>
331+<div class="dscroll">
332+<table class="dt uni" aria-label="run.sh, unified">
333+<tbody>
334+<tr class="hunk">
335+<td colspan="4">@@ -0,0 +1,2 @@</td>
336+</tr>
337+<tr class="add">
338+<td class="ln">
339+</td>
340+<td class="ln">1</td>
341+<td class="m">+</td>
342+<td class="tx">#!/bin/sh</td>
343+</tr>
344+<tr class="add">
345+<td class="ln">
346+</td>
347+<td class="ln">2</td>
348+<td class="m">+</td>
349+<td class="tx">echo hi</td>
350+</tr>
351+</tbody>
352+</table>
353+<table class="dt spl" aria-label="run.sh, split">
354+<tbody>
355+<tr class="hunk">
356+<td colspan="6">@@ -0,0 +1,2 @@</td>
357+</tr>
358+<tr>
359+<td class="ln none">
360+</td>
361+<td class="m none">
362+</td>
363+<td class="tx none">
364+</td>
365+<td class="ln add">1</td>
366+<td class="m add">+</td>
367+<td class="tx add">#!/bin/sh</td>
368+</tr>
369+<tr>
370+<td class="ln none">
371+</td>
372+<td class="m none">
373+</td>
374+<td class="tx none">
375+</td>
376+<td class="ln add">2</td>
377+<td class="m add">+</td>
378+<td class="tx add">echo hi</td>
379+</tr>
380+</tbody>
381+</table>
382+</div>
383+</details>
384+</div>
385+</main>
386+<aside class="rail" aria-label="Context">
387+<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
388+<summary>5 changed files</summary>
389+<section aria-labelledby="r-files">
390+<h2 id="r-files">files <span>5</span>
391+</h2>
392+<ol class="files" aria-label="Changed files">
393+<li>
394+<a href="#f1" :aria-current="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f1&#39; ? &#39;true&#39; : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
395+<span class="st" title="A">A</span>
396+<span class="p" title="a.txt">a.txt</span>
397+<span class="n">
398+<span class="plus">+5</span>
399+<span class="minus">-0</span>
400+</span>
401+<span class="b" aria-hidden="true">
402+<span class="plus">++++++++++</span>
403+<span class="minus">
404+</span>
405+</span>
406+</a>
407+</li>
408+<li>
409+<a href="#f2" :aria-current="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f2&#39; ? &#39;true&#39; : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
410+<span class="st" title="A">A</span>
411+<span class="p" title="gone.txt">gone.txt</span>
412+<span class="n">
413+<span class="plus">+1</span>
414+<span class="minus">-0</span>
415+</span>
416+<span class="b" aria-hidden="true">
417+<span class="plus">++</span>
418+<span class="minus">
419+</span>········</span>
420+</a>
421+</li>
422+<li>
423+<a href="#f3" :aria-current="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f3&#39; ? &#39;true&#39; : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
424+<span class="st" title="A">A</span>
425+<span class="p" title="logo.bin">logo.bin</span>
426+<span class="n">
427+<span class="plus">+0</span>
428+<span class="minus">-0</span>
429+</span>
430+<span class="b" aria-hidden="true">
431+<span class="plus">
432+</span>
433+<span class="minus">
434+</span>bin</span>
435+</a>
436+</li>
437+<li>
438+<a href="#f4" :aria-current="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f4&#39; ? &#39;true&#39; : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
439+<span class="st" title="A">A</span>
440+<span class="p" title="old.txt">old.txt</span>
441+<span class="n">
442+<span class="plus">+3</span>
443+<span class="minus">-0</span>
444+</span>
445+<span class="b" aria-hidden="true">
446+<span class="plus">++++++</span>
447+<span class="minus">
448+</span>····</span>
449+</a>
450+</li>
451+<li>
452+<a href="#f5" :aria-current="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f5&#39; ? &#39;true&#39; : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
453+<span class="st" title="A">A</span>
454+<span class="p" title="run.sh">run.sh</span>
455+<span class="n">
456+<span class="plus">+2</span>
457+<span class="minus">-0</span>
458+</span>
459+<span class="b" aria-hidden="true">
460+<span class="plus">++++</span>
461+<span class="minus">
462+</span>······</span>
463+</a>
464+</li>
465+</ol>
466+</section>
467+</details>
468+</aside>
469+</div>
470+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
471+<div class="panel-head">
472+<span>keys</span>
473+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
474+</div>
475+<dl class="keys">
476+<div>
477+<dt>
478+<kbd>j</kbd>
479+<kbd>k</kbd>
480+</dt>
481+<dd>move cursor down / up</dd>
482+</div>
483+<div>
484+<dt>
485+<kbd>enter</kbd>
486+</dt>
487+<dd>open row under cursor</dd>
488+</div>
489+<div>
490+<dt>
491+<kbd>g</kbd>
492+<kbd>g</kbd> / <kbd>G</kbd>
493+</dt>
494+<dd>first / last row</dd>
495+</div>
496+<div>
497+<dt>
498+<kbd>/</kbd>
499+</dt>
500+<dd>focus the filter</dd>
501+</div>
502+<div>
503+<dt>
504+<kbd>g h</kbd>
505+<kbd>g t</kbd>
506+</dt>
507+<dd>go home / tree</dd>
508+</div>
509+<div>
510+<dt>
511+<kbd>g c</kbd>
512+<kbd>g r</kbd>
513+</dt>
514+<dd>go commits / refs</dd>
515+</div>
516+<div>
517+<dt>
518+<kbd>b</kbd>
519+</dt>
520+<dd>switch ref (branches, tags)</dd>
521+</div>
522+<div>
523+<dt>
524+<kbd>:</kbd>
525+<kbd>f</kbd>
526+</dt>
527+<dd>go to: section, ref, file, repo</dd>
528+</div>
529+<div>
530+<dt>
531+<kbd>y</kbd>
532+</dt>
533+<dd>permalink: ref becomes the full sha</dd>
534+</div>
535+<div>
536+<dt>
537+<kbd>s</kbd>
538+<kbd>t</kbd>
539+<kbd>c</kbd>
540+<kbd>r</kbd>
541+</dt>
542+<dd>repo sections (when shown)</dd>
543+</div>
544+<div>
545+<dt>
546+<kbd>d</kbd>
547+<kbd>u</kbd>
548+<kbd>x</kbd>
549+</dt>
550+<dd>commit: split / unified / collapse all</dd>
551+</div>
552+<div>
553+<dt>
554+<kbd>esc</kbd>
555+</dt>
556+<dd>close panel</dd>
557+</div>
558+</dl>
559+</dialog>
560+<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
561+<div class="panel-head">
562+<label for="refs-filter">ref&gt;</label>
563+<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))">
564+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
565+</div>
566+<div class="panel-cols" data-picklist>
567+<section aria-label="Branches">
568+<h2>branches <span class="mute">2</span>
569+</h2>
570+<ul>
571+<li>
572+<a href="/diffs/tree/main" aria-current="true">
573+<span class="mark" aria-hidden="true">●</span>main</a>
574+</li>
575+<li>
576+<a href="/diffs/tree/side">
577+<span class="mark" aria-hidden="true">&nbsp;</span>side</a>
578+</li>
579+</ul>
580+</section>
581+<section aria-label="Tags">
582+<h2>tags <span class="mute">1</span>
583+</h2>
584+<ul>
585+<li>
586+<a href="/diffs/tree/v1.0.0">
587+<span class="mark" aria-hidden="true">&nbsp;</span>v1.0.0</a>
588+</li>
589+</ul>
590+</section>
591+</div>
592+</dialog>
593+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
594+<div class="panel-head">
595+<label for="cmd-q" class="hi">:</label>
596+<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();
597+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
598+ 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();
599+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
600+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
601+ 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' })) }
602+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
603+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
604+</div>
605+<ul id="cmd-list" class="cmd-list">
606+<li data-k="go home" data-cursor>
607+<a href="/" tabindex="-1">
608+<span class="k">go</span>home</a>
609+</li>
610+<li data-k="section summary">
611+<a href="/diffs" tabindex="-1">
612+<span class="k">section</span>summary</a>
613+</li>
614+<li data-k="section tree">
615+<a href="/diffs/tree/main" tabindex="-1">
616+<span class="k">section</span>tree</a>
617+</li>
618+<li data-k="section commits">
619+<a href="/diffs/commits/main" tabindex="-1">
620+<span class="k">section</span>commits</a>
621+</li>
622+<li data-k="section refs">
623+<a href="/diffs/refs" tabindex="-1">
624+<span class="k">section</span>refs</a>
625+</li>
626+<li data-k="branch main">
627+<a href="/diffs/tree/main" tabindex="-1">
628+<span class="k">branch</span>main</a>
629+</li>
630+<li data-k="branch side">
631+<a href="/diffs/tree/side" tabindex="-1">
632+<span class="k">branch</span>side</a>
633+</li>
634+<li data-k="tag v1.0.0">
635+<a href="/diffs/tree/v1.0.0" tabindex="-1">
636+<span class="k">tag</span>v1.0.0</a>
637+</li>
638+</ul>
639+</dialog>
640+<footer class="bar status" aria-label="Status">
641+<span class="mode">commit</span>
642+<span class="st-ctx">SHA7 · 5 files · +11 -0</span>
643+<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'))">
644+<span class="hi">:</span> go to…</button>
645+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
646+</span>
647+<span class="st-hints">d split · u unified · x collapse all · ? help</span>
648+<span class="st-src">
649+<span class="st-ver">ermite devel · </span>
650+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
651+</span>
652+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
653+</footer>
654+<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
655+ if (q('dialog[open]').count) return;
656+ let rows = q('[data-rows] > :not([hidden])').arr();
657+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
658+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
659+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
660+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
661+ })(1);
662+ k-k from:body -> ((d) => {
663+ if (q('dialog[open]').count) return;
664+ let rows = q('[data-rows] > :not([hidden])').arr();
665+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
666+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
667+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
668+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
669+ })(-1);
670+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
671+ 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));
672+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
673+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
674+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
675+ 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());
676+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
677+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
678+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
679+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
680+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
681+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
682+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
683+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
684+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
685+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
686+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
687+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
688+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
689+ k-permalink from:body -> {
690+ let b = document.body.dataset;
691+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
692+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
693+ navigator.clipboard?.writeText(location.href);
694+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
695+ }">
696+</div>
697+</body>
698+</html>
A cmd/ermite/testdata/web/commit-trojan.html +515 -0
@@ -0,0 +1,515 @@
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>SHA7 add evil.go · 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="" 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">commit</a>
31+</li>
32+<li>
33+<span aria-current="page">SHA7</span>
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+<header class="show">
59+<h1>add evil.go</h1>
60+<pre class="msg">looks &lt;U+202E&gt;fine&lt;U+202C&gt;</pre>
61+<dl class="kv show-kv">
62+<dt>commit</dt>
63+<dd>SHA40</dd>
64+<dt>author</dt>
65+<dd>T · <time datetime="STAMP" title="STAMP">AGO</time> · STAMP</dd>
66+<dt>parent</dt>
67+<dd>
68+<a href="/diffs/commit/SHA40">SHA7</a>
69+</dd>
70+</dl>
71+</header>
72+<div class="diff-tools" role="toolbar" aria-label="Diff view">
73+<span class="dim">1 file · <span class="plus">+8</span>
74+<span class="minus">-0</span>
75+</span>
76+<span class="grow">
77+</span>
78+<a href="/diffs/commit/SHA40?view=unified" class="vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;unified&#39; ? &#39;true&#39; : false">[u]nified</a>
79+<a href="/diffs/commit/SHA40?view=split" class="vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;split&#39; ? &#39;true&#39; : false">[d] split</a>
80+<button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))">[x] collapse all</button>
81+</div>
82+<p class="notice">this diff holds invisible characters (direction controls, zero-width characters). They are shown as <span class="inv">&lt;U+XXXX&gt;</span> markers, never applied.</p>
83+<div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
84+<details class="dfile" id="f1" data-file open>
85+<summary>
86+<span class="st">A</span>
87+<span class="p">evil.go</span>
88+<span class="plus">+8</span>
89+<span class="minus">-0</span>
90+</summary>
91+<div class="dscroll">
92+<table class="dt uni" aria-label="evil.go, unified">
93+<tbody>
94+<tr class="hunk">
95+<td colspan="4">@@ -0,0 +1,8 @@</td>
96+</tr>
97+<tr class="add">
98+<td class="ln">
99+</td>
100+<td class="ln">1</td>
101+<td class="m">+</td>
102+<td class="tx">package evil</td>
103+</tr>
104+<tr class="add">
105+<td class="ln">
106+</td>
107+<td class="ln">2</td>
108+<td class="m">+</td>
109+<td class="tx">
110+</td>
111+</tr>
112+<tr class="add">
113+<td class="ln">
114+</td>
115+<td class="ln">3</td>
116+<td class="m">+</td>
117+<td class="tx">func admin(user string) bool {</td>
118+</tr>
119+<tr class="add">
120+<td class="ln">
121+</td>
122+<td class="ln">4</td>
123+<td class="m">+</td>
124+<td class="tx"> if user == &#34;user<span class="inv" title="invisible character">&lt;U+202E&gt;</span>
125+<span class="inv" title="invisible character">&lt;U+2066&gt;</span>// check<span class="inv" title="invisible character">&lt;U+2069&gt;</span>
126+<span class="inv" title="invisible character">&lt;U+2066&gt;</span>&#34; {</td>
127+</tr>
128+<tr class="add">
129+<td class="ln">
130+</td>
131+<td class="ln">5</td>
132+<td class="m">+</td>
133+<td class="tx"> return true // z<span class="inv" title="invisible character">&lt;U+200B&gt;</span>w</td>
134+</tr>
135+<tr class="add">
136+<td class="ln">
137+</td>
138+<td class="ln">6</td>
139+<td class="m">+</td>
140+<td class="tx"> }</td>
141+</tr>
142+<tr class="add">
143+<td class="ln">
144+</td>
145+<td class="ln">7</td>
146+<td class="m">+</td>
147+<td class="tx"> return false</td>
148+</tr>
149+<tr class="add">
150+<td class="ln">
151+</td>
152+<td class="ln">8</td>
153+<td class="m">+</td>
154+<td class="tx">}</td>
155+</tr>
156+</tbody>
157+</table>
158+<table class="dt spl" aria-label="evil.go, split">
159+<tbody>
160+<tr class="hunk">
161+<td colspan="6">@@ -0,0 +1,8 @@</td>
162+</tr>
163+<tr>
164+<td class="ln none">
165+</td>
166+<td class="m none">
167+</td>
168+<td class="tx none">
169+</td>
170+<td class="ln add">1</td>
171+<td class="m add">+</td>
172+<td class="tx add">package evil</td>
173+</tr>
174+<tr>
175+<td class="ln none">
176+</td>
177+<td class="m none">
178+</td>
179+<td class="tx none">
180+</td>
181+<td class="ln add">2</td>
182+<td class="m add">+</td>
183+<td class="tx add">
184+</td>
185+</tr>
186+<tr>
187+<td class="ln none">
188+</td>
189+<td class="m none">
190+</td>
191+<td class="tx none">
192+</td>
193+<td class="ln add">3</td>
194+<td class="m add">+</td>
195+<td class="tx add">func admin(user string) bool {</td>
196+</tr>
197+<tr>
198+<td class="ln none">
199+</td>
200+<td class="m none">
201+</td>
202+<td class="tx none">
203+</td>
204+<td class="ln add">4</td>
205+<td class="m add">+</td>
206+<td class="tx add"> if user == &#34;user<span class="inv" title="invisible character">&lt;U+202E&gt;</span>
207+<span class="inv" title="invisible character">&lt;U+2066&gt;</span>// check<span class="inv" title="invisible character">&lt;U+2069&gt;</span>
208+<span class="inv" title="invisible character">&lt;U+2066&gt;</span>&#34; {</td>
209+</tr>
210+<tr>
211+<td class="ln none">
212+</td>
213+<td class="m none">
214+</td>
215+<td class="tx none">
216+</td>
217+<td class="ln add">5</td>
218+<td class="m add">+</td>
219+<td class="tx add"> return true // z<span class="inv" title="invisible character">&lt;U+200B&gt;</span>w</td>
220+</tr>
221+<tr>
222+<td class="ln none">
223+</td>
224+<td class="m none">
225+</td>
226+<td class="tx none">
227+</td>
228+<td class="ln add">6</td>
229+<td class="m add">+</td>
230+<td class="tx add"> }</td>
231+</tr>
232+<tr>
233+<td class="ln none">
234+</td>
235+<td class="m none">
236+</td>
237+<td class="tx none">
238+</td>
239+<td class="ln add">7</td>
240+<td class="m add">+</td>
241+<td class="tx add"> return false</td>
242+</tr>
243+<tr>
244+<td class="ln none">
245+</td>
246+<td class="m none">
247+</td>
248+<td class="tx none">
249+</td>
250+<td class="ln add">8</td>
251+<td class="m add">+</td>
252+<td class="tx add">}</td>
253+</tr>
254+</tbody>
255+</table>
256+</div>
257+</details>
258+</div>
259+</main>
260+<aside class="rail" aria-label="Context">
261+<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
262+<summary>1 changed file</summary>
263+<section aria-labelledby="r-files">
264+<h2 id="r-files">files <span>1</span>
265+</h2>
266+<ol class="files" aria-label="Changed files">
267+<li>
268+<a href="#f1" :aria-current="(() =&gt; { let all = q(&#39;.dfile&#39;).arr(); let cur = all.filter(f =&gt; f.getBoundingClientRect().top &lt; innerHeight / 3).at(-1) || all[0]; return cur?.id === &#39;f1&#39; ? &#39;true&#39; : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true">
269+<span class="st" title="A">A</span>
270+<span class="p" title="evil.go">evil.go</span>
271+<span class="n">
272+<span class="plus">+8</span>
273+<span class="minus">-0</span>
274+</span>
275+<span class="b" aria-hidden="true">
276+<span class="plus">++++++++++</span>
277+<span class="minus">
278+</span>
279+</span>
280+</a>
281+</li>
282+</ol>
283+</section>
284+</details>
285+</aside>
286+</div>
287+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
288+<div class="panel-head">
289+<span>keys</span>
290+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
291+</div>
292+<dl class="keys">
293+<div>
294+<dt>
295+<kbd>j</kbd>
296+<kbd>k</kbd>
297+</dt>
298+<dd>move cursor down / up</dd>
299+</div>
300+<div>
301+<dt>
302+<kbd>enter</kbd>
303+</dt>
304+<dd>open row under cursor</dd>
305+</div>
306+<div>
307+<dt>
308+<kbd>g</kbd>
309+<kbd>g</kbd> / <kbd>G</kbd>
310+</dt>
311+<dd>first / last row</dd>
312+</div>
313+<div>
314+<dt>
315+<kbd>/</kbd>
316+</dt>
317+<dd>focus the filter</dd>
318+</div>
319+<div>
320+<dt>
321+<kbd>g h</kbd>
322+<kbd>g t</kbd>
323+</dt>
324+<dd>go home / tree</dd>
325+</div>
326+<div>
327+<dt>
328+<kbd>g c</kbd>
329+<kbd>g r</kbd>
330+</dt>
331+<dd>go commits / refs</dd>
332+</div>
333+<div>
334+<dt>
335+<kbd>b</kbd>
336+</dt>
337+<dd>switch ref (branches, tags)</dd>
338+</div>
339+<div>
340+<dt>
341+<kbd>:</kbd>
342+<kbd>f</kbd>
343+</dt>
344+<dd>go to: section, ref, file, repo</dd>
345+</div>
346+<div>
347+<dt>
348+<kbd>y</kbd>
349+</dt>
350+<dd>permalink: ref becomes the full sha</dd>
351+</div>
352+<div>
353+<dt>
354+<kbd>s</kbd>
355+<kbd>t</kbd>
356+<kbd>c</kbd>
357+<kbd>r</kbd>
358+</dt>
359+<dd>repo sections (when shown)</dd>
360+</div>
361+<div>
362+<dt>
363+<kbd>d</kbd>
364+<kbd>u</kbd>
365+<kbd>x</kbd>
366+</dt>
367+<dd>commit: split / unified / collapse all</dd>
368+</div>
369+<div>
370+<dt>
371+<kbd>esc</kbd>
372+</dt>
373+<dd>close panel</dd>
374+</div>
375+</dl>
376+</dialog>
377+<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
378+<div class="panel-head">
379+<label for="refs-filter">ref&gt;</label>
380+<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))">
381+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
382+</div>
383+<div class="panel-cols" data-picklist>
384+<section aria-label="Branches">
385+<h2>branches <span class="mute">2</span>
386+</h2>
387+<ul>
388+<li>
389+<a href="/diffs/tree/main" aria-current="true">
390+<span class="mark" aria-hidden="true">●</span>main</a>
391+</li>
392+<li>
393+<a href="/diffs/tree/side">
394+<span class="mark" aria-hidden="true">&nbsp;</span>side</a>
395+</li>
396+</ul>
397+</section>
398+<section aria-label="Tags">
399+<h2>tags <span class="mute">1</span>
400+</h2>
401+<ul>
402+<li>
403+<a href="/diffs/tree/v1.0.0">
404+<span class="mark" aria-hidden="true">&nbsp;</span>v1.0.0</a>
405+</li>
406+</ul>
407+</section>
408+</div>
409+</dialog>
410+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
411+<div class="panel-head">
412+<label for="cmd-q" class="hi">:</label>
413+<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();
414+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
415+ 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();
416+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
417+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
418+ 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' })) }
419+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
420+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
421+</div>
422+<ul id="cmd-list" class="cmd-list">
423+<li data-k="go home" data-cursor>
424+<a href="/" tabindex="-1">
425+<span class="k">go</span>home</a>
426+</li>
427+<li data-k="section summary">
428+<a href="/diffs" tabindex="-1">
429+<span class="k">section</span>summary</a>
430+</li>
431+<li data-k="section tree">
432+<a href="/diffs/tree/main" tabindex="-1">
433+<span class="k">section</span>tree</a>
434+</li>
435+<li data-k="section commits">
436+<a href="/diffs/commits/main" tabindex="-1">
437+<span class="k">section</span>commits</a>
438+</li>
439+<li data-k="section refs">
440+<a href="/diffs/refs" tabindex="-1">
441+<span class="k">section</span>refs</a>
442+</li>
443+<li data-k="branch main">
444+<a href="/diffs/tree/main" tabindex="-1">
445+<span class="k">branch</span>main</a>
446+</li>
447+<li data-k="branch side">
448+<a href="/diffs/tree/side" tabindex="-1">
449+<span class="k">branch</span>side</a>
450+</li>
451+<li data-k="tag v1.0.0">
452+<a href="/diffs/tree/v1.0.0" tabindex="-1">
453+<span class="k">tag</span>v1.0.0</a>
454+</li>
455+</ul>
456+</dialog>
457+<footer class="bar status" aria-label="Status">
458+<span class="mode">commit</span>
459+<span class="st-ctx">SHA7 · 1 file · +8 -0</span>
460+<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'))">
461+<span class="hi">:</span> go to…</button>
462+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
463+</span>
464+<span class="st-hints">d split · u unified · x collapse all · ? help</span>
465+<span class="st-src">
466+<span class="st-ver">ermite devel · </span>
467+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
468+</span>
469+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
470+</footer>
471+<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
472+ if (q('dialog[open]').count) return;
473+ let rows = q('[data-rows] > :not([hidden])').arr();
474+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
475+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
476+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
477+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
478+ })(1);
479+ k-k from:body -> ((d) => {
480+ if (q('dialog[open]').count) return;
481+ let rows = q('[data-rows] > :not([hidden])').arr();
482+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
483+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
484+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
485+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
486+ })(-1);
487+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
488+ 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));
489+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
490+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
491+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
492+ 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());
493+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
494+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
495+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
496+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
497+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
498+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
499+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
500+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
501+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
502+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
503+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
504+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
505+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
506+ k-permalink from:body -> {
507+ let b = document.body.dataset;
508+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
509+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
510+ navigator.clipboard?.writeText(location.href);
511+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
512+ }">
513+</div>
514+</body>
515+</html>
A cmd/ermite/web_commit_test.go +157 -0
@@ -0,0 +1,157 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+// AC1: the commit pages render for the fixtures with the structure of the
16+// mockup (golden HTML, normalised).
17+func TestWebCommitGolden(t *testing.T) {
18+ h := newHarness(t, harnessOpts{})
19+ f := h.diffRepo("diffs")
20+
21+ for _, tt := range []struct{ name, path string }{
22+ {"commit-modify", "/diffs/commit/" + f.modify},
23+ {"commit-modify-split", "/diffs/commit/" + f.modify + "?view=split"},
24+ {"commit-root", "/diffs/commit/" + f.root},
25+ {"commit-rename", "/diffs/commit/" + f.rename},
26+ {"commit-binary", "/diffs/commit/" + f.binary},
27+ {"commit-merge", "/diffs/commit/" + f.merge},
28+ {"commit-trojan", "/diffs/commit/" + f.trojan},
29+ {"commit-big", "/diffs/commit/" + f.big},
30+ } {
31+ t.Run(tt.name, func(t *testing.T) {
32+ status, hdr, body := h.get(tt.path)
33+ require.Equal(t, 200, status)
34+ assert.Equal(t, "no-cache", hdr.Get("Cache-Control"))
35+ assertNoInline(t, body)
36+ golden(t, tt.name, normalise(body))
37+ })
38+ }
39+}
40+
41+// The behaviours of the commit pages, asserted on their own.
42+func TestWebCommit(t *testing.T) {
43+ h := newHarness(t, harnessOpts{})
44+ f := h.diffRepo("diffs")
45+ get := func(path string, status int) string {
46+ t.Helper()
47+ st, _, body := h.get(path)
48+ require.Equal(t, status, st, path)
49+ return body
50+ }
51+
52+ t.Run("commit", func(t *testing.T) {
53+ body := get("/diffs/commit/"+f.modify, 200)
54+ assert.Contains(t, body, "<h1>modify a file and make a script executable</h1>")
55+ assert.Contains(t, body, "The body has a second paragraph.")
56+ assert.Contains(t, body, "And &lt;b&gt;html&lt;/b&gt; &amp;", "the message is text")
57+ assert.NotContains(t, body, "<b>html</b>")
58+ assert.Contains(t, body, "mode 100644 → 100755")
59+ assert.Contains(t, body, `<td class="m">+</td>`, "added lines keep their marker")
60+ assert.Contains(t, body, `<td class="m">-</td>`, "deleted lines keep their marker")
61+ assert.Contains(t, body, `data-diff="unified"`)
62+ assert.Contains(t, body, `hx-live="this.dataset.diff = sessionStorage`, "the browser remembers the layout when the URL does not choose")
63+ assert.Contains(t, body, `<table class="dt uni"`)
64+ assert.Contains(t, body, `<table class="dt spl"`, "both layouts are sent, the toggle needs no request")
65+ assert.Contains(t, body, `href="/diffs/commit/`+f.modify+`?view=split"`, "the toggle works without scripts")
66+ // The abbreviated id opens the same commit.
67+ assert.Equal(t, normalise(body), normalise(get("/diffs/commit/"+f.modify[:12], 200)))
68+ })
69+
70+ t.Run("split without scripts", func(t *testing.T) {
71+ body := get("/diffs/commit/"+f.modify+"?view=split", 200)
72+ assert.Contains(t, body, `data-diff="split"`)
73+ assert.NotContains(t, body, `hx-live="this.dataset.diff`, "the URL decides, not the browser memory")
74+ assert.Contains(t, body, `class="ln del"`)
75+ assert.Contains(t, body, `class="ln add"`)
76+ })
77+
78+ t.Run("root commit", func(t *testing.T) {
79+ body := get("/diffs/commit/"+f.root, 200)
80+ assert.Contains(t, body, "none (root commit)")
81+ assert.NotContains(t, body, `class="m del"`)
82+ })
83+
84+ t.Run("rename delete binary", func(t *testing.T) {
85+ body := get("/diffs/commit/"+f.rename, 200)
86+ assert.Contains(t, body, "old.txt → docs-old.txt")
87+ assert.Contains(t, body, "renamed, 100% similar")
88+ assert.Contains(t, body, "gone.txt")
89+ bin := get("/diffs/commit/"+f.binary, 200)
90+ assert.Contains(t, bin, "binary file: no text diff")
91+ assert.NotContains(t, bin, `<table class="dt`)
92+ })
93+
94+ t.Run("merge", func(t *testing.T) {
95+ body := get("/diffs/commit/"+f.merge, 200)
96+ assert.Contains(t, body, "merge of")
97+ assert.Contains(t, body, "diff against the first parent")
98+ assert.Equal(t, 2, strings.Count(body, `href="/diffs/commit/`)-strings.Count(body, `href="/diffs/commit/`+f.merge), "two parent links")
99+ })
100+
101+ t.Run("trojan source", func(t *testing.T) {
102+ body := get("/diffs/commit/"+f.trojan, 200)
103+ for _, bad := range []string{"\u202e", "\u2066", "\u2069", "\u200b", "\u202c"} {
104+ assert.NotContains(t, body, bad, "no invisible character reaches the page: %U", []rune(bad))
105+ }
106+ for _, mark := range []string{"&lt;U+202E&gt;", "&lt;U+2066&gt;", "&lt;U+2069&gt;", "&lt;U+200B&gt;", "&lt;U+202C&gt;"} {
107+ assert.Contains(t, body, mark)
108+ }
109+ assert.Contains(t, body, "holds invisible characters")
110+ })
111+
112+ t.Run("truncated", func(t *testing.T) {
113+ body := get("/diffs/commit/"+f.big, 200)
114+ assert.Contains(t, body, "diff too large to show (2600 lines changed): collapsed")
115+ assert.Contains(t, body, "this diff is incomplete")
116+ assert.NotRegexp(t, `<details class="dfile" id="f1"[^>]* open`, body, "a truncated file is collapsed")
117+ assert.NotContains(t, body, "line 2000", "no lines of a truncated file")
118+ })
119+
120+ t.Run("not found", func(t *testing.T) {
121+ _, _, missing := h.get("/diffs/commit/0123456789abcdef0123456789abcdef01234567")
122+ for _, p := range []string{
123+ "/diffs/commit/0123456789abcdef0123456789abcdef01234567", // no such commit
124+ "/diffs/commit/main", "/diffs/commit/abc", "/diffs/commit/ABCDEF0", "/diffs/commit/v1.0.0", // not a commit id
125+ "/nope/commit/" + f.root, "/diffs/commit/" + f.root + "/x",
126+ } {
127+ st, _, body := h.get(p)
128+ assert.Equal(t, 404, st, p)
129+ assert.NotContains(t, body, "<h1>root", p)
130+ }
131+ st, _, body := h.get("/diffs/commit/0123456789abcdef0123456789abcdef01234567")
132+ assert.Equal(t, 404, st)
133+ assert.Equal(t, normalise(missing), normalise(body))
134+ })
135+}
136+
137+// AC2: the commit pages of a private repository are the 404 page of a
138+// repository that does not exist.
139+func TestWebCommitPrivate(t *testing.T) {
140+ h := newHarness(t, harnessOpts{})
141+ wt := h.workTree("main", "secret.txt")
142+ h.createRepo("hidden-one", domain.VisibilityPrivate)
143+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/hidden-one.git"), "main")
144+ sha := strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD"))
145+
146+ _, _, missing := h.get("/never-existed")
147+ for _, p := range []string{
148+ "/hidden-one/commit/" + sha, "/hidden-one/commit/" + sha + "?view=split",
149+ } {
150+ st, hdr, body := h.get(p)
151+ assert.Equal(t, 404, st, p)
152+ assert.Equal(t, "no-cache", hdr.Get("Cache-Control"), p)
153+ assert.NotContains(t, body, "hidden-one", p)
154+ assert.NotContains(t, body, "secret.txt", p)
155+ assert.Equal(t, normalise(missing), normalise(body), p)
156+ }
157+}
A internal/adapters/web/commit.go +188 -0
@@ -0,0 +1,188 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "net/http"
8+ "slices"
9+ "strings"
10+ "time"
11+
12+ "connectrpc.com/connect"
13+ "github.com/a-h/templ"
14+
15+ pb "bnema.dev/ermite-api/gen/ermite"
16+)
17+
18+// bothViewsLines is the largest diff, in lines, that is sent in both the
19+// unified and the split layout so that the toggle needs no round trip. A
20+// larger one is sent in the layout asked for only, and the toggle is a link.
21+const bothViewsLines = 4000
22+
23+// Person is an author or a committer with the date of the action.
24+type Person struct {
25+ Name, When, WhenAt, Abs string
26+}
27+
28+// CommitPage is a commit with its diff.
29+type CommitPage struct {
30+ SHA, Short, Subject, Body string
31+ Author Person
32+ // Committer is nil when it is the author, at the same time.
33+ Committer *Person
34+ Parents []Link
35+ Merge bool
36+ Root bool
37+ Refs []string
38+ // NFiles, Adds and Dels are the totals of the commit, including the files
39+ // that the diff omits.
40+ NFiles, Adds, Dels int
41+ Files []DiffFile
42+ // Note says the diff is incomplete.
43+ Note string
44+ // View is the layout shown: "unified" or "split". Pinned is true when the
45+ // URL chose it, which then wins over the choice remembered by the browser.
46+ View string
47+ Pinned bool
48+ // Both is true when both layouts are in the page, and the toggle works in
49+ // the browser; otherwise only View is sent.
50+ Both bool
51+ ShowUnified, ShowSplit bool
52+ UnifiedHref, SplitHref string
53+ // Invisible is true when some line holds an invisible character, shown as a
54+ // marker.
55+ Invisible bool
56+}
57+
58+// commit shows a commit: its message, its totals, the changed files and the
59+// diff against the first parent. ?view=split selects the split layout.
60+func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component, int, error) {
61+ sha := r.PathValue("sha")
62+ if !isCommitID(sha) {
63+ return nil, 0, errNotFound
64+ }
65+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
66+ if err != nil {
67+ return nil, 0, err
68+ }
69+ res, err := s.cfg.Git.GetCommit(ctx, connect.NewRequest(&pb.GetCommitRequest{Repo: d.name(), Revision: sha}))
70+ if err != nil {
71+ return nil, 0, err
72+ }
73+ c := res.Msg.GetCommit()
74+ if c == nil {
75+ return nil, 0, errNotFound
76+ }
77+ stats := res.Msg.GetStats()
78+ now := s.cfg.Now()
79+
80+ p := CommitPage{
81+ SHA: c.GetId(), Short: short(c.GetId()),
82+ Subject: visibleText(c.GetSummary()), Body: commitBody(c),
83+ Author: person(c.GetAuthor(), now),
84+ Merge: len(c.GetParentIds()) > 1,
85+ Root: len(c.GetParentIds()) == 0,
86+ NFiles: int(stats.GetFilesChanged()),
87+ Adds: int(stats.GetAdditions()),
88+ Dels: int(stats.GetDeletions()),
89+ Files: diffFiles(res.Msg.GetDiff().GetFiles()),
90+ View: "unified",
91+ }
92+ if cm := person(c.GetCommitter(), now); cm != p.Author || c.GetCommitter().GetEmail() != c.GetAuthor().GetEmail() {
93+ p.Committer = &cm
94+ }
95+ for _, id := range c.GetParentIds() {
96+ p.Parents = append(p.Parents, Link{Label: short(id), Href: repoHref(d.name(), "commit", id)})
97+ }
98+ for _, b := range d.branches {
99+ if b.GetCommitId() == c.GetId() {
100+ p.Refs = append(p.Refs, b.GetShortName())
101+ }
102+ }
103+ for _, t := range d.tags {
104+ if t.GetCommitId() == c.GetId() {
105+ p.Refs = append(p.Refs, "tag: "+t.GetShortName())
106+ }
107+ }
108+ p.Note = diffNote(res.Msg.GetDiff(), len(p.Files), p.NFiles)
109+
110+ lines := 0
111+ for _, f := range p.Files {
112+ for _, h := range f.Hunks {
113+ lines += len(h.Lines)
114+ p.Invisible = p.Invisible || slices.ContainsFunc(h.Lines, hasMarker)
115+ }
116+ }
117+ p.Both = lines <= bothViewsLines
118+ p.ShowUnified, p.ShowSplit = p.Both || p.View == "unified", p.Both || p.View == "split"
119+ switch r.URL.Query().Get("view") {
120+ case "split":
121+ p.View, p.Pinned = "split", true
122+ case "unified":
123+ p.Pinned = true
124+ }
125+ base := repoHref(d.name(), "commit", c.GetId())
126+ p.UnifiedHref, p.SplitHref = base+"?view=unified", base+"?view=split"
127+
128+ l := s.repoLayout(ctx, d, "commits")
129+ l.Title = p.Short + " " + p.Subject + " · " + d.name() + " · " + s.instance
130+ l.SHA, l.RefPath = c.GetId(), ""
131+ history := repoHref(d.name())
132+ if d.head != nil {
133+ history = repoHref(d.name(), "commits", d.head.GetShortName())
134+ }
135+ l.Crumbs = []Crumb{
136+ {Label: d.name(), Href: repoHref(d.name())},
137+ {Label: "commit", Href: history},
138+ {Label: p.Short, Current: true},
139+ }
140+ l.Status = Status{
141+ Mode: "commit", Context: p.Short + " · " + p.totals(),
142+ Hints: "d split · u unified · x collapse all · ? help",
143+ }
144+ l.HasRail, l.RailSum = true, plural(len(p.Files), "changed file", "changed files")
145+ return Commit(l, p), http.StatusOK, nil
146+}
147+
148+// totals is "3 files · +12 -4".
149+func (p CommitPage) totals() string {
150+ return plural(p.NFiles, "file", "files") + " · +" + itoa(p.Adds) + " -" + itoa(p.Dels)
151+}
152+
153+// person converts a signature.
154+func person(s *pb.Signature, now time.Time) Person {
155+ p := Person{Name: visibleText(s.GetName())}
156+ if t := s.GetTime(); t.IsValid() {
157+ p.When, p.WhenAt, p.Abs = ago(now, t.AsTime()), rfc3339(t.AsTime()), absolute(t.AsTime())
158+ }
159+ return p
160+}
161+
162+// commitBody is the message without its first line, for display.
163+func commitBody(c *pb.Commit) string {
164+ msg := strings.ReplaceAll(c.GetMessage(), "\r\n", "\n")
165+ _, body, _ := strings.Cut(msg, "\n")
166+ return messageText(strings.TrimSpace(body))
167+}
168+
169+// diffNote says what is missing from an incomplete diff, "" for a complete one.
170+func diffNote(d *pb.Diff, shown, total int) string {
171+ if !d.GetTruncated() {
172+ return ""
173+ }
174+ var parts []string
175+ if shown < total {
176+ parts = append(parts, plural(total-shown, "file is", "files are")+" not listed")
177+ }
178+ cut := 0
179+ for _, f := range d.GetFiles() {
180+ if f.GetTruncated() {
181+ cut++
182+ }
183+ }
184+ if cut > 0 {
185+ parts = append(parts, plural(cut, "file is", "files are")+" too large to show")
186+ }
187+ return "this diff is incomplete: " + strings.Join(parts, ", ") + ". the totals above count everything; clone the repository to read the rest."
188+}
A internal/adapters/web/diff.go +288 -0
@@ -0,0 +1,288 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "fmt"
8+ "html"
9+ "io"
10+ "slices"
11+ "strings"
12+ "unicode/utf8"
13+
14+ "github.com/a-h/templ"
15+
16+ pb "bnema.dev/ermite-api/gen/ermite"
17+)
18+
19+// Seg is a piece of a line of code: plain text, or the visible marker of an
20+// invisible character (Trojan Source, ADR 0017).
21+type Seg struct {
22+ Text string
23+ Mark bool
24+}
25+
26+// DiffLine is one line of a hunk, in the unified view and, paired, in the
27+// split view.
28+type DiffLine struct {
29+ Kind string // "ctx", "add", "del"
30+ Mark string // " ", "+", "-": every change keeps its marker, not only a colour
31+ Old, New string // line numbers, "" when the side has none
32+ Segs []Seg
33+ NoEOL bool // the side ends without a line break
34+}
35+
36+// SplitRow pairs a deleted line (left) with an added one (right); a context
37+// line fills both sides.
38+type SplitRow struct{ L, R *DiffLine }
39+
40+// DiffHunk is a changed region.
41+type DiffHunk struct {
42+ Header string
43+ Lines []DiffLine
44+ Split []SplitRow
45+}
46+
47+// Bar is the change bar of the file list, "+++--·····", in three runs so each
48+// can carry its colour.
49+type Bar struct{ Add, Del, Rest string }
50+
51+// DiffFile is one changed file.
52+type DiffFile struct {
53+ ID string
54+ Status string // A D M R C T
55+ Path string // display path, "old → new" for a rename
56+ Add, Del int
57+ Bar Bar
58+ // Note is why there is no text to show, or what else changed: a binary
59+ // file, a mode change, a truncation, a similarity.
60+ Note string
61+ Open bool
62+ Hunks []DiffHunk
63+}
64+
65+func lineNumber(n int32) string {
66+ if n <= 0 {
67+ return ""
68+ }
69+ return itoa(int(n))
70+}
71+
72+// segments splits a line of code into plain text and markers. One trailing
73+// carriage return, the terminator of a CRLF file, is dropped.
74+func segments(text string) []Seg {
75+ text = strings.TrimSuffix(text, "\r")
76+ if !strings.ContainsFunc(text, invisible) {
77+ return []Seg{{Text: text}}
78+ }
79+ var out []Seg
80+ start := 0
81+ for i, r := range text {
82+ if !invisible(r) {
83+ continue
84+ }
85+ if i > start {
86+ out = append(out, Seg{Text: text[start:i]})
87+ }
88+ out = append(out, Seg{Text: marker(r), Mark: true})
89+ start = i + utf8.RuneLen(r)
90+ }
91+ if start < len(text) {
92+ out = append(out, Seg{Text: text[start:]})
93+ }
94+ return out
95+}
96+
97+// messageText is a commit message for display: line breaks normalised, the
98+// invisible characters shown.
99+func messageText(s string) string {
100+ s = strings.ReplaceAll(s, "\r\n", "\n")
101+ return visibleText(strings.TrimRight(s, "\n"))
102+}
103+
104+// hasMarker reports whether a line shows a marker of an invisible character.
105+func hasMarker(l DiffLine) bool {
106+ return slices.ContainsFunc(l.Segs, func(s Seg) bool { return s.Mark })
107+}
108+
109+// pairLines builds the split view: a run of deletions lines up row by row
110+// with the run of additions that follows it; context fills both sides.
111+func pairLines(ls []DiffLine) []SplitRow {
112+ var out []SplitRow
113+ for i := 0; i < len(ls); {
114+ if ls[i].Kind == "ctx" {
115+ out = append(out, SplitRow{&ls[i], &ls[i]})
116+ i++
117+ continue
118+ }
119+ var dels, adds []*DiffLine
120+ for ; i < len(ls) && ls[i].Kind == "del"; i++ {
121+ dels = append(dels, &ls[i])
122+ }
123+ for ; i < len(ls) && ls[i].Kind == "add"; i++ {
124+ adds = append(adds, &ls[i])
125+ }
126+ for j := 0; j < len(dels) || j < len(adds); j++ {
127+ var row SplitRow
128+ if j < len(dels) {
129+ row.L = dels[j]
130+ }
131+ if j < len(adds) {
132+ row.R = adds[j]
133+ }
134+ out = append(out, row)
135+ }
136+ }
137+ return out
138+}
139+
140+// changeBar draws the change bar of a file, scaled to the largest file.
141+func changeBar(add, del, most int) Bar {
142+ const cells = 10
143+ if most == 0 {
144+ return Bar{Rest: strings.Repeat("·", cells)}
145+ }
146+ scale := func(n int) int {
147+ c := (n*cells + most - 1) / most
148+ if n > 0 && c == 0 {
149+ c = 1
150+ }
151+ return c
152+ }
153+ a, d := scale(add), scale(del)
154+ if a+d > cells {
155+ d = cells - a
156+ }
157+ return Bar{strings.Repeat("+", a), strings.Repeat("-", d), strings.Repeat("·", cells-a-d)}
158+}
159+
160+// statusLetter is the one-letter status of a file.
161+func statusLetter(s pb.FileStatus) string {
162+ switch s {
163+ case pb.FileStatus_FILE_STATUS_ADDED:
164+ return "A"
165+ case pb.FileStatus_FILE_STATUS_DELETED:
166+ return "D"
167+ case pb.FileStatus_FILE_STATUS_RENAMED:
168+ return "R"
169+ case pb.FileStatus_FILE_STATUS_COPIED:
170+ return "C"
171+ case pb.FileStatus_FILE_STATUS_TYPE_CHANGED:
172+ return "T"
173+ }
174+ return "M"
175+}
176+
177+// hunkHeader is the "@@ -a,b +c,d @@ context" line of a hunk.
178+func hunkHeader(h *pb.Hunk) string {
179+ s := fmt.Sprintf("@@ -%d,%d +%d,%d @@", h.GetOldStart(), h.GetOldLines(), h.GetNewStart(), h.GetNewLines())
180+ if t := h.GetHeader(); t != "" {
181+ s += " " + visibleText(strings.TrimSpace(t))
182+ }
183+ return s
184+}
185+
186+// diffLine converts a line of the API.
187+func diffLine(l *pb.Line) DiffLine {
188+ dl := DiffLine{Segs: segments(l.GetText()), NoEOL: l.GetNoNewlineAtEof()}
189+ switch l.GetKind() {
190+ case pb.LineKind_LINE_KIND_ADDITION:
191+ dl.Kind, dl.Mark, dl.New = "add", "+", lineNumber(l.GetNewNumber())
192+ case pb.LineKind_LINE_KIND_DELETION:
193+ dl.Kind, dl.Mark, dl.Old = "del", "-", lineNumber(l.GetOldNumber())
194+ default:
195+ dl.Kind, dl.Mark = "ctx", " "
196+ dl.Old, dl.New = lineNumber(l.GetOldNumber()), lineNumber(l.GetNewNumber())
197+ }
198+ return dl
199+}
200+
201+// diffFiles converts the files of a diff for the page.
202+func diffFiles(files []*pb.FileDiff) []DiffFile {
203+ most := 0
204+ for _, f := range files {
205+ most = max(most, int(f.GetAdditions()+f.GetDeletions()))
206+ }
207+ out := make([]DiffFile, 0, len(files))
208+ for i, f := range files {
209+ df := DiffFile{
210+ ID: "f" + itoa(i+1), Status: statusLetter(f.GetStatus()),
211+ Add: int(f.GetAdditions()), Del: int(f.GetDeletions()),
212+ Bar: changeBar(int(f.GetAdditions()), int(f.GetDeletions()), most),
213+ // A file that is shown in full opens; a file with nothing to read
214+ // (binary, truncated) stays closed under its note.
215+ Open: len(f.GetHunks()) > 0 && !f.GetTruncated(),
216+ }
217+ switch f.GetStatus() {
218+ case pb.FileStatus_FILE_STATUS_DELETED:
219+ df.Path = visibleText(f.GetOldPath())
220+ case pb.FileStatus_FILE_STATUS_RENAMED, pb.FileStatus_FILE_STATUS_COPIED:
221+ df.Path = visibleText(f.GetOldPath()) + " → " + visibleText(f.GetNewPath())
222+ default:
223+ df.Path = visibleText(f.GetNewPath())
224+ }
225+ df.Note = fileNote(f)
226+ if f.GetBinary() {
227+ df.Bar = Bar{Rest: "bin"}
228+ }
229+ for _, h := range f.GetHunks() {
230+ dh := DiffHunk{Header: hunkHeader(h)}
231+ for _, l := range h.GetLines() {
232+ dh.Lines = append(dh.Lines, diffLine(l))
233+ }
234+ dh.Split = pairLines(dh.Lines)
235+ df.Hunks = append(df.Hunks, dh)
236+ }
237+ out = append(out, df)
238+ }
239+ return out
240+}
241+
242+// fileNote says what the hunks cannot: the kind of change when there is no
243+// text to show, the mode change, the similarity of a rename.
244+func fileNote(f *pb.FileDiff) string {
245+ var parts []string
246+ switch {
247+ case f.GetBinary():
248+ parts = append(parts, "binary file: no text diff")
249+ case f.GetTruncated():
250+ parts = append(parts, fmt.Sprintf("diff too large to show (%d lines changed): collapsed", f.GetAdditions()+f.GetDeletions()))
251+ }
252+ if f.GetOldMode() != 0 && f.GetNewMode() != 0 && f.GetOldMode() != f.GetNewMode() {
253+ parts = append(parts, fmt.Sprintf("mode %06o → %06o", f.GetOldMode(), f.GetNewMode()))
254+ }
255+ switch f.GetStatus() {
256+ case pb.FileStatus_FILE_STATUS_RENAMED:
257+ parts = append(parts, fmt.Sprintf("renamed, %d%% similar", f.GetSimilarity()))
258+ case pb.FileStatus_FILE_STATUS_COPIED:
259+ parts = append(parts, fmt.Sprintf("copied, %d%% similar", f.GetSimilarity()))
260+ case pb.FileStatus_FILE_STATUS_TYPE_CHANGED:
261+ parts = append(parts, "type changed")
262+ }
263+ return strings.Join(parts, " · ")
264+}
265+
266+// noEOL marks a side that ends without a line break.
267+const noEOL = "\u23ce\u0338"
268+
269+// codeLine renders the text of a line of code. It writes exactly the text,
270+// escaped, and the markers: code cells keep their white space (pre), so the
271+// spaces a template adds between expressions would show up in the code.
272+func codeLine(ln DiffLine) templ.Component {
273+ return templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
274+ var b strings.Builder
275+ for _, s := range ln.Segs {
276+ if s.Mark {
277+ b.WriteString(`<span class="inv" title="invisible character">` + html.EscapeString(s.Text) + `</span>`)
278+ } else {
279+ b.WriteString(html.EscapeString(s.Text))
280+ }
281+ }
282+ if ln.NoEOL {
283+ b.WriteString(`<span class="eol" title="no newline at end of file">` + noEOL + `</span>`)
284+ }
285+ _, err := io.WriteString(w, b.String())
286+ return err
287+ })
288+}
A internal/adapters/web/diff.templ +234 -0
@@ -0,0 +1,234 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+// Commit is a commit: the message and facts, the diff tools, the diff.
6+templ Commit(l Layout, p CommitPage) {
7+ @Shell(l, commitRail(p)) {
8+ <header class="show">
9+ <h1>{ p.Subject }</h1>
10+ if p.Body != "" {
11+ <pre class="msg">{ p.Body }</pre>
12+ }
13+ <dl class="kv show-kv">
14+ <dt>commit</dt>
15+ <dd>{ p.SHA }</dd>
16+ <dt>author</dt>
17+ <dd>@personView(p.Author)</dd>
18+ if p.Committer != nil {
19+ <dt>committer</dt>
20+ <dd>@personView(*p.Committer)</dd>
21+ }
22+ <dt>
23+ if p.Merge {
24+ merge of
25+ } else {
26+ parent
27+ }
28+ </dt>
29+ <dd>
30+ if p.Root {
31+ none (root commit)
32+ }
33+ for i, par := range p.Parents {
34+ if i > 0 {
35+ { " + " }
36+ }
37+ <a href={ templ.SafeURL(par.Href) }>{ par.Label }</a>
38+ }
39+ if p.Merge {
40+ { " · diff against the first parent" }
41+ }
42+ </dd>
43+ if len(p.Refs) > 0 {
44+ <dt>refs</dt>
45+ <dd>
46+ for _, r := range p.Refs {
47+ [{ r }]{ " " }
48+ }
49+ </dd>
50+ }
51+ </dl>
52+ </header>
53+ <div class="diff-tools" role="toolbar" aria-label="Diff view">
54+ <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
55+ <span class="grow"></span>
56+ @viewLink(p, "unified", "[u]nified", "u", p.UnifiedHref)
57+ @viewLink(p, "split", "[d] split", "d", p.SplitHref)
58+ <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))">[x] collapse all</button>
59+ </div>
60+ if p.Invisible {
61+ <p class="notice">
62+ this diff holds invisible characters (direction controls, zero-width characters). They are shown as
63+ <span class="inv">&lt;U+XXXX&gt;</span> markers, never applied.
64+ </p>
65+ }
66+ if p.Note != "" {
67+ <p class="notice">{ p.Note }</p>
68+ }
69+ <div
70+ id="diffs"
71+ if p.Both {
72+ data-diff={ p.View }
73+ } else {
74+ data-view={ p.View }
75+ }
76+ if p.Both && !p.Pinned {
77+ hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"
78+ }
79+ >
80+ for _, f := range p.Files {
81+ @DiffFileView(f, p)
82+ }
83+ if len(p.Files) == 0 {
84+ <p class="note">no changed files{ " " }
85+ if p.Merge {
86+ against the first parent.
87+ } else {
88+ in this commit.
89+ }
90+ </p>
91+ }
92+ </div>
93+ }
94+}
95+
96+// viewLink is a button of the diff tools. It is a link, so that the split
97+// layout works without JavaScript; with it, and when both layouts are in the
98+// page, a click only flips the layout.
99+templ viewLink(p CommitPage, view, label, key, href string) {
100+ <a
101+ href={ templ.SafeURL(href) }
102+ class="vbtn"
103+ data-view={ view }
104+ aria-keyshortcuts={ key }
105+ if p.View == view {
106+ aria-current="true"
107+ }
108+ if p.Both {
109+ hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))"
110+ :aria-current={ "q('#diffs').attr['data-diff'] === '" + view + "' ? 'true' : false" }
111+ }
112+ >{ label }</a>
113+}
114+
115+templ personView(pr Person) {
116+ { pr.Name }
117+ if pr.WhenAt != "" {
118+ { " · " }<time datetime={ pr.WhenAt } title={ pr.Abs }>{ pr.When }</time>{ " · " + pr.Abs }
119+ }
120+}
121+
122+templ commitRail(p CommitPage) {
123+ <section aria-labelledby="r-files">
124+ <h2 id="r-files">files <span>{ itoa(len(p.Files)) }</span></h2>
125+ @FileList(p.Files)
126+ </section>
127+}
128+
129+// FileList is the table of contents of a diff: status letter, path, +n -m
130+// and an ASCII bar. The row of the file in view is marked.
131+templ FileList(files []DiffFile) {
132+ <ol class="files" aria-label="Changed files">
133+ for _, f := range files {
134+ <li>
135+ <a
136+ href={ templ.SafeURL("#" + f.ID) }
137+ :aria-current={ "(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === '" + f.ID + "' ? 'true' : false })()" }
138+ hx-on:click="document.getElementById(this.hash.slice(1)).open = true"
139+ >
140+ <span class="st" title={ f.Status }>{ f.Status }</span>
141+ <span class="p" title={ f.Path }>{ f.Path }</span>
142+ <span class="n"><span class="plus">+{ itoa(f.Add) }</span> <span class="minus">-{ itoa(f.Del) }</span></span>
143+ <span class="b" aria-hidden="true"><span class="plus">{ f.Bar.Add }</span><span class="minus">{ f.Bar.Del }</span>{ f.Bar.Rest }</span>
144+ </a>
145+ </li>
146+ }
147+ </ol>
148+}
149+
150+// DiffFileView is one file of the diff: a <details> (per-file collapse). The
151+// unified and the split tables are both in it; #diffs[data-diff] shows one.
152+// Every changed line keeps its +/- marker, so a change is never conveyed by
153+// colour alone.
154+templ DiffFileView(f DiffFile, p CommitPage) {
155+ <details
156+ class="dfile"
157+ id={ f.ID }
158+ data-file
159+ if f.Open {
160+ open
161+ }
162+ >
163+ <summary>
164+ <span class="st">{ f.Status }</span>{ " " }<span class="p">{ f.Path }</span>{ " " }<span class="plus">+{ itoa(f.Add) }</span>{ " " }<span class="minus">-{ itoa(f.Del) }</span>
165+ if f.Note != "" {
166+ { " " }<span class="note">{ f.Note }</span>
167+ }
168+ </summary>
169+ if len(f.Hunks) > 0 {
170+ <div class="dscroll">
171+ if p.ShowUnified {
172+ <table class="dt uni" aria-label={ f.Path + ", unified" }>
173+ for _, h := range f.Hunks {
174+ <tbody>
175+ <tr class="hunk"><td colspan="4">{ h.Header }</td></tr>
176+ for _, ln := range h.Lines {
177+ <tr class={ ln.Kind }>
178+ <td class="ln">{ ln.Old }</td>
179+ <td class="ln">{ ln.New }</td>
180+ <td class="m">{ ln.Mark }</td>
181+ <td class="tx">@codeLine(ln)</td>
182+ </tr>
183+ }
184+ </tbody>
185+ }
186+ </table>
187+ }
188+ if p.ShowSplit {
189+ <table class="dt spl" aria-label={ f.Path + ", split" }>
190+ for _, h := range f.Hunks {
191+ <tbody>
192+ <tr class="hunk"><td colspan="6">{ h.Header }</td></tr>
193+ for _, row := range h.Split {
194+ <tr>
195+ @splitSide(row.L, true)
196+ @splitSide(row.R, false)
197+ </tr>
198+ }
199+ </tbody>
200+ }
201+ </table>
202+ }
203+ </div>
204+ } else {
205+ <p class="note pad">
206+ if f.Note != "" {
207+ { f.Note }
208+ } else {
209+ no textual change.
210+ }
211+ </p>
212+ }
213+ </details>
214+}
215+
216+// splitSide is one half of a row of the split view; a side without a line is
217+// an empty cell.
218+templ splitSide(ln *DiffLine, old bool) {
219+ if ln == nil {
220+ <td class="ln none"></td>
221+ <td class="m none"></td>
222+ <td class="tx none"></td>
223+ } else {
224+ <td class={ "ln", ln.Kind }>
225+ if old {
226+ { ln.Old }
227+ } else {
228+ { ln.New }
229+ }
230+ </td>
231+ <td class={ "m", ln.Kind }>{ ln.Mark }</td>
232+ <td class={ "tx", ln.Kind }>@codeLine(*ln)</td>
233+ }
234+}
A internal/adapters/web/diff_templ.go +1425 -0 generated file: collapsed
A internal/adapters/web/diff_test.go +125 -0
@@ -0,0 +1,125 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "strings"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ pb "bnema.dev/ermite-api/gen/ermite"
14+)
15+
16+// Trojan Source (ADR 0017): direction controls, zero-width and control
17+// characters are shown as markers; ordinary text, tabs and non-Latin letters
18+// are not touched.
19+func TestSegmentsShowInvisibleCharacters(t *testing.T) {
20+ tests := []struct {
21+ name, in string
22+ want []Seg
23+ }{
24+ {"plain", "return x", []Seg{{Text: "return x"}}},
25+ {"tab and unicode", "\tπ := “é”", []Seg{{Text: "\tπ := “é”"}}},
26+ {"empty", "", []Seg{{Text: ""}}},
27+ {"crlf", "a\r", []Seg{{Text: "a"}}},
28+ {"rlo", "if x { // \u202eadmin", []Seg{{Text: "if x { // "}, {Text: "<U+202E>", Mark: true}, {Text: "admin"}}},
29+ {"isolates", "\u2066a\u2069", []Seg{{Text: "<U+2066>", Mark: true}, {Text: "a"}, {Text: "<U+2069>", Mark: true}}},
30+ {"zero width", "a\u200bb\ufeff", []Seg{{Text: "a"}, {Text: "<U+200B>", Mark: true}, {Text: "b"}, {Text: "<U+FEFF>", Mark: true}}},
31+ {"control", "a\x00\x1bb", []Seg{{Text: "a"}, {Text: "<U+0000>", Mark: true}, {Text: "<U+001B>", Mark: true}, {Text: "b"}}},
32+ {"tag character", "a\U000E0041", []Seg{{Text: "a"}, {Text: "<U+E0041>", Mark: true}}},
33+ {"hangul filler", "\u3164", []Seg{{Text: "<U+3164>", Mark: true}}},
34+ {"replacement is visible already", "a\ufffdb", []Seg{{Text: "a\ufffdb"}}},
35+ }
36+ for _, tt := range tests {
37+ t.Run(tt.name, func(t *testing.T) {
38+ assert.Equal(t, tt.want, segments(tt.in))
39+ })
40+ }
41+ assert.Equal(t, "a<U+202E>b.go", visibleText("a\u202eb.go"))
42+ assert.Equal(t, "plain", visibleText("plain"))
43+ assert.Equal(t, "l1\nl<U+200B>2", messageText("l1\r\nl\u200b2\n\n"))
44+}
45+
46+func line(kind pb.LineKind, oldN, newN int32, text string) *pb.Line {
47+ return &pb.Line{Kind: kind, OldNumber: oldN, NewNumber: newN, Text: text}
48+}
49+
50+// The split view lines a run of deletions up with the additions after it;
51+// context fills both sides; the extra lines of the longer run have an empty
52+// opposite side.
53+func TestPairLines(t *testing.T) {
54+ ctx, add, del := pb.LineKind_LINE_KIND_CONTEXT, pb.LineKind_LINE_KIND_ADDITION, pb.LineKind_LINE_KIND_DELETION
55+ var ls []DiffLine
56+ for _, l := range []*pb.Line{
57+ line(ctx, 1, 1, "c"),
58+ line(del, 2, 0, "d1"), line(del, 3, 0, "d2"), line(del, 4, 0, "d3"),
59+ line(add, 0, 2, "a1"),
60+ line(ctx, 5, 3, "c2"),
61+ line(add, 0, 4, "a2"), line(add, 0, 5, "a3"),
62+ } {
63+ ls = append(ls, diffLine(l))
64+ }
65+ rows := pairLines(ls)
66+ type pair struct{ l, r string }
67+ var got []pair
68+ for _, r := range rows {
69+ p := pair{}
70+ if r.L != nil {
71+ p.l = r.L.Mark + r.L.Segs[0].Text
72+ }
73+ if r.R != nil {
74+ p.r = r.R.Mark + r.R.Segs[0].Text
75+ }
76+ got = append(got, p)
77+ }
78+ assert.Equal(t, []pair{
79+ {" c", " c"}, {"-d1", "+a1"}, {"-d2", ""}, {"-d3", ""}, {" c2", " c2"}, {"", "+a2"}, {"", "+a3"},
80+ }, got)
81+ assert.Equal(t, DiffLine{Kind: "del", Mark: "-", Old: "2", Segs: []Seg{{Text: "d1"}}}, ls[1])
82+ assert.Equal(t, DiffLine{Kind: "add", Mark: "+", New: "2", Segs: []Seg{{Text: "a1"}}}, ls[4])
83+}
84+
85+func TestChangeBar(t *testing.T) {
86+ assert.Equal(t, Bar{Rest: "··········"}, changeBar(0, 0, 0))
87+ assert.Equal(t, Bar{Add: "++++++++++"}, changeBar(100, 0, 100))
88+ assert.Equal(t, Bar{Add: "+++++", Del: "-----", Rest: ""}, changeBar(50, 50, 100))
89+ assert.Equal(t, Bar{Add: "+", Rest: "·········"}, changeBar(1, 0, 1000), "a change is never rounded away")
90+ assert.Equal(t, 10, len(changeBar(7, 7, 8).Add+changeBar(7, 7, 8).Del+changeBar(7, 7, 8).Rest), "never wider than the bar")
91+}
92+
93+func TestFileNotes(t *testing.T) {
94+ assert.Equal(t, "binary file: no text diff", fileNote(&pb.FileDiff{Binary: true, Status: pb.FileStatus_FILE_STATUS_MODIFIED}))
95+ assert.Equal(t, "mode 100644 → 100755", fileNote(&pb.FileDiff{Status: pb.FileStatus_FILE_STATUS_MODIFIED, OldMode: 0o100644, NewMode: 0o100755}))
96+ assert.Empty(t, fileNote(&pb.FileDiff{Status: pb.FileStatus_FILE_STATUS_ADDED, NewMode: 0o100644}), "an added file has no mode change")
97+ assert.Equal(t, "renamed, 90% similar", fileNote(&pb.FileDiff{Status: pb.FileStatus_FILE_STATUS_RENAMED, Similarity: 90, OldMode: 0o100644, NewMode: 0o100644}))
98+ assert.Contains(t, fileNote(&pb.FileDiff{Truncated: true, Additions: 2500}), "too large")
99+}
100+
101+func TestDiffFilesPaths(t *testing.T) {
102+ fs := diffFiles([]*pb.FileDiff{
103+ {Status: pb.FileStatus_FILE_STATUS_ADDED, NewPath: "a.go", Additions: 1, Hunks: []*pb.Hunk{{Lines: []*pb.Line{line(pb.LineKind_LINE_KIND_ADDITION, 0, 1, "x")}}}},
104+ {Status: pb.FileStatus_FILE_STATUS_DELETED, OldPath: "b.go", Deletions: 1},
105+ {Status: pb.FileStatus_FILE_STATUS_RENAMED, OldPath: "c\u202e.go", NewPath: "d.go"},
106+ {Status: pb.FileStatus_FILE_STATUS_MODIFIED, NewPath: "big.go", Truncated: true, Additions: 3000},
107+ })
108+ assert.Equal(t, "a.go", fs[0].Path)
109+ assert.True(t, fs[0].Open)
110+ assert.Equal(t, "b.go", fs[1].Path)
111+ assert.False(t, fs[1].Open, "nothing to read: stays closed")
112+ assert.Equal(t, "c<U+202E>.go → d.go", fs[2].Path)
113+ assert.False(t, fs[3].Open, "a truncated file is collapsed")
114+ assert.Equal(t, []string{"f1", "f2", "f3", "f4"}, []string{fs[0].ID, fs[1].ID, fs[2].ID, fs[3].ID})
115+}
116+
117+// A code cell holds exactly the text: escaped, with markers, and nothing
118+// added between the pieces (the cell keeps its white space).
119+func TestCodeLine(t *testing.T) {
120+ var b strings.Builder
121+ ln := diffLine(&pb.Line{Kind: pb.LineKind_LINE_KIND_ADDITION, NewNumber: 1, Text: "a <b> \u202e c d", NoNewlineAtEof: true})
122+ require.NoError(t, codeLine(ln).Render(context.Background(), &b))
123+ assert.Equal(t, `a &lt;b&gt; <span class="inv" title="invisible character">&lt;U+202E&gt;</span> c d`+
124+ `<span class="eol" title="no newline at end of file">`+noEOL+`</span>`, b.String())
125+}
A internal/adapters/web/static/css/55-diff.css +13 -0
@@ -0,0 +1,13 @@
1+@layer components {
2+ /* an invisible character in code, shown instead of applied (Trojan Source) */
3+ .inv {
4+ color: var(--red); font-weight: bold; font-size: var(--fs-s);
5+ outline: 1px solid var(--red); outline-offset: -1px; padding: 0 .25ch;
6+ }
7+ .eol { color: var(--ink-mute); padding-left: 1ch; user-select: none; }
8+ /* the layout links of the diff tools read as the buttons of the mockup */
9+ .diff-tools a.vbtn { color: var(--ink-dim); text-decoration: none; }
10+ .diff-tools a.vbtn:hover { color: var(--ink); text-decoration: underline; }
11+ .diff-tools a.vbtn[aria-current="true"] { color: var(--ink-hi); font-weight: bold; }
12+ .diff-tools a.vbtn[aria-current="true"]::before { content: "▸"; }
13+}
M internal/adapters/web/web.go +1 -0
@@ -100,6 +100,7 @@ func (s *Server) routes() *http.ServeMux {
100100 mux.HandleFunc("GET /{repo}/refs", s.page(s.refs))
101101 mux.HandleFunc("GET /{repo}/commits", s.page(s.commits))
102102 mux.HandleFunc("GET /{repo}/commits/{rest...}", s.page(s.commits))
103+ mux.HandleFunc("GET /{repo}/commit/{sha}", s.page(s.commit))
103104 mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound))
104105 return mux
105106 }

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 42

↑↓ moveenter openesc close