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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
53 files · +1797 -334
M .forgejo/workflows/ci.yml +5 -1
| @@ -36,8 +36,9 @@ jobs: | |||
| 36 | 36 | steps: | |
| 37 | 37 | # openssh provides ssh-keygen, which the tests use to check that the squash | |
| 38 | 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 | 40 | - name: System packages | |
| 40 | - | run: pacman -Syu --noconfirm --needed git gcc openssh | |
| 41 | + | run: pacman -Syu --noconfirm --needed git gcc openssh nodejs | |
| 41 | 42 | - uses: actions/checkout@v6 | |
| 42 | 43 | # Job containers reach Forgejo through the Gordon edge on the host loopback, | |
| 43 | 44 | # in plain HTTP on :8088 (see vps-infra homelab/igor/ci-runner). | |
| @@ -66,6 +67,9 @@ jobs: | |||
| 66 | 67 | - run: go test -race ./... | |
| 67 | 68 | - name: Architecture | |
| 68 | 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 | 74 | proto: | |
| 71 | 75 | 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 squash | 37 | # 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 packages | 40 | - name: System packages | ||
| 40 | - | run: pacman -Syu --noconfirm --needed git gcc openssh | 41 | + | run: pacman -Syu --noconfirm --needed git gcc openssh nodejs |
| 41 | - uses: actions/checkout@v6 | 42 | - 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: Architecture | 68 | - 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 code | 75 | name: Protobuf and generated code | ||
M cmd/ermite/testdata/web/blob-binary.html +5 -5
| @@ -19,7 +19,7 @@ | |||
| 19 | 19 | <script src="/-/static/js/files.HASH.js" defer> | |
| 20 | 20 | </script> | |
| 21 | 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 | 23 | <a class="skip" href="#main">skip to content</a> | |
| 24 | 24 | <header class="hdr"> | |
| 25 | 25 | <div class="hdr-top"> | |
| @@ -175,7 +175,7 @@ | |||
| 175 | 175 | <dt> | |
| 176 | 176 | <kbd>/</kbd> | |
| 177 | 177 | </dt> | |
| 178 | - | <dd>filter the list</dd> | |
| 178 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 179 | 179 | </div> | |
| 180 | 180 | <div> | |
| 181 | 181 | <dt> | |
| @@ -266,8 +266,8 @@ | |||
| 266 | 266 | <div class="panel-head"> | |
| 267 | 267 | <label for="cmd-q" class="sr">go to</label> | |
| 268 | 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)); | |
| 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(); | |
| 271 | 271 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 272 | 272 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 273 | 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 | 368 | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 369 | 369 | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 370 | 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 | 372 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 373 | 373 | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 374 | 374 | 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 @@ | |||
| 19 | 19 | <script src="/-/static/js/files.HASH.js" defer> | |
| 20 | 20 | </script> | |
| 21 | 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 | 23 | <a class="skip" href="#main">skip to content</a> | |
| 24 | 24 | <header class="hdr"> | |
| 25 | 25 | <div class="hdr-top"> | |
| @@ -245,7 +245,7 @@ | |||
| 245 | 245 | <dt> | |
| 246 | 246 | <kbd>/</kbd> | |
| 247 | 247 | </dt> | |
| 248 | - | <dd>filter the list</dd> | |
| 248 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 249 | 249 | </div> | |
| 250 | 250 | <div> | |
| 251 | 251 | <dt> | |
| @@ -336,8 +336,8 @@ | |||
| 336 | 336 | <div class="panel-head"> | |
| 337 | 337 | <label for="cmd-q" class="sr">go to</label> | |
| 338 | 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)); | |
| 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(); | |
| 341 | 341 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 342 | 342 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 343 | 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 | 438 | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 439 | 439 | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 440 | 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 | 442 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 443 | 443 | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 444 | 444 | 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 @@ | |||
| 19 | 19 | <script src="/-/static/js/files.HASH.js" defer> | |
| 20 | 20 | </script> | |
| 21 | 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 | 23 | <a class="skip" href="#main">skip to content</a> | |
| 24 | 24 | <header class="hdr"> | |
| 25 | 25 | <div class="hdr-top"> | |
| @@ -175,7 +175,7 @@ | |||
| 175 | 175 | <dt> | |
| 176 | 176 | <kbd>/</kbd> | |
| 177 | 177 | </dt> | |
| 178 | - | <dd>filter the list</dd> | |
| 178 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 179 | 179 | </div> | |
| 180 | 180 | <div> | |
| 181 | 181 | <dt> | |
| @@ -266,8 +266,8 @@ | |||
| 266 | 266 | <div class="panel-head"> | |
| 267 | 267 | <label for="cmd-q" class="sr">go to</label> | |
| 268 | 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)); | |
| 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(); | |
| 271 | 271 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 272 | 272 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 273 | 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 | 368 | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 369 | 369 | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 370 | 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 | 372 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 373 | 373 | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 374 | 374 | 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 @@ | |||
| 19 | 19 | <script src="/-/static/js/files.HASH.js" defer> | |
| 20 | 20 | </script> | |
| 21 | 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 | 23 | <a class="skip" href="#main">skip to content</a> | |
| 24 | 24 | <header class="hdr"> | |
| 25 | 25 | <div class="hdr-top"> | |
| @@ -177,7 +177,7 @@ | |||
| 177 | 177 | <dt> | |
| 178 | 178 | <kbd>/</kbd> | |
| 179 | 179 | </dt> | |
| 180 | - | <dd>filter the list</dd> | |
| 180 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 181 | 181 | </div> | |
| 182 | 182 | <div> | |
| 183 | 183 | <dt> | |
| @@ -268,8 +268,8 @@ | |||
| 268 | 268 | <div class="panel-head"> | |
| 269 | 269 | <label for="cmd-q" class="sr">go to</label> | |
| 270 | 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)); | |
| 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(); | |
| 273 | 273 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 274 | 274 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 275 | 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 | 370 | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 371 | 371 | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 372 | 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 | 374 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 375 | 375 | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 376 | 376 | 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 @@ | |||
| 19 | 19 | <script src="/-/static/js/files.HASH.js" defer> | |
| 20 | 20 | </script> | |
| 21 | 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 | 23 | <a class="skip" href="#main">skip to content</a> | |
| 24 | 24 | <header class="hdr"> | |
| 25 | 25 | <div class="hdr-top"> | |
| @@ -242,7 +242,7 @@ | |||
| 242 | 242 | <dt> | |
| 243 | 243 | <kbd>/</kbd> | |
| 244 | 244 | </dt> | |
| 245 | - | <dd>filter the list</dd> | |
| 245 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 246 | 246 | </div> | |
| 247 | 247 | <div> | |
| 248 | 248 | <dt> | |
| @@ -333,8 +333,8 @@ | |||
| 333 | 333 | <div class="panel-head"> | |
| 334 | 334 | <label for="cmd-q" class="sr">go to</label> | |
| 335 | 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)); | |
| 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(); | |
| 338 | 338 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 339 | 339 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 340 | 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 | 435 | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 436 | 436 | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 437 | 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 | 439 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 440 | 440 | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 441 | 441 | 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 @@ | |||
| 19 | 19 | <script src="/-/static/js/files.HASH.js" defer> | |
| 20 | 20 | </script> | |
| 21 | 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 | 23 | <a class="skip" href="#main">skip to content</a> | |
| 24 | 24 | <header class="hdr"> | |
| 25 | 25 | <div class="hdr-top"> | |
| @@ -200,7 +200,7 @@ | |||
| 200 | 200 | <dt> | |
| 201 | 201 | <kbd>/</kbd> | |
| 202 | 202 | </dt> | |
| 203 | - | <dd>filter the list</dd> | |
| 203 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 204 | 204 | </div> | |
| 205 | 205 | <div> | |
| 206 | 206 | <dt> | |
| @@ -291,8 +291,8 @@ | |||
| 291 | 291 | <div class="panel-head"> | |
| 292 | 292 | <label for="cmd-q" class="sr">go to</label> | |
| 293 | 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)); | |
| 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(); | |
| 296 | 296 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 297 | 297 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 298 | 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 | 393 | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 394 | 394 | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 395 | 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 | 397 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 398 | 398 | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 399 | 399 | 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 @@ | |||
| 19 | 19 | <script src="/-/static/js/files.HASH.js" defer> | |
| 20 | 20 | </script> | |
| 21 | 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 | 23 | <a class="skip" href="#main">skip to content</a> | |
| 24 | 24 | <header class="hdr"> | |
| 25 | 25 | <div class="hdr-top"> | |
| @@ -195,7 +195,7 @@ | |||
| 195 | 195 | <dt> | |
| 196 | 196 | <kbd>/</kbd> | |
| 197 | 197 | </dt> | |
| 198 | - | <dd>filter the list</dd> | |
| 198 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 199 | 199 | </div> | |
| 200 | 200 | <div> | |
| 201 | 201 | <dt> | |
| @@ -286,8 +286,8 @@ | |||
| 286 | 286 | <div class="panel-head"> | |
| 287 | 287 | <label for="cmd-q" class="sr">go to</label> | |
| 288 | 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)); | |
| 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(); | |
| 291 | 291 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 292 | 292 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 293 | 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 | 388 | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 389 | 389 | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 390 | 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 | 392 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 393 | 393 | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 394 | 394 | 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 @@ | |||
| 19 | 19 | <script src="/-/static/js/files.HASH.js" defer> | |
| 20 | 20 | </script> | |
| 21 | 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 | 23 | <a class="skip" href="#main">skip to content</a> | |
| 24 | 24 | <header class="hdr"> | |
| 25 | 25 | <div class="hdr-top"> | |
| @@ -175,7 +175,7 @@ | |||
| 175 | 175 | <dt> | |
| 176 | 176 | <kbd>/</kbd> | |
| 177 | 177 | </dt> | |
| 178 | - | <dd>filter the list</dd> | |
| 178 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 179 | 179 | </div> | |
| 180 | 180 | <div> | |
| 181 | 181 | <dt> | |
| @@ -266,8 +266,8 @@ | |||
| 266 | 266 | <div class="panel-head"> | |
| 267 | 267 | <label for="cmd-q" class="sr">go to</label> | |
| 268 | 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)); | |
| 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(); | |
| 271 | 271 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 272 | 272 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 273 | 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 | 368 | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 369 | 369 | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 370 | 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 | 372 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 373 | 373 | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 374 | 374 | 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 @@ | |||
| 19 | 19 | <script src="/-/static/js/files.HASH.js" defer> | |
| 20 | 20 | </script> | |
| 21 | 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 | 23 | <a class="skip" href="#main">skip to content</a> | |
| 24 | 24 | <header class="hdr"> | |
| 25 | 25 | <div class="hdr-top"> | |
| @@ -242,7 +242,7 @@ | |||
| 242 | 242 | <dt> | |
| 243 | 243 | <kbd>/</kbd> | |
| 244 | 244 | </dt> | |
| 245 | - | <dd>filter the list</dd> | |
| 245 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 246 | 246 | </div> | |
| 247 | 247 | <div> | |
| 248 | 248 | <dt> | |
| @@ -333,8 +333,8 @@ | |||
| 333 | 333 | <div class="panel-head"> | |
| 334 | 334 | <label for="cmd-q" class="sr">go to</label> | |
| 335 | 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)); | |
| 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(); | |
| 338 | 338 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 339 | 339 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 340 | 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 | 435 | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 436 | 436 | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 437 | 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 | 439 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 440 | 440 | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 441 | 441 | 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 @@ | |||
| 19 | 19 | <script src="/-/static/js/files.HASH.js" defer> | |
| 20 | 20 | </script> | |
| 21 | 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 | 23 | <a class="skip" href="#main">skip to content</a> | |
| 24 | 24 | <header class="hdr"> | |
| 25 | 25 | <div class="hdr-top"> | |
| @@ -212,7 +212,7 @@ | |||
| 212 | 212 | <dt> | |
| 213 | 213 | <kbd>/</kbd> | |
| 214 | 214 | </dt> | |
| 215 | - | <dd>filter the list</dd> | |
| 215 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 216 | 216 | </div> | |
| 217 | 217 | <div> | |
| 218 | 218 | <dt> | |
| @@ -303,8 +303,8 @@ | |||
| 303 | 303 | <div class="panel-head"> | |
| 304 | 304 | <label for="cmd-q" class="sr">go to</label> | |
| 305 | 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)); | |
| 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(); | |
| 308 | 308 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 309 | 309 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 310 | 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 | 405 | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 406 | 406 | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 407 | 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 | 409 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 410 | 410 | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 411 | 411 | 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 @@ | |||
| 19 | 19 | <script src="/-/static/js/files.HASH.js" defer> | |
| 20 | 20 | </script> | |
| 21 | 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 | 23 | <a class="skip" href="#main">skip to content</a> | |
| 24 | 24 | <header class="hdr"> | |
| 25 | 25 | <div class="hdr-top"> | |
| @@ -207,7 +207,7 @@ | |||
| 207 | 207 | <dt> | |
| 208 | 208 | <kbd>/</kbd> | |
| 209 | 209 | </dt> | |
| 210 | - | <dd>filter the list</dd> | |
| 210 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 211 | 211 | </div> | |
| 212 | 212 | <div> | |
| 213 | 213 | <dt> | |
| @@ -298,8 +298,8 @@ | |||
| 298 | 298 | <div class="panel-head"> | |
| 299 | 299 | <label for="cmd-q" class="sr">go to</label> | |
| 300 | 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)); | |
| 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(); | |
| 303 | 303 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 304 | 304 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 305 | 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 | 400 | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 401 | 401 | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 402 | 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 | 404 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 405 | 405 | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 406 | 406 | 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 @@ | |||
| 19 | 19 | <script src="/-/static/js/files.HASH.js" defer> | |
| 20 | 20 | </script> | |
| 21 | 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 | 23 | <a class="skip" href="#main">skip to content</a> | |
| 24 | 24 | <header class="hdr"> | |
| 25 | 25 | <div class="hdr-top"> | |
| @@ -246,7 +246,7 @@ | |||
| 246 | 246 | <dt> | |
| 247 | 247 | <kbd>/</kbd> | |
| 248 | 248 | </dt> | |
| 249 | - | <dd>filter the list</dd> | |
| 249 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 250 | 250 | </div> | |
| 251 | 251 | <div> | |
| 252 | 252 | <dt> | |
| @@ -337,8 +337,8 @@ | |||
| 337 | 337 | <div class="panel-head"> | |
| 338 | 338 | <label for="cmd-q" class="sr">go to</label> | |
| 339 | 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)); | |
| 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(); | |
| 342 | 342 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 343 | 343 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 344 | 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 | 439 | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 440 | 440 | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 441 | 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 | 443 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 444 | 444 | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 445 | 445 | 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 @@ | |||
| 19 | 19 | <script src="/-/static/js/files.HASH.js" defer> | |
| 20 | 20 | </script> | |
| 21 | 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 | 23 | <a class="skip" href="#main">skip to content</a> | |
| 24 | 24 | <header class="hdr"> | |
| 25 | 25 | <div class="hdr-top"> | |
| @@ -367,7 +367,7 @@ And <b>html</b> & "quotes".</pre> | |||
| 367 | 367 | <dt> | |
| 368 | 368 | <kbd>/</kbd> | |
| 369 | 369 | </dt> | |
| 370 | - | <dd>filter the list</dd> | |
| 370 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 371 | 371 | </div> | |
| 372 | 372 | <div> | |
| 373 | 373 | <dt> | |
| @@ -458,8 +458,8 @@ And <b>html</b> & "quotes".</pre> | |||
| 458 | 458 | <div class="panel-head"> | |
| 459 | 459 | <label for="cmd-q" class="sr">go to</label> | |
| 460 | 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)); | |
| 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(); | |
| 463 | 463 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 464 | 464 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 465 | 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 <b>html</b> & "quotes".</pre> | |||
| 560 | 560 | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 561 | 561 | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 562 | 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 | 564 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 565 | 565 | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 566 | 566 | 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 <b>html</b> & "quotes".</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 <b>html</b> & "quotes".</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 <b>html</b> & "quotes".</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 @@ | |||
| 19 | 19 | <script src="/-/static/js/files.HASH.js" defer> | |
| 20 | 20 | </script> | |
| 21 | 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 | 23 | <a class="skip" href="#main">skip to content</a> | |
| 24 | 24 | <header class="hdr"> | |
| 25 | 25 | <div class="hdr-top"> | |
| @@ -367,7 +367,7 @@ And <b>html</b> & "quotes".</pre> | |||
| 367 | 367 | <dt> | |
| 368 | 368 | <kbd>/</kbd> | |
| 369 | 369 | </dt> | |
| 370 | - | <dd>filter the list</dd> | |
| 370 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 371 | 371 | </div> | |
| 372 | 372 | <div> | |
| 373 | 373 | <dt> | |
| @@ -458,8 +458,8 @@ And <b>html</b> & "quotes".</pre> | |||
| 458 | 458 | <div class="panel-head"> | |
| 459 | 459 | <label for="cmd-q" class="sr">go to</label> | |
| 460 | 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)); | |
| 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(); | |
| 463 | 463 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 464 | 464 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 465 | 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 <b>html</b> & "quotes".</pre> | |||
| 560 | 560 | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 561 | 561 | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 562 | 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 | 564 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 565 | 565 | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 566 | 566 | 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 <b>html</b> & "quotes".</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 <b>html</b> & "quotes".</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 <b>html</b> & "quotes".</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 @@ | |||
| 19 | 19 | <script src="/-/static/js/files.HASH.js" defer> | |
| 20 | 20 | </script> | |
| 21 | 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 | 23 | <a class="skip" href="#main">skip to content</a> | |
| 24 | 24 | <header class="hdr"> | |
| 25 | 25 | <div class="hdr-top"> | |
| @@ -272,7 +272,7 @@ | |||
| 272 | 272 | <dt> | |
| 273 | 273 | <kbd>/</kbd> | |
| 274 | 274 | </dt> | |
| 275 | - | <dd>filter the list</dd> | |
| 275 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 276 | 276 | </div> | |
| 277 | 277 | <div> | |
| 278 | 278 | <dt> | |
| @@ -363,8 +363,8 @@ | |||
| 363 | 363 | <div class="panel-head"> | |
| 364 | 364 | <label for="cmd-q" class="sr">go to</label> | |
| 365 | 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)); | |
| 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(); | |
| 368 | 368 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 369 | 369 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 370 | 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 | 465 | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 466 | 466 | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 467 | 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 | 469 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 470 | 470 | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 471 | 471 | 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 @@ | |||
| 19 | 19 | <script src="/-/static/js/files.HASH.js" defer> | |
| 20 | 20 | </script> | |
| 21 | 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 | 23 | <a class="skip" href="#main">skip to content</a> | |
| 24 | 24 | <header class="hdr"> | |
| 25 | 25 | <div class="hdr-top"> | |
| @@ -588,7 +588,7 @@ | |||
| 588 | 588 | <dt> | |
| 589 | 589 | <kbd>/</kbd> | |
| 590 | 590 | </dt> | |
| 591 | - | <dd>filter the list</dd> | |
| 591 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 592 | 592 | </div> | |
| 593 | 593 | <div> | |
| 594 | 594 | <dt> | |
| @@ -679,8 +679,8 @@ | |||
| 679 | 679 | <div class="panel-head"> | |
| 680 | 680 | <label for="cmd-q" class="sr">go to</label> | |
| 681 | 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)); | |
| 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(); | |
| 684 | 684 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 685 | 685 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 686 | 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 | 781 | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 782 | 782 | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 783 | 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 | 785 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 786 | 786 | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 787 | 787 | 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 @@ | |||
| 19 | 19 | <script src="/-/static/js/files.HASH.js" defer> | |
| 20 | 20 | </script> | |
| 21 | 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 | 23 | <a class="skip" href="#main">skip to content</a> | |
| 24 | 24 | <header class="hdr"> | |
| 25 | 25 | <div class="hdr-top"> | |
| @@ -381,7 +381,7 @@ | |||
| 381 | 381 | <dt> | |
| 382 | 382 | <kbd>/</kbd> | |
| 383 | 383 | </dt> | |
| 384 | - | <dd>filter the list</dd> | |
| 384 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 385 | 385 | </div> | |
| 386 | 386 | <div> | |
| 387 | 387 | <dt> | |
| @@ -472,8 +472,8 @@ | |||
| 472 | 472 | <div class="panel-head"> | |
| 473 | 473 | <label for="cmd-q" class="sr">go to</label> | |
| 474 | 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)); | |
| 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(); | |
| 477 | 477 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 478 | 478 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 479 | 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 | 574 | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 575 | 575 | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 576 | 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 | 578 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 579 | 579 | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 580 | 580 | 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 @@ | |||
| 19 | 19 | <script src="/-/static/js/files.HASH.js" defer> | |
| 20 | 20 | </script> | |
| 21 | 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 | 23 | <a class="skip" href="#main">skip to content</a> | |
| 24 | 24 | <header class="hdr"> | |
| 25 | 25 | <div class="hdr-top"> | |
| @@ -204,7 +204,7 @@ | |||
| 204 | 204 | <dt> | |
| 205 | 205 | <kbd>/</kbd> | |
| 206 | 206 | </dt> | |
| 207 | - | <dd>filter the list</dd> | |
| 207 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 208 | 208 | </div> | |
| 209 | 209 | <div> | |
| 210 | 210 | <dt> | |
| @@ -287,8 +287,8 @@ | |||
| 287 | 287 | <div class="panel-head"> | |
| 288 | 288 | <label for="cmd-q" class="sr">go to</label> | |
| 289 | 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)); | |
| 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(); | |
| 292 | 292 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 293 | 293 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 294 | 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 | 383 | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 384 | 384 | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 385 | 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 | 387 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 388 | 388 | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 389 | 389 | 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 @@ | |||
| 19 | 19 | <script src="/-/static/js/files.HASH.js" defer> | |
| 20 | 20 | </script> | |
| 21 | 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 | 23 | <a class="skip" href="#main">skip to content</a> | |
| 24 | 24 | <header class="hdr"> | |
| 25 | 25 | <div class="hdr-top"> | |
| @@ -219,7 +219,7 @@ | |||
| 219 | 219 | <dt> | |
| 220 | 220 | <kbd>/</kbd> | |
| 221 | 221 | </dt> | |
| 222 | - | <dd>filter the list</dd> | |
| 222 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 223 | 223 | </div> | |
| 224 | 224 | <div> | |
| 225 | 225 | <dt> | |
| @@ -302,8 +302,8 @@ | |||
| 302 | 302 | <div class="panel-head"> | |
| 303 | 303 | <label for="cmd-q" class="sr">go to</label> | |
| 304 | 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)); | |
| 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(); | |
| 307 | 307 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 308 | 308 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 309 | 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 | 398 | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 399 | 399 | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 400 | 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 | 402 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 403 | 403 | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 404 | 404 | 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 @@ | |||
| 19 | 19 | <script src="/-/static/js/files.HASH.js" defer> | |
| 20 | 20 | </script> | |
| 21 | 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 | 23 | <a class="skip" href="#main">skip to content</a> | |
| 24 | 24 | <header class="hdr"> | |
| 25 | 25 | <div class="hdr-top"> | |
| @@ -1006,7 +1006,7 @@ | |||
| 1006 | 1006 | <dt> | |
| 1007 | 1007 | <kbd>/</kbd> | |
| 1008 | 1008 | </dt> | |
| 1009 | - | <dd>filter the list</dd> | |
| 1009 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 1010 | 1010 | </div> | |
| 1011 | 1011 | <div> | |
| 1012 | 1012 | <dt> | |
| @@ -1089,8 +1089,8 @@ | |||
| 1089 | 1089 | <div class="panel-head"> | |
| 1090 | 1090 | <label for="cmd-q" class="sr">go to</label> | |
| 1091 | 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)); | |
| 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(); | |
| 1094 | 1094 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 1095 | 1095 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 1096 | 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 | 1185 | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 1186 | 1186 | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 1187 | 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 | 1189 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 1190 | 1190 | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 1191 | 1191 | 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 @@ | |||
| 19 | 19 | <script src="/-/static/js/files.HASH.js" defer> | |
| 20 | 20 | </script> | |
| 21 | 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 | 23 | <a class="skip" href="#main">skip to content</a> | |
| 24 | 24 | <header class="hdr"> | |
| 25 | 25 | <div class="hdr-top"> | |
| @@ -351,7 +351,7 @@ | |||
| 351 | 351 | <dt> | |
| 352 | 352 | <kbd>/</kbd> | |
| 353 | 353 | </dt> | |
| 354 | - | <dd>filter the list</dd> | |
| 354 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 355 | 355 | </div> | |
| 356 | 356 | <div> | |
| 357 | 357 | <dt> | |
| @@ -442,8 +442,8 @@ | |||
| 442 | 442 | <div class="panel-head"> | |
| 443 | 443 | <label for="cmd-q" class="sr">go to</label> | |
| 444 | 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)); | |
| 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(); | |
| 447 | 447 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 448 | 448 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 449 | 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 | 550 | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 551 | 551 | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 552 | 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 | 554 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 555 | 555 | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 556 | 556 | 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 @@ | |||
| 128 | 128 | <dt> | |
| 129 | 129 | <kbd>/</kbd> | |
| 130 | 130 | </dt> | |
| 131 | - | <dd>filter the list</dd> | |
| 131 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 132 | 132 | </div> | |
| 133 | 133 | <div> | |
| 134 | 134 | <dt> | |
| @@ -184,8 +184,8 @@ | |||
| 184 | 184 | <div class="panel-head"> | |
| 185 | 185 | <label for="cmd-q" class="sr">go to</label> | |
| 186 | 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)); | |
| 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(); | |
| 189 | 189 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 190 | 190 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 191 | 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 | 244 | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 245 | 245 | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 246 | 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 | 248 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 249 | 249 | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 250 | 250 | 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 @@ | |||
| 158 | 158 | <dt> | |
| 159 | 159 | <kbd>/</kbd> | |
| 160 | 160 | </dt> | |
| 161 | - | <dd>filter the list</dd> | |
| 161 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 162 | 162 | </div> | |
| 163 | 163 | <div> | |
| 164 | 164 | <dt> | |
| @@ -214,8 +214,8 @@ | |||
| 214 | 214 | <div class="panel-head"> | |
| 215 | 215 | <label for="cmd-q" class="sr">go to</label> | |
| 216 | 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)); | |
| 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(); | |
| 219 | 219 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 220 | 220 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 221 | 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 | 280 | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 281 | 281 | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 282 | 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 | 284 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 285 | 285 | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 286 | 286 | 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 @@ | |||
| 117 | 117 | <dt> | |
| 118 | 118 | <kbd>/</kbd> | |
| 119 | 119 | </dt> | |
| 120 | - | <dd>filter the list</dd> | |
| 120 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 121 | 121 | </div> | |
| 122 | 122 | <div> | |
| 123 | 123 | <dt> | |
| @@ -173,8 +173,8 @@ | |||
| 173 | 173 | <div class="panel-head"> | |
| 174 | 174 | <label for="cmd-q" class="sr">go to</label> | |
| 175 | 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)); | |
| 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(); | |
| 178 | 178 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 179 | 179 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 180 | 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 | 227 | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 228 | 228 | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 229 | 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 | 231 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 232 | 232 | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 233 | 233 | 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 @@ | |||
| 117 | 117 | <dt> | |
| 118 | 118 | <kbd>/</kbd> | |
| 119 | 119 | </dt> | |
| 120 | - | <dd>filter the list</dd> | |
| 120 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 121 | 121 | </div> | |
| 122 | 122 | <div> | |
| 123 | 123 | <dt> | |
| @@ -173,8 +173,8 @@ | |||
| 173 | 173 | <div class="panel-head"> | |
| 174 | 174 | <label for="cmd-q" class="sr">go to</label> | |
| 175 | 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)); | |
| 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(); | |
| 178 | 178 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 179 | 179 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 180 | 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 | 227 | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 228 | 228 | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 229 | 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 | 231 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 232 | 232 | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 233 | 233 | 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 @@ | |||
| 19 | 19 | <script src="/-/static/js/files.HASH.js" defer> | |
| 20 | 20 | </script> | |
| 21 | 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 | 23 | <a class="skip" href="#main">skip to content</a> | |
| 24 | 24 | <header class="hdr"> | |
| 25 | 25 | <div class="hdr-top"> | |
| @@ -242,7 +242,7 @@ | |||
| 242 | 242 | <dt> | |
| 243 | 243 | <kbd>/</kbd> | |
| 244 | 244 | </dt> | |
| 245 | - | <dd>filter the list</dd> | |
| 245 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 246 | 246 | </div> | |
| 247 | 247 | <div> | |
| 248 | 248 | <dt> | |
| @@ -337,8 +337,8 @@ | |||
| 337 | 337 | <div class="panel-head"> | |
| 338 | 338 | <label for="cmd-q" class="sr">go to</label> | |
| 339 | 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)); | |
| 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(); | |
| 342 | 342 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 343 | 343 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 344 | 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 | 451 | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 452 | 452 | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 453 | 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 | 455 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 456 | 456 | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 457 | 457 | 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 @@ | |||
| 19 | 19 | <script src="/-/static/js/files.HASH.js" defer> | |
| 20 | 20 | </script> | |
| 21 | 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 | 23 | <a class="skip" href="#main">skip to content</a> | |
| 24 | 24 | <header class="hdr"> | |
| 25 | 25 | <div class="hdr-top"> | |
| @@ -260,7 +260,7 @@ | |||
| 260 | 260 | <dt> | |
| 261 | 261 | <kbd>/</kbd> | |
| 262 | 262 | </dt> | |
| 263 | - | <dd>filter the list</dd> | |
| 263 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 264 | 264 | </div> | |
| 265 | 265 | <div> | |
| 266 | 266 | <dt> | |
| @@ -355,8 +355,8 @@ | |||
| 355 | 355 | <div class="panel-head"> | |
| 356 | 356 | <label for="cmd-q" class="sr">go to</label> | |
| 357 | 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)); | |
| 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(); | |
| 360 | 360 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 361 | 361 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 362 | 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 | 469 | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 470 | 470 | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 471 | 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 | 473 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 474 | 474 | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 475 | 475 | 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 @@ | |||
| 19 | 19 | <script src="/-/static/js/files.HASH.js" defer> | |
| 20 | 20 | </script> | |
| 21 | 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 | 23 | <a class="skip" href="#main">skip to content</a> | |
| 24 | 24 | <header class="hdr"> | |
| 25 | 25 | <div class="hdr-top"> | |
| @@ -91,9 +91,16 @@ | |||
| 91 | 91 | <div class="work"> | |
| 92 | 92 | <main id="main" tabindex="-1"> | |
| 93 | 93 | <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)"> | |
| 96 | 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 | 104 | <table class="list tree" aria-label="Entries"> | |
| 98 | 105 | <thead> | |
| 99 | 106 | <tr> | |
| @@ -127,6 +134,7 @@ | |||
| 127 | 134 | </tr> | |
| 128 | 135 | </tbody> | |
| 129 | 136 | </table> | |
| 137 | + | </div> | |
| 130 | 138 | </main> | |
| 131 | 139 | </div> | |
| 132 | 140 | <footer class="foot"> | |
| @@ -187,7 +195,7 @@ | |||
| 187 | 195 | <dt> | |
| 188 | 196 | <kbd>/</kbd> | |
| 189 | 197 | </dt> | |
| 190 | - | <dd>filter the list</dd> | |
| 198 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 191 | 199 | </div> | |
| 192 | 200 | <div> | |
| 193 | 201 | <dt> | |
| @@ -278,8 +286,8 @@ | |||
| 278 | 286 | <div class="panel-head"> | |
| 279 | 287 | <label for="cmd-q" class="sr">go to</label> | |
| 280 | 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)); | |
| 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(); | |
| 283 | 291 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 284 | 292 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 285 | 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 | 388 | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 381 | 389 | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 382 | 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 | 392 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 385 | 393 | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 386 | 394 | 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 @@ | |||
| 19 | 19 | <script src="/-/static/js/files.HASH.js" defer> | |
| 20 | 20 | </script> | |
| 21 | 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 | 23 | <a class="skip" href="#main">skip to content</a> | |
| 24 | 24 | <header class="hdr"> | |
| 25 | 25 | <div class="hdr-top"> | |
| @@ -85,9 +85,16 @@ | |||
| 85 | 85 | <div class="work"> | |
| 86 | 86 | <main id="main" tabindex="-1"> | |
| 87 | 87 | <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)"> | |
| 90 | 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 | 98 | <table class="list tree" aria-label="Entries"> | |
| 92 | 99 | <thead> | |
| 93 | 100 | <tr> | |
| @@ -354,6 +361,7 @@ | |||
| 354 | 361 | <p>See <a href="/files/blob/main/docs/guide.md">the guide</a>.</p> | |
| 355 | 362 | </article> | |
| 356 | 363 | </section> | |
| 364 | + | </div> | |
| 357 | 365 | </main> | |
| 358 | 366 | </div> | |
| 359 | 367 | <footer class="foot"> | |
| @@ -414,7 +422,7 @@ | |||
| 414 | 422 | <dt> | |
| 415 | 423 | <kbd>/</kbd> | |
| 416 | 424 | </dt> | |
| 417 | - | <dd>filter the list</dd> | |
| 425 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 418 | 426 | </div> | |
| 419 | 427 | <div> | |
| 420 | 428 | <dt> | |
| @@ -505,8 +513,8 @@ | |||
| 505 | 513 | <div class="panel-head"> | |
| 506 | 514 | <label for="cmd-q" class="sr">go to</label> | |
| 507 | 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)); | |
| 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(); | |
| 510 | 518 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 511 | 519 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 512 | 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 | 615 | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 608 | 616 | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 609 | 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 | 619 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 612 | 620 | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 613 | 621 | 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 @@ | |||
| 19 | 19 | <script src="/-/static/js/files.HASH.js" defer> | |
| 20 | 20 | </script> | |
| 21 | 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 | 23 | <a class="skip" href="#main">skip to content</a> | |
| 24 | 24 | <header class="hdr"> | |
| 25 | 25 | <div class="hdr-top"> | |
| @@ -88,9 +88,16 @@ | |||
| 88 | 88 | <div class="work"> | |
| 89 | 89 | <main id="main" tabindex="-1"> | |
| 90 | 90 | <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)"> | |
| 93 | 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 | 101 | <table class="list tree" aria-label="Entries"> | |
| 95 | 102 | <thead> | |
| 96 | 103 | <tr> | |
| @@ -124,6 +131,7 @@ | |||
| 124 | 131 | </tr> | |
| 125 | 132 | </tbody> | |
| 126 | 133 | </table> | |
| 134 | + | </div> | |
| 127 | 135 | </main> | |
| 128 | 136 | </div> | |
| 129 | 137 | <footer class="foot"> | |
| @@ -184,7 +192,7 @@ | |||
| 184 | 192 | <dt> | |
| 185 | 193 | <kbd>/</kbd> | |
| 186 | 194 | </dt> | |
| 187 | - | <dd>filter the list</dd> | |
| 195 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 188 | 196 | </div> | |
| 189 | 197 | <div> | |
| 190 | 198 | <dt> | |
| @@ -275,8 +283,8 @@ | |||
| 275 | 283 | <div class="panel-head"> | |
| 276 | 284 | <label for="cmd-q" class="sr">go to</label> | |
| 277 | 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)); | |
| 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(); | |
| 280 | 288 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 281 | 289 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 282 | 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 | 385 | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 378 | 386 | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 379 | 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 | 389 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 382 | 390 | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 383 | 391 | 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 @@ | |||
| 19 | 19 | <script src="/-/static/js/files.HASH.js" defer> | |
| 20 | 20 | </script> | |
| 21 | 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 | 23 | <a class="skip" href="#main">skip to content</a> | |
| 24 | 24 | <header class="hdr"> | |
| 25 | 25 | <div class="hdr-top"> | |
| @@ -88,9 +88,16 @@ | |||
| 88 | 88 | <div class="work"> | |
| 89 | 89 | <main id="main" tabindex="-1"> | |
| 90 | 90 | <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)"> | |
| 93 | 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 | 101 | <table class="list tree" aria-label="Entries"> | |
| 95 | 102 | <thead> | |
| 96 | 103 | <tr> | |
| @@ -149,6 +156,7 @@ | |||
| 149 | 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 | 157 | </article> | |
| 151 | 158 | </section> | |
| 159 | + | </div> | |
| 152 | 160 | </main> | |
| 153 | 161 | </div> | |
| 154 | 162 | <footer class="foot"> | |
| @@ -209,7 +217,7 @@ | |||
| 209 | 217 | <dt> | |
| 210 | 218 | <kbd>/</kbd> | |
| 211 | 219 | </dt> | |
| 212 | - | <dd>filter the list</dd> | |
| 220 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 213 | 221 | </div> | |
| 214 | 222 | <div> | |
| 215 | 223 | <dt> | |
| @@ -300,8 +308,8 @@ | |||
| 300 | 308 | <div class="panel-head"> | |
| 301 | 309 | <label for="cmd-q" class="sr">go to</label> | |
| 302 | 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)); | |
| 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(); | |
| 305 | 313 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 306 | 314 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 307 | 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 | 410 | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 403 | 411 | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 404 | 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 | 414 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 407 | 415 | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 408 | 416 | 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 | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "bytes" | |
| 7 | + | "compress/gzip" | |
| 8 | + | "encoding/json" | |
| 7 | 9 | "image" | |
| 8 | 10 | "image/color" | |
| 9 | 11 | "image/png" | |
| 12 | + | "io" | |
| 10 | 13 | "net/http" | |
| 11 | 14 | "regexp" | |
| 12 | 15 | "strconv" | |
| @@ -373,3 +376,165 @@ func TestWebFilesContentSecurity(t *testing.T) { | |||
| 373 | 376 | assert.Contains(t, sheet, ".chroma .kn { color: var(--syn-keyword)") | |
| 374 | 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 | + | } | |
| @@ -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. | |||
| 9 | 9 | | `static/js/hx-live.min.js` | htmx 4 `hx-live` extension | `mockups/assets/htmx/ext/hx-live.min.js` | `adf9b90949d57baf20dc2eaf1d1fd46c81347ac82c090a5e2912ce6b37726734` | | |
| 10 | 10 | | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` | | |
| 11 | 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 | 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 | 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 | 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` | | |
| @@ -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() { | |||
| 35 | 35 | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 36 | 36 | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 37 | 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 | 39 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 40 | 40 | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 41 | 41 | 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
| @@ -34,7 +34,7 @@ func Keys() templ.Component { | |||
| 34 | 34 | templ_7745c5c3_Var1 = templ.NopComponent | |
| 35 | 35 | } | |
| 36 | 36 | ctx = templ.ClearChildren(ctx) | |
| 37 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"keys\" hidden hx-on=\"k-j from:body -> (async (d) => {\n if (q('dialog[open]').count) return;\n let rows = q('[data-rows] > :not([hidden])').arr();\n if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));\n let i = rows.findIndex(r => r.hasAttribute('data-cursor'));\n let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];\n q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });\n let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;\n await timeout('1500ms'); if (b.nav === mark) b.nav = ''\n })(1);\n k-k from:body -> (async (d) => {\n if (q('dialog[open]').count) return;\n let rows = q('[data-rows] > :not([hidden])').arr();\n if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));\n let i = rows.findIndex(r => r.hasAttribute('data-cursor'));\n let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];\n q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });\n let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;\n await timeout('1500ms'); if (b.nav === mark) b.nav = ''\n })(-1);\n k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));\n k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));\n k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();\n k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());\n k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();\n 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());\n k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();\n k-filter from:body -> q('[data-filter]').arr()[0]?.focus();\n k-gh from:body -> document.querySelector('.brand')?.click();\n k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();\n k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();\n k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();\n k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();\n k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();\n k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();\n k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();\n k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();\n k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();\n k-next from:body -> {\n let ns = q('#diffs .cmt').arr(); if (!ns.length) return;\n let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);\n let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })\n };\n k-split from:body -> {\n let el = q('[data-diff]').arr()[0]; if (!el) return;\n el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split');\n q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)\n };\n k-unified from:body -> {\n let el = q('[data-diff]').arr()[0]; if (!el) return;\n el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified');\n q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)\n };\n k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) };\n k-permalink from:body -> {\n let b = document.body.dataset;\n if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }\n history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);\n navigator.clipboard?.writeText(location.href);\n b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''\n }\"></div>") | |
| 37 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"keys\" hidden hx-on=\"k-j from:body -> (async (d) => {\n if (q('dialog[open]').count) return;\n let rows = q('[data-rows] > :not([hidden])').arr();\n if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));\n let i = rows.findIndex(r => r.hasAttribute('data-cursor'));\n let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];\n q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });\n let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;\n await timeout('1500ms'); if (b.nav === mark) b.nav = ''\n })(1);\n k-k from:body -> (async (d) => {\n if (q('dialog[open]').count) return;\n let rows = q('[data-rows] > :not([hidden])').arr();\n if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));\n let i = rows.findIndex(r => r.hasAttribute('data-cursor'));\n let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];\n q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });\n let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;\n await timeout('1500ms'); if (b.nav === mark) b.nav = ''\n })(-1);\n k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));\n k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));\n k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();\n k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());\n k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();\n 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());\n 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();\n k-filter from:body -> q('[data-filter]').arr()[0]?.focus();\n k-gh from:body -> document.querySelector('.brand')?.click();\n k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();\n k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();\n k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();\n k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();\n k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();\n k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();\n k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();\n k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();\n k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();\n k-next from:body -> {\n let ns = q('#diffs .cmt').arr(); if (!ns.length) return;\n let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);\n let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })\n };\n k-split from:body -> {\n let el = q('[data-diff]').arr()[0]; if (!el) return;\n el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split');\n q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)\n };\n k-unified from:body -> {\n let el = q('[data-diff]').arr()[0]; if (!el) return;\n el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified');\n q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)\n };\n k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) };\n k-permalink from:body -> {\n let b = document.body.dataset;\n if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }\n history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);\n navigator.clipboard?.writeText(location.href);\n b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''\n }\"></div>") | |
| 38 | 38 | if templ_7745c5c3_Err != nil { | |
| 39 | 39 | return templ_7745c5c3_Err | |
| 40 | 40 | } | |
| @@ -34,7 +34,7 @@ func Keys() templ.Component { | |||||
| 34 | templ_7745c5c3_Var1 = templ.NopComponent | 34 | templ_7745c5c3_Var1 = templ.NopComponent | ||
| 35 | } | 35 | } | ||
| 36 | ctx = templ.ClearChildren(ctx) | 36 | ctx = templ.ClearChildren(ctx) | ||
| 37 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"keys\" hidden hx-on=\"k-j from:body -> (async (d) => {\n if (q('dialog[open]').count) return;\n let rows = q('[data-rows] > :not([hidden])').arr();\n if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));\n let i = rows.findIndex(r => r.hasAttribute('data-cursor'));\n let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];\n q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });\n let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;\n await timeout('1500ms'); if (b.nav === mark) b.nav = ''\n })(1);\n k-k from:body -> (async (d) => {\n if (q('dialog[open]').count) return;\n let rows = q('[data-rows] > :not([hidden])').arr();\n if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));\n let i = rows.findIndex(r => r.hasAttribute('data-cursor'));\n let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];\n q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });\n let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;\n await timeout('1500ms'); if (b.nav === mark) b.nav = ''\n })(-1);\n k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));\n k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));\n k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();\n k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());\n k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();\n 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());\n k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();\n k-filter from:body -> q('[data-filter]').arr()[0]?.focus();\n k-gh from:body -> document.querySelector('.brand')?.click();\n k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();\n k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();\n k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();\n k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();\n k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();\n k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();\n k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();\n k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();\n k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();\n k-next from:body -> {\n let ns = q('#diffs .cmt').arr(); if (!ns.length) return;\n let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);\n let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })\n };\n k-split from:body -> {\n let el = q('[data-diff]').arr()[0]; if (!el) return;\n el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split');\n q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)\n };\n k-unified from:body -> {\n let el = q('[data-diff]').arr()[0]; if (!el) return;\n el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified');\n q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)\n };\n k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) };\n k-permalink from:body -> {\n let b = document.body.dataset;\n if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }\n history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);\n navigator.clipboard?.writeText(location.href);\n b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''\n }\"></div>") | 37 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"keys\" hidden hx-on=\"k-j from:body -> (async (d) => {\n if (q('dialog[open]').count) return;\n let rows = q('[data-rows] > :not([hidden])').arr();\n if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));\n let i = rows.findIndex(r => r.hasAttribute('data-cursor'));\n let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];\n q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });\n let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;\n await timeout('1500ms'); if (b.nav === mark) b.nav = ''\n })(1);\n k-k from:body -> (async (d) => {\n if (q('dialog[open]').count) return;\n let rows = q('[data-rows] > :not([hidden])').arr();\n if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));\n let i = rows.findIndex(r => r.hasAttribute('data-cursor'));\n let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];\n q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });\n let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;\n await timeout('1500ms'); if (b.nav === mark) b.nav = ''\n })(-1);\n k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));\n k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));\n k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();\n k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());\n k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();\n 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());\n 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();\n k-filter from:body -> q('[data-filter]').arr()[0]?.focus();\n k-gh from:body -> document.querySelector('.brand')?.click();\n k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();\n k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();\n k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();\n k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();\n k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();\n k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();\n k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();\n k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();\n k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();\n k-next from:body -> {\n let ns = q('#diffs .cmt').arr(); if (!ns.length) return;\n let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);\n let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })\n };\n k-split from:body -> {\n let el = q('[data-diff]').arr()[0]; if (!el) return;\n el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split');\n q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)\n };\n k-unified from:body -> {\n let el = q('[data-diff]').arr()[0]; if (!el) return;\n el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified');\n q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true)\n };\n k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) };\n k-permalink from:body -> {\n let b = document.body.dataset;\n if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return }\n history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash);\n navigator.clipboard?.writeText(location.href);\n b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = ''\n }\"></div>") |
| 38 | if templ_7745c5c3_Err != nil { | 38 | if templ_7745c5c3_Err != nil { | ||
| 39 | return templ_7745c5c3_Err | 39 | return templ_7745c5c3_Err | ||
| 40 | } | 40 | } | ||
M internal/adapters/web/overlays.templ +3 -3
| @@ -14,7 +14,7 @@ templ OverlayHelp() { | |||
| 14 | 14 | <div><dt><kbd>j</kbd> <kbd>k</kbd></dt><dd>move the row cursor down / up</dd></div> | |
| 15 | 15 | <div><dt><kbd>enter</kbd></dt><dd>open the row under the cursor</dd></div> | |
| 16 | 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 | 18 | <div><dt><kbd>b</kbd></dt><dd>{ "switch" } branch or tag</dd></div> | |
| 19 | 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 & tags, merge requests</dd></div> | |
| 20 | 20 | <div><dt><kbd>g h</kbd></dt><dd>home</dd></div> | |
| @@ -97,8 +97,8 @@ templ OverlayCmd(items []CmdItem) { | |||
| 97 | 97 | spellcheck="false" | |
| 98 | 98 | aria-controls="cmd-list" | |
| 99 | 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)); | |
| 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)" | |
| 102 | 102 | hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 103 | 103 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 104 | 104 | 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 & 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 & 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) { | |||
| 139 | 139 | if len(t.rev) == 40 { | |
| 140 | 140 | l.SHA = t.rev | |
| 141 | 141 | } | |
| 142 | + | l.setFinder(l.Repo, l.SHA, t.rev) | |
| 142 | 143 | for i := range l.Heads { | |
| 143 | 144 | l.Heads[i].Current = false | |
| 144 | 145 | } | |
| @@ -148,6 +149,7 @@ func pinRef(l *Layout, t target) { | |||
| 148 | 149 | return | |
| 149 | 150 | } | |
| 150 | 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 | 153 | for i := range l.Heads { | |
| 152 | 154 | 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 | 174 | func hasName(items []RefItem, name string) bool { | |
| 160 | 175 | for _, it := range items { | |
| 161 | 176 | 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.rev | 140 | 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 = false | 144 | l.Heads[i].Current = false | ||
| 144 | } | 145 | } | ||
| @@ -148,6 +149,7 @@ func pinRef(l *Layout, t target) { | |||||
| 148 | return | 149 | 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.name | 154 | 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 | |||
| 98 | 98 | def = d.head.GetShortName() | |
| 99 | 99 | l.Ref, l.RefPath = def, def | |
| 100 | 100 | l.SHA, l.Short = d.head.GetCommitId(), short(d.head.GetCommitId()) | |
| 101 | + | l.setFinder(d.name(), l.SHA, def) | |
| 101 | 102 | } | |
| 102 | 103 | mk := func(key, label, href, id string) Section { | |
| 103 | 104 | 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, def | 99 | 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) { | |||
| 22 | 22 | <script src={ l.Assets.App } defer></script> | |
| 23 | 23 | <script src={ l.Assets.Files } defer></script> | |
| 24 | 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 | 35 | <a class="skip" href="#main">skip to content</a> | |
| 27 | 36 | @Header(l) | |
| 28 | 37 | <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 @@ | |||
| 74 | 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 | 86 | /* section headings inside a page */ | |
| 78 | 87 | .rule-h { | |
| 79 | 88 | 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 = (() => { | |||
| 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 | + | })(); | |
| @@ -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 | |||
| 106 | 106 | ||
| 107 | 107 | l := s.repoLayout(ctx, d, "tree") | |
| 108 | 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 | 112 | l.Crumbs = pathCrumbs(d.name(), "tree", t) | |
| 110 | 113 | if t.path == "" { | |
| 111 | 114 | 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.instance | 114 | l.Title = "tree · " + d.name() + " · " + s.instance | ||
M internal/adapters/web/tree.templ +35 -22
| @@ -2,38 +2,51 @@ | |||
| 2 | 2 | ||
| 3 | 3 | package web | |
| 4 | 4 | ||
| 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 | |
| 6 | 6 | // notes on what is left out, and the README of the directory below. | |
| 7 | 7 | templ Tree(l Layout, p TreePage) { | |
| 8 | 8 | @Shell(l, nil) { | |
| 9 | 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 | 11 | <input | |
| 12 | - | id="tree-filter" | |
| 12 | + | id="finder-q" | |
| 13 | 13 | type="search" | |
| 14 | 14 | 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 | 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 | 26 | </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> | |
| 37 | 50 | } | |
| 38 | 51 | } | |
| 39 | 52 | ||
| @@ -2,38 +2,51 @@ | |||||
| 2 | 2 | ||||
| 3 | package web | 3 | package web | ||
| 4 | 4 | ||||
| 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 |
| 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 | <input | 11 | <input | ||
| 12 | - | id="tree-filter" | 12 | + | id="finder-q" |
| 13 | type="search" | 13 | type="search" | ||
| 14 | data-filter | 14 | 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 { | |||
| 56 | 56 | Live bool | |
| 57 | 57 | // LiveURL is the stream the page follows, with its query. | |
| 58 | 58 | 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 | 65 | // Link is a plain label + href pair. | |
| @@ -56,6 +56,10 @@ type Layout struct { | |||||
| 56 | Live bool | 56 | 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 string | 58 | 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 { | |||
| 85 | 85 | origin string // scheme://host of the public URL, what a POST must come from | |
| 86 | 86 | assets *assetSet | |
| 87 | 87 | md *markdown | |
| 88 | - | mux *http.ServeMux | |
| 88 | + | // pathCache holds the path lists the file finder fetches. | |
| 89 | + | pathCache *pathCache | |
| 90 | + | mux *http.ServeMux | |
| 89 | 91 | } | |
| 90 | 92 | ||
| 91 | 93 | // New builds the web handler. | |
| @@ -109,7 +111,8 @@ func New(cfg Config) (*Server, error) { | |||
| 109 | 111 | } | |
| 110 | 112 | s := &Server{ | |
| 111 | 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 | 117 | s.mux = s.routes() | |
| 115 | 118 | return s, nil | |
| @@ -137,6 +140,7 @@ func (s *Server) routes() *http.ServeMux { | |||
| 137 | 140 | mux.HandleFunc("GET /{repo}/tree/{rest...}", s.page(s.tree)) | |
| 138 | 141 | mux.HandleFunc("GET /{repo}/blob/{rest...}", s.page(s.blob)) | |
| 139 | 142 | mux.HandleFunc("GET /{repo}/raw/{rest...}", s.raw) | |
| 143 | + | mux.HandleFunc("GET /{repo}/paths/{sha}", s.paths) | |
| 140 | 144 | if s.cfg.MergeRequests != nil { | |
| 141 | 145 | mux.HandleFunc("GET /{repo}/merge-requests", s.page(s.mrList)) | |
| 142 | 146 | 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 from | 85 | origin string // scheme://host of the public URL, what a POST must come from | ||
| 86 | assets *assetSet | 86 | assets *assetSet | ||
| 87 | md *markdown | 87 | md *markdown | ||
| 88 | - | mux *http.ServeMux | 88 | + | // 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, nil | 118 | 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)) | ||