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
@@ -0,0 +1,1425 @@
1+// Code generated by templ - DO NOT EDIT.
2+
3+// templ: version: v0.3.1020
4+// SPDX-License-Identifier: AGPL-3.0-or-later
5+
6+package web
7+
8+//lint:file-ignore SA4006 This context is only used if a nested component is present.
9+
10+import "github.com/a-h/templ"
11+import templruntime "github.com/a-h/templ/runtime"
12+
13+// Commit is a commit: the message and facts, the diff tools, the diff.
14+func Commit(l Layout, p CommitPage) templ.Component {
15+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
16+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
17+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
18+ return templ_7745c5c3_CtxErr
19+ }
20+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
21+ if !templ_7745c5c3_IsBuffer {
22+ defer func() {
23+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
24+ if templ_7745c5c3_Err == nil {
25+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
26+ }
27+ }()
28+ }
29+ ctx = templ.InitializeContext(ctx)
30+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
31+ if templ_7745c5c3_Var1 == nil {
32+ templ_7745c5c3_Var1 = templ.NopComponent
33+ }
34+ ctx = templ.ClearChildren(ctx)
35+ templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
36+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
37+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
38+ if !templ_7745c5c3_IsBuffer {
39+ defer func() {
40+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
41+ if templ_7745c5c3_Err == nil {
42+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
43+ }
44+ }()
45+ }
46+ ctx = templ.InitializeContext(ctx)
47+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<header class=\"show\"><h1>")
48+ if templ_7745c5c3_Err != nil {
49+ return templ_7745c5c3_Err
50+ }
51+ var templ_7745c5c3_Var3 string
52+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(p.Subject)
53+ if templ_7745c5c3_Err != nil {
54+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 9, Col: 18}
55+ }
56+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
57+ if templ_7745c5c3_Err != nil {
58+ return templ_7745c5c3_Err
59+ }
60+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</h1>")
61+ if templ_7745c5c3_Err != nil {
62+ return templ_7745c5c3_Err
63+ }
64+ if p.Body != "" {
65+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<pre class=\"msg\">")
66+ if templ_7745c5c3_Err != nil {
67+ return templ_7745c5c3_Err
68+ }
69+ var templ_7745c5c3_Var4 string
70+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(p.Body)
71+ if templ_7745c5c3_Err != nil {
72+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 11, Col: 29}
73+ }
74+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
75+ if templ_7745c5c3_Err != nil {
76+ return templ_7745c5c3_Err
77+ }
78+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</pre>")
79+ if templ_7745c5c3_Err != nil {
80+ return templ_7745c5c3_Err
81+ }
82+ }
83+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<dl class=\"kv show-kv\"><dt>commit</dt><dd>")
84+ if templ_7745c5c3_Err != nil {
85+ return templ_7745c5c3_Err
86+ }
87+ var templ_7745c5c3_Var5 string
88+ templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(p.SHA)
89+ if templ_7745c5c3_Err != nil {
90+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 15, Col: 15}
91+ }
92+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
93+ if templ_7745c5c3_Err != nil {
94+ return templ_7745c5c3_Err
95+ }
96+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</dd><dt>author</dt><dd>")
97+ if templ_7745c5c3_Err != nil {
98+ return templ_7745c5c3_Err
99+ }
100+ templ_7745c5c3_Err = personView(p.Author).Render(ctx, templ_7745c5c3_Buffer)
101+ if templ_7745c5c3_Err != nil {
102+ return templ_7745c5c3_Err
103+ }
104+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</dd>")
105+ if templ_7745c5c3_Err != nil {
106+ return templ_7745c5c3_Err
107+ }
108+ if p.Committer != nil {
109+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<dt>committer</dt><dd>")
110+ if templ_7745c5c3_Err != nil {
111+ return templ_7745c5c3_Err
112+ }
113+ templ_7745c5c3_Err = personView(*p.Committer).Render(ctx, templ_7745c5c3_Buffer)
114+ if templ_7745c5c3_Err != nil {
115+ return templ_7745c5c3_Err
116+ }
117+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</dd>")
118+ if templ_7745c5c3_Err != nil {
119+ return templ_7745c5c3_Err
120+ }
121+ }
122+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<dt>")
123+ if templ_7745c5c3_Err != nil {
124+ return templ_7745c5c3_Err
125+ }
126+ if p.Merge {
127+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "merge of")
128+ if templ_7745c5c3_Err != nil {
129+ return templ_7745c5c3_Err
130+ }
131+ } else {
132+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "parent")
133+ if templ_7745c5c3_Err != nil {
134+ return templ_7745c5c3_Err
135+ }
136+ }
137+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</dt><dd>")
138+ if templ_7745c5c3_Err != nil {
139+ return templ_7745c5c3_Err
140+ }
141+ if p.Root {
142+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "none (root commit) ")
143+ if templ_7745c5c3_Err != nil {
144+ return templ_7745c5c3_Err
145+ }
146+ }
147+ for i, par := range p.Parents {
148+ if i > 0 {
149+ var templ_7745c5c3_Var6 string
150+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(" + ")
151+ if templ_7745c5c3_Err != nil {
152+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 35, Col: 14}
153+ }
154+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
155+ if templ_7745c5c3_Err != nil {
156+ return templ_7745c5c3_Err
157+ }
158+ }
159+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " <a href=\"")
160+ if templ_7745c5c3_Err != nil {
161+ return templ_7745c5c3_Err
162+ }
163+ var templ_7745c5c3_Var7 templ.SafeURL
164+ templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(par.Href))
165+ if templ_7745c5c3_Err != nil {
166+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 37, Col: 39}
167+ }
168+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
169+ if templ_7745c5c3_Err != nil {
170+ return templ_7745c5c3_Err
171+ }
172+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\">")
173+ if templ_7745c5c3_Err != nil {
174+ return templ_7745c5c3_Err
175+ }
176+ var templ_7745c5c3_Var8 string
177+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(par.Label)
178+ if templ_7745c5c3_Err != nil {
179+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 37, Col: 53}
180+ }
181+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
182+ if templ_7745c5c3_Err != nil {
183+ return templ_7745c5c3_Err
184+ }
185+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</a> ")
186+ if templ_7745c5c3_Err != nil {
187+ return templ_7745c5c3_Err
188+ }
189+ }
190+ if p.Merge {
191+ var templ_7745c5c3_Var9 string
192+ templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(" · diff against the first parent")
193+ if templ_7745c5c3_Err != nil {
194+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 40, Col: 43}
195+ }
196+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
197+ if templ_7745c5c3_Err != nil {
198+ return templ_7745c5c3_Err
199+ }
200+ }
201+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</dd>")
202+ if templ_7745c5c3_Err != nil {
203+ return templ_7745c5c3_Err
204+ }
205+ if len(p.Refs) > 0 {
206+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<dt>refs</dt><dd>")
207+ if templ_7745c5c3_Err != nil {
208+ return templ_7745c5c3_Err
209+ }
210+ for _, r := range p.Refs {
211+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "[")
212+ if templ_7745c5c3_Err != nil {
213+ return templ_7745c5c3_Err
214+ }
215+ var templ_7745c5c3_Var10 string
216+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(r)
217+ if templ_7745c5c3_Err != nil {
218+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 47, Col: 11}
219+ }
220+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
221+ if templ_7745c5c3_Err != nil {
222+ return templ_7745c5c3_Err
223+ }
224+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "]")
225+ if templ_7745c5c3_Err != nil {
226+ return templ_7745c5c3_Err
227+ }
228+ var templ_7745c5c3_Var11 string
229+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
230+ if templ_7745c5c3_Err != nil {
231+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 47, Col: 19}
232+ }
233+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
234+ if templ_7745c5c3_Err != nil {
235+ return templ_7745c5c3_Err
236+ }
237+ }
238+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</dd>")
239+ if templ_7745c5c3_Err != nil {
240+ return templ_7745c5c3_Err
241+ }
242+ }
243+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</dl></header><div class=\"diff-tools\" role=\"toolbar\" aria-label=\"Diff view\"><span class=\"dim\">")
244+ if templ_7745c5c3_Err != nil {
245+ return templ_7745c5c3_Err
246+ }
247+ var templ_7745c5c3_Var12 string
248+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(plural(p.NFiles, "file", "files"))
249+ if templ_7745c5c3_Err != nil {
250+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 54, Col: 56}
251+ }
252+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
253+ if templ_7745c5c3_Err != nil {
254+ return templ_7745c5c3_Err
255+ }
256+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " · <span class=\"plus\">+")
257+ if templ_7745c5c3_Err != nil {
258+ return templ_7745c5c3_Err
259+ }
260+ var templ_7745c5c3_Var13 string
261+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Adds))
262+ if templ_7745c5c3_Err != nil {
263+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 54, Col: 96}
264+ }
265+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
266+ if templ_7745c5c3_Err != nil {
267+ return templ_7745c5c3_Err
268+ }
269+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</span> <span class=\"minus\">-")
270+ if templ_7745c5c3_Err != nil {
271+ return templ_7745c5c3_Err
272+ }
273+ var templ_7745c5c3_Var14 string
274+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Dels))
275+ if templ_7745c5c3_Err != nil {
276+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 54, Col: 141}
277+ }
278+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
279+ if templ_7745c5c3_Err != nil {
280+ return templ_7745c5c3_Err
281+ }
282+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</span></span> <span class=\"grow\"></span>")
283+ if templ_7745c5c3_Err != nil {
284+ return templ_7745c5c3_Err
285+ }
286+ templ_7745c5c3_Err = viewLink(p, "unified", "[u]nified", "u", p.UnifiedHref).Render(ctx, templ_7745c5c3_Buffer)
287+ if templ_7745c5c3_Err != nil {
288+ return templ_7745c5c3_Err
289+ }
290+ templ_7745c5c3_Err = viewLink(p, "split", "[d] split", "d", p.SplitHref).Render(ctx, templ_7745c5c3_Buffer)
291+ if templ_7745c5c3_Err != nil {
292+ return templ_7745c5c3_Err
293+ }
294+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<button type=\"button\" aria-keyshortcuts=\"x\" hx-on:click=\"document.body.dispatchEvent(new CustomEvent('k-collapse'))\">[x] collapse all</button></div>")
295+ if templ_7745c5c3_Err != nil {
296+ return templ_7745c5c3_Err
297+ }
298+ if p.Invisible {
299+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<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>")
300+ if templ_7745c5c3_Err != nil {
301+ return templ_7745c5c3_Err
302+ }
303+ }
304+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, " ")
305+ if templ_7745c5c3_Err != nil {
306+ return templ_7745c5c3_Err
307+ }
308+ if p.Note != "" {
309+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<p class=\"notice\">")
310+ if templ_7745c5c3_Err != nil {
311+ return templ_7745c5c3_Err
312+ }
313+ var templ_7745c5c3_Var15 string
314+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)
315+ if templ_7745c5c3_Err != nil {
316+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 67, Col: 29}
317+ }
318+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
319+ if templ_7745c5c3_Err != nil {
320+ return templ_7745c5c3_Err
321+ }
322+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</p>")
323+ if templ_7745c5c3_Err != nil {
324+ return templ_7745c5c3_Err
325+ }
326+ }
327+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, " <div id=\"diffs\"")
328+ if templ_7745c5c3_Err != nil {
329+ return templ_7745c5c3_Err
330+ }
331+ if p.Both {
332+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, " data-diff=\"")
333+ if templ_7745c5c3_Err != nil {
334+ return templ_7745c5c3_Err
335+ }
336+ var templ_7745c5c3_Var16 string
337+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
338+ if templ_7745c5c3_Err != nil {
339+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 72, Col: 22}
340+ }
341+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
342+ if templ_7745c5c3_Err != nil {
343+ return templ_7745c5c3_Err
344+ }
345+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\"")
346+ if templ_7745c5c3_Err != nil {
347+ return templ_7745c5c3_Err
348+ }
349+ } else {
350+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " data-view=\"")
351+ if templ_7745c5c3_Err != nil {
352+ return templ_7745c5c3_Err
353+ }
354+ var templ_7745c5c3_Var17 string
355+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
356+ if templ_7745c5c3_Err != nil {
357+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 74, Col: 22}
358+ }
359+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
360+ if templ_7745c5c3_Err != nil {
361+ return templ_7745c5c3_Err
362+ }
363+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\"")
364+ if templ_7745c5c3_Err != nil {
365+ return templ_7745c5c3_Err
366+ }
367+ }
368+ if p.Both && !p.Pinned {
369+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, " hx-live=\"this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff\"")
370+ if templ_7745c5c3_Err != nil {
371+ return templ_7745c5c3_Err
372+ }
373+ }
374+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, ">")
375+ if templ_7745c5c3_Err != nil {
376+ return templ_7745c5c3_Err
377+ }
378+ for _, f := range p.Files {
379+ templ_7745c5c3_Err = DiffFileView(f, p).Render(ctx, templ_7745c5c3_Buffer)
380+ if templ_7745c5c3_Err != nil {
381+ return templ_7745c5c3_Err
382+ }
383+ }
384+ if len(p.Files) == 0 {
385+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<p class=\"note\">no changed files")
386+ if templ_7745c5c3_Err != nil {
387+ return templ_7745c5c3_Err
388+ }
389+ var templ_7745c5c3_Var18 string
390+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
391+ if templ_7745c5c3_Err != nil {
392+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 84, Col: 41}
393+ }
394+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
395+ if templ_7745c5c3_Err != nil {
396+ return templ_7745c5c3_Err
397+ }
398+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " ")
399+ if templ_7745c5c3_Err != nil {
400+ return templ_7745c5c3_Err
401+ }
402+ if p.Merge {
403+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "against the first parent.")
404+ if templ_7745c5c3_Err != nil {
405+ return templ_7745c5c3_Err
406+ }
407+ } else {
408+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "in this commit.")
409+ if templ_7745c5c3_Err != nil {
410+ return templ_7745c5c3_Err
411+ }
412+ }
413+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</p>")
414+ if templ_7745c5c3_Err != nil {
415+ return templ_7745c5c3_Err
416+ }
417+ }
418+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</div>")
419+ if templ_7745c5c3_Err != nil {
420+ return templ_7745c5c3_Err
421+ }
422+ return nil
423+ })
424+ templ_7745c5c3_Err = Shell(l, commitRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
425+ if templ_7745c5c3_Err != nil {
426+ return templ_7745c5c3_Err
427+ }
428+ return nil
429+ })
430+}
431+
432+// viewLink is a button of the diff tools. It is a link, so that the split
433+// layout works without JavaScript; with it, and when both layouts are in the
434+// page, a click only flips the layout.
435+func viewLink(p CommitPage, view, label, key, href string) templ.Component {
436+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
437+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
438+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
439+ return templ_7745c5c3_CtxErr
440+ }
441+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
442+ if !templ_7745c5c3_IsBuffer {
443+ defer func() {
444+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
445+ if templ_7745c5c3_Err == nil {
446+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
447+ }
448+ }()
449+ }
450+ ctx = templ.InitializeContext(ctx)
451+ templ_7745c5c3_Var19 := templ.GetChildren(ctx)
452+ if templ_7745c5c3_Var19 == nil {
453+ templ_7745c5c3_Var19 = templ.NopComponent
454+ }
455+ ctx = templ.ClearChildren(ctx)
456+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<a href=\"")
457+ if templ_7745c5c3_Err != nil {
458+ return templ_7745c5c3_Err
459+ }
460+ var templ_7745c5c3_Var20 templ.SafeURL
461+ templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(href))
462+ if templ_7745c5c3_Err != nil {
463+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 101, Col: 28}
464+ }
465+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
466+ if templ_7745c5c3_Err != nil {
467+ return templ_7745c5c3_Err
468+ }
469+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" class=\"vbtn\" data-view=\"")
470+ if templ_7745c5c3_Err != nil {
471+ return templ_7745c5c3_Err
472+ }
473+ var templ_7745c5c3_Var21 string
474+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(view)
475+ if templ_7745c5c3_Err != nil {
476+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 103, Col: 18}
477+ }
478+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
479+ if templ_7745c5c3_Err != nil {
480+ return templ_7745c5c3_Err
481+ }
482+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\" aria-keyshortcuts=\"")
483+ if templ_7745c5c3_Err != nil {
484+ return templ_7745c5c3_Err
485+ }
486+ var templ_7745c5c3_Var22 string
487+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(key)
488+ if templ_7745c5c3_Err != nil {
489+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 104, Col: 25}
490+ }
491+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
492+ if templ_7745c5c3_Err != nil {
493+ return templ_7745c5c3_Err
494+ }
495+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\"")
496+ if templ_7745c5c3_Err != nil {
497+ return templ_7745c5c3_Err
498+ }
499+ if p.View == view {
500+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, " aria-current=\"true\"")
501+ if templ_7745c5c3_Err != nil {
502+ return templ_7745c5c3_Err
503+ }
504+ }
505+ if p.Both {
506+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " hx-on:click=\"event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))\" :aria-current=\"")
507+ if templ_7745c5c3_Err != nil {
508+ return templ_7745c5c3_Err
509+ }
510+ var templ_7745c5c3_Var23 string
511+ templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("q('#diffs').attr['data-diff'] === '" + view + "' ? 'true' : false")
512+ if templ_7745c5c3_Err != nil {
513+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 110, Col: 86}
514+ }
515+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
516+ if templ_7745c5c3_Err != nil {
517+ return templ_7745c5c3_Err
518+ }
519+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\"")
520+ if templ_7745c5c3_Err != nil {
521+ return templ_7745c5c3_Err
522+ }
523+ }
524+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, ">")
525+ if templ_7745c5c3_Err != nil {
526+ return templ_7745c5c3_Err
527+ }
528+ var templ_7745c5c3_Var24 string
529+ templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(label)
530+ if templ_7745c5c3_Err != nil {
531+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 112, Col: 9}
532+ }
533+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
534+ if templ_7745c5c3_Err != nil {
535+ return templ_7745c5c3_Err
536+ }
537+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</a>")
538+ if templ_7745c5c3_Err != nil {
539+ return templ_7745c5c3_Err
540+ }
541+ return nil
542+ })
543+}
544+
545+func personView(pr Person) templ.Component {
546+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
547+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
548+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
549+ return templ_7745c5c3_CtxErr
550+ }
551+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
552+ if !templ_7745c5c3_IsBuffer {
553+ defer func() {
554+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
555+ if templ_7745c5c3_Err == nil {
556+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
557+ }
558+ }()
559+ }
560+ ctx = templ.InitializeContext(ctx)
561+ templ_7745c5c3_Var25 := templ.GetChildren(ctx)
562+ if templ_7745c5c3_Var25 == nil {
563+ templ_7745c5c3_Var25 = templ.NopComponent
564+ }
565+ ctx = templ.ClearChildren(ctx)
566+ var templ_7745c5c3_Var26 string
567+ templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(pr.Name)
568+ if templ_7745c5c3_Err != nil {
569+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 116, Col: 10}
570+ }
571+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
572+ if templ_7745c5c3_Err != nil {
573+ return templ_7745c5c3_Err
574+ }
575+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " ")
576+ if templ_7745c5c3_Err != nil {
577+ return templ_7745c5c3_Err
578+ }
579+ if pr.WhenAt != "" {
580+ var templ_7745c5c3_Var27 string
581+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
582+ if templ_7745c5c3_Err != nil {
583+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 118, Col: 10}
584+ }
585+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
586+ if templ_7745c5c3_Err != nil {
587+ return templ_7745c5c3_Err
588+ }
589+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<time datetime=\"")
590+ if templ_7745c5c3_Err != nil {
591+ return templ_7745c5c3_Err
592+ }
593+ var templ_7745c5c3_Var28 string
594+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(pr.WhenAt)
595+ if templ_7745c5c3_Err != nil {
596+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 118, Col: 38}
597+ }
598+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
599+ if templ_7745c5c3_Err != nil {
600+ return templ_7745c5c3_Err
601+ }
602+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\" title=\"")
603+ if templ_7745c5c3_Err != nil {
604+ return templ_7745c5c3_Err
605+ }
606+ var templ_7745c5c3_Var29 string
607+ templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(pr.Abs)
608+ if templ_7745c5c3_Err != nil {
609+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 118, Col: 55}
610+ }
611+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
612+ if templ_7745c5c3_Err != nil {
613+ return templ_7745c5c3_Err
614+ }
615+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\">")
616+ if templ_7745c5c3_Err != nil {
617+ return templ_7745c5c3_Err
618+ }
619+ var templ_7745c5c3_Var30 string
620+ templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(pr.When)
621+ if templ_7745c5c3_Err != nil {
622+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 118, Col: 67}
623+ }
624+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
625+ if templ_7745c5c3_Err != nil {
626+ return templ_7745c5c3_Err
627+ }
628+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</time>")
629+ if templ_7745c5c3_Err != nil {
630+ return templ_7745c5c3_Err
631+ }
632+ var templ_7745c5c3_Var31 string
633+ templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(" · " + pr.Abs)
634+ if templ_7745c5c3_Err != nil {
635+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 118, Col: 93}
636+ }
637+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
638+ if templ_7745c5c3_Err != nil {
639+ return templ_7745c5c3_Err
640+ }
641+ }
642+ return nil
643+ })
644+}
645+
646+func commitRail(p CommitPage) templ.Component {
647+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
648+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
649+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
650+ return templ_7745c5c3_CtxErr
651+ }
652+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
653+ if !templ_7745c5c3_IsBuffer {
654+ defer func() {
655+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
656+ if templ_7745c5c3_Err == nil {
657+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
658+ }
659+ }()
660+ }
661+ ctx = templ.InitializeContext(ctx)
662+ templ_7745c5c3_Var32 := templ.GetChildren(ctx)
663+ if templ_7745c5c3_Var32 == nil {
664+ templ_7745c5c3_Var32 = templ.NopComponent
665+ }
666+ ctx = templ.ClearChildren(ctx)
667+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<section aria-labelledby=\"r-files\"><h2 id=\"r-files\">files <span>")
668+ if templ_7745c5c3_Err != nil {
669+ return templ_7745c5c3_Err
670+ }
671+ var templ_7745c5c3_Var33 string
672+ templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))
673+ if templ_7745c5c3_Err != nil {
674+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 124, Col: 51}
675+ }
676+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
677+ if templ_7745c5c3_Err != nil {
678+ return templ_7745c5c3_Err
679+ }
680+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</span></h2>")
681+ if templ_7745c5c3_Err != nil {
682+ return templ_7745c5c3_Err
683+ }
684+ templ_7745c5c3_Err = FileList(p.Files).Render(ctx, templ_7745c5c3_Buffer)
685+ if templ_7745c5c3_Err != nil {
686+ return templ_7745c5c3_Err
687+ }
688+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</section>")
689+ if templ_7745c5c3_Err != nil {
690+ return templ_7745c5c3_Err
691+ }
692+ return nil
693+ })
694+}
695+
696+// FileList is the table of contents of a diff: status letter, path, +n -m
697+// and an ASCII bar. The row of the file in view is marked.
698+func FileList(files []DiffFile) templ.Component {
699+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
700+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
701+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
702+ return templ_7745c5c3_CtxErr
703+ }
704+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
705+ if !templ_7745c5c3_IsBuffer {
706+ defer func() {
707+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
708+ if templ_7745c5c3_Err == nil {
709+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
710+ }
711+ }()
712+ }
713+ ctx = templ.InitializeContext(ctx)
714+ templ_7745c5c3_Var34 := templ.GetChildren(ctx)
715+ if templ_7745c5c3_Var34 == nil {
716+ templ_7745c5c3_Var34 = templ.NopComponent
717+ }
718+ ctx = templ.ClearChildren(ctx)
719+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<ol class=\"files\" aria-label=\"Changed files\">")
720+ if templ_7745c5c3_Err != nil {
721+ return templ_7745c5c3_Err
722+ }
723+ for _, f := range files {
724+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<li><a href=\"")
725+ if templ_7745c5c3_Err != nil {
726+ return templ_7745c5c3_Err
727+ }
728+ var templ_7745c5c3_Var35 templ.SafeURL
729+ templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("#" + f.ID))
730+ if templ_7745c5c3_Err != nil {
731+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 136, Col: 37}
732+ }
733+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
734+ if templ_7745c5c3_Err != nil {
735+ return templ_7745c5c3_Err
736+ }
737+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\" :aria-current=\"")
738+ if templ_7745c5c3_Err != nil {
739+ return templ_7745c5c3_Err
740+ }
741+ var templ_7745c5c3_Var36 string
742+ templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue("(() => { 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 })()")
743+ if templ_7745c5c3_Err != nil {
744+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 137, Col: 209}
745+ }
746+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
747+ if templ_7745c5c3_Err != nil {
748+ return templ_7745c5c3_Err
749+ }
750+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\" hx-on:click=\"document.getElementById(this.hash.slice(1)).open = true\"><span class=\"st\" title=\"")
751+ if templ_7745c5c3_Err != nil {
752+ return templ_7745c5c3_Err
753+ }
754+ var templ_7745c5c3_Var37 string
755+ templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Status)
756+ if templ_7745c5c3_Err != nil {
757+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 140, Col: 38}
758+ }
759+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
760+ if templ_7745c5c3_Err != nil {
761+ return templ_7745c5c3_Err
762+ }
763+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\">")
764+ if templ_7745c5c3_Err != nil {
765+ return templ_7745c5c3_Err
766+ }
767+ var templ_7745c5c3_Var38 string
768+ templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(f.Status)
769+ if templ_7745c5c3_Err != nil {
770+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 140, Col: 51}
771+ }
772+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
773+ if templ_7745c5c3_Err != nil {
774+ return templ_7745c5c3_Err
775+ }
776+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</span> <span class=\"p\" title=\"")
777+ if templ_7745c5c3_Err != nil {
778+ return templ_7745c5c3_Err
779+ }
780+ var templ_7745c5c3_Var39 string
781+ templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path)
782+ if templ_7745c5c3_Err != nil {
783+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 141, Col: 35}
784+ }
785+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)
786+ if templ_7745c5c3_Err != nil {
787+ return templ_7745c5c3_Err
788+ }
789+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "\">")
790+ if templ_7745c5c3_Err != nil {
791+ return templ_7745c5c3_Err
792+ }
793+ var templ_7745c5c3_Var40 string
794+ templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(f.Path)
795+ if templ_7745c5c3_Err != nil {
796+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 141, Col: 46}
797+ }
798+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
799+ if templ_7745c5c3_Err != nil {
800+ return templ_7745c5c3_Err
801+ }
802+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</span> <span class=\"n\"><span class=\"plus\">+")
803+ if templ_7745c5c3_Err != nil {
804+ return templ_7745c5c3_Err
805+ }
806+ var templ_7745c5c3_Var41 string
807+ templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Add))
808+ if templ_7745c5c3_Err != nil {
809+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 142, Col: 54}
810+ }
811+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
812+ if templ_7745c5c3_Err != nil {
813+ return templ_7745c5c3_Err
814+ }
815+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</span> <span class=\"minus\">-")
816+ if templ_7745c5c3_Err != nil {
817+ return templ_7745c5c3_Err
818+ }
819+ var templ_7745c5c3_Var42 string
820+ templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Del))
821+ if templ_7745c5c3_Err != nil {
822+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 142, Col: 98}
823+ }
824+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
825+ if templ_7745c5c3_Err != nil {
826+ return templ_7745c5c3_Err
827+ }
828+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</span></span> <span class=\"b\" aria-hidden=\"true\"><span class=\"plus\">")
829+ if templ_7745c5c3_Err != nil {
830+ return templ_7745c5c3_Err
831+ }
832+ var templ_7745c5c3_Var43 string
833+ templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(f.Bar.Add)
834+ if templ_7745c5c3_Err != nil {
835+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 143, Col: 70}
836+ }
837+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
838+ if templ_7745c5c3_Err != nil {
839+ return templ_7745c5c3_Err
840+ }
841+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</span><span class=\"minus\">")
842+ if templ_7745c5c3_Err != nil {
843+ return templ_7745c5c3_Err
844+ }
845+ var templ_7745c5c3_Var44 string
846+ templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(f.Bar.Del)
847+ if templ_7745c5c3_Err != nil {
848+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 143, Col: 110}
849+ }
850+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
851+ if templ_7745c5c3_Err != nil {
852+ return templ_7745c5c3_Err
853+ }
854+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</span>")
855+ if templ_7745c5c3_Err != nil {
856+ return templ_7745c5c3_Err
857+ }
858+ var templ_7745c5c3_Var45 string
859+ templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(f.Bar.Rest)
860+ if templ_7745c5c3_Err != nil {
861+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 143, Col: 131}
862+ }
863+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
864+ if templ_7745c5c3_Err != nil {
865+ return templ_7745c5c3_Err
866+ }
867+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</span></a></li>")
868+ if templ_7745c5c3_Err != nil {
869+ return templ_7745c5c3_Err
870+ }
871+ }
872+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</ol>")
873+ if templ_7745c5c3_Err != nil {
874+ return templ_7745c5c3_Err
875+ }
876+ return nil
877+ })
878+}
879+
880+// DiffFileView is one file of the diff: a <details> (per-file collapse). The
881+// unified and the split tables are both in it; #diffs[data-diff] shows one.
882+// Every changed line keeps its +/- marker, so a change is never conveyed by
883+// colour alone.
884+func DiffFileView(f DiffFile, p CommitPage) templ.Component {
885+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
886+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
887+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
888+ return templ_7745c5c3_CtxErr
889+ }
890+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
891+ if !templ_7745c5c3_IsBuffer {
892+ defer func() {
893+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
894+ if templ_7745c5c3_Err == nil {
895+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
896+ }
897+ }()
898+ }
899+ ctx = templ.InitializeContext(ctx)
900+ templ_7745c5c3_Var46 := templ.GetChildren(ctx)
901+ if templ_7745c5c3_Var46 == nil {
902+ templ_7745c5c3_Var46 = templ.NopComponent
903+ }
904+ ctx = templ.ClearChildren(ctx)
905+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<details class=\"dfile\" id=\"")
906+ if templ_7745c5c3_Err != nil {
907+ return templ_7745c5c3_Err
908+ }
909+ var templ_7745c5c3_Var47 string
910+ templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ID)
911+ if templ_7745c5c3_Err != nil {
912+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 157, Col: 11}
913+ }
914+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var47)
915+ if templ_7745c5c3_Err != nil {
916+ return templ_7745c5c3_Err
917+ }
918+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "\" data-file")
919+ if templ_7745c5c3_Err != nil {
920+ return templ_7745c5c3_Err
921+ }
922+ if f.Open {
923+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, " open")
924+ if templ_7745c5c3_Err != nil {
925+ return templ_7745c5c3_Err
926+ }
927+ }
928+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "><summary><span class=\"st\">")
929+ if templ_7745c5c3_Err != nil {
930+ return templ_7745c5c3_Err
931+ }
932+ var templ_7745c5c3_Var48 string
933+ templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(f.Status)
934+ if templ_7745c5c3_Err != nil {
935+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 164, Col: 30}
936+ }
937+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
938+ if templ_7745c5c3_Err != nil {
939+ return templ_7745c5c3_Err
940+ }
941+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</span>")
942+ if templ_7745c5c3_Err != nil {
943+ return templ_7745c5c3_Err
944+ }
945+ var templ_7745c5c3_Var49 string
946+ templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
947+ if templ_7745c5c3_Err != nil {
948+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 164, Col: 44}
949+ }
950+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
951+ if templ_7745c5c3_Err != nil {
952+ return templ_7745c5c3_Err
953+ }
954+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<span class=\"p\">")
955+ if templ_7745c5c3_Err != nil {
956+ return templ_7745c5c3_Err
957+ }
958+ var templ_7745c5c3_Var50 string
959+ templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(f.Path)
960+ if templ_7745c5c3_Err != nil {
961+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 164, Col: 70}
962+ }
963+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
964+ if templ_7745c5c3_Err != nil {
965+ return templ_7745c5c3_Err
966+ }
967+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</span>")
968+ if templ_7745c5c3_Err != nil {
969+ return templ_7745c5c3_Err
970+ }
971+ var templ_7745c5c3_Var51 string
972+ templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
973+ if templ_7745c5c3_Err != nil {
974+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 164, Col: 84}
975+ }
976+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
977+ if templ_7745c5c3_Err != nil {
978+ return templ_7745c5c3_Err
979+ }
980+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<span class=\"plus\">+")
981+ if templ_7745c5c3_Err != nil {
982+ return templ_7745c5c3_Err
983+ }
984+ var templ_7745c5c3_Var52 string
985+ templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Add))
986+ if templ_7745c5c3_Err != nil {
987+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 164, Col: 119}
988+ }
989+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))
990+ if templ_7745c5c3_Err != nil {
991+ return templ_7745c5c3_Err
992+ }
993+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</span>")
994+ if templ_7745c5c3_Err != nil {
995+ return templ_7745c5c3_Err
996+ }
997+ var templ_7745c5c3_Var53 string
998+ templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
999+ if templ_7745c5c3_Err != nil {
1000+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 164, Col: 133}
1001+ }
1002+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
1003+ if templ_7745c5c3_Err != nil {
1004+ return templ_7745c5c3_Err
1005+ }
1006+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<span class=\"minus\">-")
1007+ if templ_7745c5c3_Err != nil {
1008+ return templ_7745c5c3_Err
1009+ }
1010+ var templ_7745c5c3_Var54 string
1011+ templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Del))
1012+ if templ_7745c5c3_Err != nil {
1013+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 164, Col: 169}
1014+ }
1015+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
1016+ if templ_7745c5c3_Err != nil {
1017+ return templ_7745c5c3_Err
1018+ }
1019+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</span> ")
1020+ if templ_7745c5c3_Err != nil {
1021+ return templ_7745c5c3_Err
1022+ }
1023+ if f.Note != "" {
1024+ var templ_7745c5c3_Var55 string
1025+ templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
1026+ if templ_7745c5c3_Err != nil {
1027+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 166, Col: 9}
1028+ }
1029+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
1030+ if templ_7745c5c3_Err != nil {
1031+ return templ_7745c5c3_Err
1032+ }
1033+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "<span class=\"note\">")
1034+ if templ_7745c5c3_Err != nil {
1035+ return templ_7745c5c3_Err
1036+ }
1037+ var templ_7745c5c3_Var56 string
1038+ templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note)
1039+ if templ_7745c5c3_Err != nil {
1040+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 166, Col: 38}
1041+ }
1042+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
1043+ if templ_7745c5c3_Err != nil {
1044+ return templ_7745c5c3_Err
1045+ }
1046+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</span>")
1047+ if templ_7745c5c3_Err != nil {
1048+ return templ_7745c5c3_Err
1049+ }
1050+ }
1051+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</summary> ")
1052+ if templ_7745c5c3_Err != nil {
1053+ return templ_7745c5c3_Err
1054+ }
1055+ if len(f.Hunks) > 0 {
1056+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<div class=\"dscroll\">")
1057+ if templ_7745c5c3_Err != nil {
1058+ return templ_7745c5c3_Err
1059+ }
1060+ if p.ShowUnified {
1061+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "<table class=\"dt uni\" aria-label=\"")
1062+ if templ_7745c5c3_Err != nil {
1063+ return templ_7745c5c3_Err
1064+ }
1065+ var templ_7745c5c3_Var57 string
1066+ templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", unified")
1067+ if templ_7745c5c3_Err != nil {
1068+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 172, Col: 60}
1069+ }
1070+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var57)
1071+ if templ_7745c5c3_Err != nil {
1072+ return templ_7745c5c3_Err
1073+ }
1074+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "\">")
1075+ if templ_7745c5c3_Err != nil {
1076+ return templ_7745c5c3_Err
1077+ }
1078+ for _, h := range f.Hunks {
1079+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "<tbody><tr class=\"hunk\"><td colspan=\"4\">")
1080+ if templ_7745c5c3_Err != nil {
1081+ return templ_7745c5c3_Err
1082+ }
1083+ var templ_7745c5c3_Var58 string
1084+ templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header)
1085+ if templ_7745c5c3_Err != nil {
1086+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 175, Col: 51}
1087+ }
1088+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58))
1089+ if templ_7745c5c3_Err != nil {
1090+ return templ_7745c5c3_Err
1091+ }
1092+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "</td></tr>")
1093+ if templ_7745c5c3_Err != nil {
1094+ return templ_7745c5c3_Err
1095+ }
1096+ for _, ln := range h.Lines {
1097+ var templ_7745c5c3_Var59 = []any{ln.Kind}
1098+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var59...)
1099+ if templ_7745c5c3_Err != nil {
1100+ return templ_7745c5c3_Err
1101+ }
1102+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "<tr class=\"")
1103+ if templ_7745c5c3_Err != nil {
1104+ return templ_7745c5c3_Err
1105+ }
1106+ var templ_7745c5c3_Var60 string
1107+ templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var59).String())
1108+ if templ_7745c5c3_Err != nil {
1109+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}
1110+ }
1111+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var60)
1112+ if templ_7745c5c3_Err != nil {
1113+ return templ_7745c5c3_Err
1114+ }
1115+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "\"><td class=\"ln\">")
1116+ if templ_7745c5c3_Err != nil {
1117+ return templ_7745c5c3_Err
1118+ }
1119+ var templ_7745c5c3_Var61 string
1120+ templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Old)
1121+ if templ_7745c5c3_Err != nil {
1122+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 178, Col: 33}
1123+ }
1124+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61))
1125+ if templ_7745c5c3_Err != nil {
1126+ return templ_7745c5c3_Err
1127+ }
1128+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "</td><td class=\"ln\">")
1129+ if templ_7745c5c3_Err != nil {
1130+ return templ_7745c5c3_Err
1131+ }
1132+ var templ_7745c5c3_Var62 string
1133+ templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(ln.New)
1134+ if templ_7745c5c3_Err != nil {
1135+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 179, Col: 33}
1136+ }
1137+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62))
1138+ if templ_7745c5c3_Err != nil {
1139+ return templ_7745c5c3_Err
1140+ }
1141+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "</td><td class=\"m\">")
1142+ if templ_7745c5c3_Err != nil {
1143+ return templ_7745c5c3_Err
1144+ }
1145+ var templ_7745c5c3_Var63 string
1146+ templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
1147+ if templ_7745c5c3_Err != nil {
1148+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 180, Col: 33}
1149+ }
1150+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63))
1151+ if templ_7745c5c3_Err != nil {
1152+ return templ_7745c5c3_Err
1153+ }
1154+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "</td><td class=\"tx\">")
1155+ if templ_7745c5c3_Err != nil {
1156+ return templ_7745c5c3_Err
1157+ }
1158+ templ_7745c5c3_Err = codeLine(ln).Render(ctx, templ_7745c5c3_Buffer)
1159+ if templ_7745c5c3_Err != nil {
1160+ return templ_7745c5c3_Err
1161+ }
1162+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "</td></tr>")
1163+ if templ_7745c5c3_Err != nil {
1164+ return templ_7745c5c3_Err
1165+ }
1166+ }
1167+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "</tbody>")
1168+ if templ_7745c5c3_Err != nil {
1169+ return templ_7745c5c3_Err
1170+ }
1171+ }
1172+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "</table>")
1173+ if templ_7745c5c3_Err != nil {
1174+ return templ_7745c5c3_Err
1175+ }
1176+ }
1177+ if p.ShowSplit {
1178+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "<table class=\"dt spl\" aria-label=\"")
1179+ if templ_7745c5c3_Err != nil {
1180+ return templ_7745c5c3_Err
1181+ }
1182+ var templ_7745c5c3_Var64 string
1183+ templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", split")
1184+ if templ_7745c5c3_Err != nil {
1185+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 189, Col: 58}
1186+ }
1187+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var64)
1188+ if templ_7745c5c3_Err != nil {
1189+ return templ_7745c5c3_Err
1190+ }
1191+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "\">")
1192+ if templ_7745c5c3_Err != nil {
1193+ return templ_7745c5c3_Err
1194+ }
1195+ for _, h := range f.Hunks {
1196+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "<tbody><tr class=\"hunk\"><td colspan=\"6\">")
1197+ if templ_7745c5c3_Err != nil {
1198+ return templ_7745c5c3_Err
1199+ }
1200+ var templ_7745c5c3_Var65 string
1201+ templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header)
1202+ if templ_7745c5c3_Err != nil {
1203+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 192, Col: 51}
1204+ }
1205+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65))
1206+ if templ_7745c5c3_Err != nil {
1207+ return templ_7745c5c3_Err
1208+ }
1209+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "</td></tr>")
1210+ if templ_7745c5c3_Err != nil {
1211+ return templ_7745c5c3_Err
1212+ }
1213+ for _, row := range h.Split {
1214+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "<tr>")
1215+ if templ_7745c5c3_Err != nil {
1216+ return templ_7745c5c3_Err
1217+ }
1218+ templ_7745c5c3_Err = splitSide(row.L, true).Render(ctx, templ_7745c5c3_Buffer)
1219+ if templ_7745c5c3_Err != nil {
1220+ return templ_7745c5c3_Err
1221+ }
1222+ templ_7745c5c3_Err = splitSide(row.R, false).Render(ctx, templ_7745c5c3_Buffer)
1223+ if templ_7745c5c3_Err != nil {
1224+ return templ_7745c5c3_Err
1225+ }
1226+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "</tr>")
1227+ if templ_7745c5c3_Err != nil {
1228+ return templ_7745c5c3_Err
1229+ }
1230+ }
1231+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "</tbody>")
1232+ if templ_7745c5c3_Err != nil {
1233+ return templ_7745c5c3_Err
1234+ }
1235+ }
1236+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "</table>")
1237+ if templ_7745c5c3_Err != nil {
1238+ return templ_7745c5c3_Err
1239+ }
1240+ }
1241+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "</div>")
1242+ if templ_7745c5c3_Err != nil {
1243+ return templ_7745c5c3_Err
1244+ }
1245+ } else {
1246+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "<p class=\"note pad\">")
1247+ if templ_7745c5c3_Err != nil {
1248+ return templ_7745c5c3_Err
1249+ }
1250+ if f.Note != "" {
1251+ var templ_7745c5c3_Var66 string
1252+ templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note)
1253+ if templ_7745c5c3_Err != nil {
1254+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 207, Col: 13}
1255+ }
1256+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66))
1257+ if templ_7745c5c3_Err != nil {
1258+ return templ_7745c5c3_Err
1259+ }
1260+ } else {
1261+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "no textual change.")
1262+ if templ_7745c5c3_Err != nil {
1263+ return templ_7745c5c3_Err
1264+ }
1265+ }
1266+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "</p>")
1267+ if templ_7745c5c3_Err != nil {
1268+ return templ_7745c5c3_Err
1269+ }
1270+ }
1271+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "</details>")
1272+ if templ_7745c5c3_Err != nil {
1273+ return templ_7745c5c3_Err
1274+ }
1275+ return nil
1276+ })
1277+}
1278+
1279+// splitSide is one half of a row of the split view; a side without a line is
1280+// an empty cell.
1281+func splitSide(ln *DiffLine, old bool) templ.Component {
1282+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1283+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
1284+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
1285+ return templ_7745c5c3_CtxErr
1286+ }
1287+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
1288+ if !templ_7745c5c3_IsBuffer {
1289+ defer func() {
1290+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
1291+ if templ_7745c5c3_Err == nil {
1292+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
1293+ }
1294+ }()
1295+ }
1296+ ctx = templ.InitializeContext(ctx)
1297+ templ_7745c5c3_Var67 := templ.GetChildren(ctx)
1298+ if templ_7745c5c3_Var67 == nil {
1299+ templ_7745c5c3_Var67 = templ.NopComponent
1300+ }
1301+ ctx = templ.ClearChildren(ctx)
1302+ if ln == nil {
1303+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "<td class=\"ln none\"></td><td class=\"m none\"></td><td class=\"tx none\"></td>")
1304+ if templ_7745c5c3_Err != nil {
1305+ return templ_7745c5c3_Err
1306+ }
1307+ } else {
1308+ var templ_7745c5c3_Var68 = []any{"ln", ln.Kind}
1309+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var68...)
1310+ if templ_7745c5c3_Err != nil {
1311+ return templ_7745c5c3_Err
1312+ }
1313+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "<td class=\"")
1314+ if templ_7745c5c3_Err != nil {
1315+ return templ_7745c5c3_Err
1316+ }
1317+ var templ_7745c5c3_Var69 string
1318+ templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var68).String())
1319+ if templ_7745c5c3_Err != nil {
1320+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}
1321+ }
1322+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var69)
1323+ if templ_7745c5c3_Err != nil {
1324+ return templ_7745c5c3_Err
1325+ }
1326+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "\">")
1327+ if templ_7745c5c3_Err != nil {
1328+ return templ_7745c5c3_Err
1329+ }
1330+ if old {
1331+ var templ_7745c5c3_Var70 string
1332+ templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Old)
1333+ if templ_7745c5c3_Err != nil {
1334+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 226, Col: 12}
1335+ }
1336+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70))
1337+ if templ_7745c5c3_Err != nil {
1338+ return templ_7745c5c3_Err
1339+ }
1340+ } else {
1341+ var templ_7745c5c3_Var71 string
1342+ templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinStringErrs(ln.New)
1343+ if templ_7745c5c3_Err != nil {
1344+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 228, Col: 12}
1345+ }
1346+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71))
1347+ if templ_7745c5c3_Err != nil {
1348+ return templ_7745c5c3_Err
1349+ }
1350+ }
1351+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "</td>")
1352+ if templ_7745c5c3_Err != nil {
1353+ return templ_7745c5c3_Err
1354+ }
1355+ var templ_7745c5c3_Var72 = []any{"m", ln.Kind}
1356+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var72...)
1357+ if templ_7745c5c3_Err != nil {
1358+ return templ_7745c5c3_Err
1359+ }
1360+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "<td class=\"")
1361+ if templ_7745c5c3_Err != nil {
1362+ return templ_7745c5c3_Err
1363+ }
1364+ var templ_7745c5c3_Var73 string
1365+ templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var72).String())
1366+ if templ_7745c5c3_Err != nil {
1367+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}
1368+ }
1369+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var73)
1370+ if templ_7745c5c3_Err != nil {
1371+ return templ_7745c5c3_Err
1372+ }
1373+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "\">")
1374+ if templ_7745c5c3_Err != nil {
1375+ return templ_7745c5c3_Err
1376+ }
1377+ var templ_7745c5c3_Var74 string
1378+ templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
1379+ if templ_7745c5c3_Err != nil {
1380+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 231, Col: 38}
1381+ }
1382+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var74))
1383+ if templ_7745c5c3_Err != nil {
1384+ return templ_7745c5c3_Err
1385+ }
1386+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "</td>")
1387+ if templ_7745c5c3_Err != nil {
1388+ return templ_7745c5c3_Err
1389+ }
1390+ var templ_7745c5c3_Var75 = []any{"tx", ln.Kind}
1391+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var75...)
1392+ if templ_7745c5c3_Err != nil {
1393+ return templ_7745c5c3_Err
1394+ }
1395+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "<td class=\"")
1396+ if templ_7745c5c3_Err != nil {
1397+ return templ_7745c5c3_Err
1398+ }
1399+ var templ_7745c5c3_Var76 string
1400+ templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var75).String())
1401+ if templ_7745c5c3_Err != nil {
1402+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0}
1403+ }
1404+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var76)
1405+ if templ_7745c5c3_Err != nil {
1406+ return templ_7745c5c3_Err
1407+ }
1408+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "\">")
1409+ if templ_7745c5c3_Err != nil {
1410+ return templ_7745c5c3_Err
1411+ }
1412+ templ_7745c5c3_Err = codeLine(*ln).Render(ctx, templ_7745c5c3_Buffer)
1413+ if templ_7745c5c3_Err != nil {
1414+ return templ_7745c5c3_Err
1415+ }
1416+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "</td>")
1417+ if templ_7745c5c3_Err != nil {
1418+ return templ_7745c5c3_Err
1419+ }
1420+ }
1421+ return nil
1422+ })
1423+}
1424+
1425+var _ = templruntime.GeneratedTemplate
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 3

Tags 43

↑↓ moveenter openesc close