detached 2b2a2cf

feat(web): find files across the repository with a fuzzy finder

GET /{repo}/paths/{sha} serves the file paths of a commit as gzipped JSON, authorized on every request and cached by commit id (64 MiB LRU, one build per commit). The Files page replaces its directory filter with a finder over the whole revision, and the : palette lists the same file matches. The engine runs in the browser (files.js) and is tested in Node.
commit
2b2a2cf4695933ad9217d49ad1e2f310632d3e18
author
brice · · 2026-10-11 18:54 UTC
parent
576bc4a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
53 files · +1797 -334
M .forgejo/workflows/ci.yml +5 -1
@@ -36,8 +36,9 @@ jobs:
3636 steps:
3737 # openssh provides ssh-keygen, which the tests use to check that the squash
3838 # commits verify (git verify-commit, ADR 0022). The tests fail without it when CI is set.
39+ # nodejs runs the tests of the file finder (internal/adapters/web/jstest).
3940 - name: System packages
40- run: pacman -Syu --noconfirm --needed git gcc openssh
41+ run: pacman -Syu --noconfirm --needed git gcc openssh nodejs
4142 - uses: actions/checkout@v6
4243 # Job containers reach Forgejo through the Gordon edge on the host loopback,
4344 # in plain HTTP on :8088 (see vps-infra homelab/igor/ci-runner).
@@ -66,6 +67,9 @@ jobs:
6667 - run: go test -race ./...
6768 - name: Architecture
6869 run: go tool hexcheck -fail-on warn ./...
70+ # The core of the file finder (static/js/files.js) runs in Node.
71+ - name: JavaScript tests
72+ run: node --test 'internal/adapters/web/jstest/*.test.mjs'
6973
7074 proto:
7175 name: Protobuf and generated code
@@ -36,8 +36,9 @@ jobs:
36 steps:36 steps:
37 # openssh provides ssh-keygen, which the tests use to check that the squash37 # openssh provides ssh-keygen, which the tests use to check that the squash
38 # commits verify (git verify-commit, ADR 0022). The tests fail without it when CI is set.38 # commits verify (git verify-commit, ADR 0022). The tests fail without it when CI is set.
39+ # nodejs runs the tests of the file finder (internal/adapters/web/jstest).
39 - name: System packages40 - name: System packages
40- run: pacman -Syu --noconfirm --needed git gcc openssh41+ run: pacman -Syu --noconfirm --needed git gcc openssh nodejs
41 - uses: actions/checkout@v642 - uses: actions/checkout@v6
42 # Job containers reach Forgejo through the Gordon edge on the host loopback,43 # Job containers reach Forgejo through the Gordon edge on the host loopback,
43 # in plain HTTP on :8088 (see vps-infra homelab/igor/ci-runner).44 # in plain HTTP on :8088 (see vps-infra homelab/igor/ci-runner).
@@ -66,6 +67,9 @@ jobs:
66 - run: go test -race ./...67 - run: go test -race ./...
67 - name: Architecture68 - name: Architecture
68 run: go tool hexcheck -fail-on warn ./...69 run: go tool hexcheck -fail-on warn ./...
70+ # The core of the file finder (static/js/files.js) runs in Node.
71+ - name: JavaScript tests
72+ run: node --test 'internal/adapters/web/jstest/*.test.mjs'
69 73
70 proto:74 proto:
71 name: Protobuf and generated code75 name: Protobuf and generated code
M cmd/ermite/testdata/web/blob-binary.html +5 -5
@@ -19,7 +19,7 @@
1919 <script src="/-/static/js/files.HASH.js" defer>
2020 </script>
2121 </head>
22-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
22+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2323 <a class="skip" href="#main">skip to content</a>
2424 <header class="hdr">
2525 <div class="hdr-top">
@@ -175,7 +175,7 @@
175175 <dt>
176176 <kbd>/</kbd>
177177 </dt>
178-<dd>filter the list</dd>
178+<dd>filter the list; on Files, find a file in the repository</dd>
179179 </div>
180180 <div>
181181 <dt>
@@ -266,8 +266,8 @@
266266 <div class="panel-head">
267267 <label for="cmd-q" class="sr">go to</label>
268268 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
269- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
270- 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();
269+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
270+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
271271 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
272272 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
273273 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' })) }
@@ -368,7 +368,7 @@
368368 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
369369 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
370370 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
371- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
371+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
372372 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
373373 k-gh from:body -> document.querySelector('.brand')?.click();
374374 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
@@ -19,7 +19,7 @@
19 <script src="/-/static/js/files.HASH.js" defer>19 <script src="/-/static/js/files.HASH.js" defer>
20 </script>20 </script>
21 </head>21 </head>
22-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">22+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
23 <a class="skip" href="#main">skip to content</a>23 <a class="skip" href="#main">skip to content</a>
24 <header class="hdr">24 <header class="hdr">
25 <div class="hdr-top">25 <div class="hdr-top">
@@ -175,7 +175,7 @@
175 <dt>175 <dt>
176 <kbd>/</kbd>176 <kbd>/</kbd>
177 </dt>177 </dt>
178-<dd>filter the list</dd>178+<dd>filter the list; on Files, find a file in the repository</dd>
179 </div>179 </div>
180 <div>180 <div>
181 <dt>181 <dt>
@@ -266,8 +266,8 @@
266 <div class="panel-head">266 <div class="panel-head">
267 <label for="cmd-q" class="sr">go to</label>267 <label for="cmd-q" class="sr">go to</label>
268 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();268 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
269- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));269+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
270- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();270+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
271 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));271 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
272 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;272 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
273 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }273 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' })) }
@@ -368,7 +368,7 @@
368 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());368 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
369 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();369 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
370 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());370 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
371- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();371+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
372 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();372 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
373 k-gh from:body -> document.querySelector('.brand')?.click();373 k-gh from:body -> document.querySelector('.brand')?.click();
374 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();374 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
M cmd/ermite/testdata/web/blob-code.html +5 -5
@@ -19,7 +19,7 @@
1919 <script src="/-/static/js/files.HASH.js" defer>
2020 </script>
2121 </head>
22-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
22+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2323 <a class="skip" href="#main">skip to content</a>
2424 <header class="hdr">
2525 <div class="hdr-top">
@@ -245,7 +245,7 @@
245245 <dt>
246246 <kbd>/</kbd>
247247 </dt>
248-<dd>filter the list</dd>
248+<dd>filter the list; on Files, find a file in the repository</dd>
249249 </div>
250250 <div>
251251 <dt>
@@ -336,8 +336,8 @@
336336 <div class="panel-head">
337337 <label for="cmd-q" class="sr">go to</label>
338338 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
339- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
340- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
339+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
340+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
341341 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
342342 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
343343 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' })) }
@@ -438,7 +438,7 @@
438438 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
439439 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
440440 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
441- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
441+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
442442 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
443443 k-gh from:body -> document.querySelector('.brand')?.click();
444444 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
@@ -19,7 +19,7 @@
19 <script src="/-/static/js/files.HASH.js" defer>19 <script src="/-/static/js/files.HASH.js" defer>
20 </script>20 </script>
21 </head>21 </head>
22-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">22+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
23 <a class="skip" href="#main">skip to content</a>23 <a class="skip" href="#main">skip to content</a>
24 <header class="hdr">24 <header class="hdr">
25 <div class="hdr-top">25 <div class="hdr-top">
@@ -245,7 +245,7 @@
245 <dt>245 <dt>
246 <kbd>/</kbd>246 <kbd>/</kbd>
247 </dt>247 </dt>
248-<dd>filter the list</dd>248+<dd>filter the list; on Files, find a file in the repository</dd>
249 </div>249 </div>
250 <div>250 <div>
251 <dt>251 <dt>
@@ -336,8 +336,8 @@
336 <div class="panel-head">336 <div class="panel-head">
337 <label for="cmd-q" class="sr">go to</label>337 <label for="cmd-q" class="sr">go to</label>
338 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();338 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
339- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));339+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
340- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();340+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
341 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));341 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
342 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;342 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
343 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }343 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
@@ -438,7 +438,7 @@
438 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());438 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
439 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();439 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
440 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());440 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
441- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();441+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
442 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();442 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
443 k-gh from:body -> document.querySelector('.brand')?.click();443 k-gh from:body -> document.querySelector('.brand')?.click();
444 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();444 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
M cmd/ermite/testdata/web/blob-empty.html +5 -5
@@ -19,7 +19,7 @@
1919 <script src="/-/static/js/files.HASH.js" defer>
2020 </script>
2121 </head>
22-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
22+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2323 <a class="skip" href="#main">skip to content</a>
2424 <header class="hdr">
2525 <div class="hdr-top">
@@ -175,7 +175,7 @@
175175 <dt>
176176 <kbd>/</kbd>
177177 </dt>
178-<dd>filter the list</dd>
178+<dd>filter the list; on Files, find a file in the repository</dd>
179179 </div>
180180 <div>
181181 <dt>
@@ -266,8 +266,8 @@
266266 <div class="panel-head">
267267 <label for="cmd-q" class="sr">go to</label>
268268 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
269- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
270- 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();
269+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
270+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
271271 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
272272 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
273273 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' })) }
@@ -368,7 +368,7 @@
368368 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
369369 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
370370 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
371- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
371+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
372372 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
373373 k-gh from:body -> document.querySelector('.brand')?.click();
374374 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
@@ -19,7 +19,7 @@
19 <script src="/-/static/js/files.HASH.js" defer>19 <script src="/-/static/js/files.HASH.js" defer>
20 </script>20 </script>
21 </head>21 </head>
22-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">22+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
23 <a class="skip" href="#main">skip to content</a>23 <a class="skip" href="#main">skip to content</a>
24 <header class="hdr">24 <header class="hdr">
25 <div class="hdr-top">25 <div class="hdr-top">
@@ -175,7 +175,7 @@
175 <dt>175 <dt>
176 <kbd>/</kbd>176 <kbd>/</kbd>
177 </dt>177 </dt>
178-<dd>filter the list</dd>178+<dd>filter the list; on Files, find a file in the repository</dd>
179 </div>179 </div>
180 <div>180 <div>
181 <dt>181 <dt>
@@ -266,8 +266,8 @@
266 <div class="panel-head">266 <div class="panel-head">
267 <label for="cmd-q" class="sr">go to</label>267 <label for="cmd-q" class="sr">go to</label>
268 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();268 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
269- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));269+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
270- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();270+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
271 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));271 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
272 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;272 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
273 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }273 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' })) }
@@ -368,7 +368,7 @@
368 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());368 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
369 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();369 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
370 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());370 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
371- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();371+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
372 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();372 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
373 k-gh from:body -> document.querySelector('.brand')?.click();373 k-gh from:body -> document.querySelector('.brand')?.click();
374 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();374 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
M cmd/ermite/testdata/web/blob-image.html +5 -5
@@ -19,7 +19,7 @@
1919 <script src="/-/static/js/files.HASH.js" defer>
2020 </script>
2121 </head>
22-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
22+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2323 <a class="skip" href="#main">skip to content</a>
2424 <header class="hdr">
2525 <div class="hdr-top">
@@ -177,7 +177,7 @@
177177 <dt>
178178 <kbd>/</kbd>
179179 </dt>
180-<dd>filter the list</dd>
180+<dd>filter the list; on Files, find a file in the repository</dd>
181181 </div>
182182 <div>
183183 <dt>
@@ -268,8 +268,8 @@
268268 <div class="panel-head">
269269 <label for="cmd-q" class="sr">go to</label>
270270 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
271- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
272- 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();
271+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
272+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
273273 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
274274 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
275275 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
@@ -370,7 +370,7 @@
370370 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
371371 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
372372 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
373- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
373+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
374374 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
375375 k-gh from:body -> document.querySelector('.brand')?.click();
376376 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
@@ -19,7 +19,7 @@
19 <script src="/-/static/js/files.HASH.js" defer>19 <script src="/-/static/js/files.HASH.js" defer>
20 </script>20 </script>
21 </head>21 </head>
22-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">22+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
23 <a class="skip" href="#main">skip to content</a>23 <a class="skip" href="#main">skip to content</a>
24 <header class="hdr">24 <header class="hdr">
25 <div class="hdr-top">25 <div class="hdr-top">
@@ -177,7 +177,7 @@
177 <dt>177 <dt>
178 <kbd>/</kbd>178 <kbd>/</kbd>
179 </dt>179 </dt>
180-<dd>filter the list</dd>180+<dd>filter the list; on Files, find a file in the repository</dd>
181 </div>181 </div>
182 <div>182 <div>
183 <dt>183 <dt>
@@ -268,8 +268,8 @@
268 <div class="panel-head">268 <div class="panel-head">
269 <label for="cmd-q" class="sr">go to</label>269 <label for="cmd-q" class="sr">go to</label>
270 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();270 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
271- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));271+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
272- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();272+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
273 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));273 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
274 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;274 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
275 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }275 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
@@ -370,7 +370,7 @@
370 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());370 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
371 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();371 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
372 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());372 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
373- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();373+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
374 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();374 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
375 k-gh from:body -> document.querySelector('.brand')?.click();375 k-gh from:body -> document.querySelector('.brand')?.click();
376 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();376 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
M cmd/ermite/testdata/web/blob-markdown.html +5 -5
@@ -19,7 +19,7 @@
1919 <script src="/-/static/js/files.HASH.js" defer>
2020 </script>
2121 </head>
22-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
22+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2323 <a class="skip" href="#main">skip to content</a>
2424 <header class="hdr">
2525 <div class="hdr-top">
@@ -242,7 +242,7 @@
242242 <dt>
243243 <kbd>/</kbd>
244244 </dt>
245-<dd>filter the list</dd>
245+<dd>filter the list; on Files, find a file in the repository</dd>
246246 </div>
247247 <div>
248248 <dt>
@@ -333,8 +333,8 @@
333333 <div class="panel-head">
334334 <label for="cmd-q" class="sr">go to</label>
335335 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
336- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
337- 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();
336+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
337+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
338338 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
339339 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
340340 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' })) }
@@ -435,7 +435,7 @@
435435 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
436436 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
437437 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
438- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
438+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
439439 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
440440 k-gh from:body -> document.querySelector('.brand')?.click();
441441 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
@@ -19,7 +19,7 @@
19 <script src="/-/static/js/files.HASH.js" defer>19 <script src="/-/static/js/files.HASH.js" defer>
20 </script>20 </script>
21 </head>21 </head>
22-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">22+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
23 <a class="skip" href="#main">skip to content</a>23 <a class="skip" href="#main">skip to content</a>
24 <header class="hdr">24 <header class="hdr">
25 <div class="hdr-top">25 <div class="hdr-top">
@@ -242,7 +242,7 @@
242 <dt>242 <dt>
243 <kbd>/</kbd>243 <kbd>/</kbd>
244 </dt>244 </dt>
245-<dd>filter the list</dd>245+<dd>filter the list; on Files, find a file in the repository</dd>
246 </div>246 </div>
247 <div>247 <div>
248 <dt>248 <dt>
@@ -333,8 +333,8 @@
333 <div class="panel-head">333 <div class="panel-head">
334 <label for="cmd-q" class="sr">go to</label>334 <label for="cmd-q" class="sr">go to</label>
335 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();335 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
336- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));336+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
337- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();337+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
338 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));338 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
339 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;339 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
340 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }340 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' })) }
@@ -435,7 +435,7 @@
435 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());435 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
436 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();436 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
437 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());437 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
438- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();438+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
439 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();439 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
440 k-gh from:body -> document.querySelector('.brand')?.click();440 k-gh from:body -> document.querySelector('.brand')?.click();
441 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();441 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
M cmd/ermite/testdata/web/blob-svg.html +5 -5
@@ -19,7 +19,7 @@
1919 <script src="/-/static/js/files.HASH.js" defer>
2020 </script>
2121 </head>
22-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
22+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2323 <a class="skip" href="#main">skip to content</a>
2424 <header class="hdr">
2525 <div class="hdr-top">
@@ -200,7 +200,7 @@
200200 <dt>
201201 <kbd>/</kbd>
202202 </dt>
203-<dd>filter the list</dd>
203+<dd>filter the list; on Files, find a file in the repository</dd>
204204 </div>
205205 <div>
206206 <dt>
@@ -291,8 +291,8 @@
291291 <div class="panel-head">
292292 <label for="cmd-q" class="sr">go to</label>
293293 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
294- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
295- 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();
294+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
295+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
296296 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
297297 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
298298 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' })) }
@@ -393,7 +393,7 @@
393393 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
394394 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
395395 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
396- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
396+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
397397 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
398398 k-gh from:body -> document.querySelector('.brand')?.click();
399399 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
@@ -19,7 +19,7 @@
19 <script src="/-/static/js/files.HASH.js" defer>19 <script src="/-/static/js/files.HASH.js" defer>
20 </script>20 </script>
21 </head>21 </head>
22-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">22+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
23 <a class="skip" href="#main">skip to content</a>23 <a class="skip" href="#main">skip to content</a>
24 <header class="hdr">24 <header class="hdr">
25 <div class="hdr-top">25 <div class="hdr-top">
@@ -200,7 +200,7 @@
200 <dt>200 <dt>
201 <kbd>/</kbd>201 <kbd>/</kbd>
202 </dt>202 </dt>
203-<dd>filter the list</dd>203+<dd>filter the list; on Files, find a file in the repository</dd>
204 </div>204 </div>
205 <div>205 <div>
206 <dt>206 <dt>
@@ -291,8 +291,8 @@
291 <div class="panel-head">291 <div class="panel-head">
292 <label for="cmd-q" class="sr">go to</label>292 <label for="cmd-q" class="sr">go to</label>
293 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();293 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
294- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));294+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
295- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();295+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
296 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));296 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
297 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;297 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
298 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }298 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' })) }
@@ -393,7 +393,7 @@
393 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());393 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
394 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();394 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
395 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());395 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
396- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();396+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
397 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();397 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
398 k-gh from:body -> document.querySelector('.brand')?.click();398 k-gh from:body -> document.querySelector('.brand')?.click();
399 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();399 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
M cmd/ermite/testdata/web/blob-text.html +5 -5
@@ -19,7 +19,7 @@
1919 <script src="/-/static/js/files.HASH.js" defer>
2020 </script>
2121 </head>
22-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
22+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2323 <a class="skip" href="#main">skip to content</a>
2424 <header class="hdr">
2525 <div class="hdr-top">
@@ -195,7 +195,7 @@
195195 <dt>
196196 <kbd>/</kbd>
197197 </dt>
198-<dd>filter the list</dd>
198+<dd>filter the list; on Files, find a file in the repository</dd>
199199 </div>
200200 <div>
201201 <dt>
@@ -286,8 +286,8 @@
286286 <div class="panel-head">
287287 <label for="cmd-q" class="sr">go to</label>
288288 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
289- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
290- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
289+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
290+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
291291 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
292292 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
293293 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' })) }
@@ -388,7 +388,7 @@
388388 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
389389 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
390390 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
391- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
391+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
392392 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
393393 k-gh from:body -> document.querySelector('.brand')?.click();
394394 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
@@ -19,7 +19,7 @@
19 <script src="/-/static/js/files.HASH.js" defer>19 <script src="/-/static/js/files.HASH.js" defer>
20 </script>20 </script>
21 </head>21 </head>
22-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">22+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
23 <a class="skip" href="#main">skip to content</a>23 <a class="skip" href="#main">skip to content</a>
24 <header class="hdr">24 <header class="hdr">
25 <div class="hdr-top">25 <div class="hdr-top">
@@ -195,7 +195,7 @@
195 <dt>195 <dt>
196 <kbd>/</kbd>196 <kbd>/</kbd>
197 </dt>197 </dt>
198-<dd>filter the list</dd>198+<dd>filter the list; on Files, find a file in the repository</dd>
199 </div>199 </div>
200 <div>200 <div>
201 <dt>201 <dt>
@@ -286,8 +286,8 @@
286 <div class="panel-head">286 <div class="panel-head">
287 <label for="cmd-q" class="sr">go to</label>287 <label for="cmd-q" class="sr">go to</label>
288 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();288 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
289- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));289+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
290- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();290+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
291 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));291 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
292 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;292 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
293 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }293 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
@@ -388,7 +388,7 @@
388 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());388 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
389 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();389 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
390 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());390 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
391- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();391+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
392 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();392 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
393 k-gh from:body -> document.querySelector('.brand')?.click();393 k-gh from:body -> document.querySelector('.brand')?.click();
394 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();394 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
M cmd/ermite/testdata/web/blob-too-large.html +5 -5
@@ -19,7 +19,7 @@
1919 <script src="/-/static/js/files.HASH.js" defer>
2020 </script>
2121 </head>
22-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
22+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2323 <a class="skip" href="#main">skip to content</a>
2424 <header class="hdr">
2525 <div class="hdr-top">
@@ -175,7 +175,7 @@
175175 <dt>
176176 <kbd>/</kbd>
177177 </dt>
178-<dd>filter the list</dd>
178+<dd>filter the list; on Files, find a file in the repository</dd>
179179 </div>
180180 <div>
181181 <dt>
@@ -266,8 +266,8 @@
266266 <div class="panel-head">
267267 <label for="cmd-q" class="sr">go to</label>
268268 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
269- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
270- 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();
269+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
270+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
271271 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
272272 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
273273 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' })) }
@@ -368,7 +368,7 @@
368368 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
369369 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
370370 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
371- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
371+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
372372 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
373373 k-gh from:body -> document.querySelector('.brand')?.click();
374374 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
@@ -19,7 +19,7 @@
19 <script src="/-/static/js/files.HASH.js" defer>19 <script src="/-/static/js/files.HASH.js" defer>
20 </script>20 </script>
21 </head>21 </head>
22-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">22+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
23 <a class="skip" href="#main">skip to content</a>23 <a class="skip" href="#main">skip to content</a>
24 <header class="hdr">24 <header class="hdr">
25 <div class="hdr-top">25 <div class="hdr-top">
@@ -175,7 +175,7 @@
175 <dt>175 <dt>
176 <kbd>/</kbd>176 <kbd>/</kbd>
177 </dt>177 </dt>
178-<dd>filter the list</dd>178+<dd>filter the list; on Files, find a file in the repository</dd>
179 </div>179 </div>
180 <div>180 <div>
181 <dt>181 <dt>
@@ -266,8 +266,8 @@
266 <div class="panel-head">266 <div class="panel-head">
267 <label for="cmd-q" class="sr">go to</label>267 <label for="cmd-q" class="sr">go to</label>
268 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();268 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
269- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));269+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
270- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();270+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
271 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));271 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
272 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;272 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
273 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }273 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' })) }
@@ -368,7 +368,7 @@
368 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());368 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
369 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();369 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
370 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());370 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
371- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();371+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
372 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();372 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
373 k-gh from:body -> document.querySelector('.brand')?.click();373 k-gh from:body -> document.querySelector('.brand')?.click();
374 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();374 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
M cmd/ermite/testdata/web/blob-trojan.html +5 -5
@@ -19,7 +19,7 @@
1919 <script src="/-/static/js/files.HASH.js" defer>
2020 </script>
2121 </head>
22-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
22+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2323 <a class="skip" href="#main">skip to content</a>
2424 <header class="hdr">
2525 <div class="hdr-top">
@@ -242,7 +242,7 @@
242242 <dt>
243243 <kbd>/</kbd>
244244 </dt>
245-<dd>filter the list</dd>
245+<dd>filter the list; on Files, find a file in the repository</dd>
246246 </div>
247247 <div>
248248 <dt>
@@ -333,8 +333,8 @@
333333 <div class="panel-head">
334334 <label for="cmd-q" class="sr">go to</label>
335335 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
336- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
337- 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();
336+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
337+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
338338 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
339339 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
340340 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' })) }
@@ -435,7 +435,7 @@
435435 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
436436 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
437437 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
438- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
438+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
439439 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
440440 k-gh from:body -> document.querySelector('.brand')?.click();
441441 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
@@ -19,7 +19,7 @@
19 <script src="/-/static/js/files.HASH.js" defer>19 <script src="/-/static/js/files.HASH.js" defer>
20 </script>20 </script>
21 </head>21 </head>
22-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">22+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
23 <a class="skip" href="#main">skip to content</a>23 <a class="skip" href="#main">skip to content</a>
24 <header class="hdr">24 <header class="hdr">
25 <div class="hdr-top">25 <div class="hdr-top">
@@ -242,7 +242,7 @@
242 <dt>242 <dt>
243 <kbd>/</kbd>243 <kbd>/</kbd>
244 </dt>244 </dt>
245-<dd>filter the list</dd>245+<dd>filter the list; on Files, find a file in the repository</dd>
246 </div>246 </div>
247 <div>247 <div>
248 <dt>248 <dt>
@@ -333,8 +333,8 @@
333 <div class="panel-head">333 <div class="panel-head">
334 <label for="cmd-q" class="sr">go to</label>334 <label for="cmd-q" class="sr">go to</label>
335 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();335 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
336- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));336+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
337- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();337+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
338 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));338 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
339 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;339 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
340 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }340 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' })) }
@@ -435,7 +435,7 @@
435 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());435 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
436 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();436 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
437 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());437 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
438- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();438+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
439 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();439 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
440 k-gh from:body -> document.querySelector('.brand')?.click();440 k-gh from:body -> document.querySelector('.brand')?.click();
441 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();441 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
M cmd/ermite/testdata/web/commit-big.html +5 -5
@@ -19,7 +19,7 @@
1919 <script src="/-/static/js/files.HASH.js" defer>
2020 </script>
2121 </head>
22-<body data-ref="" data-sha="SHA40" data-sel="" data-nav="">
22+<body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2323 <a class="skip" href="#main">skip to content</a>
2424 <header class="hdr">
2525 <div class="hdr-top">
@@ -212,7 +212,7 @@
212212 <dt>
213213 <kbd>/</kbd>
214214 </dt>
215-<dd>filter the list</dd>
215+<dd>filter the list; on Files, find a file in the repository</dd>
216216 </div>
217217 <div>
218218 <dt>
@@ -303,8 +303,8 @@
303303 <div class="panel-head">
304304 <label for="cmd-q" class="sr">go to</label>
305305 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
306- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
307- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
306+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
307+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
308308 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
309309 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
310310 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' })) }
@@ -405,7 +405,7 @@
405405 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
406406 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
407407 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
408- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
408+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
409409 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
410410 k-gh from:body -> document.querySelector('.brand')?.click();
411411 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
@@ -19,7 +19,7 @@
19 <script src="/-/static/js/files.HASH.js" defer>19 <script src="/-/static/js/files.HASH.js" defer>
20 </script>20 </script>
21 </head>21 </head>
22-<body data-ref="" data-sha="SHA40" data-sel="" data-nav="">22+<body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
23 <a class="skip" href="#main">skip to content</a>23 <a class="skip" href="#main">skip to content</a>
24 <header class="hdr">24 <header class="hdr">
25 <div class="hdr-top">25 <div class="hdr-top">
@@ -212,7 +212,7 @@
212 <dt>212 <dt>
213 <kbd>/</kbd>213 <kbd>/</kbd>
214 </dt>214 </dt>
215-<dd>filter the list</dd>215+<dd>filter the list; on Files, find a file in the repository</dd>
216 </div>216 </div>
217 <div>217 <div>
218 <dt>218 <dt>
@@ -303,8 +303,8 @@
303 <div class="panel-head">303 <div class="panel-head">
304 <label for="cmd-q" class="sr">go to</label>304 <label for="cmd-q" class="sr">go to</label>
305 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();305 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
306- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));306+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
307- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();307+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
308 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));308 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
309 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;309 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
310 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }310 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' })) }
@@ -405,7 +405,7 @@
405 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());405 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
406 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();406 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
407 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());407 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
408- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();408+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
409 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();409 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
410 k-gh from:body -> document.querySelector('.brand')?.click();410 k-gh from:body -> document.querySelector('.brand')?.click();
411 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();411 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
M cmd/ermite/testdata/web/commit-binary.html +5 -5
@@ -19,7 +19,7 @@
1919 <script src="/-/static/js/files.HASH.js" defer>
2020 </script>
2121 </head>
22-<body data-ref="" data-sha="SHA40" data-sel="" data-nav="">
22+<body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2323 <a class="skip" href="#main">skip to content</a>
2424 <header class="hdr">
2525 <div class="hdr-top">
@@ -207,7 +207,7 @@
207207 <dt>
208208 <kbd>/</kbd>
209209 </dt>
210-<dd>filter the list</dd>
210+<dd>filter the list; on Files, find a file in the repository</dd>
211211 </div>
212212 <div>
213213 <dt>
@@ -298,8 +298,8 @@
298298 <div class="panel-head">
299299 <label for="cmd-q" class="sr">go to</label>
300300 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
301- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
302- 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();
301+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
302+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
303303 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
304304 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
305305 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' })) }
@@ -400,7 +400,7 @@
400400 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
401401 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
402402 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
403- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
403+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
404404 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
405405 k-gh from:body -> document.querySelector('.brand')?.click();
406406 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
@@ -19,7 +19,7 @@
19 <script src="/-/static/js/files.HASH.js" defer>19 <script src="/-/static/js/files.HASH.js" defer>
20 </script>20 </script>
21 </head>21 </head>
22-<body data-ref="" data-sha="SHA40" data-sel="" data-nav="">22+<body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
23 <a class="skip" href="#main">skip to content</a>23 <a class="skip" href="#main">skip to content</a>
24 <header class="hdr">24 <header class="hdr">
25 <div class="hdr-top">25 <div class="hdr-top">
@@ -207,7 +207,7 @@
207 <dt>207 <dt>
208 <kbd>/</kbd>208 <kbd>/</kbd>
209 </dt>209 </dt>
210-<dd>filter the list</dd>210+<dd>filter the list; on Files, find a file in the repository</dd>
211 </div>211 </div>
212 <div>212 <div>
213 <dt>213 <dt>
@@ -298,8 +298,8 @@
298 <div class="panel-head">298 <div class="panel-head">
299 <label for="cmd-q" class="sr">go to</label>299 <label for="cmd-q" class="sr">go to</label>
300 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();300 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
301- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));301+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
302- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();302+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
303 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));303 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
304 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;304 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
305 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }305 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' })) }
@@ -400,7 +400,7 @@
400 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());400 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
401 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();401 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
402 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());402 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
403- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();403+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
404 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();404 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
405 k-gh from:body -> document.querySelector('.brand')?.click();405 k-gh from:body -> document.querySelector('.brand')?.click();
406 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();406 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
M cmd/ermite/testdata/web/commit-merge.html +5 -5
@@ -19,7 +19,7 @@
1919 <script src="/-/static/js/files.HASH.js" defer>
2020 </script>
2121 </head>
22-<body data-ref="" data-sha="SHA40" data-sel="" data-nav="">
22+<body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2323 <a class="skip" href="#main">skip to content</a>
2424 <header class="hdr">
2525 <div class="hdr-top">
@@ -246,7 +246,7 @@
246246 <dt>
247247 <kbd>/</kbd>
248248 </dt>
249-<dd>filter the list</dd>
249+<dd>filter the list; on Files, find a file in the repository</dd>
250250 </div>
251251 <div>
252252 <dt>
@@ -337,8 +337,8 @@
337337 <div class="panel-head">
338338 <label for="cmd-q" class="sr">go to</label>
339339 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
340- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
341- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
340+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
341+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
342342 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
343343 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
344344 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' })) }
@@ -439,7 +439,7 @@
439439 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
440440 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
441441 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
442- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
442+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
443443 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
444444 k-gh from:body -> document.querySelector('.brand')?.click();
445445 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
@@ -19,7 +19,7 @@
19 <script src="/-/static/js/files.HASH.js" defer>19 <script src="/-/static/js/files.HASH.js" defer>
20 </script>20 </script>
21 </head>21 </head>
22-<body data-ref="" data-sha="SHA40" data-sel="" data-nav="">22+<body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
23 <a class="skip" href="#main">skip to content</a>23 <a class="skip" href="#main">skip to content</a>
24 <header class="hdr">24 <header class="hdr">
25 <div class="hdr-top">25 <div class="hdr-top">
@@ -246,7 +246,7 @@
246 <dt>246 <dt>
247 <kbd>/</kbd>247 <kbd>/</kbd>
248 </dt>248 </dt>
249-<dd>filter the list</dd>249+<dd>filter the list; on Files, find a file in the repository</dd>
250 </div>250 </div>
251 <div>251 <div>
252 <dt>252 <dt>
@@ -337,8 +337,8 @@
337 <div class="panel-head">337 <div class="panel-head">
338 <label for="cmd-q" class="sr">go to</label>338 <label for="cmd-q" class="sr">go to</label>
339 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();339 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
340- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));340+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
341- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();341+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
342 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));342 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
343 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;343 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
344 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }344 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' })) }
@@ -439,7 +439,7 @@
439 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());439 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
440 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();440 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
441 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());441 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
442- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();442+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
443 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();443 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
444 k-gh from:body -> document.querySelector('.brand')?.click();444 k-gh from:body -> document.querySelector('.brand')?.click();
445 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();445 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
M cmd/ermite/testdata/web/commit-modify-split.html +5 -5
@@ -19,7 +19,7 @@
1919 <script src="/-/static/js/files.HASH.js" defer>
2020 </script>
2121 </head>
22-<body data-ref="" data-sha="SHA40" data-sel="" data-nav="">
22+<body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2323 <a class="skip" href="#main">skip to content</a>
2424 <header class="hdr">
2525 <div class="hdr-top">
@@ -367,7 +367,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
367367 <dt>
368368 <kbd>/</kbd>
369369 </dt>
370-<dd>filter the list</dd>
370+<dd>filter the list; on Files, find a file in the repository</dd>
371371 </div>
372372 <div>
373373 <dt>
@@ -458,8 +458,8 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
458458 <div class="panel-head">
459459 <label for="cmd-q" class="sr">go to</label>
460460 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
461- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
462- 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();
461+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
462+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
463463 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
464464 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
465465 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' })) }
@@ -560,7 +560,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
560560 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
561561 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
562562 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
563- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
563+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
564564 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
565565 k-gh from:body -> document.querySelector('.brand')?.click();
566566 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
@@ -19,7 +19,7 @@
19 <script src="/-/static/js/files.HASH.js" defer>19 <script src="/-/static/js/files.HASH.js" defer>
20 </script>20 </script>
21 </head>21 </head>
22-<body data-ref="" data-sha="SHA40" data-sel="" data-nav="">22+<body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
23 <a class="skip" href="#main">skip to content</a>23 <a class="skip" href="#main">skip to content</a>
24 <header class="hdr">24 <header class="hdr">
25 <div class="hdr-top">25 <div class="hdr-top">
@@ -367,7 +367,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
367 <dt>367 <dt>
368 <kbd>/</kbd>368 <kbd>/</kbd>
369 </dt>369 </dt>
370-<dd>filter the list</dd>370+<dd>filter the list; on Files, find a file in the repository</dd>
371 </div>371 </div>
372 <div>372 <div>
373 <dt>373 <dt>
@@ -458,8 +458,8 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
458 <div class="panel-head">458 <div class="panel-head">
459 <label for="cmd-q" class="sr">go to</label>459 <label for="cmd-q" class="sr">go to</label>
460 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();460 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
461- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));461+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
462- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();462+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
463 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));463 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
464 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;464 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
465 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }465 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' })) }
@@ -560,7 +560,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
560 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());560 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
561 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();561 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
562 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());562 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
563- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();563+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
564 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();564 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
565 k-gh from:body -> document.querySelector('.brand')?.click();565 k-gh from:body -> document.querySelector('.brand')?.click();
566 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();566 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
M cmd/ermite/testdata/web/commit-modify.html +5 -5
@@ -19,7 +19,7 @@
1919 <script src="/-/static/js/files.HASH.js" defer>
2020 </script>
2121 </head>
22-<body data-ref="" data-sha="SHA40" data-sel="" data-nav="">
22+<body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2323 <a class="skip" href="#main">skip to content</a>
2424 <header class="hdr">
2525 <div class="hdr-top">
@@ -367,7 +367,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
367367 <dt>
368368 <kbd>/</kbd>
369369 </dt>
370-<dd>filter the list</dd>
370+<dd>filter the list; on Files, find a file in the repository</dd>
371371 </div>
372372 <div>
373373 <dt>
@@ -458,8 +458,8 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
458458 <div class="panel-head">
459459 <label for="cmd-q" class="sr">go to</label>
460460 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
461- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
462- 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();
461+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
462+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
463463 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
464464 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
465465 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' })) }
@@ -560,7 +560,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
560560 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
561561 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
562562 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
563- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
563+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
564564 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
565565 k-gh from:body -> document.querySelector('.brand')?.click();
566566 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
@@ -19,7 +19,7 @@
19 <script src="/-/static/js/files.HASH.js" defer>19 <script src="/-/static/js/files.HASH.js" defer>
20 </script>20 </script>
21 </head>21 </head>
22-<body data-ref="" data-sha="SHA40" data-sel="" data-nav="">22+<body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
23 <a class="skip" href="#main">skip to content</a>23 <a class="skip" href="#main">skip to content</a>
24 <header class="hdr">24 <header class="hdr">
25 <div class="hdr-top">25 <div class="hdr-top">
@@ -367,7 +367,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
367 <dt>367 <dt>
368 <kbd>/</kbd>368 <kbd>/</kbd>
369 </dt>369 </dt>
370-<dd>filter the list</dd>370+<dd>filter the list; on Files, find a file in the repository</dd>
371 </div>371 </div>
372 <div>372 <div>
373 <dt>373 <dt>
@@ -458,8 +458,8 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
458 <div class="panel-head">458 <div class="panel-head">
459 <label for="cmd-q" class="sr">go to</label>459 <label for="cmd-q" class="sr">go to</label>
460 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();460 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
461- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));461+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
462- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();462+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
463 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));463 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
464 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;464 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
465 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }465 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' })) }
@@ -560,7 +560,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
560 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());560 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
561 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();561 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
562 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());562 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
563- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();563+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
564 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();564 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
565 k-gh from:body -> document.querySelector('.brand')?.click();565 k-gh from:body -> document.querySelector('.brand')?.click();
566 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();566 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
M cmd/ermite/testdata/web/commit-rename.html +5 -5
@@ -19,7 +19,7 @@
1919 <script src="/-/static/js/files.HASH.js" defer>
2020 </script>
2121 </head>
22-<body data-ref="" data-sha="SHA40" data-sel="" data-nav="">
22+<body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2323 <a class="skip" href="#main">skip to content</a>
2424 <header class="hdr">
2525 <div class="hdr-top">
@@ -272,7 +272,7 @@
272272 <dt>
273273 <kbd>/</kbd>
274274 </dt>
275-<dd>filter the list</dd>
275+<dd>filter the list; on Files, find a file in the repository</dd>
276276 </div>
277277 <div>
278278 <dt>
@@ -363,8 +363,8 @@
363363 <div class="panel-head">
364364 <label for="cmd-q" class="sr">go to</label>
365365 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
366- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
367- 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();
366+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
367+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
368368 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
369369 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
370370 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
@@ -465,7 +465,7 @@
465465 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
466466 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
467467 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
468- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
468+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
469469 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
470470 k-gh from:body -> document.querySelector('.brand')?.click();
471471 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
@@ -19,7 +19,7 @@
19 <script src="/-/static/js/files.HASH.js" defer>19 <script src="/-/static/js/files.HASH.js" defer>
20 </script>20 </script>
21 </head>21 </head>
22-<body data-ref="" data-sha="SHA40" data-sel="" data-nav="">22+<body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
23 <a class="skip" href="#main">skip to content</a>23 <a class="skip" href="#main">skip to content</a>
24 <header class="hdr">24 <header class="hdr">
25 <div class="hdr-top">25 <div class="hdr-top">
@@ -272,7 +272,7 @@
272 <dt>272 <dt>
273 <kbd>/</kbd>273 <kbd>/</kbd>
274 </dt>274 </dt>
275-<dd>filter the list</dd>275+<dd>filter the list; on Files, find a file in the repository</dd>
276 </div>276 </div>
277 <div>277 <div>
278 <dt>278 <dt>
@@ -363,8 +363,8 @@
363 <div class="panel-head">363 <div class="panel-head">
364 <label for="cmd-q" class="sr">go to</label>364 <label for="cmd-q" class="sr">go to</label>
365 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();365 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
366- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));366+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
367- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();367+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
368 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));368 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
369 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;369 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
370 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }370 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
@@ -465,7 +465,7 @@
465 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());465 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
466 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();466 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
467 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());467 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
468- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();468+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
469 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();469 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
470 k-gh from:body -> document.querySelector('.brand')?.click();470 k-gh from:body -> document.querySelector('.brand')?.click();
471 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();471 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
M cmd/ermite/testdata/web/commit-root.html +5 -5
@@ -19,7 +19,7 @@
1919 <script src="/-/static/js/files.HASH.js" defer>
2020 </script>
2121 </head>
22-<body data-ref="" data-sha="SHA40" data-sel="" data-nav="">
22+<body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2323 <a class="skip" href="#main">skip to content</a>
2424 <header class="hdr">
2525 <div class="hdr-top">
@@ -588,7 +588,7 @@
588588 <dt>
589589 <kbd>/</kbd>
590590 </dt>
591-<dd>filter the list</dd>
591+<dd>filter the list; on Files, find a file in the repository</dd>
592592 </div>
593593 <div>
594594 <dt>
@@ -679,8 +679,8 @@
679679 <div class="panel-head">
680680 <label for="cmd-q" class="sr">go to</label>
681681 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
682- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
683- 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();
682+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
683+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
684684 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
685685 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
686686 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' })) }
@@ -781,7 +781,7 @@
781781 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
782782 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
783783 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
784- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
784+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
785785 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
786786 k-gh from:body -> document.querySelector('.brand')?.click();
787787 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
@@ -19,7 +19,7 @@
19 <script src="/-/static/js/files.HASH.js" defer>19 <script src="/-/static/js/files.HASH.js" defer>
20 </script>20 </script>
21 </head>21 </head>
22-<body data-ref="" data-sha="SHA40" data-sel="" data-nav="">22+<body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
23 <a class="skip" href="#main">skip to content</a>23 <a class="skip" href="#main">skip to content</a>
24 <header class="hdr">24 <header class="hdr">
25 <div class="hdr-top">25 <div class="hdr-top">
@@ -588,7 +588,7 @@
588 <dt>588 <dt>
589 <kbd>/</kbd>589 <kbd>/</kbd>
590 </dt>590 </dt>
591-<dd>filter the list</dd>591+<dd>filter the list; on Files, find a file in the repository</dd>
592 </div>592 </div>
593 <div>593 <div>
594 <dt>594 <dt>
@@ -679,8 +679,8 @@
679 <div class="panel-head">679 <div class="panel-head">
680 <label for="cmd-q" class="sr">go to</label>680 <label for="cmd-q" class="sr">go to</label>
681 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();681 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
682- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));682+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
683- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();683+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
684 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));684 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
685 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;685 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
686 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }686 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' })) }
@@ -781,7 +781,7 @@
781 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());781 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
782 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();782 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
783 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());783 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
784- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();784+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
785 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();785 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
786 k-gh from:body -> document.querySelector('.brand')?.click();786 k-gh from:body -> document.querySelector('.brand')?.click();
787 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();787 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
M cmd/ermite/testdata/web/commit-trojan.html +5 -5
@@ -19,7 +19,7 @@
1919 <script src="/-/static/js/files.HASH.js" defer>
2020 </script>
2121 </head>
22-<body data-ref="" data-sha="SHA40" data-sel="" data-nav="">
22+<body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2323 <a class="skip" href="#main">skip to content</a>
2424 <header class="hdr">
2525 <div class="hdr-top">
@@ -381,7 +381,7 @@
381381 <dt>
382382 <kbd>/</kbd>
383383 </dt>
384-<dd>filter the list</dd>
384+<dd>filter the list; on Files, find a file in the repository</dd>
385385 </div>
386386 <div>
387387 <dt>
@@ -472,8 +472,8 @@
472472 <div class="panel-head">
473473 <label for="cmd-q" class="sr">go to</label>
474474 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
475- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
476- 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();
475+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
476+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
477477 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
478478 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
479479 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' })) }
@@ -574,7 +574,7 @@
574574 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
575575 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
576576 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
577- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
577+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
578578 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
579579 k-gh from:body -> document.querySelector('.brand')?.click();
580580 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
@@ -19,7 +19,7 @@
19 <script src="/-/static/js/files.HASH.js" defer>19 <script src="/-/static/js/files.HASH.js" defer>
20 </script>20 </script>
21 </head>21 </head>
22-<body data-ref="" data-sha="SHA40" data-sel="" data-nav="">22+<body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
23 <a class="skip" href="#main">skip to content</a>23 <a class="skip" href="#main">skip to content</a>
24 <header class="hdr">24 <header class="hdr">
25 <div class="hdr-top">25 <div class="hdr-top">
@@ -381,7 +381,7 @@
381 <dt>381 <dt>
382 <kbd>/</kbd>382 <kbd>/</kbd>
383 </dt>383 </dt>
384-<dd>filter the list</dd>384+<dd>filter the list; on Files, find a file in the repository</dd>
385 </div>385 </div>
386 <div>386 <div>
387 <dt>387 <dt>
@@ -472,8 +472,8 @@
472 <div class="panel-head">472 <div class="panel-head">
473 <label for="cmd-q" class="sr">go to</label>473 <label for="cmd-q" class="sr">go to</label>
474 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();474 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
475- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));475+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
476- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();476+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
477 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));477 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
478 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;478 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
479 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }479 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' })) }
@@ -574,7 +574,7 @@
574 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());574 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
575 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();575 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
576 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());576 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
577- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();577+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
578 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();578 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
579 k-gh from:body -> document.querySelector('.brand')?.click();579 k-gh from:body -> document.querySelector('.brand')?.click();
580 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();580 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
M cmd/ermite/testdata/web/commits-page2.html +5 -5
@@ -19,7 +19,7 @@
1919 <script src="/-/static/js/files.HASH.js" defer>
2020 </script>
2121 </head>
22-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
22+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main">
2323 <a class="skip" href="#main">skip to content</a>
2424 <header class="hdr">
2525 <div class="hdr-top">
@@ -204,7 +204,7 @@
204204 <dt>
205205 <kbd>/</kbd>
206206 </dt>
207-<dd>filter the list</dd>
207+<dd>filter the list; on Files, find a file in the repository</dd>
208208 </div>
209209 <div>
210210 <dt>
@@ -287,8 +287,8 @@
287287 <div class="panel-head">
288288 <label for="cmd-q" class="sr">go to</label>
289289 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
290- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
291- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
290+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
291+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
292292 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
293293 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
294294 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' })) }
@@ -383,7 +383,7 @@
383383 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
384384 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
385385 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
386- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
386+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
387387 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
388388 k-gh from:body -> document.querySelector('.brand')?.click();
389389 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
@@ -19,7 +19,7 @@
19 <script src="/-/static/js/files.HASH.js" defer>19 <script src="/-/static/js/files.HASH.js" defer>
20 </script>20 </script>
21 </head>21 </head>
22-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">22+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main">
23 <a class="skip" href="#main">skip to content</a>23 <a class="skip" href="#main">skip to content</a>
24 <header class="hdr">24 <header class="hdr">
25 <div class="hdr-top">25 <div class="hdr-top">
@@ -204,7 +204,7 @@
204 <dt>204 <dt>
205 <kbd>/</kbd>205 <kbd>/</kbd>
206 </dt>206 </dt>
207-<dd>filter the list</dd>207+<dd>filter the list; on Files, find a file in the repository</dd>
208 </div>208 </div>
209 <div>209 <div>
210 <dt>210 <dt>
@@ -287,8 +287,8 @@
287 <div class="panel-head">287 <div class="panel-head">
288 <label for="cmd-q" class="sr">go to</label>288 <label for="cmd-q" class="sr">go to</label>
289 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();289 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
290- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));290+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
291- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();291+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
292 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));292 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
293 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;293 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
294 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }294 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' })) }
@@ -383,7 +383,7 @@
383 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());383 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
384 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();384 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
385 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());385 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
386- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();386+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
387 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();387 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
388 k-gh from:body -> document.querySelector('.brand')?.click();388 k-gh from:body -> document.querySelector('.brand')?.click();
389 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();389 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
M cmd/ermite/testdata/web/commits-path.html +5 -5
@@ -19,7 +19,7 @@
1919 <script src="/-/static/js/files.HASH.js" defer>
2020 </script>
2121 </head>
22-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
22+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main">
2323 <a class="skip" href="#main">skip to content</a>
2424 <header class="hdr">
2525 <div class="hdr-top">
@@ -219,7 +219,7 @@
219219 <dt>
220220 <kbd>/</kbd>
221221 </dt>
222-<dd>filter the list</dd>
222+<dd>filter the list; on Files, find a file in the repository</dd>
223223 </div>
224224 <div>
225225 <dt>
@@ -302,8 +302,8 @@
302302 <div class="panel-head">
303303 <label for="cmd-q" class="sr">go to</label>
304304 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
305- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
306- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
305+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
306+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
307307 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
308308 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
309309 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' })) }
@@ -398,7 +398,7 @@
398398 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
399399 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
400400 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
401- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
401+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
402402 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
403403 k-gh from:body -> document.querySelector('.brand')?.click();
404404 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
@@ -19,7 +19,7 @@
19 <script src="/-/static/js/files.HASH.js" defer>19 <script src="/-/static/js/files.HASH.js" defer>
20 </script>20 </script>
21 </head>21 </head>
22-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">22+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main">
23 <a class="skip" href="#main">skip to content</a>23 <a class="skip" href="#main">skip to content</a>
24 <header class="hdr">24 <header class="hdr">
25 <div class="hdr-top">25 <div class="hdr-top">
@@ -219,7 +219,7 @@
219 <dt>219 <dt>
220 <kbd>/</kbd>220 <kbd>/</kbd>
221 </dt>221 </dt>
222-<dd>filter the list</dd>222+<dd>filter the list; on Files, find a file in the repository</dd>
223 </div>223 </div>
224 <div>224 <div>
225 <dt>225 <dt>
@@ -302,8 +302,8 @@
302 <div class="panel-head">302 <div class="panel-head">
303 <label for="cmd-q" class="sr">go to</label>303 <label for="cmd-q" class="sr">go to</label>
304 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();304 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
305- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));305+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
306- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();306+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
307 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));307 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
308 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;308 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
309 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }309 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
@@ -398,7 +398,7 @@
398 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());398 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
399 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();399 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
400 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());400 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
401- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();401+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
402 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();402 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
403 k-gh from:body -> document.querySelector('.brand')?.click();403 k-gh from:body -> document.querySelector('.brand')?.click();
404 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();404 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
M cmd/ermite/testdata/web/commits-root.html +5 -5
@@ -19,7 +19,7 @@
1919 <script src="/-/static/js/files.HASH.js" defer>
2020 </script>
2121 </head>
22-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
22+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main">
2323 <a class="skip" href="#main">skip to content</a>
2424 <header class="hdr">
2525 <div class="hdr-top">
@@ -1006,7 +1006,7 @@
10061006 <dt>
10071007 <kbd>/</kbd>
10081008 </dt>
1009-<dd>filter the list</dd>
1009+<dd>filter the list; on Files, find a file in the repository</dd>
10101010 </div>
10111011 <div>
10121012 <dt>
@@ -1089,8 +1089,8 @@
10891089 <div class="panel-head">
10901090 <label for="cmd-q" class="sr">go to</label>
10911091 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
1092- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
1093- 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();
1092+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
1093+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
10941094 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
10951095 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
10961096 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' })) }
@@ -1185,7 +1185,7 @@
11851185 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
11861186 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
11871187 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
1188- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
1188+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
11891189 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
11901190 k-gh from:body -> document.querySelector('.brand')?.click();
11911191 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
@@ -19,7 +19,7 @@
19 <script src="/-/static/js/files.HASH.js" defer>19 <script src="/-/static/js/files.HASH.js" defer>
20 </script>20 </script>
21 </head>21 </head>
22-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">22+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main">
23 <a class="skip" href="#main">skip to content</a>23 <a class="skip" href="#main">skip to content</a>
24 <header class="hdr">24 <header class="hdr">
25 <div class="hdr-top">25 <div class="hdr-top">
@@ -1006,7 +1006,7 @@
1006 <dt>1006 <dt>
1007 <kbd>/</kbd>1007 <kbd>/</kbd>
1008 </dt>1008 </dt>
1009-<dd>filter the list</dd>1009+<dd>filter the list; on Files, find a file in the repository</dd>
1010 </div>1010 </div>
1011 <div>1011 <div>
1012 <dt>1012 <dt>
@@ -1089,8 +1089,8 @@
1089 <div class="panel-head">1089 <div class="panel-head">
1090 <label for="cmd-q" class="sr">go to</label>1090 <label for="cmd-q" class="sr">go to</label>
1091 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();1091 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
1092- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));1092+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
1093- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();1093+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
1094 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));1094 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
1095 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;1095 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
1096 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }1096 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' })) }
@@ -1185,7 +1185,7 @@
1185 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());1185 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
1186 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();1186 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
1187 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());1187 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
1188- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();1188+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
1189 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();1189 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
1190 k-gh from:body -> document.querySelector('.brand')?.click();1190 k-gh from:body -> document.querySelector('.brand')?.click();
1191 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();1191 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
M cmd/ermite/testdata/web/commits-small.html +5 -5
@@ -19,7 +19,7 @@
1919 <script src="/-/static/js/files.HASH.js" defer>
2020 </script>
2121 </head>
22-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
22+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/main">
2323 <a class="skip" href="#main">skip to content</a>
2424 <header class="hdr">
2525 <div class="hdr-top">
@@ -351,7 +351,7 @@
351351 <dt>
352352 <kbd>/</kbd>
353353 </dt>
354-<dd>filter the list</dd>
354+<dd>filter the list; on Files, find a file in the repository</dd>
355355 </div>
356356 <div>
357357 <dt>
@@ -442,8 +442,8 @@
442442 <div class="panel-head">
443443 <label for="cmd-q" class="sr">go to</label>
444444 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
445- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
446- 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();
445+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
446+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
447447 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
448448 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
449449 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' })) }
@@ -550,7 +550,7 @@
550550 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
551551 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
552552 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
553- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
553+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
554554 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
555555 k-gh from:body -> document.querySelector('.brand')?.click();
556556 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
@@ -19,7 +19,7 @@
19 <script src="/-/static/js/files.HASH.js" defer>19 <script src="/-/static/js/files.HASH.js" defer>
20 </script>20 </script>
21 </head>21 </head>
22-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">22+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/main">
23 <a class="skip" href="#main">skip to content</a>23 <a class="skip" href="#main">skip to content</a>
24 <header class="hdr">24 <header class="hdr">
25 <div class="hdr-top">25 <div class="hdr-top">
@@ -351,7 +351,7 @@
351 <dt>351 <dt>
352 <kbd>/</kbd>352 <kbd>/</kbd>
353 </dt>353 </dt>
354-<dd>filter the list</dd>354+<dd>filter the list; on Files, find a file in the repository</dd>
355 </div>355 </div>
356 <div>356 <div>
357 <dt>357 <dt>
@@ -442,8 +442,8 @@
442 <div class="panel-head">442 <div class="panel-head">
443 <label for="cmd-q" class="sr">go to</label>443 <label for="cmd-q" class="sr">go to</label>
444 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();444 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
445- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));445+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
446- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();446+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
447 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));447 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
448 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;448 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
449 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }449 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' })) }
@@ -550,7 +550,7 @@
550 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());550 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
551 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();551 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
552 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());552 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
553- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();553+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
554 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();554 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
555 k-gh from:body -> document.querySelector('.brand')?.click();555 k-gh from:body -> document.querySelector('.brand')?.click();
556 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();556 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
M cmd/ermite/testdata/web/empty.html +4 -4
@@ -128,7 +128,7 @@
128128 <dt>
129129 <kbd>/</kbd>
130130 </dt>
131-<dd>filter the list</dd>
131+<dd>filter the list; on Files, find a file in the repository</dd>
132132 </div>
133133 <div>
134134 <dt>
@@ -184,8 +184,8 @@
184184 <div class="panel-head">
185185 <label for="cmd-q" class="sr">go to</label>
186186 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
187- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
188- 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();
187+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
188+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
189189 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
190190 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
191191 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' })) }
@@ -244,7 +244,7 @@
244244 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
245245 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
246246 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
247- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
247+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
248248 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
249249 k-gh from:body -> document.querySelector('.brand')?.click();
250250 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
@@ -128,7 +128,7 @@
128 <dt>128 <dt>
129 <kbd>/</kbd>129 <kbd>/</kbd>
130 </dt>130 </dt>
131-<dd>filter the list</dd>131+<dd>filter the list; on Files, find a file in the repository</dd>
132 </div>132 </div>
133 <div>133 <div>
134 <dt>134 <dt>
@@ -184,8 +184,8 @@
184 <div class="panel-head">184 <div class="panel-head">
185 <label for="cmd-q" class="sr">go to</label>185 <label for="cmd-q" class="sr">go to</label>
186 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();186 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
187- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));187+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
188- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();188+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
189 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));189 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
190 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;190 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
191 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }191 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' })) }
@@ -244,7 +244,7 @@
244 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());244 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
245 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();245 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
246 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());246 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
247- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();247+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
248 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();248 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
249 k-gh from:body -> document.querySelector('.brand')?.click();249 k-gh from:body -> document.querySelector('.brand')?.click();
250 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();250 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
M cmd/ermite/testdata/web/home.html +4 -4
@@ -158,7 +158,7 @@
158158 <dt>
159159 <kbd>/</kbd>
160160 </dt>
161-<dd>filter the list</dd>
161+<dd>filter the list; on Files, find a file in the repository</dd>
162162 </div>
163163 <div>
164164 <dt>
@@ -214,8 +214,8 @@
214214 <div class="panel-head">
215215 <label for="cmd-q" class="sr">go to</label>
216216 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
217- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
218- 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();
217+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
218+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
219219 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
220220 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
221221 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' })) }
@@ -280,7 +280,7 @@
280280 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
281281 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
282282 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
283- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
283+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
284284 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
285285 k-gh from:body -> document.querySelector('.brand')?.click();
286286 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
@@ -158,7 +158,7 @@
158 <dt>158 <dt>
159 <kbd>/</kbd>159 <kbd>/</kbd>
160 </dt>160 </dt>
161-<dd>filter the list</dd>161+<dd>filter the list; on Files, find a file in the repository</dd>
162 </div>162 </div>
163 <div>163 <div>
164 <dt>164 <dt>
@@ -214,8 +214,8 @@
214 <div class="panel-head">214 <div class="panel-head">
215 <label for="cmd-q" class="sr">go to</label>215 <label for="cmd-q" class="sr">go to</label>
216 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();216 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
217- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));217+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
218- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();218+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
219 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));219 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
220 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;220 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
221 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }221 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' })) }
@@ -280,7 +280,7 @@
280 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());280 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
281 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();281 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
282 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());282 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
283- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();283+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
284 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();284 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
285 k-gh from:body -> document.querySelector('.brand')?.click();285 k-gh from:body -> document.querySelector('.brand')?.click();
286 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();286 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
M cmd/ermite/testdata/web/not-found-page.html +4 -4
@@ -117,7 +117,7 @@
117117 <dt>
118118 <kbd>/</kbd>
119119 </dt>
120-<dd>filter the list</dd>
120+<dd>filter the list; on Files, find a file in the repository</dd>
121121 </div>
122122 <div>
123123 <dt>
@@ -173,8 +173,8 @@
173173 <div class="panel-head">
174174 <label for="cmd-q" class="sr">go to</label>
175175 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
176- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
177- 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();
176+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
177+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
178178 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
179179 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
180180 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' })) }
@@ -227,7 +227,7 @@
227227 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
228228 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
229229 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
230- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
230+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
231231 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
232232 k-gh from:body -> document.querySelector('.brand')?.click();
233233 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
@@ -117,7 +117,7 @@
117 <dt>117 <dt>
118 <kbd>/</kbd>118 <kbd>/</kbd>
119 </dt>119 </dt>
120-<dd>filter the list</dd>120+<dd>filter the list; on Files, find a file in the repository</dd>
121 </div>121 </div>
122 <div>122 <div>
123 <dt>123 <dt>
@@ -173,8 +173,8 @@
173 <div class="panel-head">173 <div class="panel-head">
174 <label for="cmd-q" class="sr">go to</label>174 <label for="cmd-q" class="sr">go to</label>
175 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();175 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
176- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));176+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
177- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();177+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
178 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));178 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
179 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;179 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
180 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }180 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' })) }
@@ -227,7 +227,7 @@
227 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());227 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
228 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();228 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
229 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());229 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
230- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();230+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
231 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();231 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
232 k-gh from:body -> document.querySelector('.brand')?.click();232 k-gh from:body -> document.querySelector('.brand')?.click();
233 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();233 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
M cmd/ermite/testdata/web/not-found.html +4 -4
@@ -117,7 +117,7 @@
117117 <dt>
118118 <kbd>/</kbd>
119119 </dt>
120-<dd>filter the list</dd>
120+<dd>filter the list; on Files, find a file in the repository</dd>
121121 </div>
122122 <div>
123123 <dt>
@@ -173,8 +173,8 @@
173173 <div class="panel-head">
174174 <label for="cmd-q" class="sr">go to</label>
175175 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
176- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
177- 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();
176+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
177+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
178178 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
179179 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
180180 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' })) }
@@ -227,7 +227,7 @@
227227 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
228228 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
229229 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
230- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
230+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
231231 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
232232 k-gh from:body -> document.querySelector('.brand')?.click();
233233 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
@@ -117,7 +117,7 @@
117 <dt>117 <dt>
118 <kbd>/</kbd>118 <kbd>/</kbd>
119 </dt>119 </dt>
120-<dd>filter the list</dd>120+<dd>filter the list; on Files, find a file in the repository</dd>
121 </div>121 </div>
122 <div>122 <div>
123 <dt>123 <dt>
@@ -173,8 +173,8 @@
173 <div class="panel-head">173 <div class="panel-head">
174 <label for="cmd-q" class="sr">go to</label>174 <label for="cmd-q" class="sr">go to</label>
175 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();175 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
176- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));176+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
177- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();177+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
178 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));178 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
179 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;179 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
180 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }180 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' })) }
@@ -227,7 +227,7 @@
227 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());227 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
228 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();228 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
229 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());229 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
230- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();230+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
231 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();231 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
232 k-gh from:body -> document.querySelector('.brand')?.click();232 k-gh from:body -> document.querySelector('.brand')?.click();
233 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();233 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
M cmd/ermite/testdata/web/refs.html +5 -5
@@ -19,7 +19,7 @@
1919 <script src="/-/static/js/files.HASH.js" defer>
2020 </script>
2121 </head>
22-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
22+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/demo/paths/SHA40" data-blob="/demo/blob/main">
2323 <a class="skip" href="#main">skip to content</a>
2424 <header class="hdr">
2525 <div class="hdr-top">
@@ -242,7 +242,7 @@
242242 <dt>
243243 <kbd>/</kbd>
244244 </dt>
245-<dd>filter the list</dd>
245+<dd>filter the list; on Files, find a file in the repository</dd>
246246 </div>
247247 <div>
248248 <dt>
@@ -337,8 +337,8 @@
337337 <div class="panel-head">
338338 <label for="cmd-q" class="sr">go to</label>
339339 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
340- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
341- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
340+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
341+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
342342 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
343343 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
344344 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' })) }
@@ -451,7 +451,7 @@
451451 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
452452 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
453453 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
454- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
454+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
455455 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
456456 k-gh from:body -> document.querySelector('.brand')?.click();
457457 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
@@ -19,7 +19,7 @@
19 <script src="/-/static/js/files.HASH.js" defer>19 <script src="/-/static/js/files.HASH.js" defer>
20 </script>20 </script>
21 </head>21 </head>
22-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">22+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/demo/paths/SHA40" data-blob="/demo/blob/main">
23 <a class="skip" href="#main">skip to content</a>23 <a class="skip" href="#main">skip to content</a>
24 <header class="hdr">24 <header class="hdr">
25 <div class="hdr-top">25 <div class="hdr-top">
@@ -242,7 +242,7 @@
242 <dt>242 <dt>
243 <kbd>/</kbd>243 <kbd>/</kbd>
244 </dt>244 </dt>
245-<dd>filter the list</dd>245+<dd>filter the list; on Files, find a file in the repository</dd>
246 </div>246 </div>
247 <div>247 <div>
248 <dt>248 <dt>
@@ -337,8 +337,8 @@
337 <div class="panel-head">337 <div class="panel-head">
338 <label for="cmd-q" class="sr">go to</label>338 <label for="cmd-q" class="sr">go to</label>
339 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();339 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
340- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));340+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
341- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();341+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
342 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));342 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
343 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;343 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
344 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }344 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' })) }
@@ -451,7 +451,7 @@
451 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());451 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
452 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();452 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
453 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());453 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
454- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();454+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
455 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();455 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
456 k-gh from:body -> document.querySelector('.brand')?.click();456 k-gh from:body -> document.querySelector('.brand')?.click();
457 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();457 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
M cmd/ermite/testdata/web/summary.html +5 -5
@@ -19,7 +19,7 @@
1919 <script src="/-/static/js/files.HASH.js" defer>
2020 </script>
2121 </head>
22-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
22+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/demo/paths/SHA40" data-blob="/demo/blob/main">
2323 <a class="skip" href="#main">skip to content</a>
2424 <header class="hdr">
2525 <div class="hdr-top">
@@ -260,7 +260,7 @@
260260 <dt>
261261 <kbd>/</kbd>
262262 </dt>
263-<dd>filter the list</dd>
263+<dd>filter the list; on Files, find a file in the repository</dd>
264264 </div>
265265 <div>
266266 <dt>
@@ -355,8 +355,8 @@
355355 <div class="panel-head">
356356 <label for="cmd-q" class="sr">go to</label>
357357 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
358- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
359- 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();
358+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
359+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
360360 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
361361 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
362362 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' })) }
@@ -469,7 +469,7 @@
469469 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
470470 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
471471 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
472- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
472+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
473473 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
474474 k-gh from:body -> document.querySelector('.brand')?.click();
475475 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
@@ -19,7 +19,7 @@
19 <script src="/-/static/js/files.HASH.js" defer>19 <script src="/-/static/js/files.HASH.js" defer>
20 </script>20 </script>
21 </head>21 </head>
22-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">22+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/demo/paths/SHA40" data-blob="/demo/blob/main">
23 <a class="skip" href="#main">skip to content</a>23 <a class="skip" href="#main">skip to content</a>
24 <header class="hdr">24 <header class="hdr">
25 <div class="hdr-top">25 <div class="hdr-top">
@@ -260,7 +260,7 @@
260 <dt>260 <dt>
261 <kbd>/</kbd>261 <kbd>/</kbd>
262 </dt>262 </dt>
263-<dd>filter the list</dd>263+<dd>filter the list; on Files, find a file in the repository</dd>
264 </div>264 </div>
265 <div>265 <div>
266 <dt>266 <dt>
@@ -355,8 +355,8 @@
355 <div class="panel-head">355 <div class="panel-head">
356 <label for="cmd-q" class="sr">go to</label>356 <label for="cmd-q" class="sr">go to</label>
357 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();357 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
358- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));358+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
359- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();359+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
360 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));360 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
361 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;361 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
362 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }362 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' })) }
@@ -469,7 +469,7 @@
469 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());469 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
470 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();470 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
471 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());471 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
472- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();472+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
473 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();473 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
474 k-gh from:body -> document.querySelector('.brand')?.click();474 k-gh from:body -> document.querySelector('.brand')?.click();
475 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();475 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
M cmd/ermite/testdata/web/tree-nested.html +15 -7
@@ -19,7 +19,7 @@
1919 <script src="/-/static/js/files.HASH.js" defer>
2020 </script>
2121 </head>
22-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
22+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2323 <a class="skip" href="#main">skip to content</a>
2424 <header class="hdr">
2525 <div class="hdr-top">
@@ -91,9 +91,16 @@
9191 <div class="work">
9292 <main id="main" tabindex="-1">
9393 <div class="filter">
94-<label for="tree-filter" class="sr">filter</label>
95-<input id="tree-filter" type="search" data-filter placeholder="Filter this directory…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')">
94+<label for="finder-q" class="sr">find a file</label>
95+<input id="finder-q" type="search" data-filter role="combobox" aria-autocomplete="list" aria-controls="finder-list" aria-expanded="false" placeholder="Find a file…" autocomplete="off" spellcheck="false" hx-on:focus="ermiteFinder.load()" hx-on:input="ermiteFinder.input(this)" hx-on:keydown="ermiteFinder.key(this, event)">
9696 </div>
97+<div id="finder" hidden>
98+<p id="finder-status" class="dim" aria-live="polite">
99+</p>
100+<ul id="finder-list" class="cmd-list" role="listbox" aria-label="Files">
101+</ul>
102+</div>
103+<div id="tree-view">
97104 <table class="list tree" aria-label="Entries">
98105 <thead>
99106 <tr>
@@ -127,6 +134,7 @@
127134 </tr>
128135 </tbody>
129136 </table>
137+</div>
130138 </main>
131139 </div>
132140 <footer class="foot">
@@ -187,7 +195,7 @@
187195 <dt>
188196 <kbd>/</kbd>
189197 </dt>
190-<dd>filter the list</dd>
198+<dd>filter the list; on Files, find a file in the repository</dd>
191199 </div>
192200 <div>
193201 <dt>
@@ -278,8 +286,8 @@
278286 <div class="panel-head">
279287 <label for="cmd-q" class="sr">go to</label>
280288 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
281- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
282- 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();
289+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
290+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
283291 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
284292 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
285293 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' })) }
@@ -380,7 +388,7 @@
380388 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
381389 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
382390 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
383- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
391+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
384392 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
385393 k-gh from:body -> document.querySelector('.brand')?.click();
386394 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
@@ -19,7 +19,7 @@
19 <script src="/-/static/js/files.HASH.js" defer>19 <script src="/-/static/js/files.HASH.js" defer>
20 </script>20 </script>
21 </head>21 </head>
22-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">22+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
23 <a class="skip" href="#main">skip to content</a>23 <a class="skip" href="#main">skip to content</a>
24 <header class="hdr">24 <header class="hdr">
25 <div class="hdr-top">25 <div class="hdr-top">
@@ -91,9 +91,16 @@
91 <div class="work">91 <div class="work">
92 <main id="main" tabindex="-1">92 <main id="main" tabindex="-1">
93 <div class="filter">93 <div class="filter">
94-<label for="tree-filter" class="sr">filter</label>94+<label for="finder-q" class="sr">find a file</label>
95-<input id="tree-filter" type="search" data-filter placeholder="Filter this directory…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')">95+<input id="finder-q" type="search" data-filter role="combobox" aria-autocomplete="list" aria-controls="finder-list" aria-expanded="false" placeholder="Find a file…" autocomplete="off" spellcheck="false" hx-on:focus="ermiteFinder.load()" hx-on:input="ermiteFinder.input(this)" hx-on:keydown="ermiteFinder.key(this, event)">
96 </div>96 </div>
97+<div id="finder" hidden>
98+<p id="finder-status" class="dim" aria-live="polite">
99+</p>
100+<ul id="finder-list" class="cmd-list" role="listbox" aria-label="Files">
101+</ul>
102+</div>
103+<div id="tree-view">
97 <table class="list tree" aria-label="Entries">104 <table class="list tree" aria-label="Entries">
98 <thead>105 <thead>
99 <tr>106 <tr>
@@ -127,6 +134,7 @@
127 </tr>134 </tr>
128 </tbody>135 </tbody>
129 </table>136 </table>
137+</div>
130 </main>138 </main>
131 </div>139 </div>
132 <footer class="foot">140 <footer class="foot">
@@ -187,7 +195,7 @@
187 <dt>195 <dt>
188 <kbd>/</kbd>196 <kbd>/</kbd>
189 </dt>197 </dt>
190-<dd>filter the list</dd>198+<dd>filter the list; on Files, find a file in the repository</dd>
191 </div>199 </div>
192 <div>200 <div>
193 <dt>201 <dt>
@@ -278,8 +286,8 @@
278 <div class="panel-head">286 <div class="panel-head">
279 <label for="cmd-q" class="sr">go to</label>287 <label for="cmd-q" class="sr">go to</label>
280 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();288 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
281- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));289+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
282- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();290+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
283 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));291 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
284 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;292 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
285 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }293 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' })) }
@@ -380,7 +388,7 @@
380 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());388 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
381 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();389 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
382 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());390 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
383- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();391+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
384 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();392 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
385 k-gh from:body -> document.querySelector('.brand')?.click();393 k-gh from:body -> document.querySelector('.brand')?.click();
386 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();394 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
M cmd/ermite/testdata/web/tree-root.html +15 -7
@@ -19,7 +19,7 @@
1919 <script src="/-/static/js/files.HASH.js" defer>
2020 </script>
2121 </head>
22-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
22+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2323 <a class="skip" href="#main">skip to content</a>
2424 <header class="hdr">
2525 <div class="hdr-top">
@@ -85,9 +85,16 @@
8585 <div class="work">
8686 <main id="main" tabindex="-1">
8787 <div class="filter">
88-<label for="tree-filter" class="sr">filter</label>
89-<input id="tree-filter" type="search" data-filter placeholder="Filter this directory…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')">
88+<label for="finder-q" class="sr">find a file</label>
89+<input id="finder-q" type="search" data-filter role="combobox" aria-autocomplete="list" aria-controls="finder-list" aria-expanded="false" placeholder="Find a file…" autocomplete="off" spellcheck="false" hx-on:focus="ermiteFinder.load()" hx-on:input="ermiteFinder.input(this)" hx-on:keydown="ermiteFinder.key(this, event)">
9090 </div>
91+<div id="finder" hidden>
92+<p id="finder-status" class="dim" aria-live="polite">
93+</p>
94+<ul id="finder-list" class="cmd-list" role="listbox" aria-label="Files">
95+</ul>
96+</div>
97+<div id="tree-view">
9198 <table class="list tree" aria-label="Entries">
9299 <thead>
93100 <tr>
@@ -354,6 +361,7 @@
354361 <p>See <a href="/files/blob/main/docs/guide.md">the guide</a>.</p>
355362 </article>
356363 </section>
364+</div>
357365 </main>
358366 </div>
359367 <footer class="foot">
@@ -414,7 +422,7 @@
414422 <dt>
415423 <kbd>/</kbd>
416424 </dt>
417-<dd>filter the list</dd>
425+<dd>filter the list; on Files, find a file in the repository</dd>
418426 </div>
419427 <div>
420428 <dt>
@@ -505,8 +513,8 @@
505513 <div class="panel-head">
506514 <label for="cmd-q" class="sr">go to</label>
507515 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
508- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
509- 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();
516+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
517+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
510518 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
511519 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
512520 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' })) }
@@ -607,7 +615,7 @@
607615 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
608616 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
609617 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
610- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
618+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
611619 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
612620 k-gh from:body -> document.querySelector('.brand')?.click();
613621 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
@@ -19,7 +19,7 @@
19 <script src="/-/static/js/files.HASH.js" defer>19 <script src="/-/static/js/files.HASH.js" defer>
20 </script>20 </script>
21 </head>21 </head>
22-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">22+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
23 <a class="skip" href="#main">skip to content</a>23 <a class="skip" href="#main">skip to content</a>
24 <header class="hdr">24 <header class="hdr">
25 <div class="hdr-top">25 <div class="hdr-top">
@@ -85,9 +85,16 @@
85 <div class="work">85 <div class="work">
86 <main id="main" tabindex="-1">86 <main id="main" tabindex="-1">
87 <div class="filter">87 <div class="filter">
88-<label for="tree-filter" class="sr">filter</label>88+<label for="finder-q" class="sr">find a file</label>
89-<input id="tree-filter" type="search" data-filter placeholder="Filter this directory…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')">89+<input id="finder-q" type="search" data-filter role="combobox" aria-autocomplete="list" aria-controls="finder-list" aria-expanded="false" placeholder="Find a file…" autocomplete="off" spellcheck="false" hx-on:focus="ermiteFinder.load()" hx-on:input="ermiteFinder.input(this)" hx-on:keydown="ermiteFinder.key(this, event)">
90 </div>90 </div>
91+<div id="finder" hidden>
92+<p id="finder-status" class="dim" aria-live="polite">
93+</p>
94+<ul id="finder-list" class="cmd-list" role="listbox" aria-label="Files">
95+</ul>
96+</div>
97+<div id="tree-view">
91 <table class="list tree" aria-label="Entries">98 <table class="list tree" aria-label="Entries">
92 <thead>99 <thead>
93 <tr>100 <tr>
@@ -354,6 +361,7 @@
354 <p>See <a href="/files/blob/main/docs/guide.md">the guide</a>.</p>361 <p>See <a href="/files/blob/main/docs/guide.md">the guide</a>.</p>
355 </article>362 </article>
356 </section>363 </section>
364+</div>
357 </main>365 </main>
358 </div>366 </div>
359 <footer class="foot">367 <footer class="foot">
@@ -414,7 +422,7 @@
414 <dt>422 <dt>
415 <kbd>/</kbd>423 <kbd>/</kbd>
416 </dt>424 </dt>
417-<dd>filter the list</dd>425+<dd>filter the list; on Files, find a file in the repository</dd>
418 </div>426 </div>
419 <div>427 <div>
420 <dt>428 <dt>
@@ -505,8 +513,8 @@
505 <div class="panel-head">513 <div class="panel-head">
506 <label for="cmd-q" class="sr">go to</label>514 <label for="cmd-q" class="sr">go to</label>
507 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();515 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
508- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));516+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
509- 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();517+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
510 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));518 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
511 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;519 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
512 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' })) }520 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' })) }
@@ -607,7 +615,7 @@
607 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());615 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
608 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();616 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
609 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());617 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
610- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();618+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
611 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();619 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
612 k-gh from:body -> document.querySelector('.brand')?.click();620 k-gh from:body -> document.querySelector('.brand')?.click();
613 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();621 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
M cmd/ermite/testdata/web/tree-slash-ref.html +15 -7
@@ -19,7 +19,7 @@
1919 <script src="/-/static/js/files.HASH.js" defer>
2020 </script>
2121 </head>
22-<body data-ref="feature/x" data-sha="SHA40" data-sel="" data-nav="">
22+<body data-ref="feature/x" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/feature/x">
2323 <a class="skip" href="#main">skip to content</a>
2424 <header class="hdr">
2525 <div class="hdr-top">
@@ -88,9 +88,16 @@
8888 <div class="work">
8989 <main id="main" tabindex="-1">
9090 <div class="filter">
91-<label for="tree-filter" class="sr">filter</label>
92-<input id="tree-filter" type="search" data-filter placeholder="Filter this directory…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')">
91+<label for="finder-q" class="sr">find a file</label>
92+<input id="finder-q" type="search" data-filter role="combobox" aria-autocomplete="list" aria-controls="finder-list" aria-expanded="false" placeholder="Find a file…" autocomplete="off" spellcheck="false" hx-on:focus="ermiteFinder.load()" hx-on:input="ermiteFinder.input(this)" hx-on:keydown="ermiteFinder.key(this, event)">
9393 </div>
94+<div id="finder" hidden>
95+<p id="finder-status" class="dim" aria-live="polite">
96+</p>
97+<ul id="finder-list" class="cmd-list" role="listbox" aria-label="Files">
98+</ul>
99+</div>
100+<div id="tree-view">
94101 <table class="list tree" aria-label="Entries">
95102 <thead>
96103 <tr>
@@ -124,6 +131,7 @@
124131 </tr>
125132 </tbody>
126133 </table>
134+</div>
127135 </main>
128136 </div>
129137 <footer class="foot">
@@ -184,7 +192,7 @@
184192 <dt>
185193 <kbd>/</kbd>
186194 </dt>
187-<dd>filter the list</dd>
195+<dd>filter the list; on Files, find a file in the repository</dd>
188196 </div>
189197 <div>
190198 <dt>
@@ -275,8 +283,8 @@
275283 <div class="panel-head">
276284 <label for="cmd-q" class="sr">go to</label>
277285 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
278- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
279- 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();
286+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
287+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
280288 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
281289 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
282290 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' })) }
@@ -377,7 +385,7 @@
377385 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
378386 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
379387 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
380- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
388+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
381389 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
382390 k-gh from:body -> document.querySelector('.brand')?.click();
383391 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
@@ -19,7 +19,7 @@
19 <script src="/-/static/js/files.HASH.js" defer>19 <script src="/-/static/js/files.HASH.js" defer>
20 </script>20 </script>
21 </head>21 </head>
22-<body data-ref="feature/x" data-sha="SHA40" data-sel="" data-nav="">22+<body data-ref="feature/x" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/feature/x">
23 <a class="skip" href="#main">skip to content</a>23 <a class="skip" href="#main">skip to content</a>
24 <header class="hdr">24 <header class="hdr">
25 <div class="hdr-top">25 <div class="hdr-top">
@@ -88,9 +88,16 @@
88 <div class="work">88 <div class="work">
89 <main id="main" tabindex="-1">89 <main id="main" tabindex="-1">
90 <div class="filter">90 <div class="filter">
91-<label for="tree-filter" class="sr">filter</label>91+<label for="finder-q" class="sr">find a file</label>
92-<input id="tree-filter" type="search" data-filter placeholder="Filter this directory…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')">92+<input id="finder-q" type="search" data-filter role="combobox" aria-autocomplete="list" aria-controls="finder-list" aria-expanded="false" placeholder="Find a file…" autocomplete="off" spellcheck="false" hx-on:focus="ermiteFinder.load()" hx-on:input="ermiteFinder.input(this)" hx-on:keydown="ermiteFinder.key(this, event)">
93 </div>93 </div>
94+<div id="finder" hidden>
95+<p id="finder-status" class="dim" aria-live="polite">
96+</p>
97+<ul id="finder-list" class="cmd-list" role="listbox" aria-label="Files">
98+</ul>
99+</div>
100+<div id="tree-view">
94 <table class="list tree" aria-label="Entries">101 <table class="list tree" aria-label="Entries">
95 <thead>102 <thead>
96 <tr>103 <tr>
@@ -124,6 +131,7 @@
124 </tr>131 </tr>
125 </tbody>132 </tbody>
126 </table>133 </table>
134+</div>
127 </main>135 </main>
128 </div>136 </div>
129 <footer class="foot">137 <footer class="foot">
@@ -184,7 +192,7 @@
184 <dt>192 <dt>
185 <kbd>/</kbd>193 <kbd>/</kbd>
186 </dt>194 </dt>
187-<dd>filter the list</dd>195+<dd>filter the list; on Files, find a file in the repository</dd>
188 </div>196 </div>
189 <div>197 <div>
190 <dt>198 <dt>
@@ -275,8 +283,8 @@
275 <div class="panel-head">283 <div class="panel-head">
276 <label for="cmd-q" class="sr">go to</label>284 <label for="cmd-q" class="sr">go to</label>
277 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();285 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
278- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));286+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
279- 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();287+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
280 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));288 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
281 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;289 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
282 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' })) }290 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' })) }
@@ -377,7 +385,7 @@
377 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());385 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
378 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();386 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
379 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());387 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
380- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();388+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
381 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();389 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
382 k-gh from:body -> document.querySelector('.brand')?.click();390 k-gh from:body -> document.querySelector('.brand')?.click();
383 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();391 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
M cmd/ermite/testdata/web/tree-subdir.html +15 -7
@@ -19,7 +19,7 @@
1919 <script src="/-/static/js/files.HASH.js" defer>
2020 </script>
2121 </head>
22-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
22+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2323 <a class="skip" href="#main">skip to content</a>
2424 <header class="hdr">
2525 <div class="hdr-top">
@@ -88,9 +88,16 @@
8888 <div class="work">
8989 <main id="main" tabindex="-1">
9090 <div class="filter">
91-<label for="tree-filter" class="sr">filter</label>
92-<input id="tree-filter" type="search" data-filter placeholder="Filter this directory…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')">
91+<label for="finder-q" class="sr">find a file</label>
92+<input id="finder-q" type="search" data-filter role="combobox" aria-autocomplete="list" aria-controls="finder-list" aria-expanded="false" placeholder="Find a file…" autocomplete="off" spellcheck="false" hx-on:focus="ermiteFinder.load()" hx-on:input="ermiteFinder.input(this)" hx-on:keydown="ermiteFinder.key(this, event)">
9393 </div>
94+<div id="finder" hidden>
95+<p id="finder-status" class="dim" aria-live="polite">
96+</p>
97+<ul id="finder-list" class="cmd-list" role="listbox" aria-label="Files">
98+</ul>
99+</div>
100+<div id="tree-view">
94101 <table class="list tree" aria-label="Entries">
95102 <thead>
96103 <tr>
@@ -149,6 +156,7 @@
149156 <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>
150157 </article>
151158 </section>
159+</div>
152160 </main>
153161 </div>
154162 <footer class="foot">
@@ -209,7 +217,7 @@
209217 <dt>
210218 <kbd>/</kbd>
211219 </dt>
212-<dd>filter the list</dd>
220+<dd>filter the list; on Files, find a file in the repository</dd>
213221 </div>
214222 <div>
215223 <dt>
@@ -300,8 +308,8 @@
300308 <div class="panel-head">
301309 <label for="cmd-q" class="sr">go to</label>
302310 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
303- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
304- 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();
311+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
312+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
305313 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
306314 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
307315 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' })) }
@@ -402,7 +410,7 @@
402410 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
403411 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
404412 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
405- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
413+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
406414 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
407415 k-gh from:body -> document.querySelector('.brand')?.click();
408416 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
@@ -19,7 +19,7 @@
19 <script src="/-/static/js/files.HASH.js" defer>19 <script src="/-/static/js/files.HASH.js" defer>
20 </script>20 </script>
21 </head>21 </head>
22-<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">22+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
23 <a class="skip" href="#main">skip to content</a>23 <a class="skip" href="#main">skip to content</a>
24 <header class="hdr">24 <header class="hdr">
25 <div class="hdr-top">25 <div class="hdr-top">
@@ -88,9 +88,16 @@
88 <div class="work">88 <div class="work">
89 <main id="main" tabindex="-1">89 <main id="main" tabindex="-1">
90 <div class="filter">90 <div class="filter">
91-<label for="tree-filter" class="sr">filter</label>91+<label for="finder-q" class="sr">find a file</label>
92-<input id="tree-filter" type="search" data-filter placeholder="Filter this directory…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')">92+<input id="finder-q" type="search" data-filter role="combobox" aria-autocomplete="list" aria-controls="finder-list" aria-expanded="false" placeholder="Find a file…" autocomplete="off" spellcheck="false" hx-on:focus="ermiteFinder.load()" hx-on:input="ermiteFinder.input(this)" hx-on:keydown="ermiteFinder.key(this, event)">
93 </div>93 </div>
94+<div id="finder" hidden>
95+<p id="finder-status" class="dim" aria-live="polite">
96+</p>
97+<ul id="finder-list" class="cmd-list" role="listbox" aria-label="Files">
98+</ul>
99+</div>
100+<div id="tree-view">
94 <table class="list tree" aria-label="Entries">101 <table class="list tree" aria-label="Entries">
95 <thead>102 <thead>
96 <tr>103 <tr>
@@ -149,6 +156,7 @@
149 <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>156 <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>
150 </article>157 </article>
151 </section>158 </section>
159+</div>
152 </main>160 </main>
153 </div>161 </div>
154 <footer class="foot">162 <footer class="foot">
@@ -209,7 +217,7 @@
209 <dt>217 <dt>
210 <kbd>/</kbd>218 <kbd>/</kbd>
211 </dt>219 </dt>
212-<dd>filter the list</dd>220+<dd>filter the list; on Files, find a file in the repository</dd>
213 </div>221 </div>
214 <div>222 <div>
215 <dt>223 <dt>
@@ -300,8 +308,8 @@
300 <div class="panel-head">308 <div class="panel-head">
301 <label for="cmd-q" class="sr">go to</label>309 <label for="cmd-q" class="sr">go to</label>
302 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();310 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
303- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));311+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
304- 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();312+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
305 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));313 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
306 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;314 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
307 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' })) }315 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' })) }
@@ -402,7 +410,7 @@
402 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());410 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
403 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();411 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
404 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());412 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
405- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();413+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
406 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();414 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
407 k-gh from:body -> document.querySelector('.brand')?.click();415 k-gh from:body -> document.querySelector('.brand')?.click();
408 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();416 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
M cmd/ermite/web_files_test.go +165 -0
@@ -4,9 +4,12 @@ package main
44
55 import (
66 "bytes"
7+ "compress/gzip"
8+ "encoding/json"
79 "image"
810 "image/color"
911 "image/png"
12+ "io"
1013 "net/http"
1114 "regexp"
1215 "strconv"
@@ -373,3 +376,165 @@ func TestWebFilesContentSecurity(t *testing.T) {
373376 assert.Contains(t, sheet, ".chroma .kn { color: var(--syn-keyword)")
374377 assert.NotRegexp(t, `\.chroma[^{]*\{[^}]*#[0-9a-fA-F]{3,6}`, sheet)
375378 }
379+
380+// AC4: GET /<repo>/paths/<sha> lists the file paths of a commit as gzipped
381+// JSON, behind the access rule on every request, even when the list is
382+// already cached; a visitor revalidates (no-cache + ETag), the owner's copy is
383+// private.
384+func TestWebPaths(t *testing.T) {
385+ c := newCLIHarness(t)
386+ h := c.harness
387+ h.webFilesRepo("files")
388+ sha := h.bareGit("files", "rev-parse", "main")
389+
390+ var want []string
391+ for _, p := range strings.Split(h.bareGit("files", "ls-tree", "-r", "-z", "--name-only", "main"), "\x00") {
392+ if p != "" {
393+ want = append(want, p)
394+ }
395+ }
396+ require.NotEmpty(t, want)
397+ b := browser{h}
398+ path := "/files/paths/" + sha
399+ type list struct {
400+ Paths []string `json:"paths"`
401+ Truncated bool `json:"truncated"`
402+ }
403+ decodeList := func(t *testing.T, body string) list {
404+ t.Helper()
405+ var l list
406+ require.NoError(t, json.Unmarshal([]byte(body), &l))
407+ return l
408+ }
409+ gunzip := func(t *testing.T, body string) string {
410+ t.Helper()
411+ zr, err := gzip.NewReader(strings.NewReader(body))
412+ require.NoError(t, err)
413+ out, err := io.ReadAll(zr)
414+ require.NoError(t, err)
415+ return string(out)
416+ }
417+
418+ // Anonymous with gzip: the cached bytes as they are. The client of the
419+ // harness would unzip by itself, so the encoding is asked for explicitly
420+ // through a transport that does not.
421+ raw := func(hdr map[string]string) page {
422+ req, err := http.NewRequest(http.MethodGet, h.url(path), nil)
423+ require.NoError(t, err)
424+ for k, v := range hdr {
425+ req.Header.Set(k, v)
426+ }
427+ resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req)
428+ require.NoError(t, err)
429+ defer func() { _ = resp.Body.Close() }()
430+ out, err := io.ReadAll(resp.Body)
431+ require.NoError(t, err)
432+ return page{status: resp.StatusCode, hdr: resp.Header, body: string(out)}
433+ }
434+ gz := raw(map[string]string{"Accept-Encoding": "gzip"})
435+ require.Equal(t, 200, gz.status)
436+ assert.Equal(t, "gzip", gz.hdr.Get("Content-Encoding"))
437+ assert.Equal(t, "application/json", gz.hdr.Get("Content-Type"))
438+ assert.Equal(t, "nosniff", gz.hdr.Get("X-Content-Type-Options"))
439+ assert.Equal(t, "no-cache", gz.hdr.Get("Cache-Control"))
440+ assert.Equal(t, "Accept-Encoding", gz.hdr.Get("Vary"))
441+ etag := gz.hdr.Get("ETag")
442+ assert.Equal(t, `"paths-`+sha+`"`, etag)
443+ got := decodeList(t, gunzip(t, gz.body))
444+ assert.Equal(t, want, got.Paths, "git ls-tree -r order")
445+ assert.False(t, got.Truncated)
446+
447+ // Without gzip: plain JSON, the same list.
448+ plain := raw(nil)
449+ require.Equal(t, 200, plain.status)
450+ assert.Empty(t, plain.hdr.Get("Content-Encoding"))
451+ assert.Equal(t, want, decodeList(t, plain.body).Paths)
452+ assert.Equal(t, etag, plain.hdr.Get("ETag"))
453+
454+ // Revalidation: 304, still through the access rule.
455+ nm := raw(map[string]string{"If-None-Match": etag, "Accept-Encoding": "gzip"})
456+ assert.Equal(t, http.StatusNotModified, nm.status)
457+ assert.Empty(t, nm.body)
458+ assert.Equal(t, "no-cache", nm.hdr.Get("Cache-Control"))
459+ assert.Equal(t, 200, raw(map[string]string{"If-None-Match": `"paths-other"`}).status)
460+
461+ // Only a full commit id: abbreviations, refs, upper case and unknown ids.
462+ for _, p := range []string{
463+ "/files/paths/" + sha[:7], "/files/paths/" + sha[:39], "/files/paths/main", "/files/paths/" + strings.ToUpper(sha),
464+ "/files/paths/" + strings.Repeat("0", 40), "/files/paths/", "/never/paths/" + sha,
465+ } {
466+ assert.Equal(t, 404, b.get(p, nil).status, p)
467+ }
468+
469+ // Another repository does not serve a commit it does not have, though
470+ // the list of that commit is cached.
471+ h.seed("other", domain.VisibilityPublic, "x.txt")
472+ assert.Equal(t, 404, b.get("/other/paths/"+sha, nil).status)
473+
474+ // The owner: private, never stored; the list is the same.
475+ token := c.signIn(t)
476+ own := b.get(path, as(token))
477+ require.Equal(t, 200, own.status)
478+ assert.Equal(t, "private, no-store", own.hdr.Get("Cache-Control"))
479+ assert.Contains(t, own.hdr.Values("Vary"), "Cookie")
480+ assert.Equal(t, want, decodeList(t, own.body).Paths)
481+
482+ // The repository turns private: the list is cached on the server, and
483+ // anonymous gets the 404 of a missing repository, as the owner still
484+ // reads it.
485+ priv := domain.VisibilityPrivate
486+ _, err := h.forge.Repos.Update(h.ctx, domain.Root(), h.name("files"), app.UpdateParams{Visibility: &priv})
487+ require.NoError(t, err)
488+ for _, hdr := range []map[string]string{nil, {"Accept-Encoding": "gzip"}, {"If-None-Match": etag}} {
489+ p := b.get(path, hdr)
490+ assert.Equal(t, 404, p.status)
491+ assert.NotContains(t, p.body, "README.md")
492+ assert.NotContains(t, p.hdr.Get("Content-Type"), "json")
493+ }
494+ assert.Equal(t, 200, b.get(path, as(token)).status)
495+}
496+
497+// AC3, AC4: the Files page carries the finder and the data it needs, on the
498+// commit the page shows, however the URL spelled it; the other pages carry
499+// the paths of their commit and no finder.
500+func TestWebFinderPage(t *testing.T) {
501+ h := newHarness(t, harnessOpts{})
502+ h.webFilesRepo("files")
503+ sha := h.bareGit("files", "rev-parse", "main")
504+ attr := func(body, name string) string {
505+ m := regexp.MustCompile(`<body[^>]*\b` + name + `="([^"]*)"`).FindStringSubmatch(body)
506+ if m == nil {
507+ return "<none>"
508+ }
509+ return m[1]
510+ }
511+
512+ for _, tt := range []struct{ name, path, paths, blob string }{
513+ {"branch", "/files/tree/main", "/files/paths/" + sha, "/files/blob/main"},
514+ {"subdirectory", "/files/tree/main/docs", "/files/paths/" + sha, "/files/blob/main"},
515+ {"slash ref", "/files/tree/feature/x", "/files/paths/" + h.bareGit("files", "rev-parse", "feature/x"), "/files/blob/feature/x"},
516+ {"tag", "/files/tree/v1.0", "/files/paths/" + sha, "/files/blob/v1.0"},
517+ {"full id", "/files/tree/" + sha, "/files/paths/" + sha, "/files/blob/" + sha},
518+ {"abbreviated id", "/files/tree/" + sha[:7], "/files/paths/" + sha, "/files/blob/" + sha[:7]},
519+ {"blob page", "/files/blob/main/main.go", "/files/paths/" + sha, "/files/blob/main"},
520+ } {
521+ t.Run(tt.name, func(t *testing.T) {
522+ status, _, body := h.get(tt.path)
523+ require.Equal(t, 200, status)
524+ assert.Equal(t, tt.paths, attr(body, "data-paths"))
525+ assert.Equal(t, tt.blob, attr(body, "data-blob"))
526+ if strings.Contains(tt.path, "/tree/") {
527+ assert.Contains(t, body, `id="finder-q"`)
528+ assert.Contains(t, body, `id="finder-list"`)
529+ assert.Contains(t, body, `placeholder="Find a file…"`)
530+ } else {
531+ assert.NotContains(t, body, `id="finder-q"`)
532+ }
533+ })
534+ }
535+ // No commit to search: no data.
536+ _, _, body := h.get("/nothing-here")
537+ assert.Equal(t, "<none>", attr(body, "data-paths"))
538+ _, _, body = h.get("/files/refs")
539+ assert.Equal(t, "/files/paths/"+sha, attr(body, "data-paths"))
540+}
@@ -4,9 +4,12 @@ package main
4 4
5 import (5 import (
6 "bytes"6 "bytes"
7+ "compress/gzip"
8+ "encoding/json"
7 "image"9 "image"
8 "image/color"10 "image/color"
9 "image/png"11 "image/png"
12+ "io"
10 "net/http"13 "net/http"
11 "regexp"14 "regexp"
12 "strconv"15 "strconv"
@@ -373,3 +376,165 @@ func TestWebFilesContentSecurity(t *testing.T) {
373 assert.Contains(t, sheet, ".chroma .kn { color: var(--syn-keyword)")376 assert.Contains(t, sheet, ".chroma .kn { color: var(--syn-keyword)")
374 assert.NotRegexp(t, `\.chroma[^{]*\{[^}]*#[0-9a-fA-F]{3,6}`, sheet)377 assert.NotRegexp(t, `\.chroma[^{]*\{[^}]*#[0-9a-fA-F]{3,6}`, sheet)
375 }378 }
379+
380+// AC4: GET /<repo>/paths/<sha> lists the file paths of a commit as gzipped
381+// JSON, behind the access rule on every request, even when the list is
382+// already cached; a visitor revalidates (no-cache + ETag), the owner's copy is
383+// private.
384+func TestWebPaths(t *testing.T) {
385+ c := newCLIHarness(t)
386+ h := c.harness
387+ h.webFilesRepo("files")
388+ sha := h.bareGit("files", "rev-parse", "main")
389+
390+ var want []string
391+ for _, p := range strings.Split(h.bareGit("files", "ls-tree", "-r", "-z", "--name-only", "main"), "\x00") {
392+ if p != "" {
393+ want = append(want, p)
394+ }
395+ }
396+ require.NotEmpty(t, want)
397+ b := browser{h}
398+ path := "/files/paths/" + sha
399+ type list struct {
400+ Paths []string `json:"paths"`
401+ Truncated bool `json:"truncated"`
402+ }
403+ decodeList := func(t *testing.T, body string) list {
404+ t.Helper()
405+ var l list
406+ require.NoError(t, json.Unmarshal([]byte(body), &l))
407+ return l
408+ }
409+ gunzip := func(t *testing.T, body string) string {
410+ t.Helper()
411+ zr, err := gzip.NewReader(strings.NewReader(body))
412+ require.NoError(t, err)
413+ out, err := io.ReadAll(zr)
414+ require.NoError(t, err)
415+ return string(out)
416+ }
417+
418+ // Anonymous with gzip: the cached bytes as they are. The client of the
419+ // harness would unzip by itself, so the encoding is asked for explicitly
420+ // through a transport that does not.
421+ raw := func(hdr map[string]string) page {
422+ req, err := http.NewRequest(http.MethodGet, h.url(path), nil)
423+ require.NoError(t, err)
424+ for k, v := range hdr {
425+ req.Header.Set(k, v)
426+ }
427+ resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req)
428+ require.NoError(t, err)
429+ defer func() { _ = resp.Body.Close() }()
430+ out, err := io.ReadAll(resp.Body)
431+ require.NoError(t, err)
432+ return page{status: resp.StatusCode, hdr: resp.Header, body: string(out)}
433+ }
434+ gz := raw(map[string]string{"Accept-Encoding": "gzip"})
435+ require.Equal(t, 200, gz.status)
436+ assert.Equal(t, "gzip", gz.hdr.Get("Content-Encoding"))
437+ assert.Equal(t, "application/json", gz.hdr.Get("Content-Type"))
438+ assert.Equal(t, "nosniff", gz.hdr.Get("X-Content-Type-Options"))
439+ assert.Equal(t, "no-cache", gz.hdr.Get("Cache-Control"))
440+ assert.Equal(t, "Accept-Encoding", gz.hdr.Get("Vary"))
441+ etag := gz.hdr.Get("ETag")
442+ assert.Equal(t, `"paths-`+sha+`"`, etag)
443+ got := decodeList(t, gunzip(t, gz.body))
444+ assert.Equal(t, want, got.Paths, "git ls-tree -r order")
445+ assert.False(t, got.Truncated)
446+
447+ // Without gzip: plain JSON, the same list.
448+ plain := raw(nil)
449+ require.Equal(t, 200, plain.status)
450+ assert.Empty(t, plain.hdr.Get("Content-Encoding"))
451+ assert.Equal(t, want, decodeList(t, plain.body).Paths)
452+ assert.Equal(t, etag, plain.hdr.Get("ETag"))
453+
454+ // Revalidation: 304, still through the access rule.
455+ nm := raw(map[string]string{"If-None-Match": etag, "Accept-Encoding": "gzip"})
456+ assert.Equal(t, http.StatusNotModified, nm.status)
457+ assert.Empty(t, nm.body)
458+ assert.Equal(t, "no-cache", nm.hdr.Get("Cache-Control"))
459+ assert.Equal(t, 200, raw(map[string]string{"If-None-Match": `"paths-other"`}).status)
460+
461+ // Only a full commit id: abbreviations, refs, upper case and unknown ids.
462+ for _, p := range []string{
463+ "/files/paths/" + sha[:7], "/files/paths/" + sha[:39], "/files/paths/main", "/files/paths/" + strings.ToUpper(sha),
464+ "/files/paths/" + strings.Repeat("0", 40), "/files/paths/", "/never/paths/" + sha,
465+ } {
466+ assert.Equal(t, 404, b.get(p, nil).status, p)
467+ }
468+
469+ // Another repository does not serve a commit it does not have, though
470+ // the list of that commit is cached.
471+ h.seed("other", domain.VisibilityPublic, "x.txt")
472+ assert.Equal(t, 404, b.get("/other/paths/"+sha, nil).status)
473+
474+ // The owner: private, never stored; the list is the same.
475+ token := c.signIn(t)
476+ own := b.get(path, as(token))
477+ require.Equal(t, 200, own.status)
478+ assert.Equal(t, "private, no-store", own.hdr.Get("Cache-Control"))
479+ assert.Contains(t, own.hdr.Values("Vary"), "Cookie")
480+ assert.Equal(t, want, decodeList(t, own.body).Paths)
481+
482+ // The repository turns private: the list is cached on the server, and
483+ // anonymous gets the 404 of a missing repository, as the owner still
484+ // reads it.
485+ priv := domain.VisibilityPrivate
486+ _, err := h.forge.Repos.Update(h.ctx, domain.Root(), h.name("files"), app.UpdateParams{Visibility: &priv})
487+ require.NoError(t, err)
488+ for _, hdr := range []map[string]string{nil, {"Accept-Encoding": "gzip"}, {"If-None-Match": etag}} {
489+ p := b.get(path, hdr)
490+ assert.Equal(t, 404, p.status)
491+ assert.NotContains(t, p.body, "README.md")
492+ assert.NotContains(t, p.hdr.Get("Content-Type"), "json")
493+ }
494+ assert.Equal(t, 200, b.get(path, as(token)).status)
495+}
496+
497+// AC3, AC4: the Files page carries the finder and the data it needs, on the
498+// commit the page shows, however the URL spelled it; the other pages carry
499+// the paths of their commit and no finder.
500+func TestWebFinderPage(t *testing.T) {
501+ h := newHarness(t, harnessOpts{})
502+ h.webFilesRepo("files")
503+ sha := h.bareGit("files", "rev-parse", "main")
504+ attr := func(body, name string) string {
505+ m := regexp.MustCompile(`<body[^>]*\b` + name + `="([^"]*)"`).FindStringSubmatch(body)
506+ if m == nil {
507+ return "<none>"
508+ }
509+ return m[1]
510+ }
511+
512+ for _, tt := range []struct{ name, path, paths, blob string }{
513+ {"branch", "/files/tree/main", "/files/paths/" + sha, "/files/blob/main"},
514+ {"subdirectory", "/files/tree/main/docs", "/files/paths/" + sha, "/files/blob/main"},
515+ {"slash ref", "/files/tree/feature/x", "/files/paths/" + h.bareGit("files", "rev-parse", "feature/x"), "/files/blob/feature/x"},
516+ {"tag", "/files/tree/v1.0", "/files/paths/" + sha, "/files/blob/v1.0"},
517+ {"full id", "/files/tree/" + sha, "/files/paths/" + sha, "/files/blob/" + sha},
518+ {"abbreviated id", "/files/tree/" + sha[:7], "/files/paths/" + sha, "/files/blob/" + sha[:7]},
519+ {"blob page", "/files/blob/main/main.go", "/files/paths/" + sha, "/files/blob/main"},
520+ } {
521+ t.Run(tt.name, func(t *testing.T) {
522+ status, _, body := h.get(tt.path)
523+ require.Equal(t, 200, status)
524+ assert.Equal(t, tt.paths, attr(body, "data-paths"))
525+ assert.Equal(t, tt.blob, attr(body, "data-blob"))
526+ if strings.Contains(tt.path, "/tree/") {
527+ assert.Contains(t, body, `id="finder-q"`)
528+ assert.Contains(t, body, `id="finder-list"`)
529+ assert.Contains(t, body, `placeholder="Find a file…"`)
530+ } else {
531+ assert.NotContains(t, body, `id="finder-q"`)
532+ }
533+ })
534+ }
535+ // No commit to search: no data.
536+ _, _, body := h.get("/nothing-here")
537+ assert.Equal(t, "<none>", attr(body, "data-paths"))
538+ _, _, body = h.get("/files/refs")
539+ assert.Equal(t, "/files/paths/"+sha, attr(body, "data-paths"))
540+}
M internal/adapters/web/ASSETS.md +1 -1
@@ -9,7 +9,7 @@ content-hashed names. Nothing is fetched at runtime.
99 | `static/js/hx-live.min.js` | htmx 4 `hx-live` extension | `mockups/assets/htmx/ext/hx-live.min.js` | `adf9b90949d57baf20dc2eaf1d1fd46c81347ac82c090a5e2912ce6b37726734` |
1010 | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` |
1111 | `static/js/ermite.js` | key sequences to `k-*` events | `mockups/d/js/ermite.js` | |
12-| `static/js/files.js` | the Files view: directories open in place (`window.ermiteTree`) | written here | |
12+| `static/js/files.js` | the Files view: directories open in place (`window.ermiteTree`), the fuzzy file finder and the palette's file hits (`window.ermiteFinder`; its core is tested in Node by `jstest/files.test.mjs`) | written here | |
1313 | `static/css/56-chroma.css` | syntax classes of the code view, mapped to the `--syn-*` tokens (no colour of its own) | generated by `go run scripts/chroma-css.go` from chroma's token classes | |
1414 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |
1515 | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` |
@@ -9,7 +9,7 @@ content-hashed names. Nothing is fetched at runtime.
9 | `static/js/hx-live.min.js` | htmx 4 `hx-live` extension | `mockups/assets/htmx/ext/hx-live.min.js` | `adf9b90949d57baf20dc2eaf1d1fd46c81347ac82c090a5e2912ce6b37726734` |9 | `static/js/hx-live.min.js` | htmx 4 `hx-live` extension | `mockups/assets/htmx/ext/hx-live.min.js` | `adf9b90949d57baf20dc2eaf1d1fd46c81347ac82c090a5e2912ce6b37726734` |
10 | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` |10 | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` |
11 | `static/js/ermite.js` | key sequences to `k-*` events | `mockups/d/js/ermite.js` | |11 | `static/js/ermite.js` | key sequences to `k-*` events | `mockups/d/js/ermite.js` | |
12-| `static/js/files.js` | the Files view: directories open in place (`window.ermiteTree`) | written here | |12+| `static/js/files.js` | the Files view: directories open in place (`window.ermiteTree`), the fuzzy file finder and the palette's file hits (`window.ermiteFinder`; its core is tested in Node by `jstest/files.test.mjs`) | written here | |
13 | `static/css/56-chroma.css` | syntax classes of the code view, mapped to the `--syn-*` tokens (no colour of its own) | generated by `go run scripts/chroma-css.go` from chroma's token classes | |13 | `static/css/56-chroma.css` | syntax classes of the code view, mapped to the `--syn-*` tokens (no colour of its own) | generated by `go run scripts/chroma-css.go` from chroma's token classes | |
14 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |14 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |
15 | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` |15 | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` |
A internal/adapters/web/jstest/files.test.mjs +235 -0
@@ -0,0 +1,235 @@
1+// Tests of the pure core of the file finder (static/js/files.js). Run with
2+// node --test internal/adapters/web/jstest/
3+import assert from 'node:assert/strict';
4+import { test } from 'node:test';
5+
6+globalThis.window = globalThis;
7+await import('../static/js/files.js');
8+const F = window.ermiteFinder;
9+
10+const REPO = [
11+ 'README.md',
12+ 'cmd/ermite/main.go',
13+ 'internal/adapters/web/tree.go',
14+ 'internal/adapters/web/tree.templ',
15+ 'internal/adapters/web/tree_templ.go',
16+ 'internal/adapters/web/static/css/30-list.css',
17+ 'internal/adapters/web/static/js/files.js',
18+ 'internal/adapters/web/jstest/files.test.mjs',
19+ 'internal/adapters/gitcli/read.go',
20+ 'internal/domain/tree_path.go',
21+ 'docs/web/tree-view.md',
22+ 'src/deeply/nested/dir/with/a/treehouse.txt',
23+];
24+const idx = F.index(REPO);
25+const top = (q, k = 50, prev = null) => F.search(idx, q, k, prev);
26+const names = (r) => r.hits.map((h) => REPO[h.i]);
27+
28+test('index: ASCII-only folding keeps positions', () => {
29+ const x = F.index(['İndex/Straße.go', 'ABC/Def.TXT']);
30+ assert.equal(x.lower[1], 'abc/def.txt');
31+ assert.equal(x.lower[0].length, x.paths[0].length);
32+ assert.equal(x.lower[0][0], 'İ', 'non-ASCII is not folded');
33+ assert.equal(x.base[0], 'İndex/'.length);
34+ assert.equal(x.base[1], 4);
35+});
36+
37+test('index: mask bits', () => {
38+ const x = F.index(['a', 'z', '5', '/', '.', '-', '_', 'é', 'A']);
39+ assert.deepEqual(Array.from(x.mask), [1, 1 << 25, 1 << 26, 1 << 27, 1 << 28, 1 << 29, 1 << 29, 1 << 30, 1]);
40+});
41+
42+test('search: rejects what is not a subsequence', () => {
43+ assert.deepEqual(names(top('zzz')), []);
44+ assert.deepEqual(names(top('gnirt')), [], 'order matters');
45+ assert.equal(top('zzz').all.length, 0);
46+ assert.deepEqual(names(top('')), []);
47+ assert.deepEqual(names(top(' ')), []);
48+ // The mask rejects a character no path has.
49+ assert.equal(top('q').all.length, 0);
50+});
51+
52+test('search: ranking', () => {
53+ const r = names(top('tree'));
54+ assert.ok(r.indexOf('internal/adapters/web/tree.go') >= 0);
55+ assert.ok(r.indexOf('internal/adapters/web/tree.go') < r.indexOf('internal/adapters/web/static/css/30-list.css'),
56+ 'basename match beats a scattered one: ' + r.join(', '));
57+ // Equal scores: the shorter path first, then the path.
58+ assert.ok(r.indexOf('internal/adapters/web/tree.go') < r.indexOf('internal/adapters/web/tree.templ'), r.join(', '));
59+ assert.ok(r.indexOf('internal/adapters/web/tree.templ') < r.indexOf('internal/adapters/web/tree_templ.go'), r.join(', '));
60+ assert.ok(r.indexOf('internal/adapters/web/tree.templ') < r.indexOf('src/deeply/nested/dir/with/a/treehouse.txt'), r.join(', '));
61+});
62+
63+test('search: wbtree matches web/tree.templ', () => {
64+ const r = names(top('wbtree'));
65+ assert.ok(r.includes('internal/adapters/web/tree.templ'), r.join(', '));
66+ assert.ok(r.includes('internal/adapters/web/tree.go'));
67+});
68+
69+test('search: basename prefix beats a deep match', () => {
70+ const x = F.index(['a/b/c/d/e/main_helper.go', 'x/m/a/i/n.go', 'main.go', 'pkg/domain/admin.go']);
71+ const r = F.search(x, 'main').hits.map((h) => x.paths[h.i]);
72+ assert.equal(r[0], 'main.go');
73+ assert.ok(r.indexOf('a/b/c/d/e/main_helper.go') < r.indexOf('x/m/a/i/n.go'), r.join(', '));
74+ assert.ok(r.indexOf('a/b/c/d/e/main_helper.go') < r.indexOf('pkg/domain/admin.go'), r.join(', '));
75+});
76+
77+test('search: camelCase and separators earn bonuses', () => {
78+ const x = F.index(['pkg/fooBarBaz.go', 'pkg/foobarbaz.go', 'pkg/fxoxoxbxa.go']);
79+ const r = F.search(x, 'fbb').hits.map((h) => x.paths[h.i]);
80+ assert.equal(r[0], 'pkg/fooBarBaz.go', r.join(', '));
81+});
82+
83+test('search: several terms', () => {
84+ const r = names(top('web tree'));
85+ assert.ok(r.length > 0);
86+ assert.ok(r.slice(0, 3).every((p) => p.includes('web') && p.includes('tree')), r.join(', '));
87+ assert.ok(!r.includes('README.md'));
88+ // Every term must match.
89+ assert.deepEqual(names(top('web zzz')), []);
90+ // The order of the terms is free.
91+ assert.deepEqual(new Set(names(top('tree web'))), new Set(names(top('web tree'))));
92+});
93+
94+test('search: case is folded for ASCII only', () => {
95+ assert.ok(names(top('README')).includes('README.md'));
96+ assert.ok(names(top('readme')).includes('README.md'));
97+ const x = F.index(['İndex/Straße.go']);
98+ assert.equal(F.search(x, 'index').hits.length, 0, 'İ is not an ASCII capital I');
99+ assert.equal(F.search(x, 'ndex').hits.length, 1);
100+});
101+
102+test('search: top k, ties by length then path', () => {
103+ const ps = [];
104+ for (let i = 0; i < 200; i++) ps.push('d/file' + i + '.txt');
105+ const x = F.index(ps);
106+ const r = F.search(x, 'file', 50);
107+ assert.equal(r.hits.length, 50);
108+ assert.equal(r.all.length, 200);
109+ const got = r.hits.map((h) => x.paths[h.i]);
110+ for (let i = 1; i < got.length; i++) {
111+ const a = got[i - 1];
112+ const b = got[i];
113+ assert.ok(a.length < b.length || (a.length === b.length && a < b), a + ' before ' + b);
114+ }
115+ assert.equal(got[0], 'd/file0.txt');
116+});
117+
118+test('search: incremental results equal a cold search', () => {
119+ const big = [];
120+ let seed = 7;
121+ const rnd = () => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff;
122+ const words = ['web', 'tree', 'file', 'test', 'main', 'util', 'core', 'index', 'view', 'model'];
123+ for (let i = 0; i < 3000; i++) {
124+ const depth = 1 + Math.floor(rnd() * 4);
125+ const segs = [];
126+ for (let d = 0; d < depth; d++) segs.push(words[Math.floor(rnd() * words.length)] + (rnd() < 0.3 ? 'Case' : '') + Math.floor(rnd() * 20));
127+ big.push(segs.join('/') + ['.go', '.js', '.md'][Math.floor(rnd() * 3)]);
128+ }
129+ const x = F.index(big);
130+ const queries = ['t', 'tr', 'tre', 'tree', 'tree w', 'tree we', 'tree web', 'tre web', 'tree web m'];
131+ let prev = null;
132+ for (const q of queries) {
133+ const warm = F.search(x, q, 50, prev);
134+ const cold = F.search(x, q, 50, null);
135+ assert.deepEqual(warm.hits, cold.hits, q);
136+ assert.deepEqual(Array.from(warm.all), Array.from(cold.all), q);
137+ prev = warm;
138+ }
139+ // A query that does not extend the previous one searches everything.
140+ const a = F.search(x, 'tree', 50, null);
141+ assert.deepEqual(F.search(x, 'web', 50, a).hits, F.search(x, 'web', 50, null).hits);
142+ assert.deepEqual(F.search(x, 'tre', 50, a).hits, F.search(x, 'tre', 50, null).hits, 'shortening');
143+ // A result of another index is not a source of candidates.
144+ const y = F.index(['zzz']);
145+ assert.deepEqual(F.search(y, 'z', 50, a).hits.length, 1);
146+});
147+
148+test('positions', () => {
149+ const p = 'internal/adapters/web/tree.go';
150+ const x = F.index([p]);
151+ assert.deepEqual(F.positions(x, 0, 'tree'), [p.indexOf('tree.go'), p.indexOf('tree.go') + 1, p.indexOf('tree.go') + 2, p.indexOf('tree.go') + 3]);
152+ const y = F.index(['internal/adapters/web/tree.templ']);
153+ const pos = F.positions(y, 0, 'wbtree');
154+ const s = y.paths[0];
155+ assert.equal(pos.map((i) => s[i]).join(''), 'wbtree');
156+ assert.deepEqual(pos, [...pos].sort((a, b) => a - b));
157+ assert.equal(pos[0], s.indexOf('web'), 'w of the web directory');
158+ // Two terms.
159+ const z = F.index(['src/web/tree.go']);
160+ const both = F.positions(z, 0, 'web tree');
161+ assert.equal(both.map((i) => z.paths[0][i]).join(''), 'webtree');
162+});
163+
164+test('positions on a non-ASCII path index the original string', () => {
165+ const x = F.index(['İndex/Straße.go']);
166+ const pos = F.positions(x, 0, 'strasse');
167+ assert.deepEqual(pos, [], 'ß is not ss: nothing is claimed');
168+ const q = F.positions(x, 0, 'stra');
169+ const s = x.paths[0];
170+ assert.equal(q.map((i) => s[i]).join(''), 'Stra');
171+ const r = F.positions(x, 0, 'ndex');
172+ assert.equal(r.map((i) => s[i]).join(''), 'ndex');
173+ assert.equal(r[0], 1);
174+});
175+
176+test('positions of a long path (greedy)', () => {
177+ const long = 'a/'.repeat(600) + 'target.txt';
178+ const x = F.index([long]);
179+ const r = F.search(x, 'target');
180+ assert.equal(r.hits.length, 1);
181+ const pos = F.positions(x, 0, 'target');
182+ assert.equal(pos.map((i) => long[i]).join(''), 'target');
183+});
184+
185+test('visible', () => {
186+ assert.equal(F.visible('a\u202eb'), 'a<U+202E>b');
187+ assert.equal(F.visible('x\u200by'), 'x<U+200B>y');
188+ assert.equal(F.visible('a\u0007b'), 'a<U+0007>b');
189+ assert.equal(F.visible('\u3164'), '<U+3164>');
190+ assert.equal(F.visible('\u2028'), '<U+2028>');
191+ assert.equal(F.visible('plain/ünï.go'), 'plain/ünï.go');
192+ assert.equal(F.visible('tab\tname'), 'tab<U+0009>name', 'the finder shows a control character; Go refuses such paths anyway');
193+});
194+
195+test('performance on 100,000 paths (AC6)', () => {
196+ const dirs = ['internal', 'cmd', 'pkg', 'src', 'lib', 'docs', 'test', 'web', 'adapters', 'domain', 'app', 'static'];
197+ const names = ['tree', 'blob', 'commit', 'diff', 'index', 'main', 'util', 'handler', 'service', 'reader', 'writer', 'config'];
198+ const exts = ['.go', '.js', '.md', '.templ', '.css', '.txt'];
199+ const paths = [];
200+ for (let i = 0; paths.length < 100000; i++) {
201+ const d = [dirs[i % 12], dirs[(i * 7) % 12], dirs[(i * 13 + 5) % 12], 'pkg' + (i % 311)];
202+ paths.push(d.join('/') + '/' + names[(i * 5) % 12] + '_' + (i % 977) + names[(i >> 3) % 12] + exts[i % 6]);
203+ }
204+ let t0 = performance.now();
205+ const x = F.index(paths);
206+ const build = performance.now() - t0;
207+
208+ const timings = [];
209+ let prev = null;
210+ let worst = 0;
211+ for (const q of ['t', 'tr', 'tre', 'tree', 'tree h', 'wbtree', 'internal handler', 'zzzq']) {
212+ t0 = performance.now();
213+ const r = F.search(x, q, 50, prev);
214+ const ms = performance.now() - t0;
215+ worst = Math.max(worst, ms);
216+ timings.push(q + ': ' + ms.toFixed(1) + ' ms (' + r.all.length + ' matches)');
217+ prev = r;
218+ }
219+ // The cold query of a single character is the worst case: every path matches.
220+ t0 = performance.now();
221+ F.search(x, 'e');
222+ const cold = performance.now() - t0;
223+ t0 = performance.now();
224+ F.search(x, 'inthand');
225+ const cold2 = performance.now() - t0;
226+ t0 = performance.now();
227+ const r = F.search(x, 'tree h');
228+ for (const h of r.hits) F.positions(x, h.i, 'tree h');
229+ const withPos = performance.now() - t0;
230+ console.log(`AC6 index of ${paths.length} paths: ${build.toFixed(1)} ms (target < 150, asserted < 1500)`);
231+ console.log(`AC6 queries (incremental): ${timings.join('; ')}`);
232+ console.log(`AC6 cold 'e': ${cold.toFixed(1)} ms; cold 'inthand': ${cold2.toFixed(1)} ms; 'tree h' with positions of the hits: ${withPos.toFixed(1)} ms (target < 30, asserted < 300)`);
233+ assert.ok(build < 1500, 'index ' + build);
234+ assert.ok(Math.max(worst, cold, cold2, withPos) < 300, 'query ' + Math.max(worst, cold, cold2, withPos));
235+});
@@ -0,0 +1,235 @@
1+// Tests of the pure core of the file finder (static/js/files.js). Run with
2+// node --test internal/adapters/web/jstest/
3+import assert from 'node:assert/strict';
4+import { test } from 'node:test';
5+
6+globalThis.window = globalThis;
7+await import('../static/js/files.js');
8+const F = window.ermiteFinder;
9+
10+const REPO = [
11+ 'README.md',
12+ 'cmd/ermite/main.go',
13+ 'internal/adapters/web/tree.go',
14+ 'internal/adapters/web/tree.templ',
15+ 'internal/adapters/web/tree_templ.go',
16+ 'internal/adapters/web/static/css/30-list.css',
17+ 'internal/adapters/web/static/js/files.js',
18+ 'internal/adapters/web/jstest/files.test.mjs',
19+ 'internal/adapters/gitcli/read.go',
20+ 'internal/domain/tree_path.go',
21+ 'docs/web/tree-view.md',
22+ 'src/deeply/nested/dir/with/a/treehouse.txt',
23+];
24+const idx = F.index(REPO);
25+const top = (q, k = 50, prev = null) => F.search(idx, q, k, prev);
26+const names = (r) => r.hits.map((h) => REPO[h.i]);
27+
28+test('index: ASCII-only folding keeps positions', () => {
29+ const x = F.index(['İndex/Straße.go', 'ABC/Def.TXT']);
30+ assert.equal(x.lower[1], 'abc/def.txt');
31+ assert.equal(x.lower[0].length, x.paths[0].length);
32+ assert.equal(x.lower[0][0], 'İ', 'non-ASCII is not folded');
33+ assert.equal(x.base[0], 'İndex/'.length);
34+ assert.equal(x.base[1], 4);
35+});
36+
37+test('index: mask bits', () => {
38+ const x = F.index(['a', 'z', '5', '/', '.', '-', '_', 'é', 'A']);
39+ assert.deepEqual(Array.from(x.mask), [1, 1 << 25, 1 << 26, 1 << 27, 1 << 28, 1 << 29, 1 << 29, 1 << 30, 1]);
40+});
41+
42+test('search: rejects what is not a subsequence', () => {
43+ assert.deepEqual(names(top('zzz')), []);
44+ assert.deepEqual(names(top('gnirt')), [], 'order matters');
45+ assert.equal(top('zzz').all.length, 0);
46+ assert.deepEqual(names(top('')), []);
47+ assert.deepEqual(names(top(' ')), []);
48+ // The mask rejects a character no path has.
49+ assert.equal(top('q').all.length, 0);
50+});
51+
52+test('search: ranking', () => {
53+ const r = names(top('tree'));
54+ assert.ok(r.indexOf('internal/adapters/web/tree.go') >= 0);
55+ assert.ok(r.indexOf('internal/adapters/web/tree.go') < r.indexOf('internal/adapters/web/static/css/30-list.css'),
56+ 'basename match beats a scattered one: ' + r.join(', '));
57+ // Equal scores: the shorter path first, then the path.
58+ assert.ok(r.indexOf('internal/adapters/web/tree.go') < r.indexOf('internal/adapters/web/tree.templ'), r.join(', '));
59+ assert.ok(r.indexOf('internal/adapters/web/tree.templ') < r.indexOf('internal/adapters/web/tree_templ.go'), r.join(', '));
60+ assert.ok(r.indexOf('internal/adapters/web/tree.templ') < r.indexOf('src/deeply/nested/dir/with/a/treehouse.txt'), r.join(', '));
61+});
62+
63+test('search: wbtree matches web/tree.templ', () => {
64+ const r = names(top('wbtree'));
65+ assert.ok(r.includes('internal/adapters/web/tree.templ'), r.join(', '));
66+ assert.ok(r.includes('internal/adapters/web/tree.go'));
67+});
68+
69+test('search: basename prefix beats a deep match', () => {
70+ const x = F.index(['a/b/c/d/e/main_helper.go', 'x/m/a/i/n.go', 'main.go', 'pkg/domain/admin.go']);
71+ const r = F.search(x, 'main').hits.map((h) => x.paths[h.i]);
72+ assert.equal(r[0], 'main.go');
73+ assert.ok(r.indexOf('a/b/c/d/e/main_helper.go') < r.indexOf('x/m/a/i/n.go'), r.join(', '));
74+ assert.ok(r.indexOf('a/b/c/d/e/main_helper.go') < r.indexOf('pkg/domain/admin.go'), r.join(', '));
75+});
76+
77+test('search: camelCase and separators earn bonuses', () => {
78+ const x = F.index(['pkg/fooBarBaz.go', 'pkg/foobarbaz.go', 'pkg/fxoxoxbxa.go']);
79+ const r = F.search(x, 'fbb').hits.map((h) => x.paths[h.i]);
80+ assert.equal(r[0], 'pkg/fooBarBaz.go', r.join(', '));
81+});
82+
83+test('search: several terms', () => {
84+ const r = names(top('web tree'));
85+ assert.ok(r.length > 0);
86+ assert.ok(r.slice(0, 3).every((p) => p.includes('web') && p.includes('tree')), r.join(', '));
87+ assert.ok(!r.includes('README.md'));
88+ // Every term must match.
89+ assert.deepEqual(names(top('web zzz')), []);
90+ // The order of the terms is free.
91+ assert.deepEqual(new Set(names(top('tree web'))), new Set(names(top('web tree'))));
92+});
93+
94+test('search: case is folded for ASCII only', () => {
95+ assert.ok(names(top('README')).includes('README.md'));
96+ assert.ok(names(top('readme')).includes('README.md'));
97+ const x = F.index(['İndex/Straße.go']);
98+ assert.equal(F.search(x, 'index').hits.length, 0, 'İ is not an ASCII capital I');
99+ assert.equal(F.search(x, 'ndex').hits.length, 1);
100+});
101+
102+test('search: top k, ties by length then path', () => {
103+ const ps = [];
104+ for (let i = 0; i < 200; i++) ps.push('d/file' + i + '.txt');
105+ const x = F.index(ps);
106+ const r = F.search(x, 'file', 50);
107+ assert.equal(r.hits.length, 50);
108+ assert.equal(r.all.length, 200);
109+ const got = r.hits.map((h) => x.paths[h.i]);
110+ for (let i = 1; i < got.length; i++) {
111+ const a = got[i - 1];
112+ const b = got[i];
113+ assert.ok(a.length < b.length || (a.length === b.length && a < b), a + ' before ' + b);
114+ }
115+ assert.equal(got[0], 'd/file0.txt');
116+});
117+
118+test('search: incremental results equal a cold search', () => {
119+ const big = [];
120+ let seed = 7;
121+ const rnd = () => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff;
122+ const words = ['web', 'tree', 'file', 'test', 'main', 'util', 'core', 'index', 'view', 'model'];
123+ for (let i = 0; i < 3000; i++) {
124+ const depth = 1 + Math.floor(rnd() * 4);
125+ const segs = [];
126+ for (let d = 0; d < depth; d++) segs.push(words[Math.floor(rnd() * words.length)] + (rnd() < 0.3 ? 'Case' : '') + Math.floor(rnd() * 20));
127+ big.push(segs.join('/') + ['.go', '.js', '.md'][Math.floor(rnd() * 3)]);
128+ }
129+ const x = F.index(big);
130+ const queries = ['t', 'tr', 'tre', 'tree', 'tree w', 'tree we', 'tree web', 'tre web', 'tree web m'];
131+ let prev = null;
132+ for (const q of queries) {
133+ const warm = F.search(x, q, 50, prev);
134+ const cold = F.search(x, q, 50, null);
135+ assert.deepEqual(warm.hits, cold.hits, q);
136+ assert.deepEqual(Array.from(warm.all), Array.from(cold.all), q);
137+ prev = warm;
138+ }
139+ // A query that does not extend the previous one searches everything.
140+ const a = F.search(x, 'tree', 50, null);
141+ assert.deepEqual(F.search(x, 'web', 50, a).hits, F.search(x, 'web', 50, null).hits);
142+ assert.deepEqual(F.search(x, 'tre', 50, a).hits, F.search(x, 'tre', 50, null).hits, 'shortening');
143+ // A result of another index is not a source of candidates.
144+ const y = F.index(['zzz']);
145+ assert.deepEqual(F.search(y, 'z', 50, a).hits.length, 1);
146+});
147+
148+test('positions', () => {
149+ const p = 'internal/adapters/web/tree.go';
150+ const x = F.index([p]);
151+ assert.deepEqual(F.positions(x, 0, 'tree'), [p.indexOf('tree.go'), p.indexOf('tree.go') + 1, p.indexOf('tree.go') + 2, p.indexOf('tree.go') + 3]);
152+ const y = F.index(['internal/adapters/web/tree.templ']);
153+ const pos = F.positions(y, 0, 'wbtree');
154+ const s = y.paths[0];
155+ assert.equal(pos.map((i) => s[i]).join(''), 'wbtree');
156+ assert.deepEqual(pos, [...pos].sort((a, b) => a - b));
157+ assert.equal(pos[0], s.indexOf('web'), 'w of the web directory');
158+ // Two terms.
159+ const z = F.index(['src/web/tree.go']);
160+ const both = F.positions(z, 0, 'web tree');
161+ assert.equal(both.map((i) => z.paths[0][i]).join(''), 'webtree');
162+});
163+
164+test('positions on a non-ASCII path index the original string', () => {
165+ const x = F.index(['İndex/Straße.go']);
166+ const pos = F.positions(x, 0, 'strasse');
167+ assert.deepEqual(pos, [], 'ß is not ss: nothing is claimed');
168+ const q = F.positions(x, 0, 'stra');
169+ const s = x.paths[0];
170+ assert.equal(q.map((i) => s[i]).join(''), 'Stra');
171+ const r = F.positions(x, 0, 'ndex');
172+ assert.equal(r.map((i) => s[i]).join(''), 'ndex');
173+ assert.equal(r[0], 1);
174+});
175+
176+test('positions of a long path (greedy)', () => {
177+ const long = 'a/'.repeat(600) + 'target.txt';
178+ const x = F.index([long]);
179+ const r = F.search(x, 'target');
180+ assert.equal(r.hits.length, 1);
181+ const pos = F.positions(x, 0, 'target');
182+ assert.equal(pos.map((i) => long[i]).join(''), 'target');
183+});
184+
185+test('visible', () => {
186+ assert.equal(F.visible('a\u202eb'), 'a<U+202E>b');
187+ assert.equal(F.visible('x\u200by'), 'x<U+200B>y');
188+ assert.equal(F.visible('a\u0007b'), 'a<U+0007>b');
189+ assert.equal(F.visible('\u3164'), '<U+3164>');
190+ assert.equal(F.visible('\u2028'), '<U+2028>');
191+ assert.equal(F.visible('plain/ünï.go'), 'plain/ünï.go');
192+ assert.equal(F.visible('tab\tname'), 'tab<U+0009>name', 'the finder shows a control character; Go refuses such paths anyway');
193+});
194+
195+test('performance on 100,000 paths (AC6)', () => {
196+ const dirs = ['internal', 'cmd', 'pkg', 'src', 'lib', 'docs', 'test', 'web', 'adapters', 'domain', 'app', 'static'];
197+ const names = ['tree', 'blob', 'commit', 'diff', 'index', 'main', 'util', 'handler', 'service', 'reader', 'writer', 'config'];
198+ const exts = ['.go', '.js', '.md', '.templ', '.css', '.txt'];
199+ const paths = [];
200+ for (let i = 0; paths.length < 100000; i++) {
201+ const d = [dirs[i % 12], dirs[(i * 7) % 12], dirs[(i * 13 + 5) % 12], 'pkg' + (i % 311)];
202+ paths.push(d.join('/') + '/' + names[(i * 5) % 12] + '_' + (i % 977) + names[(i >> 3) % 12] + exts[i % 6]);
203+ }
204+ let t0 = performance.now();
205+ const x = F.index(paths);
206+ const build = performance.now() - t0;
207+
208+ const timings = [];
209+ let prev = null;
210+ let worst = 0;
211+ for (const q of ['t', 'tr', 'tre', 'tree', 'tree h', 'wbtree', 'internal handler', 'zzzq']) {
212+ t0 = performance.now();
213+ const r = F.search(x, q, 50, prev);
214+ const ms = performance.now() - t0;
215+ worst = Math.max(worst, ms);
216+ timings.push(q + ': ' + ms.toFixed(1) + ' ms (' + r.all.length + ' matches)');
217+ prev = r;
218+ }
219+ // The cold query of a single character is the worst case: every path matches.
220+ t0 = performance.now();
221+ F.search(x, 'e');
222+ const cold = performance.now() - t0;
223+ t0 = performance.now();
224+ F.search(x, 'inthand');
225+ const cold2 = performance.now() - t0;
226+ t0 = performance.now();
227+ const r = F.search(x, 'tree h');
228+ for (const h of r.hits) F.positions(x, h.i, 'tree h');
229+ const withPos = performance.now() - t0;
230+ console.log(`AC6 index of ${paths.length} paths: ${build.toFixed(1)} ms (target < 150, asserted < 1500)`);
231+ console.log(`AC6 queries (incremental): ${timings.join('; ')}`);
232+ console.log(`AC6 cold 'e': ${cold.toFixed(1)} ms; cold 'inthand': ${cold2.toFixed(1)} ms; 'tree h' with positions of the hits: ${withPos.toFixed(1)} ms (target < 30, asserted < 300)`);
233+ assert.ok(build < 1500, 'index ' + build);
234+ assert.ok(Math.max(worst, cold, cold2, withPos) < 300, 'query ' + Math.max(worst, cold, cold2, withPos));
235+});
M internal/adapters/web/keys.templ +1 -1
@@ -35,7 +35,7 @@ templ Keys() {
3535 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
3636 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
3737 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
38- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
38+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
3939 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
4040 k-gh from:body -> document.querySelector('.brand')?.click();
4141 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
@@ -35,7 +35,7 @@ templ Keys() {
35 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());35 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
36 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();36 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
37 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());37 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
38- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();38+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
39 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();39 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
40 k-gh from:body -> document.querySelector('.brand')?.click();40 k-gh from:body -> document.querySelector('.brand')?.click();
41 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();41 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
M internal/adapters/web/keys_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/overlays.templ +3 -3
@@ -14,7 +14,7 @@ templ OverlayHelp() {
1414 <div><dt><kbd>j</kbd> <kbd>k</kbd></dt><dd>move the row cursor down / up</dd></div>
1515 <div><dt><kbd>enter</kbd></dt><dd>open the row under the cursor</dd></div>
1616 <div><dt><kbd>g g</kbd> <kbd>G</kbd></dt><dd>first / last row</dd></div>
17- <div><dt><kbd>/</kbd></dt><dd>filter the list</dd></div>
17+ <div><dt><kbd>/</kbd></dt><dd>filter the list; on Files, find a file in the repository</dd></div>
1818 <div><dt><kbd>b</kbd></dt><dd>{ "switch" } branch or tag</dd></div>
1919 <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd></div>
2020 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>
@@ -97,8 +97,8 @@ templ OverlayCmd(items []CmdItem) {
9797 spellcheck="false"
9898 aria-controls="cmd-list"
9999 hx-on:input="let t = this.value.toLowerCase().trim();
100- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
101- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')"
100+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
101+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)"
102102 hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
103103 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
104104 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
@@ -14,7 +14,7 @@ templ OverlayHelp() {
14 <div><dt><kbd>j</kbd> <kbd>k</kbd></dt><dd>move the row cursor down / up</dd></div>14 <div><dt><kbd>j</kbd> <kbd>k</kbd></dt><dd>move the row cursor down / up</dd></div>
15 <div><dt><kbd>enter</kbd></dt><dd>open the row under the cursor</dd></div>15 <div><dt><kbd>enter</kbd></dt><dd>open the row under the cursor</dd></div>
16 <div><dt><kbd>g g</kbd> <kbd>G</kbd></dt><dd>first / last row</dd></div>16 <div><dt><kbd>g g</kbd> <kbd>G</kbd></dt><dd>first / last row</dd></div>
17- <div><dt><kbd>/</kbd></dt><dd>filter the list</dd></div>17+ <div><dt><kbd>/</kbd></dt><dd>filter the list; on Files, find a file in the repository</dd></div>
18 <div><dt><kbd>b</kbd></dt><dd>{ "switch" } branch or tag</dd></div>18 <div><dt><kbd>b</kbd></dt><dd>{ "switch" } branch or tag</dd></div>
19 <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd></div>19 <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd></div>
20 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>20 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>
@@ -97,8 +97,8 @@ templ OverlayCmd(items []CmdItem) {
97 spellcheck="false"97 spellcheck="false"
98 aria-controls="cmd-list"98 aria-controls="cmd-list"
99 hx-on:input="let t = this.value.toLowerCase().trim();99 hx-on:input="let t = this.value.toLowerCase().trim();
100- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));100+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
101- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')"101+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)"
102 hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();102 hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
103 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));103 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
104 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;104 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
M internal/adapters/web/overlays_templ.go +2 -2 generated file: collapsed
A internal/adapters/web/pathcache.go +144 -0
@@ -0,0 +1,144 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "container/list"
7+ "context"
8+ "sync"
9+)
10+
11+const (
12+ // pathCacheBytes is the budget of the cache of path lists.
13+ pathCacheBytes = 64 << 20
14+ // pathEntryMax is the largest list the cache keeps; a bigger one is built
15+ // again for each request that needs it.
16+ pathEntryMax = 16 << 20
17+)
18+
19+// pathCache keeps the gzipped JSON path lists of commits, least recently used
20+// out first, within a budget of bytes. A commit id names its content, so an
21+// entry is right for every repository and every visitor; who may read it is
22+// decided before the cache is asked (Server.paths).
23+//
24+// Concurrent misses for one commit share a single build.
25+type pathCache struct {
26+ mu sync.Mutex
27+ budget int
28+ size int
29+ max int // largest entry kept
30+ lru *list.List // of *pathEntry, most recently used first
31+ byID map[string]*list.Element
32+ building map[string]*pathBuild
33+}
34+
35+type pathEntry struct {
36+ id string
37+ body []byte
38+}
39+
40+// pathBuild is a build in progress; done is closed when body and err are set.
41+type pathBuild struct {
42+ done chan struct{}
43+ body []byte
44+ err error
45+}
46+
47+func newPathCache(budget, max int) *pathCache {
48+ return &pathCache{
49+ budget: budget, max: max, lru: list.New(),
50+ byID: map[string]*list.Element{}, building: map[string]*pathBuild{},
51+ }
52+}
53+
54+// get returns the list of a commit and marks it as used.
55+func (c *pathCache) get(id string) ([]byte, bool) {
56+ c.mu.Lock()
57+ defer c.mu.Unlock()
58+ return c.getLocked(id)
59+}
60+
61+func (c *pathCache) getLocked(id string) ([]byte, bool) {
62+ el, ok := c.byID[id]
63+ if !ok {
64+ return nil, false
65+ }
66+ c.lru.MoveToFront(el)
67+ return el.Value.(*pathEntry).body, true
68+}
69+
70+// put stores a list, evicting the least recently used ones to stay in budget.
71+// A list over the entry limit or the budget is not stored.
72+func (c *pathCache) put(id string, body []byte) {
73+ c.mu.Lock()
74+ defer c.mu.Unlock()
75+ c.putLocked(id, body)
76+}
77+
78+func (c *pathCache) putLocked(id string, body []byte) {
79+ if len(body) > c.max || len(body) > c.budget {
80+ return
81+ }
82+ if el, ok := c.byID[id]; ok {
83+ c.size -= len(el.Value.(*pathEntry).body)
84+ el.Value.(*pathEntry).body = body
85+ c.size += len(body)
86+ c.lru.MoveToFront(el)
87+ } else {
88+ c.byID[id] = c.lru.PushFront(&pathEntry{id: id, body: body})
89+ c.size += len(body)
90+ }
91+ for c.size > c.budget {
92+ el := c.lru.Back()
93+ e := el.Value.(*pathEntry)
94+ c.lru.Remove(el)
95+ delete(c.byID, e.id)
96+ c.size -= len(e.body)
97+ }
98+}
99+
100+// load returns the list of a commit: from the cache, or from build, which
101+// runs once however many callers ask at the same time. The caller that
102+// starts the build runs it; the others wait for it, or for their own ctx.
103+func (c *pathCache) load(ctx context.Context, id string, build func() ([]byte, error)) ([]byte, error) {
104+ c.mu.Lock()
105+ if b, ok := c.getLocked(id); ok {
106+ c.mu.Unlock()
107+ return b, nil
108+ }
109+ if f, ok := c.building[id]; ok {
110+ c.mu.Unlock()
111+ select {
112+ case <-f.done:
113+ return f.body, f.err
114+ case <-ctx.Done():
115+ return nil, ctx.Err()
116+ }
117+ }
118+ f := &pathBuild{done: make(chan struct{})}
119+ c.building[id] = f
120+ c.mu.Unlock()
121+
122+ defer func() {
123+ // Also on a panic of build: nobody waits for ever.
124+ if r := recover(); r != nil {
125+ f.err = errBuildPanicked
126+ c.finish(id, f)
127+ panic(r)
128+ }
129+ }()
130+ f.body, f.err = build()
131+ c.finish(id, f)
132+ return f.body, f.err
133+}
134+
135+// finish publishes a build: stored when it worked, then released to the waiters.
136+func (c *pathCache) finish(id string, f *pathBuild) {
137+ c.mu.Lock()
138+ delete(c.building, id)
139+ if f.err == nil {
140+ c.putLocked(id, f.body)
141+ }
142+ c.mu.Unlock()
143+ close(f.done)
144+}
@@ -0,0 +1,144 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "container/list"
7+ "context"
8+ "sync"
9+)
10+
11+const (
12+ // pathCacheBytes is the budget of the cache of path lists.
13+ pathCacheBytes = 64 << 20
14+ // pathEntryMax is the largest list the cache keeps; a bigger one is built
15+ // again for each request that needs it.
16+ pathEntryMax = 16 << 20
17+)
18+
19+// pathCache keeps the gzipped JSON path lists of commits, least recently used
20+// out first, within a budget of bytes. A commit id names its content, so an
21+// entry is right for every repository and every visitor; who may read it is
22+// decided before the cache is asked (Server.paths).
23+//
24+// Concurrent misses for one commit share a single build.
25+type pathCache struct {
26+ mu sync.Mutex
27+ budget int
28+ size int
29+ max int // largest entry kept
30+ lru *list.List // of *pathEntry, most recently used first
31+ byID map[string]*list.Element
32+ building map[string]*pathBuild
33+}
34+
35+type pathEntry struct {
36+ id string
37+ body []byte
38+}
39+
40+// pathBuild is a build in progress; done is closed when body and err are set.
41+type pathBuild struct {
42+ done chan struct{}
43+ body []byte
44+ err error
45+}
46+
47+func newPathCache(budget, max int) *pathCache {
48+ return &pathCache{
49+ budget: budget, max: max, lru: list.New(),
50+ byID: map[string]*list.Element{}, building: map[string]*pathBuild{},
51+ }
52+}
53+
54+// get returns the list of a commit and marks it as used.
55+func (c *pathCache) get(id string) ([]byte, bool) {
56+ c.mu.Lock()
57+ defer c.mu.Unlock()
58+ return c.getLocked(id)
59+}
60+
61+func (c *pathCache) getLocked(id string) ([]byte, bool) {
62+ el, ok := c.byID[id]
63+ if !ok {
64+ return nil, false
65+ }
66+ c.lru.MoveToFront(el)
67+ return el.Value.(*pathEntry).body, true
68+}
69+
70+// put stores a list, evicting the least recently used ones to stay in budget.
71+// A list over the entry limit or the budget is not stored.
72+func (c *pathCache) put(id string, body []byte) {
73+ c.mu.Lock()
74+ defer c.mu.Unlock()
75+ c.putLocked(id, body)
76+}
77+
78+func (c *pathCache) putLocked(id string, body []byte) {
79+ if len(body) > c.max || len(body) > c.budget {
80+ return
81+ }
82+ if el, ok := c.byID[id]; ok {
83+ c.size -= len(el.Value.(*pathEntry).body)
84+ el.Value.(*pathEntry).body = body
85+ c.size += len(body)
86+ c.lru.MoveToFront(el)
87+ } else {
88+ c.byID[id] = c.lru.PushFront(&pathEntry{id: id, body: body})
89+ c.size += len(body)
90+ }
91+ for c.size > c.budget {
92+ el := c.lru.Back()
93+ e := el.Value.(*pathEntry)
94+ c.lru.Remove(el)
95+ delete(c.byID, e.id)
96+ c.size -= len(e.body)
97+ }
98+}
99+
100+// load returns the list of a commit: from the cache, or from build, which
101+// runs once however many callers ask at the same time. The caller that
102+// starts the build runs it; the others wait for it, or for their own ctx.
103+func (c *pathCache) load(ctx context.Context, id string, build func() ([]byte, error)) ([]byte, error) {
104+ c.mu.Lock()
105+ if b, ok := c.getLocked(id); ok {
106+ c.mu.Unlock()
107+ return b, nil
108+ }
109+ if f, ok := c.building[id]; ok {
110+ c.mu.Unlock()
111+ select {
112+ case <-f.done:
113+ return f.body, f.err
114+ case <-ctx.Done():
115+ return nil, ctx.Err()
116+ }
117+ }
118+ f := &pathBuild{done: make(chan struct{})}
119+ c.building[id] = f
120+ c.mu.Unlock()
121+
122+ defer func() {
123+ // Also on a panic of build: nobody waits for ever.
124+ if r := recover(); r != nil {
125+ f.err = errBuildPanicked
126+ c.finish(id, f)
127+ panic(r)
128+ }
129+ }()
130+ f.body, f.err = build()
131+ c.finish(id, f)
132+ return f.body, f.err
133+}
134+
135+// finish publishes a build: stored when it worked, then released to the waiters.
136+func (c *pathCache) finish(id string, f *pathBuild) {
137+ c.mu.Lock()
138+ delete(c.building, id)
139+ if f.err == nil {
140+ c.putLocked(id, f.body)
141+ }
142+ c.mu.Unlock()
143+ close(f.done)
144+}
A internal/adapters/web/pathcache_test.go +127 -0
@@ -0,0 +1,127 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "sync"
10+ "sync/atomic"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+)
17+
18+func TestPathCacheEvictsLeastRecentlyUsed(t *testing.T) {
19+ c := newPathCache(100, 60)
20+ blob := func(n int) []byte { return make([]byte, n) }
21+ c.put("a", blob(40))
22+ c.put("b", blob(40))
23+ _, ok := c.get("a") // a is now the most recent
24+ require.True(t, ok)
25+ c.put("c", blob(40)) // 120 > 100: b, the least recent, goes
26+ _, ok = c.get("b")
27+ assert.False(t, ok, "b was evicted")
28+ _, okA := c.get("a")
29+ _, okC := c.get("c")
30+ assert.True(t, okA && okC)
31+ assert.Equal(t, 80, c.size)
32+
33+ // Replacing an entry accounts for the old size.
34+ c.put("a", blob(10))
35+ assert.Equal(t, 50, c.size)
36+
37+ // A big one pushes out as many as needed.
38+ c.put("d", blob(60))
39+ assert.LessOrEqual(t, c.size, 100)
40+ _, ok = c.get("d")
41+ assert.True(t, ok)
42+}
43+
44+func TestPathCacheSkipsOversizedEntries(t *testing.T) {
45+ c := newPathCache(100, 60)
46+ c.put("big", make([]byte, 61))
47+ _, ok := c.get("big")
48+ assert.False(t, ok, "over the entry limit")
49+ c = newPathCache(50, 60)
50+ c.put("big", make([]byte, 51))
51+ _, ok = c.get("big")
52+ assert.False(t, ok, "over the budget")
53+ assert.Zero(t, c.size)
54+}
55+
56+func TestPathCacheConcurrent(t *testing.T) {
57+ c := newPathCache(1000, 200)
58+ var wg sync.WaitGroup
59+ for g := range 8 {
60+ wg.Add(1)
61+ go func() {
62+ defer wg.Done()
63+ for i := range 500 {
64+ id := fmt.Sprint((g + i) % 17)
65+ c.put(id, make([]byte, 10+i%90))
66+ c.get(fmt.Sprint(i % 17))
67+ }
68+ }()
69+ }
70+ wg.Wait()
71+ assert.LessOrEqual(t, c.size, 1000)
72+ sum := 0
73+ for _, el := range c.byID {
74+ sum += len(el.Value.(*pathEntry).body)
75+ }
76+ assert.Equal(t, sum, c.size, "the size is the sum of the entries")
77+ assert.Equal(t, len(c.byID), c.lru.Len())
78+}
79+
80+// Concurrent misses for one commit build once; a failure is shared and not
81+// stored; a waiter that gives up leaves the build running.
82+func TestPathCacheLoadBuildsOnce(t *testing.T) {
83+ c := newPathCache(1000, 200)
84+ var builds atomic.Int32
85+ release := make(chan struct{})
86+ build := func() ([]byte, error) {
87+ builds.Add(1)
88+ <-release
89+ return []byte("list"), nil
90+ }
91+ var wg sync.WaitGroup
92+ got := make([][]byte, 10)
93+ for i := range got {
94+ wg.Add(1)
95+ go func() {
96+ defer wg.Done()
97+ b, err := c.load(context.Background(), "x", build)
98+ assert.NoError(t, err)
99+ got[i] = b
100+ }()
101+ }
102+ // A waiter whose context ends does not stop the others.
103+ require.Eventually(t, func() bool { c.mu.Lock(); defer c.mu.Unlock(); return c.building["x"] != nil }, time.Second, time.Millisecond)
104+ ctx, cancel := context.WithCancel(context.Background())
105+ cancel()
106+ _, err := c.load(ctx, "x", build)
107+ assert.ErrorIs(t, err, context.Canceled)
108+ close(release)
109+ wg.Wait()
110+ assert.EqualValues(t, 1, builds.Load())
111+ for _, b := range got {
112+ assert.Equal(t, "list", string(b))
113+ }
114+ b, ok := c.get("x")
115+ assert.True(t, ok)
116+ assert.Equal(t, "list", string(b))
117+
118+ boom := errors.New("boom")
119+ _, err = c.load(context.Background(), "y", func() ([]byte, error) { return nil, boom })
120+ assert.ErrorIs(t, err, boom)
121+ _, ok = c.get("y")
122+ assert.False(t, ok, "a failure is not cached")
123+ b, err = c.load(context.Background(), "y", func() ([]byte, error) { return []byte("ok"), nil })
124+ require.NoError(t, err)
125+ assert.Equal(t, "ok", string(b), "the next request builds again")
126+ assert.Empty(t, c.building)
127+}
@@ -0,0 +1,127 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "sync"
10+ "sync/atomic"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+)
17+
18+func TestPathCacheEvictsLeastRecentlyUsed(t *testing.T) {
19+ c := newPathCache(100, 60)
20+ blob := func(n int) []byte { return make([]byte, n) }
21+ c.put("a", blob(40))
22+ c.put("b", blob(40))
23+ _, ok := c.get("a") // a is now the most recent
24+ require.True(t, ok)
25+ c.put("c", blob(40)) // 120 > 100: b, the least recent, goes
26+ _, ok = c.get("b")
27+ assert.False(t, ok, "b was evicted")
28+ _, okA := c.get("a")
29+ _, okC := c.get("c")
30+ assert.True(t, okA && okC)
31+ assert.Equal(t, 80, c.size)
32+
33+ // Replacing an entry accounts for the old size.
34+ c.put("a", blob(10))
35+ assert.Equal(t, 50, c.size)
36+
37+ // A big one pushes out as many as needed.
38+ c.put("d", blob(60))
39+ assert.LessOrEqual(t, c.size, 100)
40+ _, ok = c.get("d")
41+ assert.True(t, ok)
42+}
43+
44+func TestPathCacheSkipsOversizedEntries(t *testing.T) {
45+ c := newPathCache(100, 60)
46+ c.put("big", make([]byte, 61))
47+ _, ok := c.get("big")
48+ assert.False(t, ok, "over the entry limit")
49+ c = newPathCache(50, 60)
50+ c.put("big", make([]byte, 51))
51+ _, ok = c.get("big")
52+ assert.False(t, ok, "over the budget")
53+ assert.Zero(t, c.size)
54+}
55+
56+func TestPathCacheConcurrent(t *testing.T) {
57+ c := newPathCache(1000, 200)
58+ var wg sync.WaitGroup
59+ for g := range 8 {
60+ wg.Add(1)
61+ go func() {
62+ defer wg.Done()
63+ for i := range 500 {
64+ id := fmt.Sprint((g + i) % 17)
65+ c.put(id, make([]byte, 10+i%90))
66+ c.get(fmt.Sprint(i % 17))
67+ }
68+ }()
69+ }
70+ wg.Wait()
71+ assert.LessOrEqual(t, c.size, 1000)
72+ sum := 0
73+ for _, el := range c.byID {
74+ sum += len(el.Value.(*pathEntry).body)
75+ }
76+ assert.Equal(t, sum, c.size, "the size is the sum of the entries")
77+ assert.Equal(t, len(c.byID), c.lru.Len())
78+}
79+
80+// Concurrent misses for one commit build once; a failure is shared and not
81+// stored; a waiter that gives up leaves the build running.
82+func TestPathCacheLoadBuildsOnce(t *testing.T) {
83+ c := newPathCache(1000, 200)
84+ var builds atomic.Int32
85+ release := make(chan struct{})
86+ build := func() ([]byte, error) {
87+ builds.Add(1)
88+ <-release
89+ return []byte("list"), nil
90+ }
91+ var wg sync.WaitGroup
92+ got := make([][]byte, 10)
93+ for i := range got {
94+ wg.Add(1)
95+ go func() {
96+ defer wg.Done()
97+ b, err := c.load(context.Background(), "x", build)
98+ assert.NoError(t, err)
99+ got[i] = b
100+ }()
101+ }
102+ // A waiter whose context ends does not stop the others.
103+ require.Eventually(t, func() bool { c.mu.Lock(); defer c.mu.Unlock(); return c.building["x"] != nil }, time.Second, time.Millisecond)
104+ ctx, cancel := context.WithCancel(context.Background())
105+ cancel()
106+ _, err := c.load(ctx, "x", build)
107+ assert.ErrorIs(t, err, context.Canceled)
108+ close(release)
109+ wg.Wait()
110+ assert.EqualValues(t, 1, builds.Load())
111+ for _, b := range got {
112+ assert.Equal(t, "list", string(b))
113+ }
114+ b, ok := c.get("x")
115+ assert.True(t, ok)
116+ assert.Equal(t, "list", string(b))
117+
118+ boom := errors.New("boom")
119+ _, err = c.load(context.Background(), "y", func() ([]byte, error) { return nil, boom })
120+ assert.ErrorIs(t, err, boom)
121+ _, ok = c.get("y")
122+ assert.False(t, ok, "a failure is not cached")
123+ b, err = c.load(context.Background(), "y", func() ([]byte, error) { return []byte("ok"), nil })
124+ require.NoError(t, err)
125+ assert.Equal(t, "ok", string(b), "the next request builds again")
126+ assert.Empty(t, c.building)
127+}
A internal/adapters/web/paths.go +152 -0
@@ -0,0 +1,152 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "bytes"
7+ "compress/gzip"
8+ "context"
9+ "encoding/json"
10+ "errors"
11+ "io"
12+ "net/http"
13+ "strconv"
14+ "strings"
15+
16+ "connectrpc.com/connect"
17+
18+ pb "bnema.dev/ermite-api/gen/ermite"
19+)
20+
21+// errBuildPanicked is what the waiters of a path list get when its build
22+// panicked.
23+var errBuildPanicked = errors.New("web: building the path list failed")
24+
25+// pathList is the JSON body of GET /{repo}/paths/{sha}.
26+type pathList struct {
27+ Paths []string `json:"paths"`
28+ Truncated bool `json:"truncated"`
29+}
30+
31+// paths is GET /{repo}/paths/{sha}: the file paths of a commit as JSON, for
32+// the file finder (files.js). The commit must be a full id; the answer never
33+// changes for it, but who may read it does, so every request is authorized
34+// first (ResolveRevision runs the access rule) and only then served from the
35+// cache of lists. Caching follows raw: a visitor revalidates every time, with
36+// an ETag that makes it a 304; the owner's copy is private and never stored.
37+func (s *Server) paths(w http.ResponseWriter, r *http.Request) {
38+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
39+ defer cancel()
40+ repo, sha := r.PathValue("repo"), r.PathValue("sha")
41+ if !isFullID(sha) {
42+ s.fail(ctx, w, r, errNotFound)
43+ return
44+ }
45+ res, err := s.cfg.Git.ResolveRevision(ctx, connect.NewRequest(&pb.ResolveRevisionRequest{Repo: repo, Revision: sha}))
46+ if err != nil {
47+ s.fail(ctx, w, r, err)
48+ return
49+ }
50+ // A ref can be named like a commit id: only the commit itself is served,
51+ // since the cache is keyed by it.
52+ if res.Msg.GetCommitId() != sha {
53+ s.fail(ctx, w, r, errNotFound)
54+ return
55+ }
56+ body, err := s.pathCache.load(ctx, sha, func() ([]byte, error) { return s.buildPaths(ctx, repo, sha) })
57+ if err != nil {
58+ s.fail(ctx, w, r, err)
59+ return
60+ }
61+
62+ h := w.Header()
63+ h.Set("Content-Type", "application/json")
64+ h.Set("X-Content-Type-Options", "nosniff")
65+ h.Set("Vary", "Accept-Encoding")
66+ h.Set("ETag", `"paths-`+sha+`"`)
67+ // Revalidated every time, as raw is: a repository can turn private, and a
68+ // shared cache must not keep serving its files. The ETag keeps it cheap.
69+ cacheHeaders(ctx, h, "no-cache", sessionCache)
70+ if etagMatches(r.Header.Get("If-None-Match"), h.Get("ETag")) {
71+ w.WriteHeader(http.StatusNotModified)
72+ return
73+ }
74+ if acceptsGzip(r.Header.Get("Accept-Encoding")) {
75+ h.Set("Content-Encoding", "gzip")
76+ h.Set("Content-Length", strconv.Itoa(len(body)))
77+ w.WriteHeader(http.StatusOK)
78+ if r.Method != http.MethodHead {
79+ _, _ = w.Write(body)
80+ }
81+ return
82+ }
83+ zr, err := gzip.NewReader(bytes.NewReader(body))
84+ if err != nil {
85+ s.fail(ctx, w, r, err)
86+ return
87+ }
88+ w.WriteHeader(http.StatusOK)
89+ if r.Method != http.MethodHead {
90+ _, _ = io.Copy(w, zr)
91+ }
92+}
93+
94+// buildPaths lists the paths of a commit and returns them as gzipped JSON.
95+// The build is shared by the requests that wait for it, so it does not stop
96+// when the request that started it ends.
97+func (s *Server) buildPaths(ctx context.Context, repo, sha string) ([]byte, error) {
98+ ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), requestTimeout)
99+ defer cancel()
100+ res, err := s.cfg.Git.ListPaths(ctx, connect.NewRequest(&pb.ListPathsRequest{Repo: repo, Revision: sha}))
101+ if err != nil {
102+ return nil, err
103+ }
104+ if res.Msg.GetCommitId() != sha {
105+ return nil, errNotFound
106+ }
107+ paths := res.Msg.GetPaths()
108+ if paths == nil {
109+ paths = []string{}
110+ }
111+ var buf bytes.Buffer
112+ zw, err := gzip.NewWriterLevel(&buf, gzip.BestSpeed)
113+ if err != nil {
114+ return nil, err
115+ }
116+ if err := json.NewEncoder(zw).Encode(pathList{Paths: paths, Truncated: res.Msg.GetTruncated()}); err != nil {
117+ return nil, err
118+ }
119+ if err := zw.Close(); err != nil {
120+ return nil, err
121+ }
122+ return buf.Bytes(), nil
123+}
124+
125+// acceptsGzip reports whether an Accept-Encoding header allows gzip.
126+func acceptsGzip(h string) bool {
127+ for f := range strings.SplitSeq(h, ",") {
128+ name, params, _ := strings.Cut(strings.TrimSpace(f), ";")
129+ if !strings.EqualFold(strings.TrimSpace(name), "gzip") {
130+ continue
131+ }
132+ if q, ok := strings.CutPrefix(strings.ReplaceAll(strings.TrimSpace(params), " ", ""), "q="); ok {
133+ if v, err := strconv.ParseFloat(q, 64); err == nil && v <= 0 {
134+ return false
135+ }
136+ }
137+ return true
138+ }
139+ return false
140+}
141+
142+// etagMatches reports whether an If-None-Match header lists etag (weak
143+// comparison, or "*").
144+func etagMatches(header, etag string) bool {
145+ for f := range strings.SplitSeq(header, ",") {
146+ f = strings.TrimSpace(f)
147+ if f == "*" || strings.TrimPrefix(f, "W/") == etag {
148+ return true
149+ }
150+ }
151+ return false
152+}
@@ -0,0 +1,152 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "bytes"
7+ "compress/gzip"
8+ "context"
9+ "encoding/json"
10+ "errors"
11+ "io"
12+ "net/http"
13+ "strconv"
14+ "strings"
15+
16+ "connectrpc.com/connect"
17+
18+ pb "bnema.dev/ermite-api/gen/ermite"
19+)
20+
21+// errBuildPanicked is what the waiters of a path list get when its build
22+// panicked.
23+var errBuildPanicked = errors.New("web: building the path list failed")
24+
25+// pathList is the JSON body of GET /{repo}/paths/{sha}.
26+type pathList struct {
27+ Paths []string `json:"paths"`
28+ Truncated bool `json:"truncated"`
29+}
30+
31+// paths is GET /{repo}/paths/{sha}: the file paths of a commit as JSON, for
32+// the file finder (files.js). The commit must be a full id; the answer never
33+// changes for it, but who may read it does, so every request is authorized
34+// first (ResolveRevision runs the access rule) and only then served from the
35+// cache of lists. Caching follows raw: a visitor revalidates every time, with
36+// an ETag that makes it a 304; the owner's copy is private and never stored.
37+func (s *Server) paths(w http.ResponseWriter, r *http.Request) {
38+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
39+ defer cancel()
40+ repo, sha := r.PathValue("repo"), r.PathValue("sha")
41+ if !isFullID(sha) {
42+ s.fail(ctx, w, r, errNotFound)
43+ return
44+ }
45+ res, err := s.cfg.Git.ResolveRevision(ctx, connect.NewRequest(&pb.ResolveRevisionRequest{Repo: repo, Revision: sha}))
46+ if err != nil {
47+ s.fail(ctx, w, r, err)
48+ return
49+ }
50+ // A ref can be named like a commit id: only the commit itself is served,
51+ // since the cache is keyed by it.
52+ if res.Msg.GetCommitId() != sha {
53+ s.fail(ctx, w, r, errNotFound)
54+ return
55+ }
56+ body, err := s.pathCache.load(ctx, sha, func() ([]byte, error) { return s.buildPaths(ctx, repo, sha) })
57+ if err != nil {
58+ s.fail(ctx, w, r, err)
59+ return
60+ }
61+
62+ h := w.Header()
63+ h.Set("Content-Type", "application/json")
64+ h.Set("X-Content-Type-Options", "nosniff")
65+ h.Set("Vary", "Accept-Encoding")
66+ h.Set("ETag", `"paths-`+sha+`"`)
67+ // Revalidated every time, as raw is: a repository can turn private, and a
68+ // shared cache must not keep serving its files. The ETag keeps it cheap.
69+ cacheHeaders(ctx, h, "no-cache", sessionCache)
70+ if etagMatches(r.Header.Get("If-None-Match"), h.Get("ETag")) {
71+ w.WriteHeader(http.StatusNotModified)
72+ return
73+ }
74+ if acceptsGzip(r.Header.Get("Accept-Encoding")) {
75+ h.Set("Content-Encoding", "gzip")
76+ h.Set("Content-Length", strconv.Itoa(len(body)))
77+ w.WriteHeader(http.StatusOK)
78+ if r.Method != http.MethodHead {
79+ _, _ = w.Write(body)
80+ }
81+ return
82+ }
83+ zr, err := gzip.NewReader(bytes.NewReader(body))
84+ if err != nil {
85+ s.fail(ctx, w, r, err)
86+ return
87+ }
88+ w.WriteHeader(http.StatusOK)
89+ if r.Method != http.MethodHead {
90+ _, _ = io.Copy(w, zr)
91+ }
92+}
93+
94+// buildPaths lists the paths of a commit and returns them as gzipped JSON.
95+// The build is shared by the requests that wait for it, so it does not stop
96+// when the request that started it ends.
97+func (s *Server) buildPaths(ctx context.Context, repo, sha string) ([]byte, error) {
98+ ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), requestTimeout)
99+ defer cancel()
100+ res, err := s.cfg.Git.ListPaths(ctx, connect.NewRequest(&pb.ListPathsRequest{Repo: repo, Revision: sha}))
101+ if err != nil {
102+ return nil, err
103+ }
104+ if res.Msg.GetCommitId() != sha {
105+ return nil, errNotFound
106+ }
107+ paths := res.Msg.GetPaths()
108+ if paths == nil {
109+ paths = []string{}
110+ }
111+ var buf bytes.Buffer
112+ zw, err := gzip.NewWriterLevel(&buf, gzip.BestSpeed)
113+ if err != nil {
114+ return nil, err
115+ }
116+ if err := json.NewEncoder(zw).Encode(pathList{Paths: paths, Truncated: res.Msg.GetTruncated()}); err != nil {
117+ return nil, err
118+ }
119+ if err := zw.Close(); err != nil {
120+ return nil, err
121+ }
122+ return buf.Bytes(), nil
123+}
124+
125+// acceptsGzip reports whether an Accept-Encoding header allows gzip.
126+func acceptsGzip(h string) bool {
127+ for f := range strings.SplitSeq(h, ",") {
128+ name, params, _ := strings.Cut(strings.TrimSpace(f), ";")
129+ if !strings.EqualFold(strings.TrimSpace(name), "gzip") {
130+ continue
131+ }
132+ if q, ok := strings.CutPrefix(strings.ReplaceAll(strings.TrimSpace(params), " ", ""), "q="); ok {
133+ if v, err := strconv.ParseFloat(q, 64); err == nil && v <= 0 {
134+ return false
135+ }
136+ }
137+ return true
138+ }
139+ return false
140+}
141+
142+// etagMatches reports whether an If-None-Match header lists etag (weak
143+// comparison, or "*").
144+func etagMatches(header, etag string) bool {
145+ for f := range strings.SplitSeq(header, ",") {
146+ f = strings.TrimSpace(f)
147+ if f == "*" || strings.TrimPrefix(f, "W/") == etag {
148+ return true
149+ }
150+ }
151+ return false
152+}
M internal/adapters/web/refs.go +15 -0
@@ -139,6 +139,7 @@ func pinRef(l *Layout, t target) {
139139 if len(t.rev) == 40 {
140140 l.SHA = t.rev
141141 }
142+ l.setFinder(l.Repo, l.SHA, t.rev)
142143 for i := range l.Heads {
143144 l.Heads[i].Current = false
144145 }
@@ -148,6 +149,7 @@ func pinRef(l *Layout, t target) {
148149 return
149150 }
150151 l.Ref, l.RefPath, l.SHA, l.Short = t.name, t.name, t.rev, short(t.rev)
152+ l.setFinder(l.Repo, t.rev, t.name)
151153 for i := range l.Heads {
152154 l.Heads[i].Current = l.Heads[i].Name == t.name
153155 }
@@ -156,6 +158,19 @@ func pinRef(l *Layout, t target) {
156158 }
157159 }
158160
161+// setFinder points the file finder at a commit: the list of its paths when sha
162+// is a full commit id, and the blob pages of ref (a ref name or a commit id)
163+// its results open. Without a full id there is nothing to search and the
164+// page has no finder data.
165+func (l *Layout) setFinder(repo, sha, ref string) {
166+ l.PathsURL, l.BlobBase = "", ""
167+ if repo == "" || ref == "" || !isFullID(sha) {
168+ return
169+ }
170+ l.PathsURL = repoHref(repo, "paths", sha)
171+ l.BlobBase = repoHref(repo, "blob", ref)
172+}
173+
159174 func hasName(items []RefItem, name string) bool {
160175 for _, it := range items {
161176 if it.Name == name {
@@ -139,6 +139,7 @@ func pinRef(l *Layout, t target) {
139 if len(t.rev) == 40 {139 if len(t.rev) == 40 {
140 l.SHA = t.rev140 l.SHA = t.rev
141 }141 }
142+ l.setFinder(l.Repo, l.SHA, t.rev)
142 for i := range l.Heads {143 for i := range l.Heads {
143 l.Heads[i].Current = false144 l.Heads[i].Current = false
144 }145 }
@@ -148,6 +149,7 @@ func pinRef(l *Layout, t target) {
148 return149 return
149 }150 }
150 l.Ref, l.RefPath, l.SHA, l.Short = t.name, t.name, t.rev, short(t.rev)151 l.Ref, l.RefPath, l.SHA, l.Short = t.name, t.name, t.rev, short(t.rev)
152+ l.setFinder(l.Repo, t.rev, t.name)
151 for i := range l.Heads {153 for i := range l.Heads {
152 l.Heads[i].Current = l.Heads[i].Name == t.name154 l.Heads[i].Current = l.Heads[i].Name == t.name
153 }155 }
@@ -156,6 +158,19 @@ func pinRef(l *Layout, t target) {
156 }158 }
157 }159 }
158 160
161+// setFinder points the file finder at a commit: the list of its paths when sha
162+// is a full commit id, and the blob pages of ref (a ref name or a commit id)
163+// its results open. Without a full id there is nothing to search and the
164+// page has no finder data.
165+func (l *Layout) setFinder(repo, sha, ref string) {
166+ l.PathsURL, l.BlobBase = "", ""
167+ if repo == "" || ref == "" || !isFullID(sha) {
168+ return
169+ }
170+ l.PathsURL = repoHref(repo, "paths", sha)
171+ l.BlobBase = repoHref(repo, "blob", ref)
172+}
173+
159 func hasName(items []RefItem, name string) bool {174 func hasName(items []RefItem, name string) bool {
160 for _, it := range items {175 for _, it := range items {
161 if it.Name == name {176 if it.Name == name {
M internal/adapters/web/repo.go +1 -0
@@ -98,6 +98,7 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La
9898 def = d.head.GetShortName()
9999 l.Ref, l.RefPath = def, def
100100 l.SHA, l.Short = d.head.GetCommitId(), short(d.head.GetCommitId())
101+ l.setFinder(d.name(), l.SHA, def)
101102 }
102103 mk := func(key, label, href, id string) Section {
103104 return Section{Key: key, Label: label, Href: href, Current: section == id}
@@ -98,6 +98,7 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La
98 def = d.head.GetShortName()98 def = d.head.GetShortName()
99 l.Ref, l.RefPath = def, def99 l.Ref, l.RefPath = def, def
100 l.SHA, l.Short = d.head.GetCommitId(), short(d.head.GetCommitId())100 l.SHA, l.Short = d.head.GetCommitId(), short(d.head.GetCommitId())
101+ l.setFinder(d.name(), l.SHA, def)
101 }102 }
102 mk := func(key, label, href, id string) Section {103 mk := func(key, label, href, id string) Section {
103 return Section{Key: key, Label: label, Href: href, Current: section == id}104 return Section{Key: key, Label: label, Href: href, Current: section == id}
M internal/adapters/web/shell.templ +10 -1
@@ -22,7 +22,16 @@ templ Shell(l Layout, rail templ.Component) {
2222 <script src={ l.Assets.App } defer></script>
2323 <script src={ l.Assets.Files } defer></script>
2424 </head>
25- <body data-ref={ l.RefPath } data-sha={ l.SHA } data-sel="" data-nav="">
25+ <body
26+ data-ref={ l.RefPath }
27+ data-sha={ l.SHA }
28+ data-sel=""
29+ data-nav=""
30+ if l.PathsURL != "" {
31+ data-paths={ l.PathsURL }
32+ data-blob={ l.BlobBase }
33+ }
34+ >
2635 <a class="skip" href="#main">skip to content</a>
2736 @Header(l)
2837 <div class={ "work", templ.KV("has-rail", l.HasRail && rail != nil) }>
@@ -22,7 +22,16 @@ templ Shell(l Layout, rail templ.Component) {
22 <script src={ l.Assets.App } defer></script>22 <script src={ l.Assets.App } defer></script>
23 <script src={ l.Assets.Files } defer></script>23 <script src={ l.Assets.Files } defer></script>
24 </head>24 </head>
25- <body data-ref={ l.RefPath } data-sha={ l.SHA } data-sel="" data-nav="">25+ <body
26+ data-ref={ l.RefPath }
27+ data-sha={ l.SHA }
28+ data-sel=""
29+ data-nav=""
30+ if l.PathsURL != "" {
31+ data-paths={ l.PathsURL }
32+ data-blob={ l.BlobBase }
33+ }
34+ >
26 <a class="skip" href="#main">skip to content</a>35 <a class="skip" href="#main">skip to content</a>
27 @Header(l)36 @Header(l)
28 <div class={ "work", templ.KV("has-rail", l.HasRail && rail != nil) }>37 <div class={ "work", templ.KV("has-rail", l.HasRail && rail != nil) }>
M internal/adapters/web/shell_templ.go +157 -121 generated file: collapsed
M internal/adapters/web/static/css/30-list.css +9 -0
@@ -74,6 +74,15 @@
7474 &:focus-within::after { display: none; }
7575 }
7676
77+ /* file finder: the results replace the tree while the field has a query */
78+ #finder {
79+ margin-top: var(--s3);
80+ & #finder-status { margin-bottom: var(--s2); font-size: var(--fs-s); }
81+ }
82+ .cmd-list .dir { color: var(--ink-dim); }
83+ .cmd-list strong { color: var(--ink-hi); font-weight: 700; }
84+ .cmd-list mark { background: none; color: var(--teal); font-weight: 700; text-decoration: underline; }
85+
7786 /* section headings inside a page */
7887 .rule-h {
7988 display: flex; align-items: center; gap: var(--s2); margin-top: var(--s5);
@@ -74,6 +74,15 @@
74 &:focus-within::after { display: none; }74 &:focus-within::after { display: none; }
75 }75 }
76 76
77+ /* file finder: the results replace the tree while the field has a query */
78+ #finder {
79+ margin-top: var(--s3);
80+ & #finder-status { margin-bottom: var(--s2); font-size: var(--fs-s); }
81+ }
82+ .cmd-list .dir { color: var(--ink-dim); }
83+ .cmd-list strong { color: var(--ink-hi); font-weight: 700; }
84+ .cmd-list mark { background: none; color: var(--teal); font-weight: 700; text-decoration: underline; }
85+
77 /* section headings inside a page */86 /* section headings inside a page */
78 .rule-h {87 .rule-h {
79 display: flex; align-items: center; gap: var(--s2); margin-top: var(--s5);88 display: flex; align-items: center; gap: var(--s2); margin-top: var(--s5);
M internal/adapters/web/static/js/files.js +510 -0
@@ -79,3 +79,513 @@ window.ermiteTree = (() => {
7979 },
8080 };
8181 })();
82+
83+// ermiteFinder: the fuzzy finder of the Files view and the file hits of the
84+// ":" palette. The core (index, search, positions, visible) is pure and runs
85+// in Node; the DOM glue below it runs only from the handlers declared in the
86+// templates. A path reaches the page through textContent, never as HTML.
87+window.ermiteFinder = (() => {
88+ // ---- core ---------------------------------------------------------------
89+
90+ const NEG = -1e9;
91+ const MAXLEN = 1024; // longer paths are scored on their greedy match
92+ const B_OTHER = 1 << 30;
93+
94+ // BIT is the mask bit of an ASCII code: a-z 0-25, digit 26, "/" 27, "." 28,
95+ // "-" and "_" 29, anything else 30.
96+ const BIT = new Uint32Array(128).fill(B_OTHER);
97+ for (let c = 97; c <= 122; c++) BIT[c] = 1 << (c - 97);
98+ for (let c = 48; c <= 57; c++) BIT[c] = 1 << 26;
99+ BIT[47] = 1 << 27;
100+ BIT[46] = 1 << 28;
101+ BIT[45] = BIT[95] = 1 << 29;
102+ const bitOf = (c) => (c < 128 ? BIT[c] : B_OTHER);
103+
104+ const UPPER = /[A-Z]/;
105+ // fold lowercases A-Z only: the length is kept, so a position in the folded
106+ // string is a position in the original.
107+ const fold = (s) => (UPPER.test(s) ? s.replace(/[A-Z]/g, (c) => String.fromCharCode(c.charCodeAt(0) | 32)) : s);
108+
109+ // index prepares the paths for search.
110+ function index(paths) {
111+ const n = paths.length;
112+ const lower = new Array(n);
113+ const mask = new Uint32Array(n);
114+ const base = new Int32Array(n);
115+ for (let i = 0; i < n; i++) {
116+ const p = paths[i];
117+ const l = fold(p);
118+ lower[i] = l;
119+ let m = 0;
120+ for (let j = 0; j < l.length; j++) m |= bitOf(l.charCodeAt(j));
121+ mask[i] = m;
122+ base[i] = p.lastIndexOf('/') + 1;
123+ }
124+ return { paths, lower, mask, base };
125+ }
126+
127+ // bonusAt rewards a match at a boundary: after "/" or at the start, after a
128+ // separator, at a camelCase hump or a letter after a digit.
129+ function bonusAt(orig, j) {
130+ if (j === 0) return 10;
131+ const p = orig.charCodeAt(j - 1);
132+ if (p === 47) return 10;
133+ if (p === 95 || p === 45 || p === 46 || p === 32) return 8;
134+ const c = orig.charCodeAt(j);
135+ const upper = c >= 65 && c <= 90;
136+ if (p >= 97 && p <= 122 && upper) return 7;
137+ if (p >= 48 && p <= 57 && (upper || (c >= 97 && c <= 122))) return 7;
138+ return 0;
139+ }
140+
141+ // Scratch space of matchTerm.
142+ let sLo = new Int32Array(64);
143+ let sHi = new Int32Array(64);
144+ let sOut = new Int32Array(64);
145+ let sPtr = new Int32Array(0);
146+ let rowA = new Int32Array(MAXLEN + 2);
147+ let rowB = new Int32Array(MAXLEN + 2);
148+
149+ // matchTerm scores the best placement of the characters of t in s (the
150+ // folded path; orig is the path as it is), starting at from or later: NEG
151+ // when there is none. With trace, the positions of the best placement are
152+ // left in sOut[0..t.length). It is the DP of fzf v2: a matched character is
153+ // worth 16 plus its boundary bonus (doubled for the first), one that follows
154+ // the previous match gains max(bonus, 4), and a gap costs 3 to open and 1
155+ // per further skipped character.
156+ function matchTerm(orig, s, t, from, trace) {
157+ const n = s.length;
158+ const m = t.length;
159+ if (sLo.length < m) {
160+ sLo = new Int32Array(m * 2);
161+ sHi = new Int32Array(m * 2);
162+ sOut = new Int32Array(m * 2);
163+ }
164+ let pos = from - 1;
165+ for (let i = 0; i < m; i++) {
166+ pos = s.indexOf(t[i], pos + 1);
167+ if (pos < 0) return NEG;
168+ sLo[i] = pos;
169+ }
170+ pos = n;
171+ for (let i = m - 1; i >= 0; i--) {
172+ pos = s.lastIndexOf(t[i], pos - 1);
173+ sHi[i] = pos;
174+ }
175+
176+ if (n > MAXLEN) {
177+ // The greedy match, scored by the same rules.
178+ let score = 0;
179+ for (let i = 0; i < m; i++) {
180+ const j = sLo[i];
181+ const b = bonusAt(orig, j);
182+ score += 16 + (i === 0 ? 2 * b : b);
183+ if (i > 0) {
184+ const gap = j - sLo[i - 1] - 1;
185+ score += gap === 0 ? Math.max(b, 4) : -(gap + 2);
186+ }
187+ if (trace) sOut[i] = j;
188+ }
189+ return score;
190+ }
191+
192+ const width = n + 1;
193+ if (trace && sPtr.length < m * width) sPtr = new Int32Array(m * width);
194+ let prev = rowA;
195+ let cur = rowB;
196+ for (let i = 0; i < m; i++) {
197+ cur.fill(NEG, 0, width);
198+ const c = t.charCodeAt(i);
199+ const hi = sHi[i];
200+ let rm = NEG; // best of prev[k] + k over the k <= j-2 seen so far
201+ let rmK = -1;
202+ let kk = i > 0 ? sLo[i - 1] : 0;
203+ for (let j = sLo[i]; j <= hi; j++) {
204+ if (s.charCodeAt(j) !== c) continue;
205+ const b = bonusAt(orig, j);
206+ let sc = 16 + (i === 0 ? 2 * b : b);
207+ let fromK = -1;
208+ if (i > 0) {
209+ let best = NEG;
210+ if (j > 0 && prev[j - 1] > NEG) {
211+ best = prev[j - 1] + (b > 4 ? b : 4);
212+ fromK = j - 1;
213+ }
214+ for (; kk <= j - 2; kk++) {
215+ if (prev[kk] > NEG && prev[kk] + kk > rm) {
216+ rm = prev[kk] + kk;
217+ rmK = kk;
218+ }
219+ }
220+ if (rm > NEG && rm - j - 1 > best) {
221+ best = rm - j - 1;
222+ fromK = rmK;
223+ }
224+ if (best <= NEG) continue;
225+ sc += best;
226+ }
227+ cur[j] = sc;
228+ if (trace) sPtr[i * width + j] = fromK;
229+ }
230+ const tmp = prev;
231+ prev = cur;
232+ cur = tmp;
233+ }
234+ // prev is the row of the last character.
235+ let bestScore = NEG;
236+ let bestJ = -1;
237+ for (let j = sLo[m - 1]; j <= sHi[m - 1]; j++) {
238+ if (prev[j] > bestScore) {
239+ bestScore = prev[j];
240+ bestJ = j;
241+ }
242+ }
243+ if (trace && bestJ >= 0) {
244+ for (let i = m - 1, j = bestJ; i >= 0; i--) {
245+ sOut[i] = j;
246+ j = sPtr[i * width + j];
247+ }
248+ }
249+ return bestScore;
250+ }
251+
252+ // termScore is matchTerm plus, for the last term, the path bonuses: 24 when
253+ // its match lies in the basename, 16 when the basename starts with it. With
254+ // trace, the winning positions are in `got`.
255+ let got = [];
256+ function termScore(orig, s, t, base, isLast, trace) {
257+ if (trace) got = [];
258+ let best = matchTerm(orig, s, t, 0, trace);
259+ if (best <= NEG) return NEG;
260+ if (trace) got = Array.from(sOut.subarray(0, t.length));
261+ if (!isLast) return best;
262+ if (base === 0) {
263+ best += 24;
264+ } else {
265+ const r = matchTerm(orig, s, t, base, trace);
266+ if (r > NEG && r + 24 > best) {
267+ best = r + 24;
268+ if (trace) got = Array.from(sOut.subarray(0, t.length));
269+ }
270+ }
271+ if (s.startsWith(t, base)) best += 16;
272+ return best;
273+ }
274+
275+ const termsOf = (query) => fold(query).split(/\s+/).filter(Boolean);
276+
277+ // search returns the k best paths for the query, and `all`, the indices of
278+ // every path that matches. prev, the result of an earlier search of the same
279+ // index, narrows the candidates when the new query extends it.
280+ function search(idx, query, k = 50, prev = null) {
281+ const terms = termsOf(query);
282+ const res = { q: query, terms, hits: [], all: new Int32Array(0), idx };
283+ if (!terms.length) return res;
284+ let qmask = 0;
285+ for (const t of terms) for (let j = 0; j < t.length; j++) qmask |= bitOf(t.charCodeAt(j));
286+
287+ let cand = null;
288+ if (prev && prev.idx === idx && prev.terms.length > 0 && prev.terms.length <= terms.length
289+ && prev.terms.every((t, n) => terms[n].startsWith(t))) cand = prev.all;
290+ const count = cand ? cand.length : idx.paths.length;
291+ const { lower, mask } = idx;
292+ const all = new Int32Array(count);
293+ let na = 0;
294+ for (let c = 0; c < count; c++) {
295+ const i = cand ? cand[c] : c;
296+ if ((mask[i] & qmask) !== qmask) continue;
297+ const s = lower[i];
298+ let ok = true;
299+ for (const t of terms) {
300+ let pos = -1;
301+ for (let n = 0; n < t.length; n++) {
302+ pos = s.indexOf(t[n], pos + 1);
303+ if (pos < 0) { ok = false; break; }
304+ }
305+ if (!ok) break;
306+ }
307+ if (ok) all[na++] = i;
308+ }
309+ res.all = all.subarray(0, na);
310+
311+ // The k best, by sorted insertion: score desc, length asc, path asc.
312+ const { paths, base } = idx;
313+ const topI = new Int32Array(k);
314+ const topS = new Int32Array(k);
315+ let nt = 0;
316+ const last = terms.length - 1;
317+ for (let c = 0; c < na; c++) {
318+ const i = all[c];
319+ const orig = paths[i];
320+ const s = lower[i];
321+ let score = 0;
322+ for (let n = 0; n <= last; n++) {
323+ const sc = termScore(orig, s, terms[n], base[i], n === last, false);
324+ if (sc <= NEG) { score = NEG; break; }
325+ score += sc;
326+ }
327+ if (score <= NEG) continue;
328+ if (nt === k) {
329+ const w = topI[k - 1];
330+ if (score < topS[k - 1]) continue;
331+ if (score === topS[k - 1]) {
332+ const a = paths[w];
333+ if (orig.length > a.length || (orig.length === a.length && orig >= a)) continue;
334+ }
335+ }
336+ let at = nt < k ? nt : k - 1;
337+ while (at > 0) {
338+ const o = topI[at - 1];
339+ const os = topS[at - 1];
340+ const a = paths[o];
341+ if (os > score || (os === score && (a.length < orig.length || (a.length === orig.length && a < orig)))) break;
342+ topI[at] = o;
343+ topS[at] = os;
344+ at--;
345+ }
346+ topI[at] = i;
347+ topS[at] = score;
348+ if (nt < k) nt++;
349+ }
350+ for (let h = 0; h < nt; h++) res.hits.push({ i: topI[h], score: topS[h] });
351+ return res;
352+ }
353+
354+ // positions are the indices of path i that the query matches, ascending.
355+ function positions(idx, i, query) {
356+ const terms = termsOf(query);
357+ const orig = idx.paths[i];
358+ const s = idx.lower[i];
359+ const set = new Set();
360+ for (let n = 0; n < terms.length; n++) {
361+ termScore(orig, s, terms[n], idx.base[i], n === terms.length - 1, true);
362+ for (const p of got) set.add(p);
363+ }
364+ return Array.from(set).sort((a, b) => a - b);
365+ }
366+
367+ // visible replaces the characters that read as nothing (the set of
368+ // invisible in text.go) by their <U+XXXX> marker.
369+ const INVISIBLE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\u115F\u1160\u3164\uFFA0]/gu;
370+ const visible = (s) => s.replace(INVISIBLE, (c) => '<U+' + c.codePointAt(0).toString(16).toUpperCase().padStart(4, '0') + '>');
371+
372+ // ---- DOM glue -----------------------------------------------------------
373+
374+ const $ = (id) => document.getElementById(id);
375+ const loads = new Map(); // URL -> promise of {idx, truncated}
376+ let rendered = null; // the result the finder list shows
377+ let frame = 0;
378+
379+ // load fetches the path list of the page once and indexes it.
380+ function load() {
381+ const url = document.body.dataset.paths;
382+ if (!url) return Promise.resolve(null);
383+ let p = loads.get(url);
384+ if (!p) {
385+ p = fetch(url, { headers: { Accept: 'application/json' } })
386+ .then((res) => {
387+ if (!res.ok) throw new Error('HTTP ' + res.status);
388+ return res.json();
389+ })
390+ .then((j) => ({ idx: index(j.paths || []), truncated: !!j.truncated }));
391+ p.catch(() => loads.delete(url)); // a failure is retried at the next use
392+ loads.set(url, p);
393+ }
394+ return p;
395+ }
396+
397+ function blobHref(path) {
398+ try {
399+ return document.body.dataset.blob + '/' + path.split('/').map(encodeURIComponent).join('/');
400+ } catch (e) {
401+ return '#';
402+ }
403+ }
404+
405+ function runs(parent, text, from, to, marks) {
406+ for (let j = from; j < to;) {
407+ const m = marks.has(j);
408+ let e = j;
409+ while (e < to && marks.has(e) === m) e++;
410+ const part = text.slice(j, e);
411+ if (m) {
412+ const mk = document.createElement('mark');
413+ mk.textContent = part;
414+ parent.append(mk);
415+ } else {
416+ parent.append(document.createTextNode(part));
417+ }
418+ j = e;
419+ }
420+ }
421+
422+ // label is the span of a path: the directory dimmed, the name strong, the
423+ // matched characters marked. A path with an invisible character shows its
424+ // markers and no highlight.
425+ function label(path, pos) {
426+ const lbl = document.createElement('span');
427+ lbl.className = 'lbl';
428+ const shown = visible(path);
429+ if (shown !== path) {
430+ lbl.textContent = shown;
431+ return lbl;
432+ }
433+ const cut = path.lastIndexOf('/') + 1;
434+ const marks = new Set(pos);
435+ if (cut > 0) {
436+ const dir = document.createElement('span');
437+ dir.className = 'dir';
438+ runs(dir, path, 0, cut, marks);
439+ lbl.append(dir);
440+ }
441+ const name = document.createElement('strong');
442+ runs(name, path, cut, path.length, marks);
443+ lbl.append(name);
444+ return lbl;
445+ }
446+
447+ function option(res, hit, n) {
448+ const path = res.idx.paths[hit.i];
449+ const li = document.createElement('li');
450+ li.setAttribute('role', 'option');
451+ li.id = 'finder-o' + n;
452+ const a = document.createElement('a');
453+ a.href = blobHref(path);
454+ a.tabIndex = -1;
455+ a.append(label(path, positions(res.idx, hit.i, res.q)));
456+ li.append(a);
457+ return li;
458+ }
459+
460+ const status = (text) => { $('finder-status').textContent = text; };
461+
462+ function setActive(el, li) {
463+ if (li) el.setAttribute('aria-activedescendant', li.id);
464+ else el.removeAttribute('aria-activedescendant');
465+ }
466+
467+ // showTree: no query, the directory again, with j/k and Enter on its rows.
468+ function showTree(el) {
469+ rendered = null;
470+ $('finder').hidden = true;
471+ $('tree-view').hidden = false;
472+ el.setAttribute('aria-expanded', 'false');
473+ setActive(el, null);
474+ const list = $('finder-list');
475+ list.removeAttribute('data-rows');
476+ list.replaceChildren();
477+ const rows = $('tree-rows');
478+ rows.setAttribute('data-rows', '');
479+ if (!rows.querySelector(':scope > tr[data-cursor]')) {
480+ rows.querySelector(':scope > tr:not([hidden])')?.setAttribute('data-cursor', '');
481+ }
482+ }
483+
484+ // showResults: j/k and Enter act on the results, not on the rows behind.
485+ function showResults(el) {
486+ $('finder').hidden = false;
487+ $('tree-view').hidden = true;
488+ el.setAttribute('aria-expanded', 'true');
489+ const rows = $('tree-rows');
490+ rows.removeAttribute('data-rows');
491+ rows.querySelectorAll(':scope > tr[data-cursor]').forEach((r) => r.removeAttribute('data-cursor'));
492+ $('finder-list').setAttribute('data-rows', '');
493+ }
494+
495+ async function run(el) {
496+ const q = el.value;
497+ if (!q.trim()) return showTree(el);
498+ showResults(el);
499+ if (!rendered) status('loading the file list…');
500+ let loaded;
501+ try {
502+ loaded = await load();
503+ } catch (e) {
504+ if (el.value === q) status('could not load the file list');
505+ return;
506+ }
507+ if (el.value !== q || !loaded) return; // the field changed during the wait
508+ const res = search(loaded.idx, q, 50, rendered);
509+ const items = res.hits.map((h, n) => option(res, h, n));
510+ const list = $('finder-list');
511+ list.replaceChildren(...items);
512+ items[0]?.setAttribute('data-cursor', '');
513+ setActive(el, items[0]);
514+ rendered = res;
515+ let msg = res.all.length === 0 ? 'no file matches' : res.all.length + (res.all.length === 1 ? ' file matches' : ' files match');
516+ if (loaded.truncated) msg += ' · only the first 100,000 files are searched';
517+ status(msg);
518+ }
519+
520+ function palette(el) {
521+ if (!document.body.dataset.paths) return Promise.resolve();
522+ const clear = () => document.querySelectorAll('#cmd-list > li.finder-hit').forEach((li) => li.remove());
523+ clear();
524+ const q = el.value;
525+ if (!q.trim()) return Promise.resolve();
526+ return load().then((loaded) => {
527+ if (el.value !== q || !loaded) return;
528+ clear();
529+ const res = search(loaded.idx, q, 20);
530+ const list = $('cmd-list');
531+ for (const h of res.hits) {
532+ const path = res.idx.paths[h.i];
533+ const li = document.createElement('li');
534+ li.className = 'finder-hit';
535+ const a = document.createElement('a');
536+ a.href = blobHref(path);
537+ a.tabIndex = -1;
538+ const k = document.createElement('span');
539+ k.className = 'k';
540+ k.textContent = 'file';
541+ a.append(label(path, positions(res.idx, h.i, q)), k);
542+ li.append(a);
543+ list.append(li);
544+ }
545+ const shown = Array.from(list.querySelectorAll(':scope > li:not([hidden])'));
546+ if (shown.length && !shown.some((li) => li.hasAttribute('data-cursor'))) {
547+ list.querySelectorAll(':scope > li[data-cursor]').forEach((li) => li.removeAttribute('data-cursor'));
548+ shown[0].setAttribute('data-cursor', '');
549+ }
550+ }, () => {});
551+ }
552+
553+ // input runs the search of the field, once per frame.
554+ function input(el) {
555+ if (frame) cancelAnimationFrame(frame);
556+ if (!el.value.trim()) {
557+ frame = 0;
558+ run(el);
559+ return;
560+ }
561+ frame = requestAnimationFrame(() => { frame = 0; run(el); });
562+ }
563+
564+ return {
565+ index, search, positions, visible, load, palette, input,
566+ // key moves the cursor of the results, opens one, or clears the field.
567+ key(el, e) {
568+ const results = !$('finder').hidden;
569+ if (e.key === 'Escape') {
570+ el.value = '';
571+ input(el);
572+ return;
573+ }
574+ if (!results) return;
575+ const list = $('finder-list');
576+ const cur = list.querySelector(':scope > li[data-cursor]');
577+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
578+ e.preventDefault();
579+ const next = e.key === 'ArrowDown' ? cur?.nextElementSibling : cur?.previousElementSibling;
580+ if (!next) return;
581+ cur.removeAttribute('data-cursor');
582+ next.setAttribute('data-cursor', '');
583+ setActive(el, next);
584+ next.scrollIntoView({ block: 'nearest' });
585+ } else if (e.key === 'Enter') {
586+ e.preventDefault();
587+ if (rendered && rendered.q === el.value) cur?.querySelector('a')?.click();
588+ }
589+ },
590+ };
591+})();
@@ -79,3 +79,513 @@ window.ermiteTree = (() => {
79 },79 },
80 };80 };
81 })();81 })();
82+
83+// ermiteFinder: the fuzzy finder of the Files view and the file hits of the
84+// ":" palette. The core (index, search, positions, visible) is pure and runs
85+// in Node; the DOM glue below it runs only from the handlers declared in the
86+// templates. A path reaches the page through textContent, never as HTML.
87+window.ermiteFinder = (() => {
88+ // ---- core ---------------------------------------------------------------
89+
90+ const NEG = -1e9;
91+ const MAXLEN = 1024; // longer paths are scored on their greedy match
92+ const B_OTHER = 1 << 30;
93+
94+ // BIT is the mask bit of an ASCII code: a-z 0-25, digit 26, "/" 27, "." 28,
95+ // "-" and "_" 29, anything else 30.
96+ const BIT = new Uint32Array(128).fill(B_OTHER);
97+ for (let c = 97; c <= 122; c++) BIT[c] = 1 << (c - 97);
98+ for (let c = 48; c <= 57; c++) BIT[c] = 1 << 26;
99+ BIT[47] = 1 << 27;
100+ BIT[46] = 1 << 28;
101+ BIT[45] = BIT[95] = 1 << 29;
102+ const bitOf = (c) => (c < 128 ? BIT[c] : B_OTHER);
103+
104+ const UPPER = /[A-Z]/;
105+ // fold lowercases A-Z only: the length is kept, so a position in the folded
106+ // string is a position in the original.
107+ const fold = (s) => (UPPER.test(s) ? s.replace(/[A-Z]/g, (c) => String.fromCharCode(c.charCodeAt(0) | 32)) : s);
108+
109+ // index prepares the paths for search.
110+ function index(paths) {
111+ const n = paths.length;
112+ const lower = new Array(n);
113+ const mask = new Uint32Array(n);
114+ const base = new Int32Array(n);
115+ for (let i = 0; i < n; i++) {
116+ const p = paths[i];
117+ const l = fold(p);
118+ lower[i] = l;
119+ let m = 0;
120+ for (let j = 0; j < l.length; j++) m |= bitOf(l.charCodeAt(j));
121+ mask[i] = m;
122+ base[i] = p.lastIndexOf('/') + 1;
123+ }
124+ return { paths, lower, mask, base };
125+ }
126+
127+ // bonusAt rewards a match at a boundary: after "/" or at the start, after a
128+ // separator, at a camelCase hump or a letter after a digit.
129+ function bonusAt(orig, j) {
130+ if (j === 0) return 10;
131+ const p = orig.charCodeAt(j - 1);
132+ if (p === 47) return 10;
133+ if (p === 95 || p === 45 || p === 46 || p === 32) return 8;
134+ const c = orig.charCodeAt(j);
135+ const upper = c >= 65 && c <= 90;
136+ if (p >= 97 && p <= 122 && upper) return 7;
137+ if (p >= 48 && p <= 57 && (upper || (c >= 97 && c <= 122))) return 7;
138+ return 0;
139+ }
140+
141+ // Scratch space of matchTerm.
142+ let sLo = new Int32Array(64);
143+ let sHi = new Int32Array(64);
144+ let sOut = new Int32Array(64);
145+ let sPtr = new Int32Array(0);
146+ let rowA = new Int32Array(MAXLEN + 2);
147+ let rowB = new Int32Array(MAXLEN + 2);
148+
149+ // matchTerm scores the best placement of the characters of t in s (the
150+ // folded path; orig is the path as it is), starting at from or later: NEG
151+ // when there is none. With trace, the positions of the best placement are
152+ // left in sOut[0..t.length). It is the DP of fzf v2: a matched character is
153+ // worth 16 plus its boundary bonus (doubled for the first), one that follows
154+ // the previous match gains max(bonus, 4), and a gap costs 3 to open and 1
155+ // per further skipped character.
156+ function matchTerm(orig, s, t, from, trace) {
157+ const n = s.length;
158+ const m = t.length;
159+ if (sLo.length < m) {
160+ sLo = new Int32Array(m * 2);
161+ sHi = new Int32Array(m * 2);
162+ sOut = new Int32Array(m * 2);
163+ }
164+ let pos = from - 1;
165+ for (let i = 0; i < m; i++) {
166+ pos = s.indexOf(t[i], pos + 1);
167+ if (pos < 0) return NEG;
168+ sLo[i] = pos;
169+ }
170+ pos = n;
171+ for (let i = m - 1; i >= 0; i--) {
172+ pos = s.lastIndexOf(t[i], pos - 1);
173+ sHi[i] = pos;
174+ }
175+
176+ if (n > MAXLEN) {
177+ // The greedy match, scored by the same rules.
178+ let score = 0;
179+ for (let i = 0; i < m; i++) {
180+ const j = sLo[i];
181+ const b = bonusAt(orig, j);
182+ score += 16 + (i === 0 ? 2 * b : b);
183+ if (i > 0) {
184+ const gap = j - sLo[i - 1] - 1;
185+ score += gap === 0 ? Math.max(b, 4) : -(gap + 2);
186+ }
187+ if (trace) sOut[i] = j;
188+ }
189+ return score;
190+ }
191+
192+ const width = n + 1;
193+ if (trace && sPtr.length < m * width) sPtr = new Int32Array(m * width);
194+ let prev = rowA;
195+ let cur = rowB;
196+ for (let i = 0; i < m; i++) {
197+ cur.fill(NEG, 0, width);
198+ const c = t.charCodeAt(i);
199+ const hi = sHi[i];
200+ let rm = NEG; // best of prev[k] + k over the k <= j-2 seen so far
201+ let rmK = -1;
202+ let kk = i > 0 ? sLo[i - 1] : 0;
203+ for (let j = sLo[i]; j <= hi; j++) {
204+ if (s.charCodeAt(j) !== c) continue;
205+ const b = bonusAt(orig, j);
206+ let sc = 16 + (i === 0 ? 2 * b : b);
207+ let fromK = -1;
208+ if (i > 0) {
209+ let best = NEG;
210+ if (j > 0 && prev[j - 1] > NEG) {
211+ best = prev[j - 1] + (b > 4 ? b : 4);
212+ fromK = j - 1;
213+ }
214+ for (; kk <= j - 2; kk++) {
215+ if (prev[kk] > NEG && prev[kk] + kk > rm) {
216+ rm = prev[kk] + kk;
217+ rmK = kk;
218+ }
219+ }
220+ if (rm > NEG && rm - j - 1 > best) {
221+ best = rm - j - 1;
222+ fromK = rmK;
223+ }
224+ if (best <= NEG) continue;
225+ sc += best;
226+ }
227+ cur[j] = sc;
228+ if (trace) sPtr[i * width + j] = fromK;
229+ }
230+ const tmp = prev;
231+ prev = cur;
232+ cur = tmp;
233+ }
234+ // prev is the row of the last character.
235+ let bestScore = NEG;
236+ let bestJ = -1;
237+ for (let j = sLo[m - 1]; j <= sHi[m - 1]; j++) {
238+ if (prev[j] > bestScore) {
239+ bestScore = prev[j];
240+ bestJ = j;
241+ }
242+ }
243+ if (trace && bestJ >= 0) {
244+ for (let i = m - 1, j = bestJ; i >= 0; i--) {
245+ sOut[i] = j;
246+ j = sPtr[i * width + j];
247+ }
248+ }
249+ return bestScore;
250+ }
251+
252+ // termScore is matchTerm plus, for the last term, the path bonuses: 24 when
253+ // its match lies in the basename, 16 when the basename starts with it. With
254+ // trace, the winning positions are in `got`.
255+ let got = [];
256+ function termScore(orig, s, t, base, isLast, trace) {
257+ if (trace) got = [];
258+ let best = matchTerm(orig, s, t, 0, trace);
259+ if (best <= NEG) return NEG;
260+ if (trace) got = Array.from(sOut.subarray(0, t.length));
261+ if (!isLast) return best;
262+ if (base === 0) {
263+ best += 24;
264+ } else {
265+ const r = matchTerm(orig, s, t, base, trace);
266+ if (r > NEG && r + 24 > best) {
267+ best = r + 24;
268+ if (trace) got = Array.from(sOut.subarray(0, t.length));
269+ }
270+ }
271+ if (s.startsWith(t, base)) best += 16;
272+ return best;
273+ }
274+
275+ const termsOf = (query) => fold(query).split(/\s+/).filter(Boolean);
276+
277+ // search returns the k best paths for the query, and `all`, the indices of
278+ // every path that matches. prev, the result of an earlier search of the same
279+ // index, narrows the candidates when the new query extends it.
280+ function search(idx, query, k = 50, prev = null) {
281+ const terms = termsOf(query);
282+ const res = { q: query, terms, hits: [], all: new Int32Array(0), idx };
283+ if (!terms.length) return res;
284+ let qmask = 0;
285+ for (const t of terms) for (let j = 0; j < t.length; j++) qmask |= bitOf(t.charCodeAt(j));
286+
287+ let cand = null;
288+ if (prev && prev.idx === idx && prev.terms.length > 0 && prev.terms.length <= terms.length
289+ && prev.terms.every((t, n) => terms[n].startsWith(t))) cand = prev.all;
290+ const count = cand ? cand.length : idx.paths.length;
291+ const { lower, mask } = idx;
292+ const all = new Int32Array(count);
293+ let na = 0;
294+ for (let c = 0; c < count; c++) {
295+ const i = cand ? cand[c] : c;
296+ if ((mask[i] & qmask) !== qmask) continue;
297+ const s = lower[i];
298+ let ok = true;
299+ for (const t of terms) {
300+ let pos = -1;
301+ for (let n = 0; n < t.length; n++) {
302+ pos = s.indexOf(t[n], pos + 1);
303+ if (pos < 0) { ok = false; break; }
304+ }
305+ if (!ok) break;
306+ }
307+ if (ok) all[na++] = i;
308+ }
309+ res.all = all.subarray(0, na);
310+
311+ // The k best, by sorted insertion: score desc, length asc, path asc.
312+ const { paths, base } = idx;
313+ const topI = new Int32Array(k);
314+ const topS = new Int32Array(k);
315+ let nt = 0;
316+ const last = terms.length - 1;
317+ for (let c = 0; c < na; c++) {
318+ const i = all[c];
319+ const orig = paths[i];
320+ const s = lower[i];
321+ let score = 0;
322+ for (let n = 0; n <= last; n++) {
323+ const sc = termScore(orig, s, terms[n], base[i], n === last, false);
324+ if (sc <= NEG) { score = NEG; break; }
325+ score += sc;
326+ }
327+ if (score <= NEG) continue;
328+ if (nt === k) {
329+ const w = topI[k - 1];
330+ if (score < topS[k - 1]) continue;
331+ if (score === topS[k - 1]) {
332+ const a = paths[w];
333+ if (orig.length > a.length || (orig.length === a.length && orig >= a)) continue;
334+ }
335+ }
336+ let at = nt < k ? nt : k - 1;
337+ while (at > 0) {
338+ const o = topI[at - 1];
339+ const os = topS[at - 1];
340+ const a = paths[o];
341+ if (os > score || (os === score && (a.length < orig.length || (a.length === orig.length && a < orig)))) break;
342+ topI[at] = o;
343+ topS[at] = os;
344+ at--;
345+ }
346+ topI[at] = i;
347+ topS[at] = score;
348+ if (nt < k) nt++;
349+ }
350+ for (let h = 0; h < nt; h++) res.hits.push({ i: topI[h], score: topS[h] });
351+ return res;
352+ }
353+
354+ // positions are the indices of path i that the query matches, ascending.
355+ function positions(idx, i, query) {
356+ const terms = termsOf(query);
357+ const orig = idx.paths[i];
358+ const s = idx.lower[i];
359+ const set = new Set();
360+ for (let n = 0; n < terms.length; n++) {
361+ termScore(orig, s, terms[n], idx.base[i], n === terms.length - 1, true);
362+ for (const p of got) set.add(p);
363+ }
364+ return Array.from(set).sort((a, b) => a - b);
365+ }
366+
367+ // visible replaces the characters that read as nothing (the set of
368+ // invisible in text.go) by their <U+XXXX> marker.
369+ const INVISIBLE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\u115F\u1160\u3164\uFFA0]/gu;
370+ const visible = (s) => s.replace(INVISIBLE, (c) => '<U+' + c.codePointAt(0).toString(16).toUpperCase().padStart(4, '0') + '>');
371+
372+ // ---- DOM glue -----------------------------------------------------------
373+
374+ const $ = (id) => document.getElementById(id);
375+ const loads = new Map(); // URL -> promise of {idx, truncated}
376+ let rendered = null; // the result the finder list shows
377+ let frame = 0;
378+
379+ // load fetches the path list of the page once and indexes it.
380+ function load() {
381+ const url = document.body.dataset.paths;
382+ if (!url) return Promise.resolve(null);
383+ let p = loads.get(url);
384+ if (!p) {
385+ p = fetch(url, { headers: { Accept: 'application/json' } })
386+ .then((res) => {
387+ if (!res.ok) throw new Error('HTTP ' + res.status);
388+ return res.json();
389+ })
390+ .then((j) => ({ idx: index(j.paths || []), truncated: !!j.truncated }));
391+ p.catch(() => loads.delete(url)); // a failure is retried at the next use
392+ loads.set(url, p);
393+ }
394+ return p;
395+ }
396+
397+ function blobHref(path) {
398+ try {
399+ return document.body.dataset.blob + '/' + path.split('/').map(encodeURIComponent).join('/');
400+ } catch (e) {
401+ return '#';
402+ }
403+ }
404+
405+ function runs(parent, text, from, to, marks) {
406+ for (let j = from; j < to;) {
407+ const m = marks.has(j);
408+ let e = j;
409+ while (e < to && marks.has(e) === m) e++;
410+ const part = text.slice(j, e);
411+ if (m) {
412+ const mk = document.createElement('mark');
413+ mk.textContent = part;
414+ parent.append(mk);
415+ } else {
416+ parent.append(document.createTextNode(part));
417+ }
418+ j = e;
419+ }
420+ }
421+
422+ // label is the span of a path: the directory dimmed, the name strong, the
423+ // matched characters marked. A path with an invisible character shows its
424+ // markers and no highlight.
425+ function label(path, pos) {
426+ const lbl = document.createElement('span');
427+ lbl.className = 'lbl';
428+ const shown = visible(path);
429+ if (shown !== path) {
430+ lbl.textContent = shown;
431+ return lbl;
432+ }
433+ const cut = path.lastIndexOf('/') + 1;
434+ const marks = new Set(pos);
435+ if (cut > 0) {
436+ const dir = document.createElement('span');
437+ dir.className = 'dir';
438+ runs(dir, path, 0, cut, marks);
439+ lbl.append(dir);
440+ }
441+ const name = document.createElement('strong');
442+ runs(name, path, cut, path.length, marks);
443+ lbl.append(name);
444+ return lbl;
445+ }
446+
447+ function option(res, hit, n) {
448+ const path = res.idx.paths[hit.i];
449+ const li = document.createElement('li');
450+ li.setAttribute('role', 'option');
451+ li.id = 'finder-o' + n;
452+ const a = document.createElement('a');
453+ a.href = blobHref(path);
454+ a.tabIndex = -1;
455+ a.append(label(path, positions(res.idx, hit.i, res.q)));
456+ li.append(a);
457+ return li;
458+ }
459+
460+ const status = (text) => { $('finder-status').textContent = text; };
461+
462+ function setActive(el, li) {
463+ if (li) el.setAttribute('aria-activedescendant', li.id);
464+ else el.removeAttribute('aria-activedescendant');
465+ }
466+
467+ // showTree: no query, the directory again, with j/k and Enter on its rows.
468+ function showTree(el) {
469+ rendered = null;
470+ $('finder').hidden = true;
471+ $('tree-view').hidden = false;
472+ el.setAttribute('aria-expanded', 'false');
473+ setActive(el, null);
474+ const list = $('finder-list');
475+ list.removeAttribute('data-rows');
476+ list.replaceChildren();
477+ const rows = $('tree-rows');
478+ rows.setAttribute('data-rows', '');
479+ if (!rows.querySelector(':scope > tr[data-cursor]')) {
480+ rows.querySelector(':scope > tr:not([hidden])')?.setAttribute('data-cursor', '');
481+ }
482+ }
483+
484+ // showResults: j/k and Enter act on the results, not on the rows behind.
485+ function showResults(el) {
486+ $('finder').hidden = false;
487+ $('tree-view').hidden = true;
488+ el.setAttribute('aria-expanded', 'true');
489+ const rows = $('tree-rows');
490+ rows.removeAttribute('data-rows');
491+ rows.querySelectorAll(':scope > tr[data-cursor]').forEach((r) => r.removeAttribute('data-cursor'));
492+ $('finder-list').setAttribute('data-rows', '');
493+ }
494+
495+ async function run(el) {
496+ const q = el.value;
497+ if (!q.trim()) return showTree(el);
498+ showResults(el);
499+ if (!rendered) status('loading the file list…');
500+ let loaded;
501+ try {
502+ loaded = await load();
503+ } catch (e) {
504+ if (el.value === q) status('could not load the file list');
505+ return;
506+ }
507+ if (el.value !== q || !loaded) return; // the field changed during the wait
508+ const res = search(loaded.idx, q, 50, rendered);
509+ const items = res.hits.map((h, n) => option(res, h, n));
510+ const list = $('finder-list');
511+ list.replaceChildren(...items);
512+ items[0]?.setAttribute('data-cursor', '');
513+ setActive(el, items[0]);
514+ rendered = res;
515+ let msg = res.all.length === 0 ? 'no file matches' : res.all.length + (res.all.length === 1 ? ' file matches' : ' files match');
516+ if (loaded.truncated) msg += ' · only the first 100,000 files are searched';
517+ status(msg);
518+ }
519+
520+ function palette(el) {
521+ if (!document.body.dataset.paths) return Promise.resolve();
522+ const clear = () => document.querySelectorAll('#cmd-list > li.finder-hit').forEach((li) => li.remove());
523+ clear();
524+ const q = el.value;
525+ if (!q.trim()) return Promise.resolve();
526+ return load().then((loaded) => {
527+ if (el.value !== q || !loaded) return;
528+ clear();
529+ const res = search(loaded.idx, q, 20);
530+ const list = $('cmd-list');
531+ for (const h of res.hits) {
532+ const path = res.idx.paths[h.i];
533+ const li = document.createElement('li');
534+ li.className = 'finder-hit';
535+ const a = document.createElement('a');
536+ a.href = blobHref(path);
537+ a.tabIndex = -1;
538+ const k = document.createElement('span');
539+ k.className = 'k';
540+ k.textContent = 'file';
541+ a.append(label(path, positions(res.idx, h.i, q)), k);
542+ li.append(a);
543+ list.append(li);
544+ }
545+ const shown = Array.from(list.querySelectorAll(':scope > li:not([hidden])'));
546+ if (shown.length && !shown.some((li) => li.hasAttribute('data-cursor'))) {
547+ list.querySelectorAll(':scope > li[data-cursor]').forEach((li) => li.removeAttribute('data-cursor'));
548+ shown[0].setAttribute('data-cursor', '');
549+ }
550+ }, () => {});
551+ }
552+
553+ // input runs the search of the field, once per frame.
554+ function input(el) {
555+ if (frame) cancelAnimationFrame(frame);
556+ if (!el.value.trim()) {
557+ frame = 0;
558+ run(el);
559+ return;
560+ }
561+ frame = requestAnimationFrame(() => { frame = 0; run(el); });
562+ }
563+
564+ return {
565+ index, search, positions, visible, load, palette, input,
566+ // key moves the cursor of the results, opens one, or clears the field.
567+ key(el, e) {
568+ const results = !$('finder').hidden;
569+ if (e.key === 'Escape') {
570+ el.value = '';
571+ input(el);
572+ return;
573+ }
574+ if (!results) return;
575+ const list = $('finder-list');
576+ const cur = list.querySelector(':scope > li[data-cursor]');
577+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
578+ e.preventDefault();
579+ const next = e.key === 'ArrowDown' ? cur?.nextElementSibling : cur?.previousElementSibling;
580+ if (!next) return;
581+ cur.removeAttribute('data-cursor');
582+ next.setAttribute('data-cursor', '');
583+ setActive(el, next);
584+ next.scrollIntoView({ block: 'nearest' });
585+ } else if (e.key === 'Enter') {
586+ e.preventDefault();
587+ if (rendered && rendered.q === el.value) cur?.querySelector('a')?.click();
588+ }
589+ },
590+ };
591+})();
M internal/adapters/web/tree.go +3 -0
@@ -106,6 +106,9 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in
106106
107107 l := s.repoLayout(ctx, d, "tree")
108108 pinRef(&l, t)
109+ // The finder searches the commit this page lists, whichever way the URL
110+ // spelled it (an abbreviated id included).
111+ l.setFinder(d.name(), res.Msg.GetCommitId(), t.name)
109112 l.Crumbs = pathCrumbs(d.name(), "tree", t)
110113 if t.path == "" {
111114 l.Title = "tree · " + d.name() + " · " + s.instance
@@ -106,6 +106,9 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in
106 106
107 l := s.repoLayout(ctx, d, "tree")107 l := s.repoLayout(ctx, d, "tree")
108 pinRef(&l, t)108 pinRef(&l, t)
109+ // The finder searches the commit this page lists, whichever way the URL
110+ // spelled it (an abbreviated id included).
111+ l.setFinder(d.name(), res.Msg.GetCommitId(), t.name)
109 l.Crumbs = pathCrumbs(d.name(), "tree", t)112 l.Crumbs = pathCrumbs(d.name(), "tree", t)
110 if t.path == "" {113 if t.path == "" {
111 l.Title = "tree · " + d.name() + " · " + s.instance114 l.Title = "tree · " + d.name() + " · " + s.instance
M internal/adapters/web/tree.templ +35 -22
@@ -2,38 +2,51 @@
22
33 package web
44
5-// Tree is a directory: the filter, the entries with their last commit, the
5+// Tree is a directory: the file finder, the entries with their last commit, the
66 // notes on what is left out, and the README of the directory below.
77 templ Tree(l Layout, p TreePage) {
88 @Shell(l, nil) {
99 <div class="filter">
10- <label for="tree-filter" class="sr">filter</label>
10+ <label for="finder-q" class="sr">find a file</label>
1111 <input
12- id="tree-filter"
12+ id="finder-q"
1313 type="search"
1414 data-filter
15- placeholder="Filter this directory…"
15+ role="combobox"
16+ aria-autocomplete="list"
17+ aria-controls="finder-list"
18+ aria-expanded="false"
19+ placeholder="Find a file…"
1620 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])')"
21+ spellcheck="false"
22+ hx-on:focus="ermiteFinder.load()"
23+ hx-on:input="ermiteFinder.input(this)"
24+ hx-on:keydown="ermiteFinder.key(this, event)"
1825 />
1926 </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 hx-on:click="ermiteTree.click(event)">
25- @TreeRows(p)
26- </tbody>
27- </table>
28- if len(p.Rows) == 0 {
29- <p class="lede dim">this directory is empty.</p>
30- }
31- for _, n := range p.Notes {
32- <p class="note">{ n }</p>
33- }
34- if p.Readme != nil {
35- @ReadmeCard(*p.Readme)
36- }
27+ <div id="finder" hidden>
28+ <p id="finder-status" class="dim" aria-live="polite"></p>
29+ <ul id="finder-list" class="cmd-list" role="listbox" aria-label="Files"></ul>
30+ </div>
31+ <div id="tree-view">
32+ <table class="list tree" aria-label="Entries">
33+ <thead>
34+ <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>
35+ </thead>
36+ <tbody id="tree-rows" data-rows hx-on:click="ermiteTree.click(event)">
37+ @TreeRows(p)
38+ </tbody>
39+ </table>
40+ if len(p.Rows) == 0 {
41+ <p class="lede dim">this directory is empty.</p>
42+ }
43+ for _, n := range p.Notes {
44+ <p class="note">{ n }</p>
45+ }
46+ if p.Readme != nil {
47+ @ReadmeCard(*p.Readme)
48+ }
49+ </div>
3750 }
3851 }
3952
@@ -2,38 +2,51 @@
2 2
3 package web3 package web
4 4
5-// Tree is a directory: the filter, the entries with their last commit, the5+// Tree is a directory: the file finder, the entries with their last commit, the
6 // notes on what is left out, and the README of the directory below.6 // notes on what is left out, and the README of the directory below.
7 templ Tree(l Layout, p TreePage) {7 templ Tree(l Layout, p TreePage) {
8 @Shell(l, nil) {8 @Shell(l, nil) {
9 <div class="filter">9 <div class="filter">
10- <label for="tree-filter" class="sr">filter</label>10+ <label for="finder-q" class="sr">find a file</label>
11 <input11 <input
12- id="tree-filter"12+ id="finder-q"
13 type="search"13 type="search"
14 data-filter14 data-filter
15- placeholder="Filter this directory…"15+ role="combobox"
16+ aria-autocomplete="list"
17+ aria-controls="finder-list"
18+ aria-expanded="false"
19+ placeholder="Find a file…"
16 autocomplete="off"20 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])')"21+ spellcheck="false"
22+ hx-on:focus="ermiteFinder.load()"
23+ hx-on:input="ermiteFinder.input(this)"
24+ hx-on:keydown="ermiteFinder.key(this, event)"
18 />25 />
19 </div>26 </div>
20- <table class="list tree" aria-label="Entries">27+ <div id="finder" hidden>
21- <thead>28+ <p id="finder-status" class="dim" aria-live="polite"></p>
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>29+ <ul id="finder-list" class="cmd-list" role="listbox" aria-label="Files"></ul>
23- </thead>30+ </div>
24- <tbody id="tree-rows" data-rows hx-on:click="ermiteTree.click(event)">31+ <div id="tree-view">
25- @TreeRows(p)32+ <table class="list tree" aria-label="Entries">
26- </tbody>33+ <thead>
27- </table>34+ <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>
28- if len(p.Rows) == 0 {35+ </thead>
29- <p class="lede dim">this directory is empty.</p>36+ <tbody id="tree-rows" data-rows hx-on:click="ermiteTree.click(event)">
30- }37+ @TreeRows(p)
31- for _, n := range p.Notes {38+ </tbody>
32- <p class="note">{ n }</p>39+ </table>
33- }40+ if len(p.Rows) == 0 {
34- if p.Readme != nil {41+ <p class="lede dim">this directory is empty.</p>
35- @ReadmeCard(*p.Readme)42+ }
36- }43+ for _, n := range p.Notes {
44+ <p class="note">{ n }</p>
45+ }
46+ if p.Readme != nil {
47+ @ReadmeCard(*p.Readme)
48+ }
49+ </div>
37 }50 }
38 }51 }
39 52
M internal/adapters/web/tree_templ.go +20 -20 generated file: collapsed
M internal/adapters/web/view.go +4 -0
@@ -56,6 +56,10 @@ type Layout struct {
5656 Live bool
5757 // LiveURL is the stream the page follows, with its query.
5858 LiveURL string
59+ // PathsURL is the list of the file paths of the commit the page shows
60+ // (/{repo}/paths/{sha}) and BlobBase the URL its file links start with;
61+ // both are "" when the page has no commit to search.
62+ PathsURL, BlobBase string
5963 }
6064
6165 // Link is a plain label + href pair.
@@ -56,6 +56,10 @@ type Layout struct {
56 Live bool56 Live bool
57 // LiveURL is the stream the page follows, with its query.57 // LiveURL is the stream the page follows, with its query.
58 LiveURL string58 LiveURL string
59+ // PathsURL is the list of the file paths of the commit the page shows
60+ // (/{repo}/paths/{sha}) and BlobBase the URL its file links start with;
61+ // both are "" when the page has no commit to search.
62+ PathsURL, BlobBase string
59 }63 }
60 64
61 // Link is a plain label + href pair.65 // Link is a plain label + href pair.
M internal/adapters/web/web.go +6 -2
@@ -85,7 +85,9 @@ type Server struct {
8585 origin string // scheme://host of the public URL, what a POST must come from
8686 assets *assetSet
8787 md *markdown
88- mux *http.ServeMux
88+ // pathCache holds the path lists the file finder fetches.
89+ pathCache *pathCache
90+ mux *http.ServeMux
8991 }
9092
9193 // New builds the web handler.
@@ -109,7 +111,8 @@ func New(cfg Config) (*Server, error) {
109111 }
110112 s := &Server{
111113 cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown(),
112- origin: originOf(u),
114+ pathCache: newPathCache(pathCacheBytes, pathEntryMax),
115+ origin: originOf(u),
113116 }
114117 s.mux = s.routes()
115118 return s, nil
@@ -137,6 +140,7 @@ func (s *Server) routes() *http.ServeMux {
137140 mux.HandleFunc("GET /{repo}/tree/{rest...}", s.page(s.tree))
138141 mux.HandleFunc("GET /{repo}/blob/{rest...}", s.page(s.blob))
139142 mux.HandleFunc("GET /{repo}/raw/{rest...}", s.raw)
143+ mux.HandleFunc("GET /{repo}/paths/{sha}", s.paths)
140144 if s.cfg.MergeRequests != nil {
141145 mux.HandleFunc("GET /{repo}/merge-requests", s.page(s.mrList))
142146 mux.HandleFunc("GET /{repo}/merge-requests/{$}", s.page(s.mrList))
@@ -85,7 +85,9 @@ type Server struct {
85 origin string // scheme://host of the public URL, what a POST must come from85 origin string // scheme://host of the public URL, what a POST must come from
86 assets *assetSet86 assets *assetSet
87 md *markdown87 md *markdown
88- mux *http.ServeMux88+ // pathCache holds the path lists the file finder fetches.
89+ pathCache *pathCache
90+ mux *http.ServeMux
89 }91 }
90 92
91 // New builds the web handler.93 // New builds the web handler.
@@ -109,7 +111,8 @@ func New(cfg Config) (*Server, error) {
109 }111 }
110 s := &Server{112 s := &Server{
111 cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown(),113 cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown(),
112- origin: originOf(u),114+ pathCache: newPathCache(pathCacheBytes, pathEntryMax),
115+ origin: originOf(u),
113 }116 }
114 s.mux = s.routes()117 s.mux = s.routes()
115 return s, nil118 return s, nil
@@ -137,6 +140,7 @@ func (s *Server) routes() *http.ServeMux {
137 mux.HandleFunc("GET /{repo}/tree/{rest...}", s.page(s.tree))140 mux.HandleFunc("GET /{repo}/tree/{rest...}", s.page(s.tree))
138 mux.HandleFunc("GET /{repo}/blob/{rest...}", s.page(s.blob))141 mux.HandleFunc("GET /{repo}/blob/{rest...}", s.page(s.blob))
139 mux.HandleFunc("GET /{repo}/raw/{rest...}", s.raw)142 mux.HandleFunc("GET /{repo}/raw/{rest...}", s.raw)
143+ mux.HandleFunc("GET /{repo}/paths/{sha}", s.paths)
140 if s.cfg.MergeRequests != nil {144 if s.cfg.MergeRequests != nil {
141 mux.HandleFunc("GET /{repo}/merge-requests", s.page(s.mrList))145 mux.HandleFunc("GET /{repo}/merge-requests", s.page(s.mrList))
142 mux.HandleFunc("GET /{repo}/merge-requests/{$}", s.page(s.mrList))146 mux.HandleFunc("GET /{repo}/merge-requests/{$}", s.page(s.mrList))

Keyboard shortcuts

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

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

Branches 2

Tags 42

↑↓ moveenter openesc close