detached 20d1f28

feat(web): add tree pages

GET /<repo>/tree[/<ref>[/<path>]] lists a directory with the last commit of
each entry (include_last_commit), notes when the listing or the last commits
are truncated, the path in the top bar, and the README of the directory
below, rendered with links relative to that directory.

The ref is the longest prefix of the path that names a branch or a tag (names
may contain a slash), or a commit id (7 to 40 hex digits); anything else, and
any missing path, is the 404 page. Tree and blob share target/locate/
pinRef/fileCrumbs. highlight.go gets its invisible-character markers and a
time budget here as well.

The e2e route test and the not-found golden used /tree/main as the example of
a page without a route; they now use a path that has none.
commit
20d1f288647cb680f6c233d378738d262f55ea5f
author
brice · · 2026-10-10 13:24 UTC
committer
brice · · 2026-10-10 13:25 UTC
parent
06c0b24
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +2357 -48
M cmd/ermite/e2e_test.go +1 -1
@@ -103,7 +103,7 @@ func TestRoutes(t *testing.T) {
103103 "/pub.git/info/refs", // dumb protocol: no service
104104 "/pub.git/info/refs?service=git-bogus",
105105 "/pub.git/config",
106- "/pub/tree/main", // a page of a later phase
106+ "/pub/events/main", // no such page
107107 "/-/nothing",
108108 "/Bad.git/info/refs?service=git-upload-pack",
109109 "/..%2f..%2fetc/info/refs?service=git-upload-pack",
@@ -103,7 +103,7 @@ func TestRoutes(t *testing.T) {
103 "/pub.git/info/refs", // dumb protocol: no service103 "/pub.git/info/refs", // dumb protocol: no service
104 "/pub.git/info/refs?service=git-bogus",104 "/pub.git/info/refs?service=git-bogus",
105 "/pub.git/config",105 "/pub.git/config",
106- "/pub/tree/main", // a page of a later phase106+ "/pub/events/main", // no such page
107 "/-/nothing",107 "/-/nothing",
108 "/Bad.git/info/refs?service=git-upload-pack",108 "/Bad.git/info/refs?service=git-upload-pack",
109 "/..%2f..%2fetc/info/refs?service=git-upload-pack",109 "/..%2f..%2fetc/info/refs?service=git-upload-pack",
A cmd/ermite/testdata/web/tree-nested.html +336 -0
@@ -0,0 +1,336 @@
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>src/lib · files · example.test</title>
8+<link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
9+<script src="/-/static/js/htmx.min.HASH.js" defer>
10+</script>
11+<script src="/-/static/js/hx-live.min.HASH.js" defer>
12+</script>
13+<script src="/-/static/js/hx-sse.min.HASH.js" defer>
14+</script>
15+<script src="/-/static/js/ermite.HASH.js" defer>
16+</script>
17+</head>
18+<body data-ref="main" data-sha="SHA40" data-sel="">
19+<a class="skip" href="#main">skip to content</a>
20+<header class="bar top">
21+<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
22+<span class="fade" aria-hidden="true">
23+</span>
24+<nav class="crumbs" aria-label="Path">
25+<ol>
26+<li>
27+<a href="/files">files</a>
28+</li>
29+<li>
30+<a href="/files/tree/main">tree</a>
31+</li>
32+<li>
33+<a href="/files/tree/main">main</a>
34+</li>
35+<li>
36+<a href="/files/tree/main/src">src</a>
37+</li>
38+<li>
39+<a href="/files/tree/main/src/lib" aria-current="page">lib</a>
40+</li>
41+</ol>
42+</nav>
43+<nav class="secs" aria-label="Sections">
44+<ul>
45+<li>
46+<a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a>
47+</li>
48+<li>
49+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a>
50+</li>
51+<li>
52+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a>
53+</li>
54+<li>
55+<a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
56+</li>
57+</ul>
58+</nav>
59+<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>
60+</button>
61+</header>
62+<div class="work">
63+<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
64+<div class="filter">
65+<label for="tree-filter">filter&gt;</label>
66+<input id="tree-filter" type="search" data-filter placeholder="this directory ( / )" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')">
67+</div>
68+<table class="list tree" aria-label="Entries">
69+<thead>
70+<tr>
71+<th class="cur">
72+</th>
73+<th class="c-kind">
74+</th>
75+<th class="c-name">name</th>
76+<th class="c-size">size</th>
77+<th class="c-sub">last commit</th>
78+<th class="c-who">author</th>
79+<th class="c-when">date</th>
80+</tr>
81+</thead>
82+<tbody id="tree-rows" data-rows>
83+<tr data-cursor>
84+<td class="cur" aria-hidden="true">
85+</td>
86+<td class="c-kind" aria-hidden="true">·</td>
87+<td class="c-name">
88+<a href="/files/blob/main/src/lib/util.go">util.go</a>
89+</td>
90+<td class="c-size num dim">12 B</td>
91+<td class="c-sub dim">
92+<a href="/files/commit/SHA40">add src/lib/util.go</a>
93+</td>
94+<td class="c-who dim">T</td>
95+<td class="c-when nowrap dim">
96+<time datetime="STAMP" title="STAMP">AGO</time>
97+</td>
98+</tr>
99+</tbody>
100+</table>
101+</main>
102+</div>
103+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
104+<div class="panel-head">
105+<span>keys</span>
106+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
107+</div>
108+<dl class="keys">
109+<div>
110+<dt>
111+<kbd>j</kbd>
112+<kbd>k</kbd>
113+</dt>
114+<dd>move cursor down / up</dd>
115+</div>
116+<div>
117+<dt>
118+<kbd>enter</kbd>
119+</dt>
120+<dd>open row under cursor</dd>
121+</div>
122+<div>
123+<dt>
124+<kbd>g</kbd>
125+<kbd>g</kbd> / <kbd>G</kbd>
126+</dt>
127+<dd>first / last row</dd>
128+</div>
129+<div>
130+<dt>
131+<kbd>/</kbd>
132+</dt>
133+<dd>focus the filter</dd>
134+</div>
135+<div>
136+<dt>
137+<kbd>g h</kbd>
138+<kbd>g t</kbd>
139+</dt>
140+<dd>go home / tree</dd>
141+</div>
142+<div>
143+<dt>
144+<kbd>g c</kbd>
145+<kbd>g r</kbd>
146+</dt>
147+<dd>go commits / refs</dd>
148+</div>
149+<div>
150+<dt>
151+<kbd>b</kbd>
152+</dt>
153+<dd>switch ref (branches, tags)</dd>
154+</div>
155+<div>
156+<dt>
157+<kbd>:</kbd>
158+<kbd>f</kbd>
159+</dt>
160+<dd>go to: section, ref, file, repo</dd>
161+</div>
162+<div>
163+<dt>
164+<kbd>y</kbd>
165+</dt>
166+<dd>permalink: ref becomes the full sha</dd>
167+</div>
168+<div>
169+<dt>
170+<kbd>s</kbd>
171+<kbd>t</kbd>
172+<kbd>c</kbd>
173+<kbd>r</kbd>
174+</dt>
175+<dd>repo sections (when shown)</dd>
176+</div>
177+<div>
178+<dt>
179+<kbd>d</kbd>
180+<kbd>u</kbd>
181+<kbd>x</kbd>
182+</dt>
183+<dd>commit: split / unified / collapse all</dd>
184+</div>
185+<div>
186+<dt>
187+<kbd>esc</kbd>
188+</dt>
189+<dd>close panel</dd>
190+</div>
191+</dl>
192+</dialog>
193+<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
194+<div class="panel-head">
195+<label for="refs-filter">ref&gt;</label>
196+<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))">
197+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
198+</div>
199+<div class="panel-cols" data-picklist>
200+<section aria-label="Branches">
201+<h2>branches <span class="mute">2</span>
202+</h2>
203+<ul>
204+<li>
205+<a href="/files/tree/feature/x">
206+<span class="mark" aria-hidden="true">&nbsp;</span>feature/x</a>
207+</li>
208+<li>
209+<a href="/files/tree/main" aria-current="true">
210+<span class="mark" aria-hidden="true">●</span>main</a>
211+</li>
212+</ul>
213+</section>
214+<section aria-label="Tags">
215+<h2>tags <span class="mute">1</span>
216+</h2>
217+<ul>
218+<li>
219+<a href="/files/tree/v1.0">
220+<span class="mark" aria-hidden="true">&nbsp;</span>v1.0</a>
221+</li>
222+</ul>
223+</section>
224+</div>
225+</dialog>
226+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
227+<div class="panel-head">
228+<label for="cmd-q" class="hi">:</label>
229+<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();
230+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
231+ 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();
232+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
233+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
234+ 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' })) }
235+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
236+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
237+</div>
238+<ul id="cmd-list" class="cmd-list">
239+<li data-k="go home" data-cursor>
240+<a href="/" tabindex="-1">
241+<span class="k">go</span>home</a>
242+</li>
243+<li data-k="section summary">
244+<a href="/files" tabindex="-1">
245+<span class="k">section</span>summary</a>
246+</li>
247+<li data-k="section tree">
248+<a href="/files/tree/main" tabindex="-1">
249+<span class="k">section</span>tree</a>
250+</li>
251+<li data-k="section commits">
252+<a href="/files/commits/main" tabindex="-1">
253+<span class="k">section</span>commits</a>
254+</li>
255+<li data-k="section refs">
256+<a href="/files/refs" tabindex="-1">
257+<span class="k">section</span>refs</a>
258+</li>
259+<li data-k="branch feature/x">
260+<a href="/files/tree/feature/x" tabindex="-1">
261+<span class="k">branch</span>feature/x</a>
262+</li>
263+<li data-k="branch main">
264+<a href="/files/tree/main" tabindex="-1">
265+<span class="k">branch</span>main</a>
266+</li>
267+<li data-k="tag v1.0">
268+<a href="/files/tree/v1.0" tabindex="-1">
269+<span class="k">tag</span>v1.0</a>
270+</li>
271+<li data-k="file src/lib/util.go">
272+<a href="/files/blob/main/src/lib/util.go" tabindex="-1">
273+<span class="k">file</span>src/lib/util.go</a>
274+</li>
275+</ul>
276+</dialog>
277+<footer class="bar status" aria-label="Status">
278+<span class="mode">tree</span>
279+<span class="st-ctx">tree · 1 entry</span>
280+<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'))">
281+<span class="hi">:</span> go to…</button>
282+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
283+</span>
284+<span class="st-hints">j/k move · enter open · / filter · b ref · ? help</span>
285+<span class="st-src">
286+<span class="st-ver">ermite devel · </span>
287+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
288+</span>
289+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
290+<span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/1</span>
291+</footer>
292+<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
293+ if (q('dialog[open]').count) return;
294+ let rows = q('[data-rows] > :not([hidden])').arr();
295+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
296+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
297+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
298+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
299+ })(1);
300+ k-k from:body -> ((d) => {
301+ if (q('dialog[open]').count) return;
302+ let rows = q('[data-rows] > :not([hidden])').arr();
303+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
304+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
305+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
306+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
307+ })(-1);
308+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
309+ 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));
310+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
311+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
312+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
313+ 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());
314+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
315+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
316+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
317+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
318+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
319+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
320+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
321+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
322+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
323+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
324+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
325+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
326+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
327+ k-permalink from:body -> {
328+ let b = document.body.dataset;
329+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
330+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
331+ navigator.clipboard?.writeText(location.href);
332+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
333+ }">
334+</div>
335+</body>
336+</html>
@@ -0,0 +1,336 @@
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>src/lib · files · example.test</title>
8+<link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
9+<script src="/-/static/js/htmx.min.HASH.js" defer>
10+</script>
11+<script src="/-/static/js/hx-live.min.HASH.js" defer>
12+</script>
13+<script src="/-/static/js/hx-sse.min.HASH.js" defer>
14+</script>
15+<script src="/-/static/js/ermite.HASH.js" defer>
16+</script>
17+</head>
18+<body data-ref="main" data-sha="SHA40" data-sel="">
19+<a class="skip" href="#main">skip to content</a>
20+<header class="bar top">
21+<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
22+<span class="fade" aria-hidden="true">
23+</span>
24+<nav class="crumbs" aria-label="Path">
25+<ol>
26+<li>
27+<a href="/files">files</a>
28+</li>
29+<li>
30+<a href="/files/tree/main">tree</a>
31+</li>
32+<li>
33+<a href="/files/tree/main">main</a>
34+</li>
35+<li>
36+<a href="/files/tree/main/src">src</a>
37+</li>
38+<li>
39+<a href="/files/tree/main/src/lib" aria-current="page">lib</a>
40+</li>
41+</ol>
42+</nav>
43+<nav class="secs" aria-label="Sections">
44+<ul>
45+<li>
46+<a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a>
47+</li>
48+<li>
49+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a>
50+</li>
51+<li>
52+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a>
53+</li>
54+<li>
55+<a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
56+</li>
57+</ul>
58+</nav>
59+<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>
60+</button>
61+</header>
62+<div class="work">
63+<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
64+<div class="filter">
65+<label for="tree-filter">filter&gt;</label>
66+<input id="tree-filter" type="search" data-filter placeholder="this directory ( / )" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')">
67+</div>
68+<table class="list tree" aria-label="Entries">
69+<thead>
70+<tr>
71+<th class="cur">
72+</th>
73+<th class="c-kind">
74+</th>
75+<th class="c-name">name</th>
76+<th class="c-size">size</th>
77+<th class="c-sub">last commit</th>
78+<th class="c-who">author</th>
79+<th class="c-when">date</th>
80+</tr>
81+</thead>
82+<tbody id="tree-rows" data-rows>
83+<tr data-cursor>
84+<td class="cur" aria-hidden="true">
85+</td>
86+<td class="c-kind" aria-hidden="true">·</td>
87+<td class="c-name">
88+<a href="/files/blob/main/src/lib/util.go">util.go</a>
89+</td>
90+<td class="c-size num dim">12 B</td>
91+<td class="c-sub dim">
92+<a href="/files/commit/SHA40">add src/lib/util.go</a>
93+</td>
94+<td class="c-who dim">T</td>
95+<td class="c-when nowrap dim">
96+<time datetime="STAMP" title="STAMP">AGO</time>
97+</td>
98+</tr>
99+</tbody>
100+</table>
101+</main>
102+</div>
103+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
104+<div class="panel-head">
105+<span>keys</span>
106+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
107+</div>
108+<dl class="keys">
109+<div>
110+<dt>
111+<kbd>j</kbd>
112+<kbd>k</kbd>
113+</dt>
114+<dd>move cursor down / up</dd>
115+</div>
116+<div>
117+<dt>
118+<kbd>enter</kbd>
119+</dt>
120+<dd>open row under cursor</dd>
121+</div>
122+<div>
123+<dt>
124+<kbd>g</kbd>
125+<kbd>g</kbd> / <kbd>G</kbd>
126+</dt>
127+<dd>first / last row</dd>
128+</div>
129+<div>
130+<dt>
131+<kbd>/</kbd>
132+</dt>
133+<dd>focus the filter</dd>
134+</div>
135+<div>
136+<dt>
137+<kbd>g h</kbd>
138+<kbd>g t</kbd>
139+</dt>
140+<dd>go home / tree</dd>
141+</div>
142+<div>
143+<dt>
144+<kbd>g c</kbd>
145+<kbd>g r</kbd>
146+</dt>
147+<dd>go commits / refs</dd>
148+</div>
149+<div>
150+<dt>
151+<kbd>b</kbd>
152+</dt>
153+<dd>switch ref (branches, tags)</dd>
154+</div>
155+<div>
156+<dt>
157+<kbd>:</kbd>
158+<kbd>f</kbd>
159+</dt>
160+<dd>go to: section, ref, file, repo</dd>
161+</div>
162+<div>
163+<dt>
164+<kbd>y</kbd>
165+</dt>
166+<dd>permalink: ref becomes the full sha</dd>
167+</div>
168+<div>
169+<dt>
170+<kbd>s</kbd>
171+<kbd>t</kbd>
172+<kbd>c</kbd>
173+<kbd>r</kbd>
174+</dt>
175+<dd>repo sections (when shown)</dd>
176+</div>
177+<div>
178+<dt>
179+<kbd>d</kbd>
180+<kbd>u</kbd>
181+<kbd>x</kbd>
182+</dt>
183+<dd>commit: split / unified / collapse all</dd>
184+</div>
185+<div>
186+<dt>
187+<kbd>esc</kbd>
188+</dt>
189+<dd>close panel</dd>
190+</div>
191+</dl>
192+</dialog>
193+<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
194+<div class="panel-head">
195+<label for="refs-filter">ref&gt;</label>
196+<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))">
197+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
198+</div>
199+<div class="panel-cols" data-picklist>
200+<section aria-label="Branches">
201+<h2>branches <span class="mute">2</span>
202+</h2>
203+<ul>
204+<li>
205+<a href="/files/tree/feature/x">
206+<span class="mark" aria-hidden="true">&nbsp;</span>feature/x</a>
207+</li>
208+<li>
209+<a href="/files/tree/main" aria-current="true">
210+<span class="mark" aria-hidden="true">●</span>main</a>
211+</li>
212+</ul>
213+</section>
214+<section aria-label="Tags">
215+<h2>tags <span class="mute">1</span>
216+</h2>
217+<ul>
218+<li>
219+<a href="/files/tree/v1.0">
220+<span class="mark" aria-hidden="true">&nbsp;</span>v1.0</a>
221+</li>
222+</ul>
223+</section>
224+</div>
225+</dialog>
226+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
227+<div class="panel-head">
228+<label for="cmd-q" class="hi">:</label>
229+<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();
230+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
231+ 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();
232+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
233+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
234+ 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' })) }
235+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
236+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
237+</div>
238+<ul id="cmd-list" class="cmd-list">
239+<li data-k="go home" data-cursor>
240+<a href="/" tabindex="-1">
241+<span class="k">go</span>home</a>
242+</li>
243+<li data-k="section summary">
244+<a href="/files" tabindex="-1">
245+<span class="k">section</span>summary</a>
246+</li>
247+<li data-k="section tree">
248+<a href="/files/tree/main" tabindex="-1">
249+<span class="k">section</span>tree</a>
250+</li>
251+<li data-k="section commits">
252+<a href="/files/commits/main" tabindex="-1">
253+<span class="k">section</span>commits</a>
254+</li>
255+<li data-k="section refs">
256+<a href="/files/refs" tabindex="-1">
257+<span class="k">section</span>refs</a>
258+</li>
259+<li data-k="branch feature/x">
260+<a href="/files/tree/feature/x" tabindex="-1">
261+<span class="k">branch</span>feature/x</a>
262+</li>
263+<li data-k="branch main">
264+<a href="/files/tree/main" tabindex="-1">
265+<span class="k">branch</span>main</a>
266+</li>
267+<li data-k="tag v1.0">
268+<a href="/files/tree/v1.0" tabindex="-1">
269+<span class="k">tag</span>v1.0</a>
270+</li>
271+<li data-k="file src/lib/util.go">
272+<a href="/files/blob/main/src/lib/util.go" tabindex="-1">
273+<span class="k">file</span>src/lib/util.go</a>
274+</li>
275+</ul>
276+</dialog>
277+<footer class="bar status" aria-label="Status">
278+<span class="mode">tree</span>
279+<span class="st-ctx">tree · 1 entry</span>
280+<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'))">
281+<span class="hi">:</span> go to…</button>
282+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
283+</span>
284+<span class="st-hints">j/k move · enter open · / filter · b ref · ? help</span>
285+<span class="st-src">
286+<span class="st-ver">ermite devel · </span>
287+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
288+</span>
289+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
290+<span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/1</span>
291+</footer>
292+<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
293+ if (q('dialog[open]').count) return;
294+ let rows = q('[data-rows] > :not([hidden])').arr();
295+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
296+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
297+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
298+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
299+ })(1);
300+ k-k from:body -> ((d) => {
301+ if (q('dialog[open]').count) return;
302+ let rows = q('[data-rows] > :not([hidden])').arr();
303+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
304+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
305+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
306+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
307+ })(-1);
308+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
309+ 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));
310+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
311+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
312+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
313+ 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());
314+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
315+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
316+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
317+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
318+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
319+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
320+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
321+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
322+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
323+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
324+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
325+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
326+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
327+ k-permalink from:body -> {
328+ let b = document.body.dataset;
329+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
330+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
331+ navigator.clipboard?.writeText(location.href);
332+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
333+ }">
334+</div>
335+</body>
336+</html>
A cmd/ermite/testdata/web/tree-root.html +609 -0
@@ -0,0 +1,609 @@
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>tree · files · example.test</title>
8+<link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
9+<script src="/-/static/js/htmx.min.HASH.js" defer>
10+</script>
11+<script src="/-/static/js/hx-live.min.HASH.js" defer>
12+</script>
13+<script src="/-/static/js/hx-sse.min.HASH.js" defer>
14+</script>
15+<script src="/-/static/js/ermite.HASH.js" defer>
16+</script>
17+</head>
18+<body data-ref="main" data-sha="SHA40" data-sel="">
19+<a class="skip" href="#main">skip to content</a>
20+<header class="bar top">
21+<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
22+<span class="fade" aria-hidden="true">
23+</span>
24+<nav class="crumbs" aria-label="Path">
25+<ol>
26+<li>
27+<a href="/files">files</a>
28+</li>
29+<li>
30+<a href="/files/tree/main">tree</a>
31+</li>
32+<li>
33+<a href="/files/tree/main" aria-current="page">main</a>
34+</li>
35+</ol>
36+</nav>
37+<nav class="secs" aria-label="Sections">
38+<ul>
39+<li>
40+<a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a>
41+</li>
42+<li>
43+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a>
44+</li>
45+<li>
46+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a>
47+</li>
48+<li>
49+<a href="/files/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">
57+<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
58+<div class="filter">
59+<label for="tree-filter">filter&gt;</label>
60+<input id="tree-filter" type="search" data-filter placeholder="this directory ( / )" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')">
61+</div>
62+<table class="list tree" aria-label="Entries">
63+<thead>
64+<tr>
65+<th class="cur">
66+</th>
67+<th class="c-kind">
68+</th>
69+<th class="c-name">name</th>
70+<th class="c-size">size</th>
71+<th class="c-sub">last commit</th>
72+<th class="c-who">author</th>
73+<th class="c-when">date</th>
74+</tr>
75+</thead>
76+<tbody id="tree-rows" data-rows>
77+<tr data-cursor>
78+<td class="cur" aria-hidden="true">
79+</td>
80+<td class="c-kind" aria-hidden="true">▾</td>
81+<td class="c-name">
82+<a href="/files/tree/main/docs">docs/</a>
83+</td>
84+<td class="c-size num dim">-</td>
85+<td class="c-sub dim">
86+<a href="/files/commit/SHA40">add docs/guide.md</a>
87+</td>
88+<td class="c-who dim">T</td>
89+<td class="c-when nowrap dim">
90+<time datetime="STAMP" title="STAMP">AGO</time>
91+</td>
92+</tr>
93+<tr>
94+<td class="cur" aria-hidden="true">
95+</td>
96+<td class="c-kind" aria-hidden="true">▾</td>
97+<td class="c-name">
98+<a href="/files/tree/main/src">src/</a>
99+</td>
100+<td class="c-size num dim">-</td>
101+<td class="c-sub dim">
102+<a href="/files/commit/SHA40">add src/lib/util.go</a>
103+</td>
104+<td class="c-who dim">T</td>
105+<td class="c-when nowrap dim">
106+<time datetime="STAMP" title="STAMP">AGO</time>
107+</td>
108+</tr>
109+<tr>
110+<td class="cur" aria-hidden="true">
111+</td>
112+<td class="c-kind" aria-hidden="true">·</td>
113+<td class="c-name">
114+<a href="/files/blob/main/README.md">README.md</a>
115+</td>
116+<td class="c-size num dim">41 B</td>
117+<td class="c-sub dim">
118+<a href="/files/commit/SHA40">add README.md</a>
119+</td>
120+<td class="c-who dim">T</td>
121+<td class="c-when nowrap dim">
122+<time datetime="STAMP" title="STAMP">AGO</time>
123+</td>
124+</tr>
125+<tr>
126+<td class="cur" aria-hidden="true">
127+</td>
128+<td class="c-kind" aria-hidden="true">·</td>
129+<td class="c-name">
130+<a href="/files/blob/main/big.txt">big.txt</a>
131+</td>
132+<td class="c-size num dim">1.1 MiB</td>
133+<td class="c-sub dim">
134+<a href="/files/commit/SHA40">add big.txt</a>
135+</td>
136+<td class="c-who dim">T</td>
137+<td class="c-when nowrap dim">
138+<time datetime="STAMP" title="STAMP">AGO</time>
139+</td>
140+</tr>
141+<tr>
142+<td class="cur" aria-hidden="true">
143+</td>
144+<td class="c-kind" aria-hidden="true">·</td>
145+<td class="c-name">
146+<a href="/files/blob/main/data.bin">data.bin</a>
147+</td>
148+<td class="c-size num dim">10 B</td>
149+<td class="c-sub dim">
150+<a href="/files/commit/SHA40">add data.bin</a>
151+</td>
152+<td class="c-who dim">T</td>
153+<td class="c-when nowrap dim">
154+<time datetime="STAMP" title="STAMP">AGO</time>
155+</td>
156+</tr>
157+<tr>
158+<td class="cur" aria-hidden="true">
159+</td>
160+<td class="c-kind" aria-hidden="true">·</td>
161+<td class="c-name">
162+<a href="/files/blob/main/empty.txt">empty.txt</a>
163+</td>
164+<td class="c-size num dim">0 B</td>
165+<td class="c-sub dim">
166+<a href="/files/commit/SHA40">add empty.txt</a>
167+</td>
168+<td class="c-who dim">T</td>
169+<td class="c-when nowrap dim">
170+<time datetime="STAMP" title="STAMP">AGO</time>
171+</td>
172+</tr>
173+<tr>
174+<td class="cur" aria-hidden="true">
175+</td>
176+<td class="c-kind" aria-hidden="true">·</td>
177+<td class="c-name">
178+<a href="/files/blob/main/evil.go">evil.go</a>
179+</td>
180+<td class="c-size num dim">87 B</td>
181+<td class="c-sub dim">
182+<a href="/files/commit/SHA40">add evil.go</a>
183+</td>
184+<td class="c-who dim">T</td>
185+<td class="c-when nowrap dim">
186+<time datetime="STAMP" title="STAMP">AGO</time>
187+</td>
188+</tr>
189+<tr>
190+<td class="cur" aria-hidden="true">
191+</td>
192+<td class="c-kind" aria-hidden="true">·</td>
193+<td class="c-name">
194+<a href="/files/blob/main/fake.jpg">fake.jpg</a>
195+</td>
196+<td class="c-size num dim">15 B</td>
197+<td class="c-sub dim">
198+<a href="/files/commit/SHA40">add fake.jpg</a>
199+</td>
200+<td class="c-who dim">T</td>
201+<td class="c-when nowrap dim">
202+<time datetime="STAMP" title="STAMP">AGO</time>
203+</td>
204+</tr>
205+<tr>
206+<td class="cur" aria-hidden="true">
207+</td>
208+<td class="c-kind" aria-hidden="true">·</td>
209+<td class="c-name">
210+<a href="/files/blob/main/fake.png">fake.png</a>
211+</td>
212+<td class="c-size num dim">39 B</td>
213+<td class="c-sub dim">
214+<a href="/files/commit/SHA40">add fake.png</a>
215+</td>
216+<td class="c-who dim">T</td>
217+<td class="c-when nowrap dim">
218+<time datetime="STAMP" title="STAMP">AGO</time>
219+</td>
220+</tr>
221+<tr>
222+<td class="cur" aria-hidden="true">
223+</td>
224+<td class="c-kind" aria-hidden="true">·</td>
225+<td class="c-name">
226+<a href="/files/blob/main/lines.txt">lines.txt</a>
227+</td>
228+<td class="c-size num dim">39.1 KiB</td>
229+<td class="c-sub dim">
230+<a href="/files/commit/SHA40">add lines.txt</a>
231+</td>
232+<td class="c-who dim">T</td>
233+<td class="c-when nowrap dim">
234+<time datetime="STAMP" title="STAMP">AGO</time>
235+</td>
236+</tr>
237+<tr>
238+<td class="cur" aria-hidden="true">
239+</td>
240+<td class="c-kind" aria-hidden="true">·</td>
241+<td class="c-name">
242+<a href="/files/blob/main/logo.svg">logo.svg</a>
243+</td>
244+<td class="c-size num dim">89 B</td>
245+<td class="c-sub dim">
246+<a href="/files/commit/SHA40">add logo.svg</a>
247+</td>
248+<td class="c-who dim">T</td>
249+<td class="c-when nowrap dim">
250+<time datetime="STAMP" title="STAMP">AGO</time>
251+</td>
252+</tr>
253+<tr>
254+<td class="cur" aria-hidden="true">
255+</td>
256+<td class="c-kind" aria-hidden="true">·</td>
257+<td class="c-name">
258+<a href="/files/blob/main/main.go">main.go</a>
259+</td>
260+<td class="c-size num dim">87 B</td>
261+<td class="c-sub dim">
262+<a href="/files/commit/SHA40">add main.go</a>
263+</td>
264+<td class="c-who dim">T</td>
265+<td class="c-when nowrap dim">
266+<time datetime="STAMP" title="STAMP">AGO</time>
267+</td>
268+</tr>
269+<tr>
270+<td class="cur" aria-hidden="true">
271+</td>
272+<td class="c-kind" aria-hidden="true">·</td>
273+<td class="c-name">
274+<a href="/files/blob/main/notes.txt">notes.txt</a>
275+</td>
276+<td class="c-size num dim">24 B</td>
277+<td class="c-sub dim">
278+<a href="/files/commit/SHA40">add notes.txt</a>
279+</td>
280+<td class="c-who dim">T</td>
281+<td class="c-when nowrap dim">
282+<time datetime="STAMP" title="STAMP">AGO</time>
283+</td>
284+</tr>
285+<tr>
286+<td class="cur" aria-hidden="true">
287+</td>
288+<td class="c-kind" aria-hidden="true">·</td>
289+<td class="c-name">
290+<a href="/files/blob/main/page.html">page.html</a>
291+</td>
292+<td class="c-size num dim">26 B</td>
293+<td class="c-sub dim">
294+<a href="/files/commit/SHA40">add page.html</a>
295+</td>
296+<td class="c-who dim">T</td>
297+<td class="c-when nowrap dim">
298+<time datetime="STAMP" title="STAMP">AGO</time>
299+</td>
300+</tr>
301+<tr>
302+<td class="cur" aria-hidden="true">
303+</td>
304+<td class="c-kind" aria-hidden="true">·</td>
305+<td class="c-name">
306+<a href="/files/blob/main/pic.png">pic.png</a>
307+</td>
308+<td class="c-size num dim">88 B</td>
309+<td class="c-sub dim">
310+<a href="/files/commit/SHA40">add pic.png</a>
311+</td>
312+<td class="c-who dim">T</td>
313+<td class="c-when nowrap dim">
314+<time datetime="STAMP" title="STAMP">AGO</time>
315+</td>
316+</tr>
317+</tbody>
318+</table>
319+<h2 class="rule-h">
320+<span>README.md</span>
321+</h2>
322+<article class="prose" aria-label="README.md">
323+<h1 id="md-files">Files</h1>
324+<p>See <a href="/files/blob/main/docs/guide.md">the guide</a>.</p>
325+</article>
326+</main>
327+</div>
328+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
329+<div class="panel-head">
330+<span>keys</span>
331+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
332+</div>
333+<dl class="keys">
334+<div>
335+<dt>
336+<kbd>j</kbd>
337+<kbd>k</kbd>
338+</dt>
339+<dd>move cursor down / up</dd>
340+</div>
341+<div>
342+<dt>
343+<kbd>enter</kbd>
344+</dt>
345+<dd>open row under cursor</dd>
346+</div>
347+<div>
348+<dt>
349+<kbd>g</kbd>
350+<kbd>g</kbd> / <kbd>G</kbd>
351+</dt>
352+<dd>first / last row</dd>
353+</div>
354+<div>
355+<dt>
356+<kbd>/</kbd>
357+</dt>
358+<dd>focus the filter</dd>
359+</div>
360+<div>
361+<dt>
362+<kbd>g h</kbd>
363+<kbd>g t</kbd>
364+</dt>
365+<dd>go home / tree</dd>
366+</div>
367+<div>
368+<dt>
369+<kbd>g c</kbd>
370+<kbd>g r</kbd>
371+</dt>
372+<dd>go commits / refs</dd>
373+</div>
374+<div>
375+<dt>
376+<kbd>b</kbd>
377+</dt>
378+<dd>switch ref (branches, tags)</dd>
379+</div>
380+<div>
381+<dt>
382+<kbd>:</kbd>
383+<kbd>f</kbd>
384+</dt>
385+<dd>go to: section, ref, file, repo</dd>
386+</div>
387+<div>
388+<dt>
389+<kbd>y</kbd>
390+</dt>
391+<dd>permalink: ref becomes the full sha</dd>
392+</div>
393+<div>
394+<dt>
395+<kbd>s</kbd>
396+<kbd>t</kbd>
397+<kbd>c</kbd>
398+<kbd>r</kbd>
399+</dt>
400+<dd>repo sections (when shown)</dd>
401+</div>
402+<div>
403+<dt>
404+<kbd>d</kbd>
405+<kbd>u</kbd>
406+<kbd>x</kbd>
407+</dt>
408+<dd>commit: split / unified / collapse all</dd>
409+</div>
410+<div>
411+<dt>
412+<kbd>esc</kbd>
413+</dt>
414+<dd>close panel</dd>
415+</div>
416+</dl>
417+</dialog>
418+<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
419+<div class="panel-head">
420+<label for="refs-filter">ref&gt;</label>
421+<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))">
422+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
423+</div>
424+<div class="panel-cols" data-picklist>
425+<section aria-label="Branches">
426+<h2>branches <span class="mute">2</span>
427+</h2>
428+<ul>
429+<li>
430+<a href="/files/tree/feature/x">
431+<span class="mark" aria-hidden="true">&nbsp;</span>feature/x</a>
432+</li>
433+<li>
434+<a href="/files/tree/main" aria-current="true">
435+<span class="mark" aria-hidden="true">●</span>main</a>
436+</li>
437+</ul>
438+</section>
439+<section aria-label="Tags">
440+<h2>tags <span class="mute">1</span>
441+</h2>
442+<ul>
443+<li>
444+<a href="/files/tree/v1.0">
445+<span class="mark" aria-hidden="true">&nbsp;</span>v1.0</a>
446+</li>
447+</ul>
448+</section>
449+</div>
450+</dialog>
451+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
452+<div class="panel-head">
453+<label for="cmd-q" class="hi">:</label>
454+<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();
455+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
456+ 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();
457+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
458+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
459+ 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' })) }
460+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
461+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
462+</div>
463+<ul id="cmd-list" class="cmd-list">
464+<li data-k="go home" data-cursor>
465+<a href="/" tabindex="-1">
466+<span class="k">go</span>home</a>
467+</li>
468+<li data-k="section summary">
469+<a href="/files" tabindex="-1">
470+<span class="k">section</span>summary</a>
471+</li>
472+<li data-k="section tree">
473+<a href="/files/tree/main" tabindex="-1">
474+<span class="k">section</span>tree</a>
475+</li>
476+<li data-k="section commits">
477+<a href="/files/commits/main" tabindex="-1">
478+<span class="k">section</span>commits</a>
479+</li>
480+<li data-k="section refs">
481+<a href="/files/refs" tabindex="-1">
482+<span class="k">section</span>refs</a>
483+</li>
484+<li data-k="branch feature/x">
485+<a href="/files/tree/feature/x" tabindex="-1">
486+<span class="k">branch</span>feature/x</a>
487+</li>
488+<li data-k="branch main">
489+<a href="/files/tree/main" tabindex="-1">
490+<span class="k">branch</span>main</a>
491+</li>
492+<li data-k="tag v1.0">
493+<a href="/files/tree/v1.0" tabindex="-1">
494+<span class="k">tag</span>v1.0</a>
495+</li>
496+<li data-k="file readme.md">
497+<a href="/files/blob/main/README.md" tabindex="-1">
498+<span class="k">file</span>README.md</a>
499+</li>
500+<li data-k="file big.txt">
501+<a href="/files/blob/main/big.txt" tabindex="-1">
502+<span class="k">file</span>big.txt</a>
503+</li>
504+<li data-k="file data.bin">
505+<a href="/files/blob/main/data.bin" tabindex="-1">
506+<span class="k">file</span>data.bin</a>
507+</li>
508+<li data-k="file empty.txt">
509+<a href="/files/blob/main/empty.txt" tabindex="-1">
510+<span class="k">file</span>empty.txt</a>
511+</li>
512+<li data-k="file evil.go">
513+<a href="/files/blob/main/evil.go" tabindex="-1">
514+<span class="k">file</span>evil.go</a>
515+</li>
516+<li data-k="file fake.jpg">
517+<a href="/files/blob/main/fake.jpg" tabindex="-1">
518+<span class="k">file</span>fake.jpg</a>
519+</li>
520+<li data-k="file fake.png">
521+<a href="/files/blob/main/fake.png" tabindex="-1">
522+<span class="k">file</span>fake.png</a>
523+</li>
524+<li data-k="file lines.txt">
525+<a href="/files/blob/main/lines.txt" tabindex="-1">
526+<span class="k">file</span>lines.txt</a>
527+</li>
528+<li data-k="file logo.svg">
529+<a href="/files/blob/main/logo.svg" tabindex="-1">
530+<span class="k">file</span>logo.svg</a>
531+</li>
532+<li data-k="file main.go">
533+<a href="/files/blob/main/main.go" tabindex="-1">
534+<span class="k">file</span>main.go</a>
535+</li>
536+<li data-k="file notes.txt">
537+<a href="/files/blob/main/notes.txt" tabindex="-1">
538+<span class="k">file</span>notes.txt</a>
539+</li>
540+<li data-k="file page.html">
541+<a href="/files/blob/main/page.html" tabindex="-1">
542+<span class="k">file</span>page.html</a>
543+</li>
544+<li data-k="file pic.png">
545+<a href="/files/blob/main/pic.png" tabindex="-1">
546+<span class="k">file</span>pic.png</a>
547+</li>
548+</ul>
549+</dialog>
550+<footer class="bar status" aria-label="Status">
551+<span class="mode">tree</span>
552+<span class="st-ctx">tree · 15 entries</span>
553+<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'))">
554+<span class="hi">:</span> go to…</button>
555+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
556+</span>
557+<span class="st-hints">j/k move · enter open · / filter · b ref · ? help</span>
558+<span class="st-src">
559+<span class="st-ver">ermite devel · </span>
560+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
561+</span>
562+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
563+<span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/15</span>
564+</footer>
565+<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
566+ if (q('dialog[open]').count) return;
567+ let rows = q('[data-rows] > :not([hidden])').arr();
568+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
569+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
570+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
571+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
572+ })(1);
573+ k-k from:body -> ((d) => {
574+ if (q('dialog[open]').count) return;
575+ let rows = q('[data-rows] > :not([hidden])').arr();
576+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
577+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
578+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
579+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
580+ })(-1);
581+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
582+ 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));
583+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
584+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
585+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
586+ 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());
587+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
588+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
589+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
590+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
591+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
592+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
593+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
594+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
595+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
596+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
597+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
598+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
599+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
600+ k-permalink from:body -> {
601+ let b = document.body.dataset;
602+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
603+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
604+ navigator.clipboard?.writeText(location.href);
605+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
606+ }">
607+</div>
608+</body>
609+</html>
@@ -0,0 +1,609 @@
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>tree · files · example.test</title>
8+<link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
9+<script src="/-/static/js/htmx.min.HASH.js" defer>
10+</script>
11+<script src="/-/static/js/hx-live.min.HASH.js" defer>
12+</script>
13+<script src="/-/static/js/hx-sse.min.HASH.js" defer>
14+</script>
15+<script src="/-/static/js/ermite.HASH.js" defer>
16+</script>
17+</head>
18+<body data-ref="main" data-sha="SHA40" data-sel="">
19+<a class="skip" href="#main">skip to content</a>
20+<header class="bar top">
21+<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
22+<span class="fade" aria-hidden="true">
23+</span>
24+<nav class="crumbs" aria-label="Path">
25+<ol>
26+<li>
27+<a href="/files">files</a>
28+</li>
29+<li>
30+<a href="/files/tree/main">tree</a>
31+</li>
32+<li>
33+<a href="/files/tree/main" aria-current="page">main</a>
34+</li>
35+</ol>
36+</nav>
37+<nav class="secs" aria-label="Sections">
38+<ul>
39+<li>
40+<a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a>
41+</li>
42+<li>
43+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a>
44+</li>
45+<li>
46+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a>
47+</li>
48+<li>
49+<a href="/files/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">
57+<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
58+<div class="filter">
59+<label for="tree-filter">filter&gt;</label>
60+<input id="tree-filter" type="search" data-filter placeholder="this directory ( / )" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')">
61+</div>
62+<table class="list tree" aria-label="Entries">
63+<thead>
64+<tr>
65+<th class="cur">
66+</th>
67+<th class="c-kind">
68+</th>
69+<th class="c-name">name</th>
70+<th class="c-size">size</th>
71+<th class="c-sub">last commit</th>
72+<th class="c-who">author</th>
73+<th class="c-when">date</th>
74+</tr>
75+</thead>
76+<tbody id="tree-rows" data-rows>
77+<tr data-cursor>
78+<td class="cur" aria-hidden="true">
79+</td>
80+<td class="c-kind" aria-hidden="true">▾</td>
81+<td class="c-name">
82+<a href="/files/tree/main/docs">docs/</a>
83+</td>
84+<td class="c-size num dim">-</td>
85+<td class="c-sub dim">
86+<a href="/files/commit/SHA40">add docs/guide.md</a>
87+</td>
88+<td class="c-who dim">T</td>
89+<td class="c-when nowrap dim">
90+<time datetime="STAMP" title="STAMP">AGO</time>
91+</td>
92+</tr>
93+<tr>
94+<td class="cur" aria-hidden="true">
95+</td>
96+<td class="c-kind" aria-hidden="true">▾</td>
97+<td class="c-name">
98+<a href="/files/tree/main/src">src/</a>
99+</td>
100+<td class="c-size num dim">-</td>
101+<td class="c-sub dim">
102+<a href="/files/commit/SHA40">add src/lib/util.go</a>
103+</td>
104+<td class="c-who dim">T</td>
105+<td class="c-when nowrap dim">
106+<time datetime="STAMP" title="STAMP">AGO</time>
107+</td>
108+</tr>
109+<tr>
110+<td class="cur" aria-hidden="true">
111+</td>
112+<td class="c-kind" aria-hidden="true">·</td>
113+<td class="c-name">
114+<a href="/files/blob/main/README.md">README.md</a>
115+</td>
116+<td class="c-size num dim">41 B</td>
117+<td class="c-sub dim">
118+<a href="/files/commit/SHA40">add README.md</a>
119+</td>
120+<td class="c-who dim">T</td>
121+<td class="c-when nowrap dim">
122+<time datetime="STAMP" title="STAMP">AGO</time>
123+</td>
124+</tr>
125+<tr>
126+<td class="cur" aria-hidden="true">
127+</td>
128+<td class="c-kind" aria-hidden="true">·</td>
129+<td class="c-name">
130+<a href="/files/blob/main/big.txt">big.txt</a>
131+</td>
132+<td class="c-size num dim">1.1 MiB</td>
133+<td class="c-sub dim">
134+<a href="/files/commit/SHA40">add big.txt</a>
135+</td>
136+<td class="c-who dim">T</td>
137+<td class="c-when nowrap dim">
138+<time datetime="STAMP" title="STAMP">AGO</time>
139+</td>
140+</tr>
141+<tr>
142+<td class="cur" aria-hidden="true">
143+</td>
144+<td class="c-kind" aria-hidden="true">·</td>
145+<td class="c-name">
146+<a href="/files/blob/main/data.bin">data.bin</a>
147+</td>
148+<td class="c-size num dim">10 B</td>
149+<td class="c-sub dim">
150+<a href="/files/commit/SHA40">add data.bin</a>
151+</td>
152+<td class="c-who dim">T</td>
153+<td class="c-when nowrap dim">
154+<time datetime="STAMP" title="STAMP">AGO</time>
155+</td>
156+</tr>
157+<tr>
158+<td class="cur" aria-hidden="true">
159+</td>
160+<td class="c-kind" aria-hidden="true">·</td>
161+<td class="c-name">
162+<a href="/files/blob/main/empty.txt">empty.txt</a>
163+</td>
164+<td class="c-size num dim">0 B</td>
165+<td class="c-sub dim">
166+<a href="/files/commit/SHA40">add empty.txt</a>
167+</td>
168+<td class="c-who dim">T</td>
169+<td class="c-when nowrap dim">
170+<time datetime="STAMP" title="STAMP">AGO</time>
171+</td>
172+</tr>
173+<tr>
174+<td class="cur" aria-hidden="true">
175+</td>
176+<td class="c-kind" aria-hidden="true">·</td>
177+<td class="c-name">
178+<a href="/files/blob/main/evil.go">evil.go</a>
179+</td>
180+<td class="c-size num dim">87 B</td>
181+<td class="c-sub dim">
182+<a href="/files/commit/SHA40">add evil.go</a>
183+</td>
184+<td class="c-who dim">T</td>
185+<td class="c-when nowrap dim">
186+<time datetime="STAMP" title="STAMP">AGO</time>
187+</td>
188+</tr>
189+<tr>
190+<td class="cur" aria-hidden="true">
191+</td>
192+<td class="c-kind" aria-hidden="true">·</td>
193+<td class="c-name">
194+<a href="/files/blob/main/fake.jpg">fake.jpg</a>
195+</td>
196+<td class="c-size num dim">15 B</td>
197+<td class="c-sub dim">
198+<a href="/files/commit/SHA40">add fake.jpg</a>
199+</td>
200+<td class="c-who dim">T</td>
201+<td class="c-when nowrap dim">
202+<time datetime="STAMP" title="STAMP">AGO</time>
203+</td>
204+</tr>
205+<tr>
206+<td class="cur" aria-hidden="true">
207+</td>
208+<td class="c-kind" aria-hidden="true">·</td>
209+<td class="c-name">
210+<a href="/files/blob/main/fake.png">fake.png</a>
211+</td>
212+<td class="c-size num dim">39 B</td>
213+<td class="c-sub dim">
214+<a href="/files/commit/SHA40">add fake.png</a>
215+</td>
216+<td class="c-who dim">T</td>
217+<td class="c-when nowrap dim">
218+<time datetime="STAMP" title="STAMP">AGO</time>
219+</td>
220+</tr>
221+<tr>
222+<td class="cur" aria-hidden="true">
223+</td>
224+<td class="c-kind" aria-hidden="true">·</td>
225+<td class="c-name">
226+<a href="/files/blob/main/lines.txt">lines.txt</a>
227+</td>
228+<td class="c-size num dim">39.1 KiB</td>
229+<td class="c-sub dim">
230+<a href="/files/commit/SHA40">add lines.txt</a>
231+</td>
232+<td class="c-who dim">T</td>
233+<td class="c-when nowrap dim">
234+<time datetime="STAMP" title="STAMP">AGO</time>
235+</td>
236+</tr>
237+<tr>
238+<td class="cur" aria-hidden="true">
239+</td>
240+<td class="c-kind" aria-hidden="true">·</td>
241+<td class="c-name">
242+<a href="/files/blob/main/logo.svg">logo.svg</a>
243+</td>
244+<td class="c-size num dim">89 B</td>
245+<td class="c-sub dim">
246+<a href="/files/commit/SHA40">add logo.svg</a>
247+</td>
248+<td class="c-who dim">T</td>
249+<td class="c-when nowrap dim">
250+<time datetime="STAMP" title="STAMP">AGO</time>
251+</td>
252+</tr>
253+<tr>
254+<td class="cur" aria-hidden="true">
255+</td>
256+<td class="c-kind" aria-hidden="true">·</td>
257+<td class="c-name">
258+<a href="/files/blob/main/main.go">main.go</a>
259+</td>
260+<td class="c-size num dim">87 B</td>
261+<td class="c-sub dim">
262+<a href="/files/commit/SHA40">add main.go</a>
263+</td>
264+<td class="c-who dim">T</td>
265+<td class="c-when nowrap dim">
266+<time datetime="STAMP" title="STAMP">AGO</time>
267+</td>
268+</tr>
269+<tr>
270+<td class="cur" aria-hidden="true">
271+</td>
272+<td class="c-kind" aria-hidden="true">·</td>
273+<td class="c-name">
274+<a href="/files/blob/main/notes.txt">notes.txt</a>
275+</td>
276+<td class="c-size num dim">24 B</td>
277+<td class="c-sub dim">
278+<a href="/files/commit/SHA40">add notes.txt</a>
279+</td>
280+<td class="c-who dim">T</td>
281+<td class="c-when nowrap dim">
282+<time datetime="STAMP" title="STAMP">AGO</time>
283+</td>
284+</tr>
285+<tr>
286+<td class="cur" aria-hidden="true">
287+</td>
288+<td class="c-kind" aria-hidden="true">·</td>
289+<td class="c-name">
290+<a href="/files/blob/main/page.html">page.html</a>
291+</td>
292+<td class="c-size num dim">26 B</td>
293+<td class="c-sub dim">
294+<a href="/files/commit/SHA40">add page.html</a>
295+</td>
296+<td class="c-who dim">T</td>
297+<td class="c-when nowrap dim">
298+<time datetime="STAMP" title="STAMP">AGO</time>
299+</td>
300+</tr>
301+<tr>
302+<td class="cur" aria-hidden="true">
303+</td>
304+<td class="c-kind" aria-hidden="true">·</td>
305+<td class="c-name">
306+<a href="/files/blob/main/pic.png">pic.png</a>
307+</td>
308+<td class="c-size num dim">88 B</td>
309+<td class="c-sub dim">
310+<a href="/files/commit/SHA40">add pic.png</a>
311+</td>
312+<td class="c-who dim">T</td>
313+<td class="c-when nowrap dim">
314+<time datetime="STAMP" title="STAMP">AGO</time>
315+</td>
316+</tr>
317+</tbody>
318+</table>
319+<h2 class="rule-h">
320+<span>README.md</span>
321+</h2>
322+<article class="prose" aria-label="README.md">
323+<h1 id="md-files">Files</h1>
324+<p>See <a href="/files/blob/main/docs/guide.md">the guide</a>.</p>
325+</article>
326+</main>
327+</div>
328+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
329+<div class="panel-head">
330+<span>keys</span>
331+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
332+</div>
333+<dl class="keys">
334+<div>
335+<dt>
336+<kbd>j</kbd>
337+<kbd>k</kbd>
338+</dt>
339+<dd>move cursor down / up</dd>
340+</div>
341+<div>
342+<dt>
343+<kbd>enter</kbd>
344+</dt>
345+<dd>open row under cursor</dd>
346+</div>
347+<div>
348+<dt>
349+<kbd>g</kbd>
350+<kbd>g</kbd> / <kbd>G</kbd>
351+</dt>
352+<dd>first / last row</dd>
353+</div>
354+<div>
355+<dt>
356+<kbd>/</kbd>
357+</dt>
358+<dd>focus the filter</dd>
359+</div>
360+<div>
361+<dt>
362+<kbd>g h</kbd>
363+<kbd>g t</kbd>
364+</dt>
365+<dd>go home / tree</dd>
366+</div>
367+<div>
368+<dt>
369+<kbd>g c</kbd>
370+<kbd>g r</kbd>
371+</dt>
372+<dd>go commits / refs</dd>
373+</div>
374+<div>
375+<dt>
376+<kbd>b</kbd>
377+</dt>
378+<dd>switch ref (branches, tags)</dd>
379+</div>
380+<div>
381+<dt>
382+<kbd>:</kbd>
383+<kbd>f</kbd>
384+</dt>
385+<dd>go to: section, ref, file, repo</dd>
386+</div>
387+<div>
388+<dt>
389+<kbd>y</kbd>
390+</dt>
391+<dd>permalink: ref becomes the full sha</dd>
392+</div>
393+<div>
394+<dt>
395+<kbd>s</kbd>
396+<kbd>t</kbd>
397+<kbd>c</kbd>
398+<kbd>r</kbd>
399+</dt>
400+<dd>repo sections (when shown)</dd>
401+</div>
402+<div>
403+<dt>
404+<kbd>d</kbd>
405+<kbd>u</kbd>
406+<kbd>x</kbd>
407+</dt>
408+<dd>commit: split / unified / collapse all</dd>
409+</div>
410+<div>
411+<dt>
412+<kbd>esc</kbd>
413+</dt>
414+<dd>close panel</dd>
415+</div>
416+</dl>
417+</dialog>
418+<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
419+<div class="panel-head">
420+<label for="refs-filter">ref&gt;</label>
421+<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))">
422+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
423+</div>
424+<div class="panel-cols" data-picklist>
425+<section aria-label="Branches">
426+<h2>branches <span class="mute">2</span>
427+</h2>
428+<ul>
429+<li>
430+<a href="/files/tree/feature/x">
431+<span class="mark" aria-hidden="true">&nbsp;</span>feature/x</a>
432+</li>
433+<li>
434+<a href="/files/tree/main" aria-current="true">
435+<span class="mark" aria-hidden="true">●</span>main</a>
436+</li>
437+</ul>
438+</section>
439+<section aria-label="Tags">
440+<h2>tags <span class="mute">1</span>
441+</h2>
442+<ul>
443+<li>
444+<a href="/files/tree/v1.0">
445+<span class="mark" aria-hidden="true">&nbsp;</span>v1.0</a>
446+</li>
447+</ul>
448+</section>
449+</div>
450+</dialog>
451+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
452+<div class="panel-head">
453+<label for="cmd-q" class="hi">:</label>
454+<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();
455+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
456+ 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();
457+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
458+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
459+ 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' })) }
460+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
461+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
462+</div>
463+<ul id="cmd-list" class="cmd-list">
464+<li data-k="go home" data-cursor>
465+<a href="/" tabindex="-1">
466+<span class="k">go</span>home</a>
467+</li>
468+<li data-k="section summary">
469+<a href="/files" tabindex="-1">
470+<span class="k">section</span>summary</a>
471+</li>
472+<li data-k="section tree">
473+<a href="/files/tree/main" tabindex="-1">
474+<span class="k">section</span>tree</a>
475+</li>
476+<li data-k="section commits">
477+<a href="/files/commits/main" tabindex="-1">
478+<span class="k">section</span>commits</a>
479+</li>
480+<li data-k="section refs">
481+<a href="/files/refs" tabindex="-1">
482+<span class="k">section</span>refs</a>
483+</li>
484+<li data-k="branch feature/x">
485+<a href="/files/tree/feature/x" tabindex="-1">
486+<span class="k">branch</span>feature/x</a>
487+</li>
488+<li data-k="branch main">
489+<a href="/files/tree/main" tabindex="-1">
490+<span class="k">branch</span>main</a>
491+</li>
492+<li data-k="tag v1.0">
493+<a href="/files/tree/v1.0" tabindex="-1">
494+<span class="k">tag</span>v1.0</a>
495+</li>
496+<li data-k="file readme.md">
497+<a href="/files/blob/main/README.md" tabindex="-1">
498+<span class="k">file</span>README.md</a>
499+</li>
500+<li data-k="file big.txt">
501+<a href="/files/blob/main/big.txt" tabindex="-1">
502+<span class="k">file</span>big.txt</a>
503+</li>
504+<li data-k="file data.bin">
505+<a href="/files/blob/main/data.bin" tabindex="-1">
506+<span class="k">file</span>data.bin</a>
507+</li>
508+<li data-k="file empty.txt">
509+<a href="/files/blob/main/empty.txt" tabindex="-1">
510+<span class="k">file</span>empty.txt</a>
511+</li>
512+<li data-k="file evil.go">
513+<a href="/files/blob/main/evil.go" tabindex="-1">
514+<span class="k">file</span>evil.go</a>
515+</li>
516+<li data-k="file fake.jpg">
517+<a href="/files/blob/main/fake.jpg" tabindex="-1">
518+<span class="k">file</span>fake.jpg</a>
519+</li>
520+<li data-k="file fake.png">
521+<a href="/files/blob/main/fake.png" tabindex="-1">
522+<span class="k">file</span>fake.png</a>
523+</li>
524+<li data-k="file lines.txt">
525+<a href="/files/blob/main/lines.txt" tabindex="-1">
526+<span class="k">file</span>lines.txt</a>
527+</li>
528+<li data-k="file logo.svg">
529+<a href="/files/blob/main/logo.svg" tabindex="-1">
530+<span class="k">file</span>logo.svg</a>
531+</li>
532+<li data-k="file main.go">
533+<a href="/files/blob/main/main.go" tabindex="-1">
534+<span class="k">file</span>main.go</a>
535+</li>
536+<li data-k="file notes.txt">
537+<a href="/files/blob/main/notes.txt" tabindex="-1">
538+<span class="k">file</span>notes.txt</a>
539+</li>
540+<li data-k="file page.html">
541+<a href="/files/blob/main/page.html" tabindex="-1">
542+<span class="k">file</span>page.html</a>
543+</li>
544+<li data-k="file pic.png">
545+<a href="/files/blob/main/pic.png" tabindex="-1">
546+<span class="k">file</span>pic.png</a>
547+</li>
548+</ul>
549+</dialog>
550+<footer class="bar status" aria-label="Status">
551+<span class="mode">tree</span>
552+<span class="st-ctx">tree · 15 entries</span>
553+<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'))">
554+<span class="hi">:</span> go to…</button>
555+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
556+</span>
557+<span class="st-hints">j/k move · enter open · / filter · b ref · ? help</span>
558+<span class="st-src">
559+<span class="st-ver">ermite devel · </span>
560+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
561+</span>
562+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
563+<span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/15</span>
564+</footer>
565+<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
566+ if (q('dialog[open]').count) return;
567+ let rows = q('[data-rows] > :not([hidden])').arr();
568+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
569+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
570+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
571+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
572+ })(1);
573+ k-k from:body -> ((d) => {
574+ if (q('dialog[open]').count) return;
575+ let rows = q('[data-rows] > :not([hidden])').arr();
576+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
577+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
578+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
579+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
580+ })(-1);
581+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
582+ 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));
583+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
584+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
585+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
586+ 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());
587+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
588+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
589+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
590+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
591+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
592+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
593+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
594+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
595+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
596+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
597+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
598+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
599+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
600+ k-permalink from:body -> {
601+ let b = document.body.dataset;
602+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
603+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
604+ navigator.clipboard?.writeText(location.href);
605+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
606+ }">
607+</div>
608+</body>
609+</html>
A cmd/ermite/testdata/web/tree-slash-ref.html +333 -0
@@ -0,0 +1,333 @@
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>x · files · 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="feature/x" 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="/files">files</a>
28+</li>
29+<li>
30+<a href="/files/tree/feature/x">tree</a>
31+</li>
32+<li>
33+<a href="/files/tree/feature/x">feature/x</a>
34+</li>
35+<li>
36+<a href="/files/tree/feature/x/x" aria-current="page">x</a>
37+</li>
38+</ol>
39+</nav>
40+<nav class="secs" aria-label="Sections">
41+<ul>
42+<li>
43+<a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a>
44+</li>
45+<li>
46+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a>
47+</li>
48+<li>
49+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a>
50+</li>
51+<li>
52+<a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
53+</li>
54+</ul>
55+</nav>
56+<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">feature/x<span class="sha">@SHA7</span>
57+</button>
58+</header>
59+<div class="work">
60+<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
61+<div class="filter">
62+<label for="tree-filter">filter&gt;</label>
63+<input id="tree-filter" type="search" data-filter placeholder="this directory ( / )" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')">
64+</div>
65+<table class="list tree" aria-label="Entries">
66+<thead>
67+<tr>
68+<th class="cur">
69+</th>
70+<th class="c-kind">
71+</th>
72+<th class="c-name">name</th>
73+<th class="c-size">size</th>
74+<th class="c-sub">last commit</th>
75+<th class="c-who">author</th>
76+<th class="c-when">date</th>
77+</tr>
78+</thead>
79+<tbody id="tree-rows" data-rows>
80+<tr data-cursor>
81+<td class="cur" aria-hidden="true">
82+</td>
83+<td class="c-kind" aria-hidden="true">·</td>
84+<td class="c-name">
85+<a href="/files/blob/feature/x/x/y.txt">y.txt</a>
86+</td>
87+<td class="c-size num dim">22 B</td>
88+<td class="c-sub dim">
89+<a href="/files/commit/SHA40">add x/y.txt</a>
90+</td>
91+<td class="c-who dim">T</td>
92+<td class="c-when nowrap dim">
93+<time datetime="STAMP" title="STAMP">AGO</time>
94+</td>
95+</tr>
96+</tbody>
97+</table>
98+</main>
99+</div>
100+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
101+<div class="panel-head">
102+<span>keys</span>
103+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
104+</div>
105+<dl class="keys">
106+<div>
107+<dt>
108+<kbd>j</kbd>
109+<kbd>k</kbd>
110+</dt>
111+<dd>move cursor down / up</dd>
112+</div>
113+<div>
114+<dt>
115+<kbd>enter</kbd>
116+</dt>
117+<dd>open row under cursor</dd>
118+</div>
119+<div>
120+<dt>
121+<kbd>g</kbd>
122+<kbd>g</kbd> / <kbd>G</kbd>
123+</dt>
124+<dd>first / last row</dd>
125+</div>
126+<div>
127+<dt>
128+<kbd>/</kbd>
129+</dt>
130+<dd>focus the filter</dd>
131+</div>
132+<div>
133+<dt>
134+<kbd>g h</kbd>
135+<kbd>g t</kbd>
136+</dt>
137+<dd>go home / tree</dd>
138+</div>
139+<div>
140+<dt>
141+<kbd>g c</kbd>
142+<kbd>g r</kbd>
143+</dt>
144+<dd>go commits / refs</dd>
145+</div>
146+<div>
147+<dt>
148+<kbd>b</kbd>
149+</dt>
150+<dd>switch ref (branches, tags)</dd>
151+</div>
152+<div>
153+<dt>
154+<kbd>:</kbd>
155+<kbd>f</kbd>
156+</dt>
157+<dd>go to: section, ref, file, repo</dd>
158+</div>
159+<div>
160+<dt>
161+<kbd>y</kbd>
162+</dt>
163+<dd>permalink: ref becomes the full sha</dd>
164+</div>
165+<div>
166+<dt>
167+<kbd>s</kbd>
168+<kbd>t</kbd>
169+<kbd>c</kbd>
170+<kbd>r</kbd>
171+</dt>
172+<dd>repo sections (when shown)</dd>
173+</div>
174+<div>
175+<dt>
176+<kbd>d</kbd>
177+<kbd>u</kbd>
178+<kbd>x</kbd>
179+</dt>
180+<dd>commit: split / unified / collapse all</dd>
181+</div>
182+<div>
183+<dt>
184+<kbd>esc</kbd>
185+</dt>
186+<dd>close panel</dd>
187+</div>
188+</dl>
189+</dialog>
190+<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
191+<div class="panel-head">
192+<label for="refs-filter">ref&gt;</label>
193+<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))">
194+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
195+</div>
196+<div class="panel-cols" data-picklist>
197+<section aria-label="Branches">
198+<h2>branches <span class="mute">2</span>
199+</h2>
200+<ul>
201+<li>
202+<a href="/files/tree/feature/x" aria-current="true">
203+<span class="mark" aria-hidden="true">●</span>feature/x</a>
204+</li>
205+<li>
206+<a href="/files/tree/main">
207+<span class="mark" aria-hidden="true">&nbsp;</span>main</a>
208+</li>
209+</ul>
210+</section>
211+<section aria-label="Tags">
212+<h2>tags <span class="mute">1</span>
213+</h2>
214+<ul>
215+<li>
216+<a href="/files/tree/v1.0">
217+<span class="mark" aria-hidden="true">&nbsp;</span>v1.0</a>
218+</li>
219+</ul>
220+</section>
221+</div>
222+</dialog>
223+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
224+<div class="panel-head">
225+<label for="cmd-q" class="hi">:</label>
226+<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();
227+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
228+ 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();
229+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
230+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
231+ 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' })) }
232+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
233+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
234+</div>
235+<ul id="cmd-list" class="cmd-list">
236+<li data-k="go home" data-cursor>
237+<a href="/" tabindex="-1">
238+<span class="k">go</span>home</a>
239+</li>
240+<li data-k="section summary">
241+<a href="/files" tabindex="-1">
242+<span class="k">section</span>summary</a>
243+</li>
244+<li data-k="section tree">
245+<a href="/files/tree/main" tabindex="-1">
246+<span class="k">section</span>tree</a>
247+</li>
248+<li data-k="section commits">
249+<a href="/files/commits/main" tabindex="-1">
250+<span class="k">section</span>commits</a>
251+</li>
252+<li data-k="section refs">
253+<a href="/files/refs" tabindex="-1">
254+<span class="k">section</span>refs</a>
255+</li>
256+<li data-k="branch feature/x">
257+<a href="/files/tree/feature/x" tabindex="-1">
258+<span class="k">branch</span>feature/x</a>
259+</li>
260+<li data-k="branch main">
261+<a href="/files/tree/main" tabindex="-1">
262+<span class="k">branch</span>main</a>
263+</li>
264+<li data-k="tag v1.0">
265+<a href="/files/tree/v1.0" tabindex="-1">
266+<span class="k">tag</span>v1.0</a>
267+</li>
268+<li data-k="file x/y.txt">
269+<a href="/files/blob/feature/x/x/y.txt" tabindex="-1">
270+<span class="k">file</span>x/y.txt</a>
271+</li>
272+</ul>
273+</dialog>
274+<footer class="bar status" aria-label="Status">
275+<span class="mode">tree</span>
276+<span class="st-ctx">tree · 1 entry</span>
277+<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'))">
278+<span class="hi">:</span> go to…</button>
279+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
280+</span>
281+<span class="st-hints">j/k move · enter open · / filter · b ref · ? help</span>
282+<span class="st-src">
283+<span class="st-ver">ermite devel · </span>
284+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
285+</span>
286+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
287+<span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/1</span>
288+</footer>
289+<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
290+ if (q('dialog[open]').count) return;
291+ let rows = q('[data-rows] > :not([hidden])').arr();
292+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
293+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
294+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
295+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
296+ })(1);
297+ k-k from:body -> ((d) => {
298+ if (q('dialog[open]').count) return;
299+ let rows = q('[data-rows] > :not([hidden])').arr();
300+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
301+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
302+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
303+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
304+ })(-1);
305+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
306+ 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));
307+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
308+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
309+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
310+ 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());
311+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
312+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
313+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
314+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
315+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
316+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
317+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
318+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
319+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
320+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
321+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
322+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
323+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
324+ k-permalink from:body -> {
325+ let b = document.body.dataset;
326+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
327+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
328+ navigator.clipboard?.writeText(location.href);
329+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
330+ }">
331+</div>
332+</body>
333+</html>
@@ -0,0 +1,333 @@
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>x · files · 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="feature/x" 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="/files">files</a>
28+</li>
29+<li>
30+<a href="/files/tree/feature/x">tree</a>
31+</li>
32+<li>
33+<a href="/files/tree/feature/x">feature/x</a>
34+</li>
35+<li>
36+<a href="/files/tree/feature/x/x" aria-current="page">x</a>
37+</li>
38+</ol>
39+</nav>
40+<nav class="secs" aria-label="Sections">
41+<ul>
42+<li>
43+<a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a>
44+</li>
45+<li>
46+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a>
47+</li>
48+<li>
49+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a>
50+</li>
51+<li>
52+<a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
53+</li>
54+</ul>
55+</nav>
56+<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">feature/x<span class="sha">@SHA7</span>
57+</button>
58+</header>
59+<div class="work">
60+<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
61+<div class="filter">
62+<label for="tree-filter">filter&gt;</label>
63+<input id="tree-filter" type="search" data-filter placeholder="this directory ( / )" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')">
64+</div>
65+<table class="list tree" aria-label="Entries">
66+<thead>
67+<tr>
68+<th class="cur">
69+</th>
70+<th class="c-kind">
71+</th>
72+<th class="c-name">name</th>
73+<th class="c-size">size</th>
74+<th class="c-sub">last commit</th>
75+<th class="c-who">author</th>
76+<th class="c-when">date</th>
77+</tr>
78+</thead>
79+<tbody id="tree-rows" data-rows>
80+<tr data-cursor>
81+<td class="cur" aria-hidden="true">
82+</td>
83+<td class="c-kind" aria-hidden="true">·</td>
84+<td class="c-name">
85+<a href="/files/blob/feature/x/x/y.txt">y.txt</a>
86+</td>
87+<td class="c-size num dim">22 B</td>
88+<td class="c-sub dim">
89+<a href="/files/commit/SHA40">add x/y.txt</a>
90+</td>
91+<td class="c-who dim">T</td>
92+<td class="c-when nowrap dim">
93+<time datetime="STAMP" title="STAMP">AGO</time>
94+</td>
95+</tr>
96+</tbody>
97+</table>
98+</main>
99+</div>
100+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
101+<div class="panel-head">
102+<span>keys</span>
103+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
104+</div>
105+<dl class="keys">
106+<div>
107+<dt>
108+<kbd>j</kbd>
109+<kbd>k</kbd>
110+</dt>
111+<dd>move cursor down / up</dd>
112+</div>
113+<div>
114+<dt>
115+<kbd>enter</kbd>
116+</dt>
117+<dd>open row under cursor</dd>
118+</div>
119+<div>
120+<dt>
121+<kbd>g</kbd>
122+<kbd>g</kbd> / <kbd>G</kbd>
123+</dt>
124+<dd>first / last row</dd>
125+</div>
126+<div>
127+<dt>
128+<kbd>/</kbd>
129+</dt>
130+<dd>focus the filter</dd>
131+</div>
132+<div>
133+<dt>
134+<kbd>g h</kbd>
135+<kbd>g t</kbd>
136+</dt>
137+<dd>go home / tree</dd>
138+</div>
139+<div>
140+<dt>
141+<kbd>g c</kbd>
142+<kbd>g r</kbd>
143+</dt>
144+<dd>go commits / refs</dd>
145+</div>
146+<div>
147+<dt>
148+<kbd>b</kbd>
149+</dt>
150+<dd>switch ref (branches, tags)</dd>
151+</div>
152+<div>
153+<dt>
154+<kbd>:</kbd>
155+<kbd>f</kbd>
156+</dt>
157+<dd>go to: section, ref, file, repo</dd>
158+</div>
159+<div>
160+<dt>
161+<kbd>y</kbd>
162+</dt>
163+<dd>permalink: ref becomes the full sha</dd>
164+</div>
165+<div>
166+<dt>
167+<kbd>s</kbd>
168+<kbd>t</kbd>
169+<kbd>c</kbd>
170+<kbd>r</kbd>
171+</dt>
172+<dd>repo sections (when shown)</dd>
173+</div>
174+<div>
175+<dt>
176+<kbd>d</kbd>
177+<kbd>u</kbd>
178+<kbd>x</kbd>
179+</dt>
180+<dd>commit: split / unified / collapse all</dd>
181+</div>
182+<div>
183+<dt>
184+<kbd>esc</kbd>
185+</dt>
186+<dd>close panel</dd>
187+</div>
188+</dl>
189+</dialog>
190+<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
191+<div class="panel-head">
192+<label for="refs-filter">ref&gt;</label>
193+<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))">
194+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
195+</div>
196+<div class="panel-cols" data-picklist>
197+<section aria-label="Branches">
198+<h2>branches <span class="mute">2</span>
199+</h2>
200+<ul>
201+<li>
202+<a href="/files/tree/feature/x" aria-current="true">
203+<span class="mark" aria-hidden="true">●</span>feature/x</a>
204+</li>
205+<li>
206+<a href="/files/tree/main">
207+<span class="mark" aria-hidden="true">&nbsp;</span>main</a>
208+</li>
209+</ul>
210+</section>
211+<section aria-label="Tags">
212+<h2>tags <span class="mute">1</span>
213+</h2>
214+<ul>
215+<li>
216+<a href="/files/tree/v1.0">
217+<span class="mark" aria-hidden="true">&nbsp;</span>v1.0</a>
218+</li>
219+</ul>
220+</section>
221+</div>
222+</dialog>
223+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
224+<div class="panel-head">
225+<label for="cmd-q" class="hi">:</label>
226+<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();
227+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
228+ 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();
229+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
230+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
231+ 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' })) }
232+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
233+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
234+</div>
235+<ul id="cmd-list" class="cmd-list">
236+<li data-k="go home" data-cursor>
237+<a href="/" tabindex="-1">
238+<span class="k">go</span>home</a>
239+</li>
240+<li data-k="section summary">
241+<a href="/files" tabindex="-1">
242+<span class="k">section</span>summary</a>
243+</li>
244+<li data-k="section tree">
245+<a href="/files/tree/main" tabindex="-1">
246+<span class="k">section</span>tree</a>
247+</li>
248+<li data-k="section commits">
249+<a href="/files/commits/main" tabindex="-1">
250+<span class="k">section</span>commits</a>
251+</li>
252+<li data-k="section refs">
253+<a href="/files/refs" tabindex="-1">
254+<span class="k">section</span>refs</a>
255+</li>
256+<li data-k="branch feature/x">
257+<a href="/files/tree/feature/x" tabindex="-1">
258+<span class="k">branch</span>feature/x</a>
259+</li>
260+<li data-k="branch main">
261+<a href="/files/tree/main" tabindex="-1">
262+<span class="k">branch</span>main</a>
263+</li>
264+<li data-k="tag v1.0">
265+<a href="/files/tree/v1.0" tabindex="-1">
266+<span class="k">tag</span>v1.0</a>
267+</li>
268+<li data-k="file x/y.txt">
269+<a href="/files/blob/feature/x/x/y.txt" tabindex="-1">
270+<span class="k">file</span>x/y.txt</a>
271+</li>
272+</ul>
273+</dialog>
274+<footer class="bar status" aria-label="Status">
275+<span class="mode">tree</span>
276+<span class="st-ctx">tree · 1 entry</span>
277+<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'))">
278+<span class="hi">:</span> go to…</button>
279+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
280+</span>
281+<span class="st-hints">j/k move · enter open · / filter · b ref · ? help</span>
282+<span class="st-src">
283+<span class="st-ver">ermite devel · </span>
284+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
285+</span>
286+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
287+<span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/1</span>
288+</footer>
289+<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
290+ if (q('dialog[open]').count) return;
291+ let rows = q('[data-rows] > :not([hidden])').arr();
292+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
293+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
294+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
295+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
296+ })(1);
297+ k-k from:body -> ((d) => {
298+ if (q('dialog[open]').count) return;
299+ let rows = q('[data-rows] > :not([hidden])').arr();
300+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
301+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
302+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
303+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
304+ })(-1);
305+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
306+ 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));
307+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
308+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
309+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
310+ 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());
311+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
312+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
313+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
314+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
315+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
316+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
317+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
318+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
319+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
320+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
321+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
322+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
323+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
324+ k-permalink from:body -> {
325+ let b = document.body.dataset;
326+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
327+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
328+ navigator.clipboard?.writeText(location.href);
329+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
330+ }">
331+</div>
332+</body>
333+</html>
A cmd/ermite/testdata/web/tree-subdir.html +360 -0
@@ -0,0 +1,360 @@
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>docs · files · example.test</title>
8+<link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
9+<script src="/-/static/js/htmx.min.HASH.js" defer>
10+</script>
11+<script src="/-/static/js/hx-live.min.HASH.js" defer>
12+</script>
13+<script src="/-/static/js/hx-sse.min.HASH.js" defer>
14+</script>
15+<script src="/-/static/js/ermite.HASH.js" defer>
16+</script>
17+</head>
18+<body data-ref="main" data-sha="SHA40" data-sel="">
19+<a class="skip" href="#main">skip to content</a>
20+<header class="bar top">
21+<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
22+<span class="fade" aria-hidden="true">
23+</span>
24+<nav class="crumbs" aria-label="Path">
25+<ol>
26+<li>
27+<a href="/files">files</a>
28+</li>
29+<li>
30+<a href="/files/tree/main">tree</a>
31+</li>
32+<li>
33+<a href="/files/tree/main">main</a>
34+</li>
35+<li>
36+<a href="/files/tree/main/docs" aria-current="page">docs</a>
37+</li>
38+</ol>
39+</nav>
40+<nav class="secs" aria-label="Sections">
41+<ul>
42+<li>
43+<a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a>
44+</li>
45+<li>
46+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a>
47+</li>
48+<li>
49+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a>
50+</li>
51+<li>
52+<a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
53+</li>
54+</ul>
55+</nav>
56+<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
57+</button>
58+</header>
59+<div class="work">
60+<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
61+<div class="filter">
62+<label for="tree-filter">filter&gt;</label>
63+<input id="tree-filter" type="search" data-filter placeholder="this directory ( / )" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')">
64+</div>
65+<table class="list tree" aria-label="Entries">
66+<thead>
67+<tr>
68+<th class="cur">
69+</th>
70+<th class="c-kind">
71+</th>
72+<th class="c-name">name</th>
73+<th class="c-size">size</th>
74+<th class="c-sub">last commit</th>
75+<th class="c-who">author</th>
76+<th class="c-when">date</th>
77+</tr>
78+</thead>
79+<tbody id="tree-rows" data-rows>
80+<tr data-cursor>
81+<td class="cur" aria-hidden="true">
82+</td>
83+<td class="c-kind" aria-hidden="true">·</td>
84+<td class="c-name">
85+<a href="/files/blob/main/docs/README.md">README.md</a>
86+</td>
87+<td class="c-size num dim">62 B</td>
88+<td class="c-sub dim">
89+<a href="/files/commit/SHA40">add docs/README.md</a>
90+</td>
91+<td class="c-who dim">T</td>
92+<td class="c-when nowrap dim">
93+<time datetime="STAMP" title="STAMP">AGO</time>
94+</td>
95+</tr>
96+<tr>
97+<td class="cur" aria-hidden="true">
98+</td>
99+<td class="c-kind" aria-hidden="true">·</td>
100+<td class="c-name">
101+<a href="/files/blob/main/docs/guide.md">guide.md</a>
102+</td>
103+<td class="c-size num dim">47 B</td>
104+<td class="c-sub dim">
105+<a href="/files/commit/SHA40">add docs/guide.md</a>
106+</td>
107+<td class="c-who dim">T</td>
108+<td class="c-when nowrap dim">
109+<time datetime="STAMP" title="STAMP">AGO</time>
110+</td>
111+</tr>
112+</tbody>
113+</table>
114+<h2 class="rule-h">
115+<span>README.md</span>
116+</h2>
117+<article class="prose" aria-label="README.md">
118+<h1 id="md-docs">Docs</h1>
119+<p>Back to <a href="/files/blob/main/docs/guide.md">the guide</a> and <a href="/files/blob/main/main.go">root</a>.</p>
120+</article>
121+</main>
122+</div>
123+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
124+<div class="panel-head">
125+<span>keys</span>
126+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
127+</div>
128+<dl class="keys">
129+<div>
130+<dt>
131+<kbd>j</kbd>
132+<kbd>k</kbd>
133+</dt>
134+<dd>move cursor down / up</dd>
135+</div>
136+<div>
137+<dt>
138+<kbd>enter</kbd>
139+</dt>
140+<dd>open row under cursor</dd>
141+</div>
142+<div>
143+<dt>
144+<kbd>g</kbd>
145+<kbd>g</kbd> / <kbd>G</kbd>
146+</dt>
147+<dd>first / last row</dd>
148+</div>
149+<div>
150+<dt>
151+<kbd>/</kbd>
152+</dt>
153+<dd>focus the filter</dd>
154+</div>
155+<div>
156+<dt>
157+<kbd>g h</kbd>
158+<kbd>g t</kbd>
159+</dt>
160+<dd>go home / tree</dd>
161+</div>
162+<div>
163+<dt>
164+<kbd>g c</kbd>
165+<kbd>g r</kbd>
166+</dt>
167+<dd>go commits / refs</dd>
168+</div>
169+<div>
170+<dt>
171+<kbd>b</kbd>
172+</dt>
173+<dd>switch ref (branches, tags)</dd>
174+</div>
175+<div>
176+<dt>
177+<kbd>:</kbd>
178+<kbd>f</kbd>
179+</dt>
180+<dd>go to: section, ref, file, repo</dd>
181+</div>
182+<div>
183+<dt>
184+<kbd>y</kbd>
185+</dt>
186+<dd>permalink: ref becomes the full sha</dd>
187+</div>
188+<div>
189+<dt>
190+<kbd>s</kbd>
191+<kbd>t</kbd>
192+<kbd>c</kbd>
193+<kbd>r</kbd>
194+</dt>
195+<dd>repo sections (when shown)</dd>
196+</div>
197+<div>
198+<dt>
199+<kbd>d</kbd>
200+<kbd>u</kbd>
201+<kbd>x</kbd>
202+</dt>
203+<dd>commit: split / unified / collapse all</dd>
204+</div>
205+<div>
206+<dt>
207+<kbd>esc</kbd>
208+</dt>
209+<dd>close panel</dd>
210+</div>
211+</dl>
212+</dialog>
213+<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
214+<div class="panel-head">
215+<label for="refs-filter">ref&gt;</label>
216+<input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
217+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
218+</div>
219+<div class="panel-cols" data-picklist>
220+<section aria-label="Branches">
221+<h2>branches <span class="mute">2</span>
222+</h2>
223+<ul>
224+<li>
225+<a href="/files/tree/feature/x">
226+<span class="mark" aria-hidden="true">&nbsp;</span>feature/x</a>
227+</li>
228+<li>
229+<a href="/files/tree/main" aria-current="true">
230+<span class="mark" aria-hidden="true">●</span>main</a>
231+</li>
232+</ul>
233+</section>
234+<section aria-label="Tags">
235+<h2>tags <span class="mute">1</span>
236+</h2>
237+<ul>
238+<li>
239+<a href="/files/tree/v1.0">
240+<span class="mark" aria-hidden="true">&nbsp;</span>v1.0</a>
241+</li>
242+</ul>
243+</section>
244+</div>
245+</dialog>
246+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
247+<div class="panel-head">
248+<label for="cmd-q" class="hi">:</label>
249+<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();
250+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
251+ 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();
252+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
253+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
254+ 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' })) }
255+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
256+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
257+</div>
258+<ul id="cmd-list" class="cmd-list">
259+<li data-k="go home" data-cursor>
260+<a href="/" tabindex="-1">
261+<span class="k">go</span>home</a>
262+</li>
263+<li data-k="section summary">
264+<a href="/files" tabindex="-1">
265+<span class="k">section</span>summary</a>
266+</li>
267+<li data-k="section tree">
268+<a href="/files/tree/main" tabindex="-1">
269+<span class="k">section</span>tree</a>
270+</li>
271+<li data-k="section commits">
272+<a href="/files/commits/main" tabindex="-1">
273+<span class="k">section</span>commits</a>
274+</li>
275+<li data-k="section refs">
276+<a href="/files/refs" tabindex="-1">
277+<span class="k">section</span>refs</a>
278+</li>
279+<li data-k="branch feature/x">
280+<a href="/files/tree/feature/x" tabindex="-1">
281+<span class="k">branch</span>feature/x</a>
282+</li>
283+<li data-k="branch main">
284+<a href="/files/tree/main" tabindex="-1">
285+<span class="k">branch</span>main</a>
286+</li>
287+<li data-k="tag v1.0">
288+<a href="/files/tree/v1.0" tabindex="-1">
289+<span class="k">tag</span>v1.0</a>
290+</li>
291+<li data-k="file docs/readme.md">
292+<a href="/files/blob/main/docs/README.md" tabindex="-1">
293+<span class="k">file</span>docs/README.md</a>
294+</li>
295+<li data-k="file docs/guide.md">
296+<a href="/files/blob/main/docs/guide.md" tabindex="-1">
297+<span class="k">file</span>docs/guide.md</a>
298+</li>
299+</ul>
300+</dialog>
301+<footer class="bar status" aria-label="Status">
302+<span class="mode">tree</span>
303+<span class="st-ctx">tree · 2 entries</span>
304+<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'))">
305+<span class="hi">:</span> go to…</button>
306+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
307+</span>
308+<span class="st-hints">j/k move · enter open · / filter · b ref · ? help</span>
309+<span class="st-src">
310+<span class="st-ver">ermite devel · </span>
311+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
312+</span>
313+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
314+<span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/2</span>
315+</footer>
316+<div id="keys" hidden hx-on="k-j 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-k from:body -> ((d) => {
325+ if (q('dialog[open]').count) return;
326+ let rows = q('[data-rows] > :not([hidden])').arr();
327+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
328+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
329+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
330+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
331+ })(-1);
332+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
333+ 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));
334+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
335+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
336+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
337+ 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());
338+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
339+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
340+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
341+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
342+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
343+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
344+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
345+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
346+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
347+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
348+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
349+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
350+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
351+ k-permalink from:body -> {
352+ let b = document.body.dataset;
353+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
354+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
355+ navigator.clipboard?.writeText(location.href);
356+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
357+ }">
358+</div>
359+</body>
360+</html>
@@ -0,0 +1,360 @@
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>docs · files · example.test</title>
8+<link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
9+<script src="/-/static/js/htmx.min.HASH.js" defer>
10+</script>
11+<script src="/-/static/js/hx-live.min.HASH.js" defer>
12+</script>
13+<script src="/-/static/js/hx-sse.min.HASH.js" defer>
14+</script>
15+<script src="/-/static/js/ermite.HASH.js" defer>
16+</script>
17+</head>
18+<body data-ref="main" data-sha="SHA40" data-sel="">
19+<a class="skip" href="#main">skip to content</a>
20+<header class="bar top">
21+<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
22+<span class="fade" aria-hidden="true">
23+</span>
24+<nav class="crumbs" aria-label="Path">
25+<ol>
26+<li>
27+<a href="/files">files</a>
28+</li>
29+<li>
30+<a href="/files/tree/main">tree</a>
31+</li>
32+<li>
33+<a href="/files/tree/main">main</a>
34+</li>
35+<li>
36+<a href="/files/tree/main/docs" aria-current="page">docs</a>
37+</li>
38+</ol>
39+</nav>
40+<nav class="secs" aria-label="Sections">
41+<ul>
42+<li>
43+<a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a>
44+</li>
45+<li>
46+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a>
47+</li>
48+<li>
49+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a>
50+</li>
51+<li>
52+<a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
53+</li>
54+</ul>
55+</nav>
56+<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
57+</button>
58+</header>
59+<div class="work">
60+<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
61+<div class="filter">
62+<label for="tree-filter">filter&gt;</label>
63+<input id="tree-filter" type="search" data-filter placeholder="this directory ( / )" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')">
64+</div>
65+<table class="list tree" aria-label="Entries">
66+<thead>
67+<tr>
68+<th class="cur">
69+</th>
70+<th class="c-kind">
71+</th>
72+<th class="c-name">name</th>
73+<th class="c-size">size</th>
74+<th class="c-sub">last commit</th>
75+<th class="c-who">author</th>
76+<th class="c-when">date</th>
77+</tr>
78+</thead>
79+<tbody id="tree-rows" data-rows>
80+<tr data-cursor>
81+<td class="cur" aria-hidden="true">
82+</td>
83+<td class="c-kind" aria-hidden="true">·</td>
84+<td class="c-name">
85+<a href="/files/blob/main/docs/README.md">README.md</a>
86+</td>
87+<td class="c-size num dim">62 B</td>
88+<td class="c-sub dim">
89+<a href="/files/commit/SHA40">add docs/README.md</a>
90+</td>
91+<td class="c-who dim">T</td>
92+<td class="c-when nowrap dim">
93+<time datetime="STAMP" title="STAMP">AGO</time>
94+</td>
95+</tr>
96+<tr>
97+<td class="cur" aria-hidden="true">
98+</td>
99+<td class="c-kind" aria-hidden="true">·</td>
100+<td class="c-name">
101+<a href="/files/blob/main/docs/guide.md">guide.md</a>
102+</td>
103+<td class="c-size num dim">47 B</td>
104+<td class="c-sub dim">
105+<a href="/files/commit/SHA40">add docs/guide.md</a>
106+</td>
107+<td class="c-who dim">T</td>
108+<td class="c-when nowrap dim">
109+<time datetime="STAMP" title="STAMP">AGO</time>
110+</td>
111+</tr>
112+</tbody>
113+</table>
114+<h2 class="rule-h">
115+<span>README.md</span>
116+</h2>
117+<article class="prose" aria-label="README.md">
118+<h1 id="md-docs">Docs</h1>
119+<p>Back to <a href="/files/blob/main/docs/guide.md">the guide</a> and <a href="/files/blob/main/main.go">root</a>.</p>
120+</article>
121+</main>
122+</div>
123+<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
124+<div class="panel-head">
125+<span>keys</span>
126+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
127+</div>
128+<dl class="keys">
129+<div>
130+<dt>
131+<kbd>j</kbd>
132+<kbd>k</kbd>
133+</dt>
134+<dd>move cursor down / up</dd>
135+</div>
136+<div>
137+<dt>
138+<kbd>enter</kbd>
139+</dt>
140+<dd>open row under cursor</dd>
141+</div>
142+<div>
143+<dt>
144+<kbd>g</kbd>
145+<kbd>g</kbd> / <kbd>G</kbd>
146+</dt>
147+<dd>first / last row</dd>
148+</div>
149+<div>
150+<dt>
151+<kbd>/</kbd>
152+</dt>
153+<dd>focus the filter</dd>
154+</div>
155+<div>
156+<dt>
157+<kbd>g h</kbd>
158+<kbd>g t</kbd>
159+</dt>
160+<dd>go home / tree</dd>
161+</div>
162+<div>
163+<dt>
164+<kbd>g c</kbd>
165+<kbd>g r</kbd>
166+</dt>
167+<dd>go commits / refs</dd>
168+</div>
169+<div>
170+<dt>
171+<kbd>b</kbd>
172+</dt>
173+<dd>switch ref (branches, tags)</dd>
174+</div>
175+<div>
176+<dt>
177+<kbd>:</kbd>
178+<kbd>f</kbd>
179+</dt>
180+<dd>go to: section, ref, file, repo</dd>
181+</div>
182+<div>
183+<dt>
184+<kbd>y</kbd>
185+</dt>
186+<dd>permalink: ref becomes the full sha</dd>
187+</div>
188+<div>
189+<dt>
190+<kbd>s</kbd>
191+<kbd>t</kbd>
192+<kbd>c</kbd>
193+<kbd>r</kbd>
194+</dt>
195+<dd>repo sections (when shown)</dd>
196+</div>
197+<div>
198+<dt>
199+<kbd>d</kbd>
200+<kbd>u</kbd>
201+<kbd>x</kbd>
202+</dt>
203+<dd>commit: split / unified / collapse all</dd>
204+</div>
205+<div>
206+<dt>
207+<kbd>esc</kbd>
208+</dt>
209+<dd>close panel</dd>
210+</div>
211+</dl>
212+</dialog>
213+<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
214+<div class="panel-head">
215+<label for="refs-filter">ref&gt;</label>
216+<input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
217+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
218+</div>
219+<div class="panel-cols" data-picklist>
220+<section aria-label="Branches">
221+<h2>branches <span class="mute">2</span>
222+</h2>
223+<ul>
224+<li>
225+<a href="/files/tree/feature/x">
226+<span class="mark" aria-hidden="true">&nbsp;</span>feature/x</a>
227+</li>
228+<li>
229+<a href="/files/tree/main" aria-current="true">
230+<span class="mark" aria-hidden="true">●</span>main</a>
231+</li>
232+</ul>
233+</section>
234+<section aria-label="Tags">
235+<h2>tags <span class="mute">1</span>
236+</h2>
237+<ul>
238+<li>
239+<a href="/files/tree/v1.0">
240+<span class="mark" aria-hidden="true">&nbsp;</span>v1.0</a>
241+</li>
242+</ul>
243+</section>
244+</div>
245+</dialog>
246+<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
247+<div class="panel-head">
248+<label for="cmd-q" class="hi">:</label>
249+<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();
250+ q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
251+ 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();
252+ let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
253+ let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
254+ 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' })) }
255+ else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
256+<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
257+</div>
258+<ul id="cmd-list" class="cmd-list">
259+<li data-k="go home" data-cursor>
260+<a href="/" tabindex="-1">
261+<span class="k">go</span>home</a>
262+</li>
263+<li data-k="section summary">
264+<a href="/files" tabindex="-1">
265+<span class="k">section</span>summary</a>
266+</li>
267+<li data-k="section tree">
268+<a href="/files/tree/main" tabindex="-1">
269+<span class="k">section</span>tree</a>
270+</li>
271+<li data-k="section commits">
272+<a href="/files/commits/main" tabindex="-1">
273+<span class="k">section</span>commits</a>
274+</li>
275+<li data-k="section refs">
276+<a href="/files/refs" tabindex="-1">
277+<span class="k">section</span>refs</a>
278+</li>
279+<li data-k="branch feature/x">
280+<a href="/files/tree/feature/x" tabindex="-1">
281+<span class="k">branch</span>feature/x</a>
282+</li>
283+<li data-k="branch main">
284+<a href="/files/tree/main" tabindex="-1">
285+<span class="k">branch</span>main</a>
286+</li>
287+<li data-k="tag v1.0">
288+<a href="/files/tree/v1.0" tabindex="-1">
289+<span class="k">tag</span>v1.0</a>
290+</li>
291+<li data-k="file docs/readme.md">
292+<a href="/files/blob/main/docs/README.md" tabindex="-1">
293+<span class="k">file</span>docs/README.md</a>
294+</li>
295+<li data-k="file docs/guide.md">
296+<a href="/files/blob/main/docs/guide.md" tabindex="-1">
297+<span class="k">file</span>docs/guide.md</a>
298+</li>
299+</ul>
300+</dialog>
301+<footer class="bar status" aria-label="Status">
302+<span class="mode">tree</span>
303+<span class="st-ctx">tree · 2 entries</span>
304+<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'))">
305+<span class="hi">:</span> go to…</button>
306+<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
307+</span>
308+<span class="st-hints">j/k move · enter open · / filter · b ref · ? help</span>
309+<span class="st-src">
310+<span class="st-ver">ermite devel · </span>
311+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
312+</span>
313+<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
314+<span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/2</span>
315+</footer>
316+<div id="keys" hidden hx-on="k-j 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-k from:body -> ((d) => {
325+ if (q('dialog[open]').count) return;
326+ let rows = q('[data-rows] > :not([hidden])').arr();
327+ if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
328+ let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
329+ let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
330+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
331+ })(-1);
332+ k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
333+ 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));
334+ k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
335+ k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
336+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
337+ 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());
338+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
339+ k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
340+ k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
341+ k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
342+ k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
343+ k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
344+ k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
345+ k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
346+ k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
347+ k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
348+ k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
349+ k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
350+ k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
351+ k-permalink from:body -> {
352+ let b = document.body.dataset;
353+ if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }
354+ history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);
355+ navigator.clipboard?.writeText(location.href);
356+ b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''
357+ }">
358+</div>
359+</body>
360+</html>
M cmd/ermite/web_test.go +1 -1
@@ -119,7 +119,7 @@ func TestWebGolden(t *testing.T) {
119119 {"refs", "/demo/refs", 200},
120120 {"empty", "/quiet", 200},
121121 {"not-found", "/nope", 404},
122- {"not-found-page", "/demo/tree/main", 404},
122+ {"not-found-page", "/demo/nowhere/main", 404},
123123 } {
124124 t.Run(tt.name, func(t *testing.T) {
125125 status, hdr, body := h.get(tt.path)
@@ -119,7 +119,7 @@ func TestWebGolden(t *testing.T) {
119 {"refs", "/demo/refs", 200},119 {"refs", "/demo/refs", 200},
120 {"empty", "/quiet", 200},120 {"empty", "/quiet", 200},
121 {"not-found", "/nope", 404},121 {"not-found", "/nope", 404},
122- {"not-found-page", "/demo/tree/main", 404},122+ {"not-found-page", "/demo/nowhere/main", 404},
123 } {123 } {
124 t.Run(tt.name, func(t *testing.T) {124 t.Run(tt.name, func(t *testing.T) {
125 status, hdr, body := h.get(tt.path)125 status, hdr, body := h.get(tt.path)
M internal/adapters/web/highlight.go +91 -46
@@ -3,11 +3,14 @@
33 package web
44
55 import (
6+ "context"
7+ "fmt"
68 "html"
79 "html/template"
810 "path"
911 "strconv"
1012 "strings"
13+ "time"
1114 "unicode"
1215 "unicode/utf8"
1316
@@ -15,10 +18,10 @@ import (
1518 "github.com/alecthomas/chroma/v2/lexers"
1619 )
1720
18-// code is a source file ready for the code view: one <span class="line"> per
21+// codeView is a source file ready for the code view: one <span class="line"> per
1922 // line, each with its number as an anchor, tokens as chroma CSS classes (the
20-// colours are in static/css/55-chroma.css, no inline style).
21-type code struct {
23+// colours are in static/css/56-chroma.css, no inline style).
24+type codeView struct {
2225 html template.HTML
2326 lines int
2427 // lang is the name of the language the file was highlighted as, "" for
@@ -32,7 +35,7 @@ type code struct {
3235 // never lost or altered. Characters that can mislead a reader (bidi
3336 // controls, invisible and control characters) are replaced by visible
3437 // markers.
35-func highlight(name, src string) code {
38+func highlight(name, src string) codeView {
3639 src = normaliseText(src)
3740 var lines [][]chroma.Token
3841 lang := ""
@@ -68,9 +71,31 @@ func highlight(name, src string) code {
6871 }
6972 // #nosec G203 -- every piece of the source goes through writeMarked, which
7073 // escapes it; the rest is markup and class names of this file.
71- return code{html: template.HTML(b.String()), lines: len(lines), lang: lang}
74+ return codeView{html: template.HTML(b.String()), lines: len(lines), lang: lang}
7275 }
7376
77+// highlightBudget bounds the time spent lexing one file: a lexer on hostile
78+// input can be slow, and the page then shows the file as plain text.
79+const highlightBudget = 10 * time.Second
80+
81+// highlightCtx is highlight with a time budget and the cancellation of ctx.
82+// A lexer cannot be interrupted, so one that overruns is left to finish on
83+// its own (its regexps have their own timeout) and its result is dropped.
84+func highlightCtx(ctx context.Context, name, src string) codeView {
85+ done := make(chan codeView, 1)
86+ go func() { done <- highlight(name, src) }()
87+ select {
88+ case v := <-done:
89+ return v
90+ case <-ctx.Done():
91+ case <-time.After(highlightBudget):
92+ }
93+ return highlightPlain(src)
94+}
95+
96+// highlightPlain is the code view of src with no language.
97+func highlightPlain(src string) codeView { return highlight("", src) }
98+
7499 // maxViewLines bounds the lines of the code view: every line costs about a
75100 // hundred bytes of HTML, and a megabyte of line feeds must not become a
76101 // hundred megabytes of page.
@@ -108,22 +133,17 @@ func lexerFor(name string) chroma.Lexer {
108133
109134 // tokenise lexes src. It reports false when the tokens do not add up to the
110135 // source, which is what a lexer that gave up half-way (its regexp timeout)
111-// leaves behind.
136+// leaves behind. A lexer may add a final line feed (EnsureNL), nothing else.
112137 func tokenise(lx chroma.Lexer, src string) ([]chroma.Token, bool) {
113138 it, err := lx.Tokenise(&chroma.TokeniseOptions{State: "root", EnsureLF: true}, src)
114139 if err != nil {
115140 return nil, false
116141 }
117142 toks := it.Tokens()
118- n := 0
119- for _, t := range toks {
120- n += len(t.Value)
121- }
122- if strings.TrimRight(src, "\n") == "" {
123- return nil, n == len(src) || n == len(src)+1
143+ if strings.TrimRight(joinTokens(toks), "\n") != strings.TrimRight(src, "\n") {
144+ return nil, false
124145 }
125- // A lexer may add a final line feed (EnsureNL) but nothing else.
126- return toks, len(strings.TrimRight(joinTokens(toks), "\n")) == len(strings.TrimRight(src, "\n"))
146+ return toks, true
127147 }
128148
129149 func joinTokens(toks []chroma.Token) string {
@@ -159,13 +179,14 @@ func tokenClass(t chroma.TokenType) string {
159179 return chroma.StandardTypes[t]
160180 }
161181
162-// misleading are the characters, other than the controls, that the code
163-// view shows as markers instead of itself: the bidirectional controls of
164-// "Trojan Source" attacks (CVE-2021-42574) and the characters with no
165-// glyph.
166-var misleading = map[rune]string{
182+// invisibleNames name the characters of the code view that are shown as
183+// markers, for the tooltip. Any other invisible character is called by its
184+// category.
185+var invisibleNames = map[rune]string{
167186 0x061C: "arabic letter mark",
168187 0x200B: "zero width space",
188+ 0x200C: "zero width non-joiner",
189+ 0x200D: "zero width joiner",
169190 0x200E: "left-to-right mark",
170191 0x200F: "right-to-left mark",
171192 0x202A: "left-to-right embedding",
@@ -173,51 +194,75 @@ var misleading = map[rune]string{
173194 0x202C: "pop directional formatting",
174195 0x202D: "left-to-right override",
175196 0x202E: "right-to-left override",
197+ 0x2028: "line separator",
198+ 0x2029: "paragraph separator",
176199 0x2060: "word joiner",
177200 0x2066: "left-to-right isolate",
178201 0x2067: "right-to-left isolate",
179202 0x2068: "first strong isolate",
180203 0x2069: "pop directional isolate",
181- 0xFEFF: "zero width no-break space (byte order mark)",
204+ 0xFEFF: "byte order mark",
182205 }
183206
184-// markerName says whether r is shown as a marker, and how it is called.
185-// Tab is ordinary; the line feed never reaches here.
186-func markerName(r rune) (string, bool) {
187- if n, ok := misleading[r]; ok {
188- return n, true
189- }
190- switch {
191- case r == '\t':
192- return "", false
193- case r == 0x2028 || r == 0x2029:
194- return "line or paragraph separator", true
195- case unicode.IsControl(r):
196- return "control character", true
197- }
198- return "", false
207+// isInvisible reports whether r changes how code reads without being seen:
208+// the bidirectional controls of "Trojan Source" attacks (CVE-2021-42574) and
209+// the other format characters, the controls, the line and paragraph
210+// separators and the blank Hangul fillers. A tab is ordinary.
211+func isInvisible(r rune) bool {
212+ switch r {
213+ case '\t', '\n':
214+ return false
215+ case 0x115F, 0x1160, 0x3164, 0xFFA0:
216+ return true
217+ }
218+ return unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r)
199219 }
200220
201-func isMarked(r rune) bool { _, ok := markerName(r); return ok }
221+// codePoint is "U+202E".
222+func codePoint(r rune) string { return fmt.Sprintf("U+%04X", r) }
202223
203-// writeMarked writes s escaped for HTML, with each misleading character
204-// replaced by <span class="ctl" title="U+202E right-to-left override">
205-// &lt;U+202E&gt;</span>. The character itself is not output.
224+// invisibleName is what the tooltip of a marker says.
225+func invisibleName(r rune) string {
226+ if n, ok := invisibleNames[r]; ok {
227+ return n
228+ }
229+ if unicode.IsControl(r) {
230+ return "control character"
231+ }
232+ return "invisible character"
233+}
234+
235+// writeMarked writes s escaped for HTML, each invisible character replaced
236+// by <span class="ctl" title="U+202E right-to-left override">&lt;U+202E&gt;
237+// </span>. The character itself is never output.
206238 func writeMarked(b *strings.Builder, s string) {
207239 for {
208- i := strings.IndexFunc(s, isMarked)
240+ i := strings.IndexFunc(s, isInvisible)
209241 if i < 0 {
210242 b.WriteString(html.EscapeString(s))
211243 return
212244 }
213245 b.WriteString(html.EscapeString(s[:i]))
214246 r, size := utf8.DecodeRuneInString(s[i:])
215- name, _ := markerName(r)
216- code := "U+" + strings.ToUpper(strconv.FormatInt(int64(r)+0x10000, 16)[1:])
217- if r > 0xFFFF {
218- code = "U+" + strings.ToUpper(strconv.FormatInt(int64(r), 16))
219- }
220- b.WriteString(`<span class="ctl" title="` + code + ` ` + name + `">&lt;` + code + `&gt;</span>`)
247+ cp := codePoint(r)
248+ b.WriteString(`<span class="ctl" title="` + cp + ` ` + invisibleName(r) + `">&lt;` + cp + `&gt;</span>`)
221249 s = s[i+size:]
222250 }
223251 }
252+
253+// markedText is s as plain text with each invisible character replaced by
254+// its <U+XXXX> marker, for names and paths shown outside the code view.
255+func markedText(s string) string {
256+ if !strings.ContainsFunc(s, isInvisible) {
257+ return s
258+ }
259+ var b strings.Builder
260+ for _, r := range s {
261+ if isInvisible(r) {
262+ b.WriteString("<" + codePoint(r) + ">")
263+ } else {
264+ b.WriteRune(r)
265+ }
266+ }
267+ return b.String()
268+}
@@ -3,11 +3,14 @@
3 package web3 package web
4 4
5 import (5 import (
6+ "context"
7+ "fmt"
6 "html"8 "html"
7 "html/template"9 "html/template"
8 "path"10 "path"
9 "strconv"11 "strconv"
10 "strings"12 "strings"
13+ "time"
11 "unicode"14 "unicode"
12 "unicode/utf8"15 "unicode/utf8"
13 16
@@ -15,10 +18,10 @@ import (
15 "github.com/alecthomas/chroma/v2/lexers"18 "github.com/alecthomas/chroma/v2/lexers"
16 )19 )
17 20
18-// code is a source file ready for the code view: one <span class="line"> per21+// codeView is a source file ready for the code view: one <span class="line"> per
19 // line, each with its number as an anchor, tokens as chroma CSS classes (the22 // line, each with its number as an anchor, tokens as chroma CSS classes (the
20-// colours are in static/css/55-chroma.css, no inline style).23+// colours are in static/css/56-chroma.css, no inline style).
21-type code struct {24+type codeView struct {
22 html template.HTML25 html template.HTML
23 lines int26 lines int
24 // lang is the name of the language the file was highlighted as, "" for27 // lang is the name of the language the file was highlighted as, "" for
@@ -32,7 +35,7 @@ type code struct {
32 // never lost or altered. Characters that can mislead a reader (bidi35 // never lost or altered. Characters that can mislead a reader (bidi
33 // controls, invisible and control characters) are replaced by visible36 // controls, invisible and control characters) are replaced by visible
34 // markers.37 // markers.
35-func highlight(name, src string) code {38+func highlight(name, src string) codeView {
36 src = normaliseText(src)39 src = normaliseText(src)
37 var lines [][]chroma.Token40 var lines [][]chroma.Token
38 lang := ""41 lang := ""
@@ -68,9 +71,31 @@ func highlight(name, src string) code {
68 }71 }
69 // #nosec G203 -- every piece of the source goes through writeMarked, which72 // #nosec G203 -- every piece of the source goes through writeMarked, which
70 // escapes it; the rest is markup and class names of this file.73 // escapes it; the rest is markup and class names of this file.
71- return code{html: template.HTML(b.String()), lines: len(lines), lang: lang}74+ return codeView{html: template.HTML(b.String()), lines: len(lines), lang: lang}
72 }75 }
73 76
77+// highlightBudget bounds the time spent lexing one file: a lexer on hostile
78+// input can be slow, and the page then shows the file as plain text.
79+const highlightBudget = 10 * time.Second
80+
81+// highlightCtx is highlight with a time budget and the cancellation of ctx.
82+// A lexer cannot be interrupted, so one that overruns is left to finish on
83+// its own (its regexps have their own timeout) and its result is dropped.
84+func highlightCtx(ctx context.Context, name, src string) codeView {
85+ done := make(chan codeView, 1)
86+ go func() { done <- highlight(name, src) }()
87+ select {
88+ case v := <-done:
89+ return v
90+ case <-ctx.Done():
91+ case <-time.After(highlightBudget):
92+ }
93+ return highlightPlain(src)
94+}
95+
96+// highlightPlain is the code view of src with no language.
97+func highlightPlain(src string) codeView { return highlight("", src) }
98+
74 // maxViewLines bounds the lines of the code view: every line costs about a99 // maxViewLines bounds the lines of the code view: every line costs about a
75 // hundred bytes of HTML, and a megabyte of line feeds must not become a100 // hundred bytes of HTML, and a megabyte of line feeds must not become a
76 // hundred megabytes of page.101 // hundred megabytes of page.
@@ -108,22 +133,17 @@ func lexerFor(name string) chroma.Lexer {
108 133
109 // tokenise lexes src. It reports false when the tokens do not add up to the134 // tokenise lexes src. It reports false when the tokens do not add up to the
110 // source, which is what a lexer that gave up half-way (its regexp timeout)135 // source, which is what a lexer that gave up half-way (its regexp timeout)
111-// leaves behind.136+// leaves behind. A lexer may add a final line feed (EnsureNL), nothing else.
112 func tokenise(lx chroma.Lexer, src string) ([]chroma.Token, bool) {137 func tokenise(lx chroma.Lexer, src string) ([]chroma.Token, bool) {
113 it, err := lx.Tokenise(&chroma.TokeniseOptions{State: "root", EnsureLF: true}, src)138 it, err := lx.Tokenise(&chroma.TokeniseOptions{State: "root", EnsureLF: true}, src)
114 if err != nil {139 if err != nil {
115 return nil, false140 return nil, false
116 }141 }
117 toks := it.Tokens()142 toks := it.Tokens()
118- n := 0143+ if strings.TrimRight(joinTokens(toks), "\n") != strings.TrimRight(src, "\n") {
119- for _, t := range toks {144+ return nil, false
120- n += len(t.Value)
121- }
122- if strings.TrimRight(src, "\n") == "" {
123- return nil, n == len(src) || n == len(src)+1
124 }145 }
125- // A lexer may add a final line feed (EnsureNL) but nothing else.146+ return toks, true
126- return toks, len(strings.TrimRight(joinTokens(toks), "\n")) == len(strings.TrimRight(src, "\n"))
127 }147 }
128 148
129 func joinTokens(toks []chroma.Token) string {149 func joinTokens(toks []chroma.Token) string {
@@ -159,13 +179,14 @@ func tokenClass(t chroma.TokenType) string {
159 return chroma.StandardTypes[t]179 return chroma.StandardTypes[t]
160 }180 }
161 181
162-// misleading are the characters, other than the controls, that the code182+// invisibleNames name the characters of the code view that are shown as
163-// view shows as markers instead of itself: the bidirectional controls of183+// markers, for the tooltip. Any other invisible character is called by its
164-// "Trojan Source" attacks (CVE-2021-42574) and the characters with no184+// category.
165-// glyph.185+var invisibleNames = map[rune]string{
166-var misleading = map[rune]string{
167 0x061C: "arabic letter mark",186 0x061C: "arabic letter mark",
168 0x200B: "zero width space",187 0x200B: "zero width space",
188+ 0x200C: "zero width non-joiner",
189+ 0x200D: "zero width joiner",
169 0x200E: "left-to-right mark",190 0x200E: "left-to-right mark",
170 0x200F: "right-to-left mark",191 0x200F: "right-to-left mark",
171 0x202A: "left-to-right embedding",192 0x202A: "left-to-right embedding",
@@ -173,51 +194,75 @@ var misleading = map[rune]string{
173 0x202C: "pop directional formatting",194 0x202C: "pop directional formatting",
174 0x202D: "left-to-right override",195 0x202D: "left-to-right override",
175 0x202E: "right-to-left override",196 0x202E: "right-to-left override",
197+ 0x2028: "line separator",
198+ 0x2029: "paragraph separator",
176 0x2060: "word joiner",199 0x2060: "word joiner",
177 0x2066: "left-to-right isolate",200 0x2066: "left-to-right isolate",
178 0x2067: "right-to-left isolate",201 0x2067: "right-to-left isolate",
179 0x2068: "first strong isolate",202 0x2068: "first strong isolate",
180 0x2069: "pop directional isolate",203 0x2069: "pop directional isolate",
181- 0xFEFF: "zero width no-break space (byte order mark)",204+ 0xFEFF: "byte order mark",
182 }205 }
183 206
184-// markerName says whether r is shown as a marker, and how it is called.207+// isInvisible reports whether r changes how code reads without being seen:
185-// Tab is ordinary; the line feed never reaches here.208+// the bidirectional controls of "Trojan Source" attacks (CVE-2021-42574) and
186-func markerName(r rune) (string, bool) {209+// the other format characters, the controls, the line and paragraph
187- if n, ok := misleading[r]; ok {210+// separators and the blank Hangul fillers. A tab is ordinary.
188- return n, true211+func isInvisible(r rune) bool {
189- }212+ switch r {
190- switch {213+ case '\t', '\n':
191- case r == '\t':214+ return false
192- return "", false215+ case 0x115F, 0x1160, 0x3164, 0xFFA0:
193- case r == 0x2028 || r == 0x2029:216+ return true
194- return "line or paragraph separator", true217+ }
195- case unicode.IsControl(r):218+ return unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r)
196- return "control character", true
197- }
198- return "", false
199 }219 }
200 220
201-func isMarked(r rune) bool { _, ok := markerName(r); return ok }221+// codePoint is "U+202E".
222+func codePoint(r rune) string { return fmt.Sprintf("U+%04X", r) }
202 223
203-// writeMarked writes s escaped for HTML, with each misleading character224+// invisibleName is what the tooltip of a marker says.
204-// replaced by <span class="ctl" title="U+202E right-to-left override">225+func invisibleName(r rune) string {
205-// &lt;U+202E&gt;</span>. The character itself is not output.226+ if n, ok := invisibleNames[r]; ok {
227+ return n
228+ }
229+ if unicode.IsControl(r) {
230+ return "control character"
231+ }
232+ return "invisible character"
233+}
234+
235+// writeMarked writes s escaped for HTML, each invisible character replaced
236+// by <span class="ctl" title="U+202E right-to-left override">&lt;U+202E&gt;
237+// </span>. The character itself is never output.
206 func writeMarked(b *strings.Builder, s string) {238 func writeMarked(b *strings.Builder, s string) {
207 for {239 for {
208- i := strings.IndexFunc(s, isMarked)240+ i := strings.IndexFunc(s, isInvisible)
209 if i < 0 {241 if i < 0 {
210 b.WriteString(html.EscapeString(s))242 b.WriteString(html.EscapeString(s))
211 return243 return
212 }244 }
213 b.WriteString(html.EscapeString(s[:i]))245 b.WriteString(html.EscapeString(s[:i]))
214 r, size := utf8.DecodeRuneInString(s[i:])246 r, size := utf8.DecodeRuneInString(s[i:])
215- name, _ := markerName(r)247+ cp := codePoint(r)
216- code := "U+" + strings.ToUpper(strconv.FormatInt(int64(r)+0x10000, 16)[1:])248+ b.WriteString(`<span class="ctl" title="` + cp + ` ` + invisibleName(r) + `">&lt;` + cp + `&gt;</span>`)
217- if r > 0xFFFF {
218- code = "U+" + strings.ToUpper(strconv.FormatInt(int64(r), 16))
219- }
220- b.WriteString(`<span class="ctl" title="` + code + ` ` + name + `">&lt;` + code + `&gt;</span>`)
221 s = s[i+size:]249 s = s[i+size:]
222 }250 }
223 }251 }
252+
253+// markedText is s as plain text with each invisible character replaced by
254+// its <U+XXXX> marker, for names and paths shown outside the code view.
255+func markedText(s string) string {
256+ if !strings.ContainsFunc(s, isInvisible) {
257+ return s
258+ }
259+ var b strings.Builder
260+ for _, r := range s {
261+ if isInvisible(r) {
262+ b.WriteString("<" + codePoint(r) + ">")
263+ } else {
264+ b.WriteRune(r)
265+ }
266+ }
267+ return b.String()
268+}
A internal/adapters/web/tree.go +220 -0
@@ -0,0 +1,220 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "net/http"
8+ "strings"
9+
10+ "connectrpc.com/connect"
11+ "github.com/a-h/templ"
12+
13+ pb "bnema.dev/ermite-api/gen/ermite"
14+)
15+
16+// target is what the URL of a tree, blob or raw page designates: a commit,
17+// the name it was reached by, and a path in its tree.
18+type target struct {
19+ // rev is what the API is asked for: the id of the commit of a ref, or
20+ // the commit id of the URL itself.
21+ rev string
22+ // name is how the URL spells the ref: a branch or a tag, or the commit id
23+ // when detached.
24+ name string
25+ path string
26+ // detached is true when the URL names a commit and not a ref.
27+ detached bool
28+}
29+
30+// locate reads "<ref>[/<path>]" or "<commit>[/<path>]". The ref is the
31+// longest prefix of the path that is the short name of a branch or a tag
32+// (names may contain "/"); a branch wins over a tag of the same name. An
33+// empty rest designates the head of the repository. Whether the commit or
34+// the path exists is for the API to say.
35+func (d *repoData) locate(rest string) (target, bool) {
36+ rest = strings.Trim(rest, "/")
37+ if rest == "" {
38+ if d.head == nil {
39+ return target{}, false
40+ }
41+ return target{rev: d.head.GetCommitId(), name: d.head.GetShortName()}, true
42+ }
43+ all := append(append([]*pb.Ref(nil), d.branches...), d.tags...)
44+ if ref, p, ok := resolveRef(all, rest); ok && ref.GetCommitId() != "" {
45+ return target{rev: ref.GetCommitId(), name: ref.GetShortName(), path: p}, true
46+ }
47+ first, p, _ := strings.Cut(rest, "/")
48+ if looksLikeCommit(first) {
49+ return target{rev: first, name: first, path: p, detached: true}, true
50+ }
51+ return target{}, false
52+}
53+
54+// looksLikeCommit reports whether s can be a commit id or an abbreviation of
55+// one (7 to 40 lowercase hexadecimal digits).
56+func looksLikeCommit(s string) bool {
57+ if len(s) < 7 || len(s) > 40 {
58+ return false
59+ }
60+ for _, c := range s {
61+ if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
62+ return false
63+ }
64+ }
65+ return true
66+}
67+
68+// pinRef points the ref switcher of the layout at the ref a page shows.
69+func pinRef(l *Layout, t target) {
70+ if t.detached {
71+ l.Ref, l.RefPath, l.Short, l.SHA = "detached", "", short(t.rev), ""
72+ if len(t.rev) == 40 {
73+ l.SHA = t.rev
74+ }
75+ return
76+ }
77+ l.Ref, l.RefPath, l.SHA, l.Short = t.name, t.name, t.rev, short(t.rev)
78+ for i := range l.Heads {
79+ l.Heads[i].Current = l.Heads[i].Name == t.name
80+ }
81+ for i := range l.Tags {
82+ l.Tags[i].Current = l.Tags[i].Name == t.name && !hasName(l.Heads, t.name)
83+ }
84+}
85+
86+func hasName(items []RefItem, name string) bool {
87+ for _, it := range items {
88+ if it.Name == name {
89+ return true
90+ }
91+ }
92+ return false
93+}
94+
95+// fileCrumbs is the path in the top bar: repo, kind ("tree" or "blob"), ref,
96+// then the segments of the path. The directories link to their tree.
97+func fileCrumbs(repo, kind string, t target) []Crumb {
98+ root := repoHref(repo, "tree", t.name)
99+ cs := []Crumb{
100+ {Label: repo, Href: repoHref(repo)},
101+ {Label: kind, Href: root},
102+ {Label: t.name, Href: root, Current: t.path == ""},
103+ }
104+ if t.path == "" {
105+ return cs
106+ }
107+ segs := strings.Split(t.path, "/")
108+ for i, seg := range segs {
109+ last := i == len(segs)-1
110+ c := Crumb{Label: markedText(seg), Current: last}
111+ if !last || kind == "tree" {
112+ c.Href = repoHref(repo, "tree", t.name, strings.Join(segs[:i+1], "/"))
113+ } else {
114+ c.Href = repoHref(repo, "blob", t.name, t.path)
115+ }
116+ cs = append(cs, c)
117+ }
118+ return cs
119+}
120+
121+// maxFileTargets bounds the files of a directory offered in the ":" list.
122+const maxFileTargets = 200
123+
124+// TreeRow is one entry of a directory.
125+type TreeRow struct {
126+ Glyph string // ▾ directory, @ symbolic link, + submodule, · file
127+ Name string
128+ Href string // "" for a submodule
129+ Size string
130+ Subject string
131+ CHref string
132+ Author string
133+ When string
134+ WhenAt string
135+ Cursor bool
136+}
137+
138+// TreePage is a directory.
139+type TreePage struct {
140+ Rows []TreeRow
141+ Readme *Readme
142+ // Notes say what the listing leaves out.
143+ Notes []string
144+}
145+
146+// tree lists a directory of a ref, with the last commit of each entry, and
147+// renders the README of the directory below.
148+func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, int, error) {
149+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
150+ if err != nil {
151+ return nil, 0, err
152+ }
153+ t, ok := d.locate(r.PathValue("rest"))
154+ if !ok {
155+ return nil, 0, errNotFound
156+ }
157+ res, err := s.cfg.Git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{
158+ Repo: d.name(), Revision: t.rev, Path: t.path, IncludeLastCommit: true,
159+ }))
160+ if err != nil {
161+ return nil, 0, err
162+ }
163+ entries := res.Msg.GetEntries()
164+ now := s.cfg.Now()
165+
166+ p := TreePage{}
167+ l := s.repoLayout(ctx, d, "tree")
168+ for i, e := range entries {
169+ row := TreeRow{Glyph: "·", Name: markedText(e.GetName()), Size: humanSize(e.GetSize()), Cursor: i == 0}
170+ switch e.GetKind() {
171+ case pb.EntryKind_ENTRY_KIND_DIRECTORY:
172+ row.Glyph, row.Name, row.Size = "▾", row.Name+"/", "-"
173+ row.Href = repoHref(d.name(), "tree", t.name, e.GetPath())
174+ case pb.EntryKind_ENTRY_KIND_SYMLINK:
175+ row.Glyph = "@"
176+ row.Href = repoHref(d.name(), "blob", t.name, e.GetPath())
177+ case pb.EntryKind_ENTRY_KIND_SUBMODULE:
178+ row.Glyph, row.Size = "+", "-"
179+ default:
180+ row.Href = repoHref(d.name(), "blob", t.name, e.GetPath())
181+ if len(l.Cmd) < maxFileTargets {
182+ l.Cmd = append(l.Cmd, CmdItem{Kind: "file", Label: markedText(e.GetPath()), Href: row.Href})
183+ }
184+ }
185+ if c := e.GetLastCommit(); c != nil {
186+ row.Subject, row.Author = markedText(c.GetSummary()), c.GetAuthor().GetName()
187+ row.CHref = repoHref(d.name(), "commit", c.GetId())
188+ if ts := c.GetCommitter().GetTime(); ts.IsValid() {
189+ row.When, row.WhenAt = ago(now, ts.AsTime()), rfc3339(ts.AsTime())
190+ }
191+ }
192+ p.Rows = append(p.Rows, row)
193+ }
194+ if res.Msg.GetTruncated() {
195+ p.Notes = append(p.Notes, "this directory has more entries than are listed here: the first "+itoa(len(entries))+" are shown.")
196+ }
197+ if res.Msg.GetLastCommitsTruncated() {
198+ p.Notes = append(p.Notes, "the last commit is shown for the first entries only.")
199+ }
200+ rd, err := s.readmeIn(ctx, d.name(), res.Msg.GetCommitId(), t.name, t.path, entries)
201+ if err != nil {
202+ return nil, 0, err
203+ }
204+ if rd != nil {
205+ p.Readme = rd.readme
206+ }
207+
208+ pinRef(&l, t)
209+ l.Crumbs = fileCrumbs(d.name(), "tree", t)
210+ if t.path == "" {
211+ l.Title = "tree · " + d.name() + " · " + s.instance
212+ } else {
213+ l.Title = markedText(t.path) + " · " + d.name() + " · " + s.instance
214+ }
215+ l.Status = Status{
216+ Mode: "tree", Context: "tree · " + plural(len(entries), "entry", "entries"),
217+ Hints: "j/k move · enter open · / filter · b ref · ? help", Cursor: true, Count: len(p.Rows),
218+ }
219+ return Tree(l, p), http.StatusOK, nil
220+}
@@ -0,0 +1,220 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "net/http"
8+ "strings"
9+
10+ "connectrpc.com/connect"
11+ "github.com/a-h/templ"
12+
13+ pb "bnema.dev/ermite-api/gen/ermite"
14+)
15+
16+// target is what the URL of a tree, blob or raw page designates: a commit,
17+// the name it was reached by, and a path in its tree.
18+type target struct {
19+ // rev is what the API is asked for: the id of the commit of a ref, or
20+ // the commit id of the URL itself.
21+ rev string
22+ // name is how the URL spells the ref: a branch or a tag, or the commit id
23+ // when detached.
24+ name string
25+ path string
26+ // detached is true when the URL names a commit and not a ref.
27+ detached bool
28+}
29+
30+// locate reads "<ref>[/<path>]" or "<commit>[/<path>]". The ref is the
31+// longest prefix of the path that is the short name of a branch or a tag
32+// (names may contain "/"); a branch wins over a tag of the same name. An
33+// empty rest designates the head of the repository. Whether the commit or
34+// the path exists is for the API to say.
35+func (d *repoData) locate(rest string) (target, bool) {
36+ rest = strings.Trim(rest, "/")
37+ if rest == "" {
38+ if d.head == nil {
39+ return target{}, false
40+ }
41+ return target{rev: d.head.GetCommitId(), name: d.head.GetShortName()}, true
42+ }
43+ all := append(append([]*pb.Ref(nil), d.branches...), d.tags...)
44+ if ref, p, ok := resolveRef(all, rest); ok && ref.GetCommitId() != "" {
45+ return target{rev: ref.GetCommitId(), name: ref.GetShortName(), path: p}, true
46+ }
47+ first, p, _ := strings.Cut(rest, "/")
48+ if looksLikeCommit(first) {
49+ return target{rev: first, name: first, path: p, detached: true}, true
50+ }
51+ return target{}, false
52+}
53+
54+// looksLikeCommit reports whether s can be a commit id or an abbreviation of
55+// one (7 to 40 lowercase hexadecimal digits).
56+func looksLikeCommit(s string) bool {
57+ if len(s) < 7 || len(s) > 40 {
58+ return false
59+ }
60+ for _, c := range s {
61+ if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
62+ return false
63+ }
64+ }
65+ return true
66+}
67+
68+// pinRef points the ref switcher of the layout at the ref a page shows.
69+func pinRef(l *Layout, t target) {
70+ if t.detached {
71+ l.Ref, l.RefPath, l.Short, l.SHA = "detached", "", short(t.rev), ""
72+ if len(t.rev) == 40 {
73+ l.SHA = t.rev
74+ }
75+ return
76+ }
77+ l.Ref, l.RefPath, l.SHA, l.Short = t.name, t.name, t.rev, short(t.rev)
78+ for i := range l.Heads {
79+ l.Heads[i].Current = l.Heads[i].Name == t.name
80+ }
81+ for i := range l.Tags {
82+ l.Tags[i].Current = l.Tags[i].Name == t.name && !hasName(l.Heads, t.name)
83+ }
84+}
85+
86+func hasName(items []RefItem, name string) bool {
87+ for _, it := range items {
88+ if it.Name == name {
89+ return true
90+ }
91+ }
92+ return false
93+}
94+
95+// fileCrumbs is the path in the top bar: repo, kind ("tree" or "blob"), ref,
96+// then the segments of the path. The directories link to their tree.
97+func fileCrumbs(repo, kind string, t target) []Crumb {
98+ root := repoHref(repo, "tree", t.name)
99+ cs := []Crumb{
100+ {Label: repo, Href: repoHref(repo)},
101+ {Label: kind, Href: root},
102+ {Label: t.name, Href: root, Current: t.path == ""},
103+ }
104+ if t.path == "" {
105+ return cs
106+ }
107+ segs := strings.Split(t.path, "/")
108+ for i, seg := range segs {
109+ last := i == len(segs)-1
110+ c := Crumb{Label: markedText(seg), Current: last}
111+ if !last || kind == "tree" {
112+ c.Href = repoHref(repo, "tree", t.name, strings.Join(segs[:i+1], "/"))
113+ } else {
114+ c.Href = repoHref(repo, "blob", t.name, t.path)
115+ }
116+ cs = append(cs, c)
117+ }
118+ return cs
119+}
120+
121+// maxFileTargets bounds the files of a directory offered in the ":" list.
122+const maxFileTargets = 200
123+
124+// TreeRow is one entry of a directory.
125+type TreeRow struct {
126+ Glyph string // ▾ directory, @ symbolic link, + submodule, · file
127+ Name string
128+ Href string // "" for a submodule
129+ Size string
130+ Subject string
131+ CHref string
132+ Author string
133+ When string
134+ WhenAt string
135+ Cursor bool
136+}
137+
138+// TreePage is a directory.
139+type TreePage struct {
140+ Rows []TreeRow
141+ Readme *Readme
142+ // Notes say what the listing leaves out.
143+ Notes []string
144+}
145+
146+// tree lists a directory of a ref, with the last commit of each entry, and
147+// renders the README of the directory below.
148+func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, int, error) {
149+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
150+ if err != nil {
151+ return nil, 0, err
152+ }
153+ t, ok := d.locate(r.PathValue("rest"))
154+ if !ok {
155+ return nil, 0, errNotFound
156+ }
157+ res, err := s.cfg.Git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{
158+ Repo: d.name(), Revision: t.rev, Path: t.path, IncludeLastCommit: true,
159+ }))
160+ if err != nil {
161+ return nil, 0, err
162+ }
163+ entries := res.Msg.GetEntries()
164+ now := s.cfg.Now()
165+
166+ p := TreePage{}
167+ l := s.repoLayout(ctx, d, "tree")
168+ for i, e := range entries {
169+ row := TreeRow{Glyph: "·", Name: markedText(e.GetName()), Size: humanSize(e.GetSize()), Cursor: i == 0}
170+ switch e.GetKind() {
171+ case pb.EntryKind_ENTRY_KIND_DIRECTORY:
172+ row.Glyph, row.Name, row.Size = "▾", row.Name+"/", "-"
173+ row.Href = repoHref(d.name(), "tree", t.name, e.GetPath())
174+ case pb.EntryKind_ENTRY_KIND_SYMLINK:
175+ row.Glyph = "@"
176+ row.Href = repoHref(d.name(), "blob", t.name, e.GetPath())
177+ case pb.EntryKind_ENTRY_KIND_SUBMODULE:
178+ row.Glyph, row.Size = "+", "-"
179+ default:
180+ row.Href = repoHref(d.name(), "blob", t.name, e.GetPath())
181+ if len(l.Cmd) < maxFileTargets {
182+ l.Cmd = append(l.Cmd, CmdItem{Kind: "file", Label: markedText(e.GetPath()), Href: row.Href})
183+ }
184+ }
185+ if c := e.GetLastCommit(); c != nil {
186+ row.Subject, row.Author = markedText(c.GetSummary()), c.GetAuthor().GetName()
187+ row.CHref = repoHref(d.name(), "commit", c.GetId())
188+ if ts := c.GetCommitter().GetTime(); ts.IsValid() {
189+ row.When, row.WhenAt = ago(now, ts.AsTime()), rfc3339(ts.AsTime())
190+ }
191+ }
192+ p.Rows = append(p.Rows, row)
193+ }
194+ if res.Msg.GetTruncated() {
195+ p.Notes = append(p.Notes, "this directory has more entries than are listed here: the first "+itoa(len(entries))+" are shown.")
196+ }
197+ if res.Msg.GetLastCommitsTruncated() {
198+ p.Notes = append(p.Notes, "the last commit is shown for the first entries only.")
199+ }
200+ rd, err := s.readmeIn(ctx, d.name(), res.Msg.GetCommitId(), t.name, t.path, entries)
201+ if err != nil {
202+ return nil, 0, err
203+ }
204+ if rd != nil {
205+ p.Readme = rd.readme
206+ }
207+
208+ pinRef(&l, t)
209+ l.Crumbs = fileCrumbs(d.name(), "tree", t)
210+ if t.path == "" {
211+ l.Title = "tree · " + d.name() + " · " + s.instance
212+ } else {
213+ l.Title = markedText(t.path) + " · " + d.name() + " · " + s.instance
214+ }
215+ l.Status = Status{
216+ Mode: "tree", Context: "tree · " + plural(len(entries), "entry", "entries"),
217+ Hints: "j/k move · enter open · / filter · b ref · ? help", Cursor: true, Count: len(p.Rows),
218+ }
219+ return Tree(l, p), http.StatusOK, nil
220+}
A internal/adapters/web/tree.templ +69 -0
@@ -0,0 +1,69 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+// Tree is a directory: the filter, the entries with their last commit, the
6+// notes on what is left out, and the README of the directory below.
7+templ Tree(l Layout, p TreePage) {
8+ @Shell(l, nil) {
9+ <div class="filter">
10+ <label for="tree-filter">filter&gt;</label>
11+ <input
12+ id="tree-filter"
13+ type="search"
14+ data-filter
15+ placeholder="this directory ( / )"
16+ autocomplete="off"
17+ hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')"
18+ />
19+ </div>
20+ <table class="list tree" aria-label="Entries">
21+ <thead>
22+ <tr><th class="cur"></th><th class="c-kind"></th><th class="c-name">name</th><th class="c-size">size</th><th class="c-sub">last commit</th><th class="c-who">author</th><th class="c-when">date</th></tr>
23+ </thead>
24+ <tbody id="tree-rows" data-rows>
25+ for _, r := range p.Rows {
26+ @TreeRowView(r)
27+ }
28+ </tbody>
29+ </table>
30+ if len(p.Rows) == 0 {
31+ <p class="lede dim">this directory is empty.</p>
32+ }
33+ for _, n := range p.Notes {
34+ <p class="note">{ n }</p>
35+ }
36+ if p.Readme != nil {
37+ <h2 class="rule-h"><span>{ p.Readme.Name }</span></h2>
38+ @ReadmeView(*p.Readme)
39+ }
40+ }
41+}
42+
43+// TreeRowView is one entry of a directory. Glyph: ▾ directory, @ symbolic
44+// link, + submodule, · file.
45+templ TreeRowView(r TreeRow) {
46+ <tr data-cursor?={ r.Cursor }>
47+ <td class="cur" aria-hidden="true"></td>
48+ <td class="c-kind" aria-hidden="true">{ r.Glyph }</td>
49+ <td class="c-name">
50+ if r.Href != "" {
51+ <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>
52+ } else {
53+ { r.Name }
54+ }
55+ </td>
56+ <td class="c-size num dim">{ r.Size }</td>
57+ <td class="c-sub dim">
58+ if r.CHref != "" {
59+ <a href={ templ.SafeURL(r.CHref) }>{ r.Subject }</a>
60+ }
61+ </td>
62+ <td class="c-who dim">{ r.Author }</td>
63+ <td class="c-when nowrap dim">
64+ if r.WhenAt != "" {
65+ <time datetime={ r.WhenAt } title={ r.WhenAt }>{ r.When }</time>
66+ }
67+ </td>
68+ </tr>
69+}
@@ -0,0 +1,69 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+// Tree is a directory: the filter, the entries with their last commit, the
6+// notes on what is left out, and the README of the directory below.
7+templ Tree(l Layout, p TreePage) {
8+ @Shell(l, nil) {
9+ <div class="filter">
10+ <label for="tree-filter">filter&gt;</label>
11+ <input
12+ id="tree-filter"
13+ type="search"
14+ data-filter
15+ placeholder="this directory ( / )"
16+ autocomplete="off"
17+ hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')"
18+ />
19+ </div>
20+ <table class="list tree" aria-label="Entries">
21+ <thead>
22+ <tr><th class="cur"></th><th class="c-kind"></th><th class="c-name">name</th><th class="c-size">size</th><th class="c-sub">last commit</th><th class="c-who">author</th><th class="c-when">date</th></tr>
23+ </thead>
24+ <tbody id="tree-rows" data-rows>
25+ for _, r := range p.Rows {
26+ @TreeRowView(r)
27+ }
28+ </tbody>
29+ </table>
30+ if len(p.Rows) == 0 {
31+ <p class="lede dim">this directory is empty.</p>
32+ }
33+ for _, n := range p.Notes {
34+ <p class="note">{ n }</p>
35+ }
36+ if p.Readme != nil {
37+ <h2 class="rule-h"><span>{ p.Readme.Name }</span></h2>
38+ @ReadmeView(*p.Readme)
39+ }
40+ }
41+}
42+
43+// TreeRowView is one entry of a directory. Glyph: ▾ directory, @ symbolic
44+// link, + submodule, · file.
45+templ TreeRowView(r TreeRow) {
46+ <tr data-cursor?={ r.Cursor }>
47+ <td class="cur" aria-hidden="true"></td>
48+ <td class="c-kind" aria-hidden="true">{ r.Glyph }</td>
49+ <td class="c-name">
50+ if r.Href != "" {
51+ <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>
52+ } else {
53+ { r.Name }
54+ }
55+ </td>
56+ <td class="c-size num dim">{ r.Size }</td>
57+ <td class="c-sub dim">
58+ if r.CHref != "" {
59+ <a href={ templ.SafeURL(r.CHref) }>{ r.Subject }</a>
60+ }
61+ </td>
62+ <td class="c-who dim">{ r.Author }</td>
63+ <td class="c-when nowrap dim">
64+ if r.WhenAt != "" {
65+ <time datetime={ r.WhenAt } title={ r.WhenAt }>{ r.When }</time>
66+ }
67+ </td>
68+ </tr>
69+}
A internal/adapters/web/tree_templ.go +335 -0
@@ -0,0 +1,335 @@
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+// Tree is a directory: the filter, the entries with their last commit, the
14+// notes on what is left out, and the README of the directory below.
15+func Tree(l Layout, p TreePage) templ.Component {
16+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
17+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
18+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
19+ return templ_7745c5c3_CtxErr
20+ }
21+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
22+ if !templ_7745c5c3_IsBuffer {
23+ defer func() {
24+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
25+ if templ_7745c5c3_Err == nil {
26+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
27+ }
28+ }()
29+ }
30+ ctx = templ.InitializeContext(ctx)
31+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
32+ if templ_7745c5c3_Var1 == nil {
33+ templ_7745c5c3_Var1 = templ.NopComponent
34+ }
35+ ctx = templ.ClearChildren(ctx)
36+ templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
37+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
38+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
39+ if !templ_7745c5c3_IsBuffer {
40+ defer func() {
41+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
42+ if templ_7745c5c3_Err == nil {
43+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
44+ }
45+ }()
46+ }
47+ ctx = templ.InitializeContext(ctx)
48+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"filter\"><label for=\"tree-filter\">filter&gt;</label> <input id=\"tree-filter\" type=\"search\" data-filter placeholder=\"this directory ( / )\" autocomplete=\"off\" hx-on:input=\"let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')\"></div><table class=\"list tree\" aria-label=\"Entries\"><thead><tr><th class=\"cur\"></th><th class=\"c-kind\"></th><th class=\"c-name\">name</th><th class=\"c-size\">size</th><th class=\"c-sub\">last commit</th><th class=\"c-who\">author</th><th class=\"c-when\">date</th></tr></thead> <tbody id=\"tree-rows\" data-rows>")
49+ if templ_7745c5c3_Err != nil {
50+ return templ_7745c5c3_Err
51+ }
52+ for _, r := range p.Rows {
53+ templ_7745c5c3_Err = TreeRowView(r).Render(ctx, templ_7745c5c3_Buffer)
54+ if templ_7745c5c3_Err != nil {
55+ return templ_7745c5c3_Err
56+ }
57+ }
58+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</tbody></table>")
59+ if templ_7745c5c3_Err != nil {
60+ return templ_7745c5c3_Err
61+ }
62+ if len(p.Rows) == 0 {
63+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<p class=\"lede dim\">this directory is empty.</p>")
64+ if templ_7745c5c3_Err != nil {
65+ return templ_7745c5c3_Err
66+ }
67+ }
68+ for _, n := range p.Notes {
69+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<p class=\"note\">")
70+ if templ_7745c5c3_Err != nil {
71+ return templ_7745c5c3_Err
72+ }
73+ var templ_7745c5c3_Var3 string
74+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(n)
75+ if templ_7745c5c3_Err != nil {
76+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 34, Col: 22}
77+ }
78+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
79+ if templ_7745c5c3_Err != nil {
80+ return templ_7745c5c3_Err
81+ }
82+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</p>")
83+ if templ_7745c5c3_Err != nil {
84+ return templ_7745c5c3_Err
85+ }
86+ }
87+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " ")
88+ if templ_7745c5c3_Err != nil {
89+ return templ_7745c5c3_Err
90+ }
91+ if p.Readme != nil {
92+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<h2 class=\"rule-h\"><span>")
93+ if templ_7745c5c3_Err != nil {
94+ return templ_7745c5c3_Err
95+ }
96+ var templ_7745c5c3_Var4 string
97+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(p.Readme.Name)
98+ if templ_7745c5c3_Err != nil {
99+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 37, Col: 43}
100+ }
101+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
102+ if templ_7745c5c3_Err != nil {
103+ return templ_7745c5c3_Err
104+ }
105+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span></h2>")
106+ if templ_7745c5c3_Err != nil {
107+ return templ_7745c5c3_Err
108+ }
109+ templ_7745c5c3_Err = ReadmeView(*p.Readme).Render(ctx, templ_7745c5c3_Buffer)
110+ if templ_7745c5c3_Err != nil {
111+ return templ_7745c5c3_Err
112+ }
113+ }
114+ return nil
115+ })
116+ templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
117+ if templ_7745c5c3_Err != nil {
118+ return templ_7745c5c3_Err
119+ }
120+ return nil
121+ })
122+}
123+
124+// TreeRowView is one entry of a directory. Glyph: ▾ directory, @ symbolic
125+// link, + submodule, · file.
126+func TreeRowView(r TreeRow) templ.Component {
127+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
128+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
129+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
130+ return templ_7745c5c3_CtxErr
131+ }
132+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
133+ if !templ_7745c5c3_IsBuffer {
134+ defer func() {
135+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
136+ if templ_7745c5c3_Err == nil {
137+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
138+ }
139+ }()
140+ }
141+ ctx = templ.InitializeContext(ctx)
142+ templ_7745c5c3_Var5 := templ.GetChildren(ctx)
143+ if templ_7745c5c3_Var5 == nil {
144+ templ_7745c5c3_Var5 = templ.NopComponent
145+ }
146+ ctx = templ.ClearChildren(ctx)
147+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<tr")
148+ if templ_7745c5c3_Err != nil {
149+ return templ_7745c5c3_Err
150+ }
151+ if r.Cursor {
152+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " data-cursor")
153+ if templ_7745c5c3_Err != nil {
154+ return templ_7745c5c3_Err
155+ }
156+ }
157+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "><td class=\"cur\" aria-hidden=\"true\"></td><td class=\"c-kind\" aria-hidden=\"true\">")
158+ if templ_7745c5c3_Err != nil {
159+ return templ_7745c5c3_Err
160+ }
161+ var templ_7745c5c3_Var6 string
162+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(r.Glyph)
163+ if templ_7745c5c3_Err != nil {
164+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 48, Col: 49}
165+ }
166+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
167+ if templ_7745c5c3_Err != nil {
168+ return templ_7745c5c3_Err
169+ }
170+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</td><td class=\"c-name\">")
171+ if templ_7745c5c3_Err != nil {
172+ return templ_7745c5c3_Err
173+ }
174+ if r.Href != "" {
175+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<a href=\"")
176+ if templ_7745c5c3_Err != nil {
177+ return templ_7745c5c3_Err
178+ }
179+ var templ_7745c5c3_Var7 templ.SafeURL
180+ templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
181+ if templ_7745c5c3_Err != nil {
182+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 51, Col: 35}
183+ }
184+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
185+ if templ_7745c5c3_Err != nil {
186+ return templ_7745c5c3_Err
187+ }
188+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\">")
189+ if templ_7745c5c3_Err != nil {
190+ return templ_7745c5c3_Err
191+ }
192+ var templ_7745c5c3_Var8 string
193+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
194+ if templ_7745c5c3_Err != nil {
195+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 51, Col: 46}
196+ }
197+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
198+ if templ_7745c5c3_Err != nil {
199+ return templ_7745c5c3_Err
200+ }
201+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</a>")
202+ if templ_7745c5c3_Err != nil {
203+ return templ_7745c5c3_Err
204+ }
205+ } else {
206+ var templ_7745c5c3_Var9 string
207+ templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
208+ if templ_7745c5c3_Err != nil {
209+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 53, Col: 12}
210+ }
211+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
212+ if templ_7745c5c3_Err != nil {
213+ return templ_7745c5c3_Err
214+ }
215+ }
216+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</td><td class=\"c-size num dim\">")
217+ if templ_7745c5c3_Err != nil {
218+ return templ_7745c5c3_Err
219+ }
220+ var templ_7745c5c3_Var10 string
221+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(r.Size)
222+ if templ_7745c5c3_Err != nil {
223+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 56, Col: 37}
224+ }
225+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
226+ if templ_7745c5c3_Err != nil {
227+ return templ_7745c5c3_Err
228+ }
229+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</td><td class=\"c-sub dim\">")
230+ if templ_7745c5c3_Err != nil {
231+ return templ_7745c5c3_Err
232+ }
233+ if r.CHref != "" {
234+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<a href=\"")
235+ if templ_7745c5c3_Err != nil {
236+ return templ_7745c5c3_Err
237+ }
238+ var templ_7745c5c3_Var11 templ.SafeURL
239+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CHref))
240+ if templ_7745c5c3_Err != nil {
241+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 59, Col: 36}
242+ }
243+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
244+ if templ_7745c5c3_Err != nil {
245+ return templ_7745c5c3_Err
246+ }
247+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\">")
248+ if templ_7745c5c3_Err != nil {
249+ return templ_7745c5c3_Err
250+ }
251+ var templ_7745c5c3_Var12 string
252+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(r.Subject)
253+ if templ_7745c5c3_Err != nil {
254+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 59, Col: 50}
255+ }
256+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
257+ if templ_7745c5c3_Err != nil {
258+ return templ_7745c5c3_Err
259+ }
260+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</a>")
261+ if templ_7745c5c3_Err != nil {
262+ return templ_7745c5c3_Err
263+ }
264+ }
265+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</td><td class=\"c-who dim\">")
266+ if templ_7745c5c3_Err != nil {
267+ return templ_7745c5c3_Err
268+ }
269+ var templ_7745c5c3_Var13 string
270+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(r.Author)
271+ if templ_7745c5c3_Err != nil {
272+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 62, Col: 34}
273+ }
274+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
275+ if templ_7745c5c3_Err != nil {
276+ return templ_7745c5c3_Err
277+ }
278+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</td><td class=\"c-when nowrap dim\">")
279+ if templ_7745c5c3_Err != nil {
280+ return templ_7745c5c3_Err
281+ }
282+ if r.WhenAt != "" {
283+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<time datetime=\"")
284+ if templ_7745c5c3_Err != nil {
285+ return templ_7745c5c3_Err
286+ }
287+ var templ_7745c5c3_Var14 string
288+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
289+ if templ_7745c5c3_Err != nil {
290+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 65, Col: 29}
291+ }
292+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
293+ if templ_7745c5c3_Err != nil {
294+ return templ_7745c5c3_Err
295+ }
296+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" title=\"")
297+ if templ_7745c5c3_Err != nil {
298+ return templ_7745c5c3_Err
299+ }
300+ var templ_7745c5c3_Var15 string
301+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
302+ if templ_7745c5c3_Err != nil {
303+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 65, Col: 48}
304+ }
305+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
306+ if templ_7745c5c3_Err != nil {
307+ return templ_7745c5c3_Err
308+ }
309+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\">")
310+ if templ_7745c5c3_Err != nil {
311+ return templ_7745c5c3_Err
312+ }
313+ var templ_7745c5c3_Var16 string
314+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(r.When)
315+ if templ_7745c5c3_Err != nil {
316+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 65, Col: 59}
317+ }
318+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
319+ if templ_7745c5c3_Err != nil {
320+ return templ_7745c5c3_Err
321+ }
322+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</time>")
323+ if templ_7745c5c3_Err != nil {
324+ return templ_7745c5c3_Err
325+ }
326+ }
327+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</td></tr>")
328+ if templ_7745c5c3_Err != nil {
329+ return templ_7745c5c3_Err
330+ }
331+ return nil
332+ })
333+}
334+
335+var _ = templruntime.GeneratedTemplate
@@ -0,0 +1,335 @@
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+// Tree is a directory: the filter, the entries with their last commit, the
14+// notes on what is left out, and the README of the directory below.
15+func Tree(l Layout, p TreePage) templ.Component {
16+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
17+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
18+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
19+ return templ_7745c5c3_CtxErr
20+ }
21+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
22+ if !templ_7745c5c3_IsBuffer {
23+ defer func() {
24+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
25+ if templ_7745c5c3_Err == nil {
26+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
27+ }
28+ }()
29+ }
30+ ctx = templ.InitializeContext(ctx)
31+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
32+ if templ_7745c5c3_Var1 == nil {
33+ templ_7745c5c3_Var1 = templ.NopComponent
34+ }
35+ ctx = templ.ClearChildren(ctx)
36+ templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
37+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
38+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
39+ if !templ_7745c5c3_IsBuffer {
40+ defer func() {
41+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
42+ if templ_7745c5c3_Err == nil {
43+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
44+ }
45+ }()
46+ }
47+ ctx = templ.InitializeContext(ctx)
48+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"filter\"><label for=\"tree-filter\">filter&gt;</label> <input id=\"tree-filter\" type=\"search\" data-filter placeholder=\"this directory ( / )\" autocomplete=\"off\" hx-on:input=\"let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')\"></div><table class=\"list tree\" aria-label=\"Entries\"><thead><tr><th class=\"cur\"></th><th class=\"c-kind\"></th><th class=\"c-name\">name</th><th class=\"c-size\">size</th><th class=\"c-sub\">last commit</th><th class=\"c-who\">author</th><th class=\"c-when\">date</th></tr></thead> <tbody id=\"tree-rows\" data-rows>")
49+ if templ_7745c5c3_Err != nil {
50+ return templ_7745c5c3_Err
51+ }
52+ for _, r := range p.Rows {
53+ templ_7745c5c3_Err = TreeRowView(r).Render(ctx, templ_7745c5c3_Buffer)
54+ if templ_7745c5c3_Err != nil {
55+ return templ_7745c5c3_Err
56+ }
57+ }
58+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</tbody></table>")
59+ if templ_7745c5c3_Err != nil {
60+ return templ_7745c5c3_Err
61+ }
62+ if len(p.Rows) == 0 {
63+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<p class=\"lede dim\">this directory is empty.</p>")
64+ if templ_7745c5c3_Err != nil {
65+ return templ_7745c5c3_Err
66+ }
67+ }
68+ for _, n := range p.Notes {
69+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<p class=\"note\">")
70+ if templ_7745c5c3_Err != nil {
71+ return templ_7745c5c3_Err
72+ }
73+ var templ_7745c5c3_Var3 string
74+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(n)
75+ if templ_7745c5c3_Err != nil {
76+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 34, Col: 22}
77+ }
78+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
79+ if templ_7745c5c3_Err != nil {
80+ return templ_7745c5c3_Err
81+ }
82+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</p>")
83+ if templ_7745c5c3_Err != nil {
84+ return templ_7745c5c3_Err
85+ }
86+ }
87+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " ")
88+ if templ_7745c5c3_Err != nil {
89+ return templ_7745c5c3_Err
90+ }
91+ if p.Readme != nil {
92+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<h2 class=\"rule-h\"><span>")
93+ if templ_7745c5c3_Err != nil {
94+ return templ_7745c5c3_Err
95+ }
96+ var templ_7745c5c3_Var4 string
97+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(p.Readme.Name)
98+ if templ_7745c5c3_Err != nil {
99+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 37, Col: 43}
100+ }
101+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
102+ if templ_7745c5c3_Err != nil {
103+ return templ_7745c5c3_Err
104+ }
105+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span></h2>")
106+ if templ_7745c5c3_Err != nil {
107+ return templ_7745c5c3_Err
108+ }
109+ templ_7745c5c3_Err = ReadmeView(*p.Readme).Render(ctx, templ_7745c5c3_Buffer)
110+ if templ_7745c5c3_Err != nil {
111+ return templ_7745c5c3_Err
112+ }
113+ }
114+ return nil
115+ })
116+ templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
117+ if templ_7745c5c3_Err != nil {
118+ return templ_7745c5c3_Err
119+ }
120+ return nil
121+ })
122+}
123+
124+// TreeRowView is one entry of a directory. Glyph: ▾ directory, @ symbolic
125+// link, + submodule, · file.
126+func TreeRowView(r TreeRow) templ.Component {
127+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
128+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
129+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
130+ return templ_7745c5c3_CtxErr
131+ }
132+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
133+ if !templ_7745c5c3_IsBuffer {
134+ defer func() {
135+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
136+ if templ_7745c5c3_Err == nil {
137+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
138+ }
139+ }()
140+ }
141+ ctx = templ.InitializeContext(ctx)
142+ templ_7745c5c3_Var5 := templ.GetChildren(ctx)
143+ if templ_7745c5c3_Var5 == nil {
144+ templ_7745c5c3_Var5 = templ.NopComponent
145+ }
146+ ctx = templ.ClearChildren(ctx)
147+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<tr")
148+ if templ_7745c5c3_Err != nil {
149+ return templ_7745c5c3_Err
150+ }
151+ if r.Cursor {
152+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " data-cursor")
153+ if templ_7745c5c3_Err != nil {
154+ return templ_7745c5c3_Err
155+ }
156+ }
157+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "><td class=\"cur\" aria-hidden=\"true\"></td><td class=\"c-kind\" aria-hidden=\"true\">")
158+ if templ_7745c5c3_Err != nil {
159+ return templ_7745c5c3_Err
160+ }
161+ var templ_7745c5c3_Var6 string
162+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(r.Glyph)
163+ if templ_7745c5c3_Err != nil {
164+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 48, Col: 49}
165+ }
166+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
167+ if templ_7745c5c3_Err != nil {
168+ return templ_7745c5c3_Err
169+ }
170+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</td><td class=\"c-name\">")
171+ if templ_7745c5c3_Err != nil {
172+ return templ_7745c5c3_Err
173+ }
174+ if r.Href != "" {
175+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<a href=\"")
176+ if templ_7745c5c3_Err != nil {
177+ return templ_7745c5c3_Err
178+ }
179+ var templ_7745c5c3_Var7 templ.SafeURL
180+ templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
181+ if templ_7745c5c3_Err != nil {
182+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 51, Col: 35}
183+ }
184+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
185+ if templ_7745c5c3_Err != nil {
186+ return templ_7745c5c3_Err
187+ }
188+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\">")
189+ if templ_7745c5c3_Err != nil {
190+ return templ_7745c5c3_Err
191+ }
192+ var templ_7745c5c3_Var8 string
193+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
194+ if templ_7745c5c3_Err != nil {
195+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 51, Col: 46}
196+ }
197+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
198+ if templ_7745c5c3_Err != nil {
199+ return templ_7745c5c3_Err
200+ }
201+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</a>")
202+ if templ_7745c5c3_Err != nil {
203+ return templ_7745c5c3_Err
204+ }
205+ } else {
206+ var templ_7745c5c3_Var9 string
207+ templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
208+ if templ_7745c5c3_Err != nil {
209+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 53, Col: 12}
210+ }
211+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
212+ if templ_7745c5c3_Err != nil {
213+ return templ_7745c5c3_Err
214+ }
215+ }
216+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</td><td class=\"c-size num dim\">")
217+ if templ_7745c5c3_Err != nil {
218+ return templ_7745c5c3_Err
219+ }
220+ var templ_7745c5c3_Var10 string
221+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(r.Size)
222+ if templ_7745c5c3_Err != nil {
223+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 56, Col: 37}
224+ }
225+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
226+ if templ_7745c5c3_Err != nil {
227+ return templ_7745c5c3_Err
228+ }
229+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</td><td class=\"c-sub dim\">")
230+ if templ_7745c5c3_Err != nil {
231+ return templ_7745c5c3_Err
232+ }
233+ if r.CHref != "" {
234+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<a href=\"")
235+ if templ_7745c5c3_Err != nil {
236+ return templ_7745c5c3_Err
237+ }
238+ var templ_7745c5c3_Var11 templ.SafeURL
239+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CHref))
240+ if templ_7745c5c3_Err != nil {
241+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 59, Col: 36}
242+ }
243+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
244+ if templ_7745c5c3_Err != nil {
245+ return templ_7745c5c3_Err
246+ }
247+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\">")
248+ if templ_7745c5c3_Err != nil {
249+ return templ_7745c5c3_Err
250+ }
251+ var templ_7745c5c3_Var12 string
252+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(r.Subject)
253+ if templ_7745c5c3_Err != nil {
254+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 59, Col: 50}
255+ }
256+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
257+ if templ_7745c5c3_Err != nil {
258+ return templ_7745c5c3_Err
259+ }
260+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</a>")
261+ if templ_7745c5c3_Err != nil {
262+ return templ_7745c5c3_Err
263+ }
264+ }
265+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</td><td class=\"c-who dim\">")
266+ if templ_7745c5c3_Err != nil {
267+ return templ_7745c5c3_Err
268+ }
269+ var templ_7745c5c3_Var13 string
270+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(r.Author)
271+ if templ_7745c5c3_Err != nil {
272+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 62, Col: 34}
273+ }
274+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
275+ if templ_7745c5c3_Err != nil {
276+ return templ_7745c5c3_Err
277+ }
278+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</td><td class=\"c-when nowrap dim\">")
279+ if templ_7745c5c3_Err != nil {
280+ return templ_7745c5c3_Err
281+ }
282+ if r.WhenAt != "" {
283+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<time datetime=\"")
284+ if templ_7745c5c3_Err != nil {
285+ return templ_7745c5c3_Err
286+ }
287+ var templ_7745c5c3_Var14 string
288+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
289+ if templ_7745c5c3_Err != nil {
290+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 65, Col: 29}
291+ }
292+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
293+ if templ_7745c5c3_Err != nil {
294+ return templ_7745c5c3_Err
295+ }
296+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" title=\"")
297+ if templ_7745c5c3_Err != nil {
298+ return templ_7745c5c3_Err
299+ }
300+ var templ_7745c5c3_Var15 string
301+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
302+ if templ_7745c5c3_Err != nil {
303+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 65, Col: 48}
304+ }
305+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
306+ if templ_7745c5c3_Err != nil {
307+ return templ_7745c5c3_Err
308+ }
309+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\">")
310+ if templ_7745c5c3_Err != nil {
311+ return templ_7745c5c3_Err
312+ }
313+ var templ_7745c5c3_Var16 string
314+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(r.When)
315+ if templ_7745c5c3_Err != nil {
316+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 65, Col: 59}
317+ }
318+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
319+ if templ_7745c5c3_Err != nil {
320+ return templ_7745c5c3_Err
321+ }
322+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</time>")
323+ if templ_7745c5c3_Err != nil {
324+ return templ_7745c5c3_Err
325+ }
326+ }
327+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</td></tr>")
328+ if templ_7745c5c3_Err != nil {
329+ return templ_7745c5c3_Err
330+ }
331+ return nil
332+ })
333+}
334+
335+var _ = templruntime.GeneratedTemplate
M internal/adapters/web/web.go +2 -0
@@ -101,6 +101,8 @@ func (s *Server) routes() *http.ServeMux {
101101 mux.HandleFunc("GET /{repo}/commits", s.page(s.commits))
102102 mux.HandleFunc("GET /{repo}/commits/{rest...}", s.page(s.commits))
103103 mux.HandleFunc("GET /{repo}/commit/{sha}", s.page(s.commit))
104+ mux.HandleFunc("GET /{repo}/tree", s.page(s.tree))
105+ mux.HandleFunc("GET /{repo}/tree/{rest...}", s.page(s.tree))
104106 mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound))
105107 return mux
106108 }
@@ -101,6 +101,8 @@ func (s *Server) routes() *http.ServeMux {
101 mux.HandleFunc("GET /{repo}/commits", s.page(s.commits))101 mux.HandleFunc("GET /{repo}/commits", s.page(s.commits))
102 mux.HandleFunc("GET /{repo}/commits/{rest...}", s.page(s.commits))102 mux.HandleFunc("GET /{repo}/commits/{rest...}", s.page(s.commits))
103 mux.HandleFunc("GET /{repo}/commit/{sha}", s.page(s.commit))103 mux.HandleFunc("GET /{repo}/commit/{sha}", s.page(s.commit))
104+ mux.HandleFunc("GET /{repo}/tree", s.page(s.tree))
105+ mux.HandleFunc("GET /{repo}/tree/{rest...}", s.page(s.tree))
104 mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound))106 mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound))
105 return mux107 return mux
106 }108 }

Keyboard shortcuts

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

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

Branches 2

Tags 44

↑↓ moveenter openesc close