Merge pull request 'feat(web): in-place tree and repo-wide file finder' (#11) from feat/file-finder into main
Reviewed-on: https://git.bnema.dev/bnema/ermite/pulls/11
82 files · +4248 -586
M .forgejo/workflows/ci.yml +5 -1
| @@ -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 README.md +1 -1
| @@ -2,7 +2,7 @@ | |||||
| 2 | 2 | ||||
| 3 | A single-user Git forge in one Go binary. ermite hosts bare repositories, serves clone and push over HTTPS, reviews branches as merge requests, and shows everything in a web interface with live updates. | 3 | A single-user Git forge in one Go binary. ermite hosts bare repositories, serves clone and push over HTTPS, reviews branches as merge requests, and shows everything in a web interface with live updates. | ||
| 4 | 4 | ||||
| 5 | - | Status: early development. `ermite serve` serves Git over HTTP (clone, fetch, push), the API, `GET /-/health`, and the web: repositories, tree, files, commits, refs and merge requests. Visitors read public repositories; the owner signs in with a one-time link from the CLI to see private ones and to review and merge. | 5 | + | Status: early development. `ermite serve` serves Git over HTTP (clone, fetch, push), the API, `GET /-/health`, and the web: repositories, an expandable file tree with a repository-wide fuzzy file finder, files, commits, refs and merge requests. Visitors read public repositories; the owner signs in with a one-time link from the CLI to see private ones and to review and merge. |
| 6 | 6 | ||||
| 7 | ## Run | 7 | ## Run | ||
| 8 | 8 | ||||
M cmd/ermite/api_test.go +39 -1
| @@ -68,6 +68,40 @@ func newAccessRig(t *testing.T) *accessRig { | |||||
| 68 | // every principal in a stable order. | 68 | // every principal in a stable order. | ||
| 69 | var allPrincipals = []string{pAnon, pRoot, pReader, pLimited, pNoScope, pBad} | 69 | var allPrincipals = []string{pAnon, pRoot, pReader, pLimited, pNoScope, pBad} | ||
| 70 | 70 | ||||
| 71 | + | // TestListPathsRPC: the owner lists public and private repositories, an | |||
| 72 | + | // anonymous caller sees the private one as missing, and so is a bad revision. | |||
| 73 | + | func TestListPathsRPC(t *testing.T) { | |||
| 74 | + | r := newAccessRig(t) | |||
| 75 | + | r.seed("pub", domain.VisibilityPublic, "README.md", "docs/a.txt") | |||
| 76 | + | r.seed("priv", domain.VisibilityPrivate, "README.md") | |||
| 77 | + | ctx := r.ctx | |||
| 78 | + | root, anon := r.who[pRoot], r.who[pAnon] | |||
| 79 | + | ||||
| 80 | + | list := func(c *clients, repo, rev string) (*pb.ListPathsResponse, error) { | |||
| 81 | + | resp, err := c.git.ListPaths(ctx, connect.NewRequest(&pb.ListPathsRequest{Repo: repo, Revision: rev})) | |||
| 82 | + | if err != nil { | |||
| 83 | + | return nil, err | |||
| 84 | + | } | |||
| 85 | + | return resp.Msg, nil | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | pub, err := list(root, "pub", "") | |||
| 89 | + | require.NoError(t, err) | |||
| 90 | + | assert.Equal(t, []string{"README.md", "docs/a.txt"}, pub.GetPaths()) | |||
| 91 | + | assert.NotEmpty(t, pub.GetCommitId()) | |||
| 92 | + | assert.NotEmpty(t, pub.GetTreeId()) | |||
| 93 | + | assert.False(t, pub.GetTruncated()) | |||
| 94 | + | ||||
| 95 | + | priv, err := list(root, "priv", "") | |||
| 96 | + | require.NoError(t, err) | |||
| 97 | + | assert.Equal(t, []string{"README.md"}, priv.GetPaths()) | |||
| 98 | + | ||||
| 99 | + | _, err = list(anon, "priv", "") | |||
| 100 | + | assert.Equal(t, connect.CodeNotFound, connect.CodeOf(err)) | |||
| 101 | + | _, err = list(root, "pub", "nope") | |||
| 102 | + | assert.Equal(t, connect.CodeNotFound, connect.CodeOf(err)) | |||
| 103 | + | } | |||
| 104 | + | ||||
| 71 | // TestAC2ReadAccess: every read RPC on a public repository, a private one and | 105 | // TestAC2ReadAccess: every read RPC on a public repository, a private one and | ||
| 72 | // one inside the limited token's list. | 106 | // one inside the limited token's list. | ||
| 73 | func TestAC2ReadAccess(t *testing.T) { | 107 | func TestAC2ReadAccess(t *testing.T) { | ||
| @@ -90,6 +124,10 @@ func TestAC2ReadAccess(t *testing.T) { | |||||
| 90 | _, err := c.git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{Repo: repo})) | 124 | _, err := c.git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{Repo: repo})) | ||
| 91 | return err | 125 | return err | ||
| 92 | }, | 126 | }, | ||
| 127 | + | "ListPaths": func(c *clients, repo string) error { | |||
| 128 | + | _, err := c.git.ListPaths(ctx, connect.NewRequest(&pb.ListPathsRequest{Repo: repo})) | |||
| 129 | + | return err | |||
| 130 | + | }, | |||
| 93 | "GetBlob": func(c *clients, repo string) error { | 131 | "GetBlob": func(c *clients, repo string) error { | ||
| 94 | _, err := readBlob(ctx, c, &pb.GetBlobRequest{Repo: repo, Path: "README.md"}) | 132 | _, err := readBlob(ctx, c, &pb.GetBlobRequest{Repo: repo, Path: "README.md"}) | ||
| 95 | return err | 133 | return err | ||
| @@ -158,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) { | |||||
| 158 | for _, who := range allPrincipals { | 196 | for _, who := range allPrincipals { | ||
| 159 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | 197 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | ||
| 160 | require.NoError(t, err, who) | 198 | require.NoError(t, err, who) | ||
| 161 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session"}, resp.Msg.GetFeatures(), who) | 199 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}, resp.Msg.GetFeatures(), who) |
| 162 | assert.NotEmpty(t, resp.Msg.GetVersion()) | 200 | assert.NotEmpty(t, resp.Msg.GetVersion()) | ||
| 163 | } | 201 | } | ||
| 164 | }) | 202 | }) | ||
M cmd/ermite/cli_e2e_test.go +1 -1
| @@ -324,7 +324,7 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||||
| 324 | ver := decode[versionOut](t, res.stdout) | 324 | ver := decode[versionOut](t, res.stdout) | ||
| 325 | assert.Equal(t, c.url, ver.Server.URL) | 325 | assert.Equal(t, c.url, ver.Server.URL) | ||
| 326 | assert.NotEmpty(t, ver.Server.Version) | 326 | assert.NotEmpty(t, ver.Server.Version) | ||
| 327 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session"}, ver.Server.Features) | 327 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}, ver.Server.Features) |
| 328 | assert.Equal(t, []string{}, ver.Server.MissingFeatures) | 328 | assert.Equal(t, []string{}, ver.Server.MissingFeatures) | ||
| 329 | stdout, stderr, code = c.bin(t, secret, "version") | 329 | stdout, stderr, code = c.bin(t, secret, "version") | ||
| 330 | require.Equal(t, 0, code, stderr) | 330 | require.Equal(t, 0, code, stderr) | ||
M cmd/ermite/testdata/web/blob-binary.html +8 -6
| @@ -16,8 +16,10 @@ | |||||
| 16 | </script> | 16 | </script> | ||
| 17 | <script src="/-/static/js/ermite.HASH.js" defer> | 17 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 18 | </script> | 18 | </script> | ||
| 19 | + | <script src="/-/static/js/files.HASH.js" defer> | |||
| 20 | + | </script> | |||
| 19 | </head> | 21 | </head> | ||
| 20 | - | <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"> |
| 21 | <a class="skip" href="#main">skip to content</a> | 23 | <a class="skip" href="#main">skip to content</a> | ||
| 22 | <header class="hdr"> | 24 | <header class="hdr"> | ||
| 23 | <div class="hdr-top"> | 25 | <div class="hdr-top"> | ||
| @@ -173,7 +175,7 @@ | |||||
| 173 | <dt> | 175 | <dt> | ||
| 174 | <kbd>/</kbd> | 176 | <kbd>/</kbd> | ||
| 175 | </dt> | 177 | </dt> | ||
| 176 | - | <dd>filter the list</dd> | 178 | + | <dd>filter the list; on Files, find a file in the repository</dd> |
| 177 | </div> | 179 | </div> | ||
| 178 | <div> | 180 | <div> | ||
| 179 | <dt> | 181 | <dt> | ||
| @@ -260,12 +262,12 @@ | |||||
| 260 | </section> | 262 | </section> | ||
| 261 | </div> | 263 | </div> | ||
| 262 | </dialog> | 264 | </dialog> | ||
| 263 | - | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 265 | + | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> |
| 264 | <div class="panel-head"> | 266 | <div class="panel-head"> | ||
| 265 | <label for="cmd-q" class="sr">go to</label> | 267 | <label for="cmd-q" class="sr">go to</label> | ||
| 266 | <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(); | ||
| 267 | - | 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)); |
| 268 | - | 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(); |
| 269 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | 271 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | ||
| 270 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | 272 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | ||
| 271 | 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' })) } | ||
| @@ -366,7 +368,7 @@ | |||||
| 366 | 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()); | ||
| 367 | 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(); | ||
| 368 | 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()); | ||
| 369 | - | 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(); ermiteFinder.load(); |
| 370 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | 372 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | ||
| 371 | k-gh from:body -> document.querySelector('.brand')?.click(); | 373 | k-gh from:body -> document.querySelector('.brand')?.click(); | ||
| 372 | 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 +8 -6
| @@ -16,8 +16,10 @@ | |||||
| 16 | </script> | 16 | </script> | ||
| 17 | <script src="/-/static/js/ermite.HASH.js" defer> | 17 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 18 | </script> | 18 | </script> | ||
| 19 | + | <script src="/-/static/js/files.HASH.js" defer> | |||
| 20 | + | </script> | |||
| 19 | </head> | 21 | </head> | ||
| 20 | - | <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"> |
| 21 | <a class="skip" href="#main">skip to content</a> | 23 | <a class="skip" href="#main">skip to content</a> | ||
| 22 | <header class="hdr"> | 24 | <header class="hdr"> | ||
| 23 | <div class="hdr-top"> | 25 | <div class="hdr-top"> | ||
| @@ -243,7 +245,7 @@ | |||||
| 243 | <dt> | 245 | <dt> | ||
| 244 | <kbd>/</kbd> | 246 | <kbd>/</kbd> | ||
| 245 | </dt> | 247 | </dt> | ||
| 246 | - | <dd>filter the list</dd> | 248 | + | <dd>filter the list; on Files, find a file in the repository</dd> |
| 247 | </div> | 249 | </div> | ||
| 248 | <div> | 250 | <div> | ||
| 249 | <dt> | 251 | <dt> | ||
| @@ -330,12 +332,12 @@ | |||||
| 330 | </section> | 332 | </section> | ||
| 331 | </div> | 333 | </div> | ||
| 332 | </dialog> | 334 | </dialog> | ||
| 333 | - | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 335 | + | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> |
| 334 | <div class="panel-head"> | 336 | <div class="panel-head"> | ||
| 335 | <label for="cmd-q" class="sr">go to</label> | 337 | <label for="cmd-q" class="sr">go to</label> | ||
| 336 | <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(); | ||
| 337 | - | 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)); |
| 338 | - | 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(); |
| 339 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | 341 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | ||
| 340 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | 342 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | ||
| 341 | 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' })) } | ||
| @@ -436,7 +438,7 @@ | |||||
| 436 | 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()); | ||
| 437 | 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(); | ||
| 438 | 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()); | ||
| 439 | - | 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(); ermiteFinder.load(); |
| 440 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | 442 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | ||
| 441 | k-gh from:body -> document.querySelector('.brand')?.click(); | 443 | k-gh from:body -> document.querySelector('.brand')?.click(); | ||
| 442 | 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 +8 -6
| @@ -16,8 +16,10 @@ | |||||
| 16 | </script> | 16 | </script> | ||
| 17 | <script src="/-/static/js/ermite.HASH.js" defer> | 17 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 18 | </script> | 18 | </script> | ||
| 19 | + | <script src="/-/static/js/files.HASH.js" defer> | |||
| 20 | + | </script> | |||
| 19 | </head> | 21 | </head> | ||
| 20 | - | <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"> |
| 21 | <a class="skip" href="#main">skip to content</a> | 23 | <a class="skip" href="#main">skip to content</a> | ||
| 22 | <header class="hdr"> | 24 | <header class="hdr"> | ||
| 23 | <div class="hdr-top"> | 25 | <div class="hdr-top"> | ||
| @@ -173,7 +175,7 @@ | |||||
| 173 | <dt> | 175 | <dt> | ||
| 174 | <kbd>/</kbd> | 176 | <kbd>/</kbd> | ||
| 175 | </dt> | 177 | </dt> | ||
| 176 | - | <dd>filter the list</dd> | 178 | + | <dd>filter the list; on Files, find a file in the repository</dd> |
| 177 | </div> | 179 | </div> | ||
| 178 | <div> | 180 | <div> | ||
| 179 | <dt> | 181 | <dt> | ||
| @@ -260,12 +262,12 @@ | |||||
| 260 | </section> | 262 | </section> | ||
| 261 | </div> | 263 | </div> | ||
| 262 | </dialog> | 264 | </dialog> | ||
| 263 | - | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 265 | + | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> |
| 264 | <div class="panel-head"> | 266 | <div class="panel-head"> | ||
| 265 | <label for="cmd-q" class="sr">go to</label> | 267 | <label for="cmd-q" class="sr">go to</label> | ||
| 266 | <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(); | ||
| 267 | - | 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)); |
| 268 | - | 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(); |
| 269 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | 271 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | ||
| 270 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | 272 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | ||
| 271 | 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' })) } | ||
| @@ -366,7 +368,7 @@ | |||||
| 366 | 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()); | ||
| 367 | 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(); | ||
| 368 | 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()); | ||
| 369 | - | 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(); ermiteFinder.load(); |
| 370 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | 372 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | ||
| 371 | k-gh from:body -> document.querySelector('.brand')?.click(); | 373 | k-gh from:body -> document.querySelector('.brand')?.click(); | ||
| 372 | 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 +8 -6
| @@ -16,8 +16,10 @@ | |||||
| 16 | </script> | 16 | </script> | ||
| 17 | <script src="/-/static/js/ermite.HASH.js" defer> | 17 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 18 | </script> | 18 | </script> | ||
| 19 | + | <script src="/-/static/js/files.HASH.js" defer> | |||
| 20 | + | </script> | |||
| 19 | </head> | 21 | </head> | ||
| 20 | - | <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"> |
| 21 | <a class="skip" href="#main">skip to content</a> | 23 | <a class="skip" href="#main">skip to content</a> | ||
| 22 | <header class="hdr"> | 24 | <header class="hdr"> | ||
| 23 | <div class="hdr-top"> | 25 | <div class="hdr-top"> | ||
| @@ -175,7 +177,7 @@ | |||||
| 175 | <dt> | 177 | <dt> | ||
| 176 | <kbd>/</kbd> | 178 | <kbd>/</kbd> | ||
| 177 | </dt> | 179 | </dt> | ||
| 178 | - | <dd>filter the list</dd> | 180 | + | <dd>filter the list; on Files, find a file in the repository</dd> |
| 179 | </div> | 181 | </div> | ||
| 180 | <div> | 182 | <div> | ||
| 181 | <dt> | 183 | <dt> | ||
| @@ -262,12 +264,12 @@ | |||||
| 262 | </section> | 264 | </section> | ||
| 263 | </div> | 265 | </div> | ||
| 264 | </dialog> | 266 | </dialog> | ||
| 265 | - | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 267 | + | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> |
| 266 | <div class="panel-head"> | 268 | <div class="panel-head"> | ||
| 267 | <label for="cmd-q" class="sr">go to</label> | 269 | <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(); | 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(); | ||
| 269 | - | 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)); |
| 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(); | 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(); |
| 271 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | 273 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | ||
| 272 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | 274 | 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' })) } | 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' })) } | ||
| @@ -368,7 +370,7 @@ | |||||
| 368 | 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()); | ||
| 369 | 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(); | ||
| 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()); | 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()); | ||
| 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(); | 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(); ermiteFinder.load(); |
| 372 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | 374 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | ||
| 373 | k-gh from:body -> document.querySelector('.brand')?.click(); | 375 | k-gh from:body -> document.querySelector('.brand')?.click(); | ||
| 374 | 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 +8 -6
| @@ -16,8 +16,10 @@ | |||||
| 16 | </script> | 16 | </script> | ||
| 17 | <script src="/-/static/js/ermite.HASH.js" defer> | 17 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 18 | </script> | 18 | </script> | ||
| 19 | + | <script src="/-/static/js/files.HASH.js" defer> | |||
| 20 | + | </script> | |||
| 19 | </head> | 21 | </head> | ||
| 20 | - | <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"> |
| 21 | <a class="skip" href="#main">skip to content</a> | 23 | <a class="skip" href="#main">skip to content</a> | ||
| 22 | <header class="hdr"> | 24 | <header class="hdr"> | ||
| 23 | <div class="hdr-top"> | 25 | <div class="hdr-top"> | ||
| @@ -240,7 +242,7 @@ | |||||
| 240 | <dt> | 242 | <dt> | ||
| 241 | <kbd>/</kbd> | 243 | <kbd>/</kbd> | ||
| 242 | </dt> | 244 | </dt> | ||
| 243 | - | <dd>filter the list</dd> | 245 | + | <dd>filter the list; on Files, find a file in the repository</dd> |
| 244 | </div> | 246 | </div> | ||
| 245 | <div> | 247 | <div> | ||
| 246 | <dt> | 248 | <dt> | ||
| @@ -327,12 +329,12 @@ | |||||
| 327 | </section> | 329 | </section> | ||
| 328 | </div> | 330 | </div> | ||
| 329 | </dialog> | 331 | </dialog> | ||
| 330 | - | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 332 | + | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> |
| 331 | <div class="panel-head"> | 333 | <div class="panel-head"> | ||
| 332 | <label for="cmd-q" class="sr">go to</label> | 334 | <label for="cmd-q" class="sr">go to</label> | ||
| 333 | <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(); | ||
| 334 | - | 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)); |
| 335 | - | 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(); |
| 336 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | 338 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | ||
| 337 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | 339 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | ||
| 338 | 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' })) } | ||
| @@ -433,7 +435,7 @@ | |||||
| 433 | 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()); | ||
| 434 | 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(); | ||
| 435 | 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()); | ||
| 436 | - | 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(); ermiteFinder.load(); |
| 437 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | 439 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | ||
| 438 | k-gh from:body -> document.querySelector('.brand')?.click(); | 440 | k-gh from:body -> document.querySelector('.brand')?.click(); | ||
| 439 | 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 +8 -6
| @@ -16,8 +16,10 @@ | |||||
| 16 | </script> | 16 | </script> | ||
| 17 | <script src="/-/static/js/ermite.HASH.js" defer> | 17 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 18 | </script> | 18 | </script> | ||
| 19 | + | <script src="/-/static/js/files.HASH.js" defer> | |||
| 20 | + | </script> | |||
| 19 | </head> | 21 | </head> | ||
| 20 | - | <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"> |
| 21 | <a class="skip" href="#main">skip to content</a> | 23 | <a class="skip" href="#main">skip to content</a> | ||
| 22 | <header class="hdr"> | 24 | <header class="hdr"> | ||
| 23 | <div class="hdr-top"> | 25 | <div class="hdr-top"> | ||
| @@ -198,7 +200,7 @@ | |||||
| 198 | <dt> | 200 | <dt> | ||
| 199 | <kbd>/</kbd> | 201 | <kbd>/</kbd> | ||
| 200 | </dt> | 202 | </dt> | ||
| 201 | - | <dd>filter the list</dd> | 203 | + | <dd>filter the list; on Files, find a file in the repository</dd> |
| 202 | </div> | 204 | </div> | ||
| 203 | <div> | 205 | <div> | ||
| 204 | <dt> | 206 | <dt> | ||
| @@ -285,12 +287,12 @@ | |||||
| 285 | </section> | 287 | </section> | ||
| 286 | </div> | 288 | </div> | ||
| 287 | </dialog> | 289 | </dialog> | ||
| 288 | - | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 290 | + | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> |
| 289 | <div class="panel-head"> | 291 | <div class="panel-head"> | ||
| 290 | <label for="cmd-q" class="sr">go to</label> | 292 | <label for="cmd-q" class="sr">go to</label> | ||
| 291 | <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(); | ||
| 292 | - | 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)); |
| 293 | - | 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(); |
| 294 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | 296 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | ||
| 295 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | 297 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | ||
| 296 | 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' })) } | ||
| @@ -391,7 +393,7 @@ | |||||
| 391 | 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()); | ||
| 392 | 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(); | ||
| 393 | 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()); | ||
| 394 | - | 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(); ermiteFinder.load(); |
| 395 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | 397 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | ||
| 396 | k-gh from:body -> document.querySelector('.brand')?.click(); | 398 | k-gh from:body -> document.querySelector('.brand')?.click(); | ||
| 397 | 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 +8 -6
| @@ -16,8 +16,10 @@ | |||||
| 16 | </script> | 16 | </script> | ||
| 17 | <script src="/-/static/js/ermite.HASH.js" defer> | 17 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 18 | </script> | 18 | </script> | ||
| 19 | + | <script src="/-/static/js/files.HASH.js" defer> | |||
| 20 | + | </script> | |||
| 19 | </head> | 21 | </head> | ||
| 20 | - | <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"> |
| 21 | <a class="skip" href="#main">skip to content</a> | 23 | <a class="skip" href="#main">skip to content</a> | ||
| 22 | <header class="hdr"> | 24 | <header class="hdr"> | ||
| 23 | <div class="hdr-top"> | 25 | <div class="hdr-top"> | ||
| @@ -193,7 +195,7 @@ | |||||
| 193 | <dt> | 195 | <dt> | ||
| 194 | <kbd>/</kbd> | 196 | <kbd>/</kbd> | ||
| 195 | </dt> | 197 | </dt> | ||
| 196 | - | <dd>filter the list</dd> | 198 | + | <dd>filter the list; on Files, find a file in the repository</dd> |
| 197 | </div> | 199 | </div> | ||
| 198 | <div> | 200 | <div> | ||
| 199 | <dt> | 201 | <dt> | ||
| @@ -280,12 +282,12 @@ | |||||
| 280 | </section> | 282 | </section> | ||
| 281 | </div> | 283 | </div> | ||
| 282 | </dialog> | 284 | </dialog> | ||
| 283 | - | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 285 | + | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> |
| 284 | <div class="panel-head"> | 286 | <div class="panel-head"> | ||
| 285 | <label for="cmd-q" class="sr">go to</label> | 287 | <label for="cmd-q" class="sr">go to</label> | ||
| 286 | <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(); | ||
| 287 | - | 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)); |
| 288 | - | 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(); |
| 289 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | 291 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | ||
| 290 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | 292 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | ||
| 291 | 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' })) } | ||
| @@ -386,7 +388,7 @@ | |||||
| 386 | 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()); | ||
| 387 | 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(); | ||
| 388 | 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()); | ||
| 389 | - | 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(); ermiteFinder.load(); |
| 390 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | 392 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | ||
| 391 | k-gh from:body -> document.querySelector('.brand')?.click(); | 393 | k-gh from:body -> document.querySelector('.brand')?.click(); | ||
| 392 | 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 +8 -6
| @@ -16,8 +16,10 @@ | |||||
| 16 | </script> | 16 | </script> | ||
| 17 | <script src="/-/static/js/ermite.HASH.js" defer> | 17 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 18 | </script> | 18 | </script> | ||
| 19 | + | <script src="/-/static/js/files.HASH.js" defer> | |||
| 20 | + | </script> | |||
| 19 | </head> | 21 | </head> | ||
| 20 | - | <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"> |
| 21 | <a class="skip" href="#main">skip to content</a> | 23 | <a class="skip" href="#main">skip to content</a> | ||
| 22 | <header class="hdr"> | 24 | <header class="hdr"> | ||
| 23 | <div class="hdr-top"> | 25 | <div class="hdr-top"> | ||
| @@ -173,7 +175,7 @@ | |||||
| 173 | <dt> | 175 | <dt> | ||
| 174 | <kbd>/</kbd> | 176 | <kbd>/</kbd> | ||
| 175 | </dt> | 177 | </dt> | ||
| 176 | - | <dd>filter the list</dd> | 178 | + | <dd>filter the list; on Files, find a file in the repository</dd> |
| 177 | </div> | 179 | </div> | ||
| 178 | <div> | 180 | <div> | ||
| 179 | <dt> | 181 | <dt> | ||
| @@ -260,12 +262,12 @@ | |||||
| 260 | </section> | 262 | </section> | ||
| 261 | </div> | 263 | </div> | ||
| 262 | </dialog> | 264 | </dialog> | ||
| 263 | - | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 265 | + | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> |
| 264 | <div class="panel-head"> | 266 | <div class="panel-head"> | ||
| 265 | <label for="cmd-q" class="sr">go to</label> | 267 | <label for="cmd-q" class="sr">go to</label> | ||
| 266 | <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(); | ||
| 267 | - | 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)); |
| 268 | - | 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(); |
| 269 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | 271 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | ||
| 270 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | 272 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | ||
| 271 | 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' })) } | ||
| @@ -366,7 +368,7 @@ | |||||
| 366 | 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()); | ||
| 367 | 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(); | ||
| 368 | 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()); | ||
| 369 | - | 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(); ermiteFinder.load(); |
| 370 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | 372 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | ||
| 371 | k-gh from:body -> document.querySelector('.brand')?.click(); | 373 | k-gh from:body -> document.querySelector('.brand')?.click(); | ||
| 372 | 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 +8 -6
| @@ -16,8 +16,10 @@ | |||||
| 16 | </script> | 16 | </script> | ||
| 17 | <script src="/-/static/js/ermite.HASH.js" defer> | 17 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 18 | </script> | 18 | </script> | ||
| 19 | + | <script src="/-/static/js/files.HASH.js" defer> | |||
| 20 | + | </script> | |||
| 19 | </head> | 21 | </head> | ||
| 20 | - | <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"> |
| 21 | <a class="skip" href="#main">skip to content</a> | 23 | <a class="skip" href="#main">skip to content</a> | ||
| 22 | <header class="hdr"> | 24 | <header class="hdr"> | ||
| 23 | <div class="hdr-top"> | 25 | <div class="hdr-top"> | ||
| @@ -240,7 +242,7 @@ | |||||
| 240 | <dt> | 242 | <dt> | ||
| 241 | <kbd>/</kbd> | 243 | <kbd>/</kbd> | ||
| 242 | </dt> | 244 | </dt> | ||
| 243 | - | <dd>filter the list</dd> | 245 | + | <dd>filter the list; on Files, find a file in the repository</dd> |
| 244 | </div> | 246 | </div> | ||
| 245 | <div> | 247 | <div> | ||
| 246 | <dt> | 248 | <dt> | ||
| @@ -327,12 +329,12 @@ | |||||
| 327 | </section> | 329 | </section> | ||
| 328 | </div> | 330 | </div> | ||
| 329 | </dialog> | 331 | </dialog> | ||
| 330 | - | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 332 | + | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> |
| 331 | <div class="panel-head"> | 333 | <div class="panel-head"> | ||
| 332 | <label for="cmd-q" class="sr">go to</label> | 334 | <label for="cmd-q" class="sr">go to</label> | ||
| 333 | <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(); | ||
| 334 | - | 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)); |
| 335 | - | 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(); |
| 336 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | 338 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | ||
| 337 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | 339 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | ||
| 338 | 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' })) } | ||
| @@ -433,7 +435,7 @@ | |||||
| 433 | 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()); | ||
| 434 | 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(); | ||
| 435 | 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()); | ||
| 436 | - | 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(); ermiteFinder.load(); |
| 437 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | 439 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | ||
| 438 | k-gh from:body -> document.querySelector('.brand')?.click(); | 440 | k-gh from:body -> document.querySelector('.brand')?.click(); | ||
| 439 | 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 +8 -6
| @@ -16,8 +16,10 @@ | |||||
| 16 | </script> | 16 | </script> | ||
| 17 | <script src="/-/static/js/ermite.HASH.js" defer> | 17 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 18 | </script> | 18 | </script> | ||
| 19 | + | <script src="/-/static/js/files.HASH.js" defer> | |||
| 20 | + | </script> | |||
| 19 | </head> | 21 | </head> | ||
| 20 | - | <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"> |
| 21 | <a class="skip" href="#main">skip to content</a> | 23 | <a class="skip" href="#main">skip to content</a> | ||
| 22 | <header class="hdr"> | 24 | <header class="hdr"> | ||
| 23 | <div class="hdr-top"> | 25 | <div class="hdr-top"> | ||
| @@ -210,7 +212,7 @@ | |||||
| 210 | <dt> | 212 | <dt> | ||
| 211 | <kbd>/</kbd> | 213 | <kbd>/</kbd> | ||
| 212 | </dt> | 214 | </dt> | ||
| 213 | - | <dd>filter the list</dd> | 215 | + | <dd>filter the list; on Files, find a file in the repository</dd> |
| 214 | </div> | 216 | </div> | ||
| 215 | <div> | 217 | <div> | ||
| 216 | <dt> | 218 | <dt> | ||
| @@ -297,12 +299,12 @@ | |||||
| 297 | </section> | 299 | </section> | ||
| 298 | </div> | 300 | </div> | ||
| 299 | </dialog> | 301 | </dialog> | ||
| 300 | - | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 302 | + | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> |
| 301 | <div class="panel-head"> | 303 | <div class="panel-head"> | ||
| 302 | <label for="cmd-q" class="sr">go to</label> | 304 | <label for="cmd-q" class="sr">go to</label> | ||
| 303 | <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(); | ||
| 304 | - | 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)); |
| 305 | - | 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(); |
| 306 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | 308 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | ||
| 307 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | 309 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | ||
| 308 | 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' })) } | ||
| @@ -403,7 +405,7 @@ | |||||
| 403 | 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()); | ||
| 404 | 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(); | ||
| 405 | 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()); | ||
| 406 | - | 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(); ermiteFinder.load(); |
| 407 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | 409 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | ||
| 408 | k-gh from:body -> document.querySelector('.brand')?.click(); | 410 | k-gh from:body -> document.querySelector('.brand')?.click(); | ||
| 409 | 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 +8 -6
| @@ -16,8 +16,10 @@ | |||||
| 16 | </script> | 16 | </script> | ||
| 17 | <script src="/-/static/js/ermite.HASH.js" defer> | 17 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 18 | </script> | 18 | </script> | ||
| 19 | + | <script src="/-/static/js/files.HASH.js" defer> | |||
| 20 | + | </script> | |||
| 19 | </head> | 21 | </head> | ||
| 20 | - | <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"> |
| 21 | <a class="skip" href="#main">skip to content</a> | 23 | <a class="skip" href="#main">skip to content</a> | ||
| 22 | <header class="hdr"> | 24 | <header class="hdr"> | ||
| 23 | <div class="hdr-top"> | 25 | <div class="hdr-top"> | ||
| @@ -205,7 +207,7 @@ | |||||
| 205 | <dt> | 207 | <dt> | ||
| 206 | <kbd>/</kbd> | 208 | <kbd>/</kbd> | ||
| 207 | </dt> | 209 | </dt> | ||
| 208 | - | <dd>filter the list</dd> | 210 | + | <dd>filter the list; on Files, find a file in the repository</dd> |
| 209 | </div> | 211 | </div> | ||
| 210 | <div> | 212 | <div> | ||
| 211 | <dt> | 213 | <dt> | ||
| @@ -292,12 +294,12 @@ | |||||
| 292 | </section> | 294 | </section> | ||
| 293 | </div> | 295 | </div> | ||
| 294 | </dialog> | 296 | </dialog> | ||
| 295 | - | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 297 | + | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> |
| 296 | <div class="panel-head"> | 298 | <div class="panel-head"> | ||
| 297 | <label for="cmd-q" class="sr">go to</label> | 299 | <label for="cmd-q" class="sr">go to</label> | ||
| 298 | <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(); | ||
| 299 | - | 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)); |
| 300 | - | 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(); |
| 301 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | 303 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | ||
| 302 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | 304 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | ||
| 303 | 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' })) } | ||
| @@ -398,7 +400,7 @@ | |||||
| 398 | 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()); | ||
| 399 | 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(); | ||
| 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()); | 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()); | ||
| 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(); | 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(); ermiteFinder.load(); |
| 402 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | 404 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | ||
| 403 | k-gh from:body -> document.querySelector('.brand')?.click(); | 405 | k-gh from:body -> document.querySelector('.brand')?.click(); | ||
| 404 | 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 +8 -6
| @@ -16,8 +16,10 @@ | |||||
| 16 | </script> | 16 | </script> | ||
| 17 | <script src="/-/static/js/ermite.HASH.js" defer> | 17 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 18 | </script> | 18 | </script> | ||
| 19 | + | <script src="/-/static/js/files.HASH.js" defer> | |||
| 20 | + | </script> | |||
| 19 | </head> | 21 | </head> | ||
| 20 | - | <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"> |
| 21 | <a class="skip" href="#main">skip to content</a> | 23 | <a class="skip" href="#main">skip to content</a> | ||
| 22 | <header class="hdr"> | 24 | <header class="hdr"> | ||
| 23 | <div class="hdr-top"> | 25 | <div class="hdr-top"> | ||
| @@ -244,7 +246,7 @@ | |||||
| 244 | <dt> | 246 | <dt> | ||
| 245 | <kbd>/</kbd> | 247 | <kbd>/</kbd> | ||
| 246 | </dt> | 248 | </dt> | ||
| 247 | - | <dd>filter the list</dd> | 249 | + | <dd>filter the list; on Files, find a file in the repository</dd> |
| 248 | </div> | 250 | </div> | ||
| 249 | <div> | 251 | <div> | ||
| 250 | <dt> | 252 | <dt> | ||
| @@ -331,12 +333,12 @@ | |||||
| 331 | </section> | 333 | </section> | ||
| 332 | </div> | 334 | </div> | ||
| 333 | </dialog> | 335 | </dialog> | ||
| 334 | - | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 336 | + | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> |
| 335 | <div class="panel-head"> | 337 | <div class="panel-head"> | ||
| 336 | <label for="cmd-q" class="sr">go to</label> | 338 | <label for="cmd-q" class="sr">go to</label> | ||
| 337 | <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(); | ||
| 338 | - | 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)); |
| 339 | - | 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(); |
| 340 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | 342 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | ||
| 341 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | 343 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | ||
| 342 | 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' })) } | ||
| @@ -437,7 +439,7 @@ | |||||
| 437 | 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()); | ||
| 438 | 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(); | ||
| 439 | 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()); | ||
| 440 | - | 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(); ermiteFinder.load(); |
| 441 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | 443 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | ||
| 442 | k-gh from:body -> document.querySelector('.brand')?.click(); | 444 | k-gh from:body -> document.querySelector('.brand')?.click(); | ||
| 443 | 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 +8 -6
| @@ -16,8 +16,10 @@ | |||||
| 16 | </script> | 16 | </script> | ||
| 17 | <script src="/-/static/js/ermite.HASH.js" defer> | 17 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 18 | </script> | 18 | </script> | ||
| 19 | + | <script src="/-/static/js/files.HASH.js" defer> | |||
| 20 | + | </script> | |||
| 19 | </head> | 21 | </head> | ||
| 20 | - | <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"> |
| 21 | <a class="skip" href="#main">skip to content</a> | 23 | <a class="skip" href="#main">skip to content</a> | ||
| 22 | <header class="hdr"> | 24 | <header class="hdr"> | ||
| 23 | <div class="hdr-top"> | 25 | <div class="hdr-top"> | ||
| @@ -365,7 +367,7 @@ And <b>html</b> & "quotes".</pre> | |||||
| 365 | <dt> | 367 | <dt> | ||
| 366 | <kbd>/</kbd> | 368 | <kbd>/</kbd> | ||
| 367 | </dt> | 369 | </dt> | ||
| 368 | - | <dd>filter the list</dd> | 370 | + | <dd>filter the list; on Files, find a file in the repository</dd> |
| 369 | </div> | 371 | </div> | ||
| 370 | <div> | 372 | <div> | ||
| 371 | <dt> | 373 | <dt> | ||
| @@ -452,12 +454,12 @@ And <b>html</b> & "quotes".</pre> | |||||
| 452 | </section> | 454 | </section> | ||
| 453 | </div> | 455 | </div> | ||
| 454 | </dialog> | 456 | </dialog> | ||
| 455 | - | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 457 | + | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> |
| 456 | <div class="panel-head"> | 458 | <div class="panel-head"> | ||
| 457 | <label for="cmd-q" class="sr">go to</label> | 459 | <label for="cmd-q" class="sr">go to</label> | ||
| 458 | <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(); | ||
| 459 | - | 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)); |
| 460 | - | 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(); |
| 461 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | 463 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | ||
| 462 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | 464 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | ||
| 463 | 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' })) } | ||
| @@ -558,7 +560,7 @@ And <b>html</b> & "quotes".</pre> | |||||
| 558 | 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()); | ||
| 559 | 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(); | ||
| 560 | 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()); | ||
| 561 | - | 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(); ermiteFinder.load(); |
| 562 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | 564 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | ||
| 563 | k-gh from:body -> document.querySelector('.brand')?.click(); | 565 | k-gh from:body -> document.querySelector('.brand')?.click(); | ||
| 564 | 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 +8 -6
| @@ -16,8 +16,10 @@ | |||||
| 16 | </script> | 16 | </script> | ||
| 17 | <script src="/-/static/js/ermite.HASH.js" defer> | 17 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 18 | </script> | 18 | </script> | ||
| 19 | + | <script src="/-/static/js/files.HASH.js" defer> | |||
| 20 | + | </script> | |||
| 19 | </head> | 21 | </head> | ||
| 20 | - | <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"> |
| 21 | <a class="skip" href="#main">skip to content</a> | 23 | <a class="skip" href="#main">skip to content</a> | ||
| 22 | <header class="hdr"> | 24 | <header class="hdr"> | ||
| 23 | <div class="hdr-top"> | 25 | <div class="hdr-top"> | ||
| @@ -365,7 +367,7 @@ And <b>html</b> & "quotes".</pre> | |||||
| 365 | <dt> | 367 | <dt> | ||
| 366 | <kbd>/</kbd> | 368 | <kbd>/</kbd> | ||
| 367 | </dt> | 369 | </dt> | ||
| 368 | - | <dd>filter the list</dd> | 370 | + | <dd>filter the list; on Files, find a file in the repository</dd> |
| 369 | </div> | 371 | </div> | ||
| 370 | <div> | 372 | <div> | ||
| 371 | <dt> | 373 | <dt> | ||
| @@ -452,12 +454,12 @@ And <b>html</b> & "quotes".</pre> | |||||
| 452 | </section> | 454 | </section> | ||
| 453 | </div> | 455 | </div> | ||
| 454 | </dialog> | 456 | </dialog> | ||
| 455 | - | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 457 | + | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> |
| 456 | <div class="panel-head"> | 458 | <div class="panel-head"> | ||
| 457 | <label for="cmd-q" class="sr">go to</label> | 459 | <label for="cmd-q" class="sr">go to</label> | ||
| 458 | <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(); | ||
| 459 | - | 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)); |
| 460 | - | 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(); |
| 461 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | 463 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | ||
| 462 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | 464 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | ||
| 463 | 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' })) } | ||
| @@ -558,7 +560,7 @@ And <b>html</b> & "quotes".</pre> | |||||
| 558 | 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()); | ||
| 559 | 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(); | ||
| 560 | 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()); | ||
| 561 | - | 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(); ermiteFinder.load(); |
| 562 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | 564 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | ||
| 563 | k-gh from:body -> document.querySelector('.brand')?.click(); | 565 | k-gh from:body -> document.querySelector('.brand')?.click(); | ||
| 564 | 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 +8 -6
| @@ -16,8 +16,10 @@ | |||||
| 16 | </script> | 16 | </script> | ||
| 17 | <script src="/-/static/js/ermite.HASH.js" defer> | 17 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 18 | </script> | 18 | </script> | ||
| 19 | + | <script src="/-/static/js/files.HASH.js" defer> | |||
| 20 | + | </script> | |||
| 19 | </head> | 21 | </head> | ||
| 20 | - | <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"> |
| 21 | <a class="skip" href="#main">skip to content</a> | 23 | <a class="skip" href="#main">skip to content</a> | ||
| 22 | <header class="hdr"> | 24 | <header class="hdr"> | ||
| 23 | <div class="hdr-top"> | 25 | <div class="hdr-top"> | ||
| @@ -270,7 +272,7 @@ | |||||
| 270 | <dt> | 272 | <dt> | ||
| 271 | <kbd>/</kbd> | 273 | <kbd>/</kbd> | ||
| 272 | </dt> | 274 | </dt> | ||
| 273 | - | <dd>filter the list</dd> | 275 | + | <dd>filter the list; on Files, find a file in the repository</dd> |
| 274 | </div> | 276 | </div> | ||
| 275 | <div> | 277 | <div> | ||
| 276 | <dt> | 278 | <dt> | ||
| @@ -357,12 +359,12 @@ | |||||
| 357 | </section> | 359 | </section> | ||
| 358 | </div> | 360 | </div> | ||
| 359 | </dialog> | 361 | </dialog> | ||
| 360 | - | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 362 | + | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> |
| 361 | <div class="panel-head"> | 363 | <div class="panel-head"> | ||
| 362 | <label for="cmd-q" class="sr">go to</label> | 364 | <label for="cmd-q" class="sr">go to</label> | ||
| 363 | <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(); | ||
| 364 | - | 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)); |
| 365 | - | 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(); |
| 366 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | 368 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | ||
| 367 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | 369 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | ||
| 368 | 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' })) } | ||
| @@ -463,7 +465,7 @@ | |||||
| 463 | 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()); | ||
| 464 | 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(); | ||
| 465 | 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()); | ||
| 466 | - | 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(); ermiteFinder.load(); |
| 467 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | 469 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | ||
| 468 | k-gh from:body -> document.querySelector('.brand')?.click(); | 470 | k-gh from:body -> document.querySelector('.brand')?.click(); | ||
| 469 | 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 +8 -6
| @@ -16,8 +16,10 @@ | |||||
| 16 | </script> | 16 | </script> | ||
| 17 | <script src="/-/static/js/ermite.HASH.js" defer> | 17 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 18 | </script> | 18 | </script> | ||
| 19 | + | <script src="/-/static/js/files.HASH.js" defer> | |||
| 20 | + | </script> | |||
| 19 | </head> | 21 | </head> | ||
| 20 | - | <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"> |
| 21 | <a class="skip" href="#main">skip to content</a> | 23 | <a class="skip" href="#main">skip to content</a> | ||
| 22 | <header class="hdr"> | 24 | <header class="hdr"> | ||
| 23 | <div class="hdr-top"> | 25 | <div class="hdr-top"> | ||
| @@ -586,7 +588,7 @@ | |||||
| 586 | <dt> | 588 | <dt> | ||
| 587 | <kbd>/</kbd> | 589 | <kbd>/</kbd> | ||
| 588 | </dt> | 590 | </dt> | ||
| 589 | - | <dd>filter the list</dd> | 591 | + | <dd>filter the list; on Files, find a file in the repository</dd> |
| 590 | </div> | 592 | </div> | ||
| 591 | <div> | 593 | <div> | ||
| 592 | <dt> | 594 | <dt> | ||
| @@ -673,12 +675,12 @@ | |||||
| 673 | </section> | 675 | </section> | ||
| 674 | </div> | 676 | </div> | ||
| 675 | </dialog> | 677 | </dialog> | ||
| 676 | - | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 678 | + | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> |
| 677 | <div class="panel-head"> | 679 | <div class="panel-head"> | ||
| 678 | <label for="cmd-q" class="sr">go to</label> | 680 | <label for="cmd-q" class="sr">go to</label> | ||
| 679 | <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(); | ||
| 680 | - | 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)); |
| 681 | - | 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(); |
| 682 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | 684 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | ||
| 683 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | 685 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | ||
| 684 | 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' })) } | ||
| @@ -779,7 +781,7 @@ | |||||
| 779 | 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()); | ||
| 780 | 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(); | ||
| 781 | 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()); | ||
| 782 | - | 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(); ermiteFinder.load(); |
| 783 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | 785 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | ||
| 784 | k-gh from:body -> document.querySelector('.brand')?.click(); | 786 | k-gh from:body -> document.querySelector('.brand')?.click(); | ||
| 785 | 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 +8 -6
| @@ -16,8 +16,10 @@ | |||||
| 16 | </script> | 16 | </script> | ||
| 17 | <script src="/-/static/js/ermite.HASH.js" defer> | 17 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 18 | </script> | 18 | </script> | ||
| 19 | + | <script src="/-/static/js/files.HASH.js" defer> | |||
| 20 | + | </script> | |||
| 19 | </head> | 21 | </head> | ||
| 20 | - | <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"> |
| 21 | <a class="skip" href="#main">skip to content</a> | 23 | <a class="skip" href="#main">skip to content</a> | ||
| 22 | <header class="hdr"> | 24 | <header class="hdr"> | ||
| 23 | <div class="hdr-top"> | 25 | <div class="hdr-top"> | ||
| @@ -379,7 +381,7 @@ | |||||
| 379 | <dt> | 381 | <dt> | ||
| 380 | <kbd>/</kbd> | 382 | <kbd>/</kbd> | ||
| 381 | </dt> | 383 | </dt> | ||
| 382 | - | <dd>filter the list</dd> | 384 | + | <dd>filter the list; on Files, find a file in the repository</dd> |
| 383 | </div> | 385 | </div> | ||
| 384 | <div> | 386 | <div> | ||
| 385 | <dt> | 387 | <dt> | ||
| @@ -466,12 +468,12 @@ | |||||
| 466 | </section> | 468 | </section> | ||
| 467 | </div> | 469 | </div> | ||
| 468 | </dialog> | 470 | </dialog> | ||
| 469 | - | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 471 | + | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> |
| 470 | <div class="panel-head"> | 472 | <div class="panel-head"> | ||
| 471 | <label for="cmd-q" class="sr">go to</label> | 473 | <label for="cmd-q" class="sr">go to</label> | ||
| 472 | <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(); | ||
| 473 | - | 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)); |
| 474 | - | 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(); |
| 475 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | 477 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | ||
| 476 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | 478 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | ||
| 477 | 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' })) } | ||
| @@ -572,7 +574,7 @@ | |||||
| 572 | 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()); | ||
| 573 | 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(); | ||
| 574 | 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()); | ||
| 575 | - | 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(); ermiteFinder.load(); |
| 576 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | 578 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | ||
| 577 | k-gh from:body -> document.querySelector('.brand')?.click(); | 579 | k-gh from:body -> document.querySelector('.brand')?.click(); | ||
| 578 | 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 +8 -6
| @@ -16,8 +16,10 @@ | |||||
| 16 | </script> | 16 | </script> | ||
| 17 | <script src="/-/static/js/ermite.HASH.js" defer> | 17 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 18 | </script> | 18 | </script> | ||
| 19 | + | <script src="/-/static/js/files.HASH.js" defer> | |||
| 20 | + | </script> | |||
| 19 | </head> | 21 | </head> | ||
| 20 | - | <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"> |
| 21 | <a class="skip" href="#main">skip to content</a> | 23 | <a class="skip" href="#main">skip to content</a> | ||
| 22 | <header class="hdr"> | 24 | <header class="hdr"> | ||
| 23 | <div class="hdr-top"> | 25 | <div class="hdr-top"> | ||
| @@ -202,7 +204,7 @@ | |||||
| 202 | <dt> | 204 | <dt> | ||
| 203 | <kbd>/</kbd> | 205 | <kbd>/</kbd> | ||
| 204 | </dt> | 206 | </dt> | ||
| 205 | - | <dd>filter the list</dd> | 207 | + | <dd>filter the list; on Files, find a file in the repository</dd> |
| 206 | </div> | 208 | </div> | ||
| 207 | <div> | 209 | <div> | ||
| 208 | <dt> | 210 | <dt> | ||
| @@ -281,12 +283,12 @@ | |||||
| 281 | </section> | 283 | </section> | ||
| 282 | </div> | 284 | </div> | ||
| 283 | </dialog> | 285 | </dialog> | ||
| 284 | - | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 286 | + | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> |
| 285 | <div class="panel-head"> | 287 | <div class="panel-head"> | ||
| 286 | <label for="cmd-q" class="sr">go to</label> | 288 | <label for="cmd-q" class="sr">go to</label> | ||
| 287 | <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(); | ||
| 288 | - | 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)); |
| 289 | - | 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(); |
| 290 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | 292 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | ||
| 291 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | 293 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | ||
| 292 | 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' })) } | ||
| @@ -381,7 +383,7 @@ | |||||
| 381 | 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()); | ||
| 382 | 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(); | ||
| 383 | 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()); | ||
| 384 | - | 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(); ermiteFinder.load(); |
| 385 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | 387 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | ||
| 386 | k-gh from:body -> document.querySelector('.brand')?.click(); | 388 | k-gh from:body -> document.querySelector('.brand')?.click(); | ||
| 387 | 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 +8 -6
| @@ -16,8 +16,10 @@ | |||||
| 16 | </script> | 16 | </script> | ||
| 17 | <script src="/-/static/js/ermite.HASH.js" defer> | 17 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 18 | </script> | 18 | </script> | ||
| 19 | + | <script src="/-/static/js/files.HASH.js" defer> | |||
| 20 | + | </script> | |||
| 19 | </head> | 21 | </head> | ||
| 20 | - | <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"> |
| 21 | <a class="skip" href="#main">skip to content</a> | 23 | <a class="skip" href="#main">skip to content</a> | ||
| 22 | <header class="hdr"> | 24 | <header class="hdr"> | ||
| 23 | <div class="hdr-top"> | 25 | <div class="hdr-top"> | ||
| @@ -217,7 +219,7 @@ | |||||
| 217 | <dt> | 219 | <dt> | ||
| 218 | <kbd>/</kbd> | 220 | <kbd>/</kbd> | ||
| 219 | </dt> | 221 | </dt> | ||
| 220 | - | <dd>filter the list</dd> | 222 | + | <dd>filter the list; on Files, find a file in the repository</dd> |
| 221 | </div> | 223 | </div> | ||
| 222 | <div> | 224 | <div> | ||
| 223 | <dt> | 225 | <dt> | ||
| @@ -296,12 +298,12 @@ | |||||
| 296 | </section> | 298 | </section> | ||
| 297 | </div> | 299 | </div> | ||
| 298 | </dialog> | 300 | </dialog> | ||
| 299 | - | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 301 | + | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> |
| 300 | <div class="panel-head"> | 302 | <div class="panel-head"> | ||
| 301 | <label for="cmd-q" class="sr">go to</label> | 303 | <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(); | 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(); | ||
| 303 | - | 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)); |
| 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(); | 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(); |
| 305 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | 307 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | ||
| 306 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | 308 | 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' })) } | 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' })) } | ||
| @@ -396,7 +398,7 @@ | |||||
| 396 | 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()); | ||
| 397 | 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(); | ||
| 398 | 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()); | ||
| 399 | - | 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(); ermiteFinder.load(); |
| 400 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | 402 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | ||
| 401 | k-gh from:body -> document.querySelector('.brand')?.click(); | 403 | k-gh from:body -> document.querySelector('.brand')?.click(); | ||
| 402 | 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 +8 -6
| @@ -16,8 +16,10 @@ | |||||
| 16 | </script> | 16 | </script> | ||
| 17 | <script src="/-/static/js/ermite.HASH.js" defer> | 17 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 18 | </script> | 18 | </script> | ||
| 19 | + | <script src="/-/static/js/files.HASH.js" defer> | |||
| 20 | + | </script> | |||
| 19 | </head> | 21 | </head> | ||
| 20 | - | <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"> |
| 21 | <a class="skip" href="#main">skip to content</a> | 23 | <a class="skip" href="#main">skip to content</a> | ||
| 22 | <header class="hdr"> | 24 | <header class="hdr"> | ||
| 23 | <div class="hdr-top"> | 25 | <div class="hdr-top"> | ||
| @@ -1004,7 +1006,7 @@ | |||||
| 1004 | <dt> | 1006 | <dt> | ||
| 1005 | <kbd>/</kbd> | 1007 | <kbd>/</kbd> | ||
| 1006 | </dt> | 1008 | </dt> | ||
| 1007 | - | <dd>filter the list</dd> | 1009 | + | <dd>filter the list; on Files, find a file in the repository</dd> |
| 1008 | </div> | 1010 | </div> | ||
| 1009 | <div> | 1011 | <div> | ||
| 1010 | <dt> | 1012 | <dt> | ||
| @@ -1083,12 +1085,12 @@ | |||||
| 1083 | </section> | 1085 | </section> | ||
| 1084 | </div> | 1086 | </div> | ||
| 1085 | </dialog> | 1087 | </dialog> | ||
| 1086 | - | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 1088 | + | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> |
| 1087 | <div class="panel-head"> | 1089 | <div class="panel-head"> | ||
| 1088 | <label for="cmd-q" class="sr">go to</label> | 1090 | <label for="cmd-q" class="sr">go to</label> | ||
| 1089 | <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(); | ||
| 1090 | - | 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)); |
| 1091 | - | 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(); |
| 1092 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | 1094 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | ||
| 1093 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | 1095 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | ||
| 1094 | 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' })) } | ||
| @@ -1183,7 +1185,7 @@ | |||||
| 1183 | 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()); | ||
| 1184 | 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(); | ||
| 1185 | 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()); | ||
| 1186 | - | 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(); ermiteFinder.load(); |
| 1187 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | 1189 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | ||
| 1188 | k-gh from:body -> document.querySelector('.brand')?.click(); | 1190 | k-gh from:body -> document.querySelector('.brand')?.click(); | ||
| 1189 | 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 +8 -6
| @@ -16,8 +16,10 @@ | |||||
| 16 | </script> | 16 | </script> | ||
| 17 | <script src="/-/static/js/ermite.HASH.js" defer> | 17 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 18 | </script> | 18 | </script> | ||
| 19 | + | <script src="/-/static/js/files.HASH.js" defer> | |||
| 20 | + | </script> | |||
| 19 | </head> | 21 | </head> | ||
| 20 | - | <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"> |
| 21 | <a class="skip" href="#main">skip to content</a> | 23 | <a class="skip" href="#main">skip to content</a> | ||
| 22 | <header class="hdr"> | 24 | <header class="hdr"> | ||
| 23 | <div class="hdr-top"> | 25 | <div class="hdr-top"> | ||
| @@ -349,7 +351,7 @@ | |||||
| 349 | <dt> | 351 | <dt> | ||
| 350 | <kbd>/</kbd> | 352 | <kbd>/</kbd> | ||
| 351 | </dt> | 353 | </dt> | ||
| 352 | - | <dd>filter the list</dd> | 354 | + | <dd>filter the list; on Files, find a file in the repository</dd> |
| 353 | </div> | 355 | </div> | ||
| 354 | <div> | 356 | <div> | ||
| 355 | <dt> | 357 | <dt> | ||
| @@ -436,12 +438,12 @@ | |||||
| 436 | </section> | 438 | </section> | ||
| 437 | </div> | 439 | </div> | ||
| 438 | </dialog> | 440 | </dialog> | ||
| 439 | - | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 441 | + | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> |
| 440 | <div class="panel-head"> | 442 | <div class="panel-head"> | ||
| 441 | <label for="cmd-q" class="sr">go to</label> | 443 | <label for="cmd-q" class="sr">go to</label> | ||
| 442 | <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(); | ||
| 443 | - | 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)); |
| 444 | - | 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(); |
| 445 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | 447 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | ||
| 446 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | 448 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | ||
| 447 | 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' })) } | ||
| @@ -548,7 +550,7 @@ | |||||
| 548 | 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()); | ||
| 549 | 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(); | ||
| 550 | 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()); | ||
| 551 | - | 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(); ermiteFinder.load(); |
| 552 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | 554 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | ||
| 553 | k-gh from:body -> document.querySelector('.brand')?.click(); | 555 | k-gh from:body -> document.querySelector('.brand')?.click(); | ||
| 554 | 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 +7 -5
| @@ -16,6 +16,8 @@ | |||||
| 16 | </script> | 16 | </script> | ||
| 17 | <script src="/-/static/js/ermite.HASH.js" defer> | 17 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 18 | </script> | 18 | </script> | ||
| 19 | + | <script src="/-/static/js/files.HASH.js" defer> | |||
| 20 | + | </script> | |||
| 19 | </head> | 21 | </head> | ||
| 20 | <body data-ref="" data-sha="" data-sel="" data-nav=""> | 22 | <body data-ref="" data-sha="" data-sel="" data-nav=""> | ||
| 21 | <a class="skip" href="#main">skip to content</a> | 23 | <a class="skip" href="#main">skip to content</a> | ||
| @@ -126,7 +128,7 @@ | |||||
| 126 | <dt> | 128 | <dt> | ||
| 127 | <kbd>/</kbd> | 129 | <kbd>/</kbd> | ||
| 128 | </dt> | 130 | </dt> | ||
| 129 | - | <dd>filter the list</dd> | 131 | + | <dd>filter the list; on Files, find a file in the repository</dd> |
| 130 | </div> | 132 | </div> | ||
| 131 | <div> | 133 | <div> | ||
| 132 | <dt> | 134 | <dt> | ||
| @@ -178,12 +180,12 @@ | |||||
| 178 | </div> | 180 | </div> | ||
| 179 | </dl> | 181 | </dl> | ||
| 180 | </dialog> | 182 | </dialog> | ||
| 181 | - | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 183 | + | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> |
| 182 | <div class="panel-head"> | 184 | <div class="panel-head"> | ||
| 183 | <label for="cmd-q" class="sr">go to</label> | 185 | <label for="cmd-q" class="sr">go to</label> | ||
| 184 | <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(); | ||
| 185 | - | 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)); |
| 186 | - | 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(); |
| 187 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | 189 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | ||
| 188 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | 190 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | ||
| 189 | 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' })) } | ||
| @@ -242,7 +244,7 @@ | |||||
| 242 | 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()); | ||
| 243 | 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(); | ||
| 244 | 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()); | ||
| 245 | - | 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(); ermiteFinder.load(); |
| 246 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | 248 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | ||
| 247 | k-gh from:body -> document.querySelector('.brand')?.click(); | 249 | k-gh from:body -> document.querySelector('.brand')?.click(); | ||
| 248 | 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 +7 -5
| @@ -16,6 +16,8 @@ | |||||
| 16 | </script> | 16 | </script> | ||
| 17 | <script src="/-/static/js/ermite.HASH.js" defer> | 17 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 18 | </script> | 18 | </script> | ||
| 19 | + | <script src="/-/static/js/files.HASH.js" defer> | |||
| 20 | + | </script> | |||
| 19 | </head> | 21 | </head> | ||
| 20 | <body data-ref="" data-sha="" data-sel="" data-nav=""> | 22 | <body data-ref="" data-sha="" data-sel="" data-nav=""> | ||
| 21 | <a class="skip" href="#main">skip to content</a> | 23 | <a class="skip" href="#main">skip to content</a> | ||
| @@ -156,7 +158,7 @@ | |||||
| 156 | <dt> | 158 | <dt> | ||
| 157 | <kbd>/</kbd> | 159 | <kbd>/</kbd> | ||
| 158 | </dt> | 160 | </dt> | ||
| 159 | - | <dd>filter the list</dd> | 161 | + | <dd>filter the list; on Files, find a file in the repository</dd> |
| 160 | </div> | 162 | </div> | ||
| 161 | <div> | 163 | <div> | ||
| 162 | <dt> | 164 | <dt> | ||
| @@ -208,12 +210,12 @@ | |||||
| 208 | </div> | 210 | </div> | ||
| 209 | </dl> | 211 | </dl> | ||
| 210 | </dialog> | 212 | </dialog> | ||
| 211 | - | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 213 | + | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> |
| 212 | <div class="panel-head"> | 214 | <div class="panel-head"> | ||
| 213 | <label for="cmd-q" class="sr">go to</label> | 215 | <label for="cmd-q" class="sr">go to</label> | ||
| 214 | <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(); | ||
| 215 | - | 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)); |
| 216 | - | 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(); |
| 217 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | 219 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | ||
| 218 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | 220 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | ||
| 219 | 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' })) } | ||
| @@ -278,7 +280,7 @@ | |||||
| 278 | 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()); | ||
| 279 | 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(); | ||
| 280 | 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()); | ||
| 281 | - | 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(); ermiteFinder.load(); |
| 282 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | 284 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | ||
| 283 | k-gh from:body -> document.querySelector('.brand')?.click(); | 285 | k-gh from:body -> document.querySelector('.brand')?.click(); | ||
| 284 | 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 +7 -5
| @@ -16,6 +16,8 @@ | |||||
| 16 | </script> | 16 | </script> | ||
| 17 | <script src="/-/static/js/ermite.HASH.js" defer> | 17 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 18 | </script> | 18 | </script> | ||
| 19 | + | <script src="/-/static/js/files.HASH.js" defer> | |||
| 20 | + | </script> | |||
| 19 | </head> | 21 | </head> | ||
| 20 | <body data-ref="" data-sha="" data-sel="" data-nav=""> | 22 | <body data-ref="" data-sha="" data-sel="" data-nav=""> | ||
| 21 | <a class="skip" href="#main">skip to content</a> | 23 | <a class="skip" href="#main">skip to content</a> | ||
| @@ -115,7 +117,7 @@ | |||||
| 115 | <dt> | 117 | <dt> | ||
| 116 | <kbd>/</kbd> | 118 | <kbd>/</kbd> | ||
| 117 | </dt> | 119 | </dt> | ||
| 118 | - | <dd>filter the list</dd> | 120 | + | <dd>filter the list; on Files, find a file in the repository</dd> |
| 119 | </div> | 121 | </div> | ||
| 120 | <div> | 122 | <div> | ||
| 121 | <dt> | 123 | <dt> | ||
| @@ -167,12 +169,12 @@ | |||||
| 167 | </div> | 169 | </div> | ||
| 168 | </dl> | 170 | </dl> | ||
| 169 | </dialog> | 171 | </dialog> | ||
| 170 | - | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 172 | + | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> |
| 171 | <div class="panel-head"> | 173 | <div class="panel-head"> | ||
| 172 | <label for="cmd-q" class="sr">go to</label> | 174 | <label for="cmd-q" class="sr">go to</label> | ||
| 173 | <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(); | ||
| 174 | - | 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)); |
| 175 | - | 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(); |
| 176 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | 178 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | ||
| 177 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | 179 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | ||
| 178 | 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' })) } | ||
| @@ -225,7 +227,7 @@ | |||||
| 225 | 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()); | ||
| 226 | 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(); | ||
| 227 | 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()); | ||
| 228 | - | 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(); ermiteFinder.load(); |
| 229 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | 231 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | ||
| 230 | k-gh from:body -> document.querySelector('.brand')?.click(); | 232 | k-gh from:body -> document.querySelector('.brand')?.click(); | ||
| 231 | 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 +7 -5
| @@ -16,6 +16,8 @@ | |||||
| 16 | </script> | 16 | </script> | ||
| 17 | <script src="/-/static/js/ermite.HASH.js" defer> | 17 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 18 | </script> | 18 | </script> | ||
| 19 | + | <script src="/-/static/js/files.HASH.js" defer> | |||
| 20 | + | </script> | |||
| 19 | </head> | 21 | </head> | ||
| 20 | <body data-ref="" data-sha="" data-sel="" data-nav=""> | 22 | <body data-ref="" data-sha="" data-sel="" data-nav=""> | ||
| 21 | <a class="skip" href="#main">skip to content</a> | 23 | <a class="skip" href="#main">skip to content</a> | ||
| @@ -115,7 +117,7 @@ | |||||
| 115 | <dt> | 117 | <dt> | ||
| 116 | <kbd>/</kbd> | 118 | <kbd>/</kbd> | ||
| 117 | </dt> | 119 | </dt> | ||
| 118 | - | <dd>filter the list</dd> | 120 | + | <dd>filter the list; on Files, find a file in the repository</dd> |
| 119 | </div> | 121 | </div> | ||
| 120 | <div> | 122 | <div> | ||
| 121 | <dt> | 123 | <dt> | ||
| @@ -167,12 +169,12 @@ | |||||
| 167 | </div> | 169 | </div> | ||
| 168 | </dl> | 170 | </dl> | ||
| 169 | </dialog> | 171 | </dialog> | ||
| 170 | - | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 172 | + | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> |
| 171 | <div class="panel-head"> | 173 | <div class="panel-head"> | ||
| 172 | <label for="cmd-q" class="sr">go to</label> | 174 | <label for="cmd-q" class="sr">go to</label> | ||
| 173 | <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(); | ||
| 174 | - | 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)); |
| 175 | - | 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(); |
| 176 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | 178 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | ||
| 177 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | 179 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | ||
| 178 | 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' })) } | ||
| @@ -225,7 +227,7 @@ | |||||
| 225 | 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()); | ||
| 226 | 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(); | ||
| 227 | 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()); | ||
| 228 | - | 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(); ermiteFinder.load(); |
| 229 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | 231 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | ||
| 230 | k-gh from:body -> document.querySelector('.brand')?.click(); | 232 | k-gh from:body -> document.querySelector('.brand')?.click(); | ||
| 231 | 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 +8 -6
| @@ -16,8 +16,10 @@ | |||||
| 16 | </script> | 16 | </script> | ||
| 17 | <script src="/-/static/js/ermite.HASH.js" defer> | 17 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 18 | </script> | 18 | </script> | ||
| 19 | + | <script src="/-/static/js/files.HASH.js" defer> | |||
| 20 | + | </script> | |||
| 19 | </head> | 21 | </head> | ||
| 20 | - | <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"> |
| 21 | <a class="skip" href="#main">skip to content</a> | 23 | <a class="skip" href="#main">skip to content</a> | ||
| 22 | <header class="hdr"> | 24 | <header class="hdr"> | ||
| 23 | <div class="hdr-top"> | 25 | <div class="hdr-top"> | ||
| @@ -240,7 +242,7 @@ | |||||
| 240 | <dt> | 242 | <dt> | ||
| 241 | <kbd>/</kbd> | 243 | <kbd>/</kbd> | ||
| 242 | </dt> | 244 | </dt> | ||
| 243 | - | <dd>filter the list</dd> | 245 | + | <dd>filter the list; on Files, find a file in the repository</dd> |
| 244 | </div> | 246 | </div> | ||
| 245 | <div> | 247 | <div> | ||
| 246 | <dt> | 248 | <dt> | ||
| @@ -331,12 +333,12 @@ | |||||
| 331 | </section> | 333 | </section> | ||
| 332 | </div> | 334 | </div> | ||
| 333 | </dialog> | 335 | </dialog> | ||
| 334 | - | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 336 | + | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> |
| 335 | <div class="panel-head"> | 337 | <div class="panel-head"> | ||
| 336 | <label for="cmd-q" class="sr">go to</label> | 338 | <label for="cmd-q" class="sr">go to</label> | ||
| 337 | <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(); | ||
| 338 | - | 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)); |
| 339 | - | 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(); |
| 340 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | 342 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | ||
| 341 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | 343 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | ||
| 342 | 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' })) } | ||
| @@ -449,7 +451,7 @@ | |||||
| 449 | 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()); | ||
| 450 | 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(); | ||
| 451 | 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()); | ||
| 452 | - | 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(); ermiteFinder.load(); |
| 453 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | 455 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | ||
| 454 | k-gh from:body -> document.querySelector('.brand')?.click(); | 456 | k-gh from:body -> document.querySelector('.brand')?.click(); | ||
| 455 | 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 +8 -6
| @@ -16,8 +16,10 @@ | |||||
| 16 | </script> | 16 | </script> | ||
| 17 | <script src="/-/static/js/ermite.HASH.js" defer> | 17 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 18 | </script> | 18 | </script> | ||
| 19 | + | <script src="/-/static/js/files.HASH.js" defer> | |||
| 20 | + | </script> | |||
| 19 | </head> | 21 | </head> | ||
| 20 | - | <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"> |
| 21 | <a class="skip" href="#main">skip to content</a> | 23 | <a class="skip" href="#main">skip to content</a> | ||
| 22 | <header class="hdr"> | 24 | <header class="hdr"> | ||
| 23 | <div class="hdr-top"> | 25 | <div class="hdr-top"> | ||
| @@ -258,7 +260,7 @@ | |||||
| 258 | <dt> | 260 | <dt> | ||
| 259 | <kbd>/</kbd> | 261 | <kbd>/</kbd> | ||
| 260 | </dt> | 262 | </dt> | ||
| 261 | - | <dd>filter the list</dd> | 263 | + | <dd>filter the list; on Files, find a file in the repository</dd> |
| 262 | </div> | 264 | </div> | ||
| 263 | <div> | 265 | <div> | ||
| 264 | <dt> | 266 | <dt> | ||
| @@ -349,12 +351,12 @@ | |||||
| 349 | </section> | 351 | </section> | ||
| 350 | </div> | 352 | </div> | ||
| 351 | </dialog> | 353 | </dialog> | ||
| 352 | - | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 354 | + | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> |
| 353 | <div class="panel-head"> | 355 | <div class="panel-head"> | ||
| 354 | <label for="cmd-q" class="sr">go to</label> | 356 | <label for="cmd-q" class="sr">go to</label> | ||
| 355 | <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(); | ||
| 356 | - | 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)); |
| 357 | - | 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(); |
| 358 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | 360 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | ||
| 359 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | 361 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | ||
| 360 | 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' })) } | ||
| @@ -467,7 +469,7 @@ | |||||
| 467 | 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()); | ||
| 468 | 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(); | ||
| 469 | 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()); | ||
| 470 | - | 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(); ermiteFinder.load(); |
| 471 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | 473 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | ||
| 472 | k-gh from:body -> document.querySelector('.brand')?.click(); | 474 | k-gh from:body -> document.querySelector('.brand')?.click(); | ||
| 473 | 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 +19 -15
| @@ -16,8 +16,10 @@ | |||||
| 16 | </script> | 16 | </script> | ||
| 17 | <script src="/-/static/js/ermite.HASH.js" defer> | 17 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 18 | </script> | 18 | </script> | ||
| 19 | + | <script src="/-/static/js/files.HASH.js" defer> | |||
| 20 | + | </script> | |||
| 19 | </head> | 21 | </head> | ||
| 20 | - | <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"> |
| 21 | <a class="skip" href="#main">skip to content</a> | 23 | <a class="skip" href="#main">skip to content</a> | ||
| 22 | <header class="hdr"> | 24 | <header class="hdr"> | ||
| 23 | <div class="hdr-top"> | 25 | <div class="hdr-top"> | ||
| @@ -89,9 +91,16 @@ | |||||
| 89 | <div class="work"> | 91 | <div class="work"> | ||
| 90 | <main id="main" tabindex="-1"> | 92 | <main id="main" tabindex="-1"> | ||
| 91 | <div class="filter"> | 93 | <div class="filter"> | ||
| 92 | - | <label for="tree-filter" class="sr">filter</label> | 94 | + | <label for="finder-q" class="sr">find a file</label> |
| 93 | - | <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> | |||
| 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> | |||
| 94 | </div> | 102 | </div> | ||
| 103 | + | <div id="tree-view"> | |||
| 95 | <table class="list tree" aria-label="Entries"> | 104 | <table class="list tree" aria-label="Entries"> | ||
| 96 | <thead> | 105 | <thead> | ||
| 97 | <tr> | 106 | <tr> | ||
| @@ -106,7 +115,7 @@ | |||||
| 106 | <th class="c-when">date</th> | 115 | <th class="c-when">date</th> | ||
| 107 | </tr> | 116 | </tr> | ||
| 108 | </thead> | 117 | </thead> | ||
| 109 | - | <tbody id="tree-rows" data-rows> | 118 | + | <tbody id="tree-rows" data-rows hx-on:click="ermiteTree.click(event)"> |
| 110 | <tr data-cursor> | 119 | <tr data-cursor> | ||
| 111 | <td class="cur" aria-hidden="true"> | 120 | <td class="cur" aria-hidden="true"> | ||
| 112 | </td> | 121 | </td> | ||
| @@ -125,6 +134,7 @@ | |||||
| 125 | </tr> | 134 | </tr> | ||
| 126 | </tbody> | 135 | </tbody> | ||
| 127 | </table> | 136 | </table> | ||
| 137 | + | </div> | |||
| 128 | </main> | 138 | </main> | ||
| 129 | </div> | 139 | </div> | ||
| 130 | <footer class="foot"> | 140 | <footer class="foot"> | ||
| @@ -185,7 +195,7 @@ | |||||
| 185 | <dt> | 195 | <dt> | ||
| 186 | <kbd>/</kbd> | 196 | <kbd>/</kbd> | ||
| 187 | </dt> | 197 | </dt> | ||
| 188 | - | <dd>filter the list</dd> | 198 | + | <dd>filter the list; on Files, find a file in the repository</dd> |
| 189 | </div> | 199 | </div> | ||
| 190 | <div> | 200 | <div> | ||
| 191 | <dt> | 201 | <dt> | ||
| @@ -272,12 +282,12 @@ | |||||
| 272 | </section> | 282 | </section> | ||
| 273 | </div> | 283 | </div> | ||
| 274 | </dialog> | 284 | </dialog> | ||
| 275 | - | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 285 | + | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> |
| 276 | <div class="panel-head"> | 286 | <div class="panel-head"> | ||
| 277 | <label for="cmd-q" class="sr">go to</label> | 287 | <label for="cmd-q" class="sr">go to</label> | ||
| 278 | <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(); | ||
| 279 | - | 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)); |
| 280 | - | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | 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(); |
| 281 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | 291 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | ||
| 282 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | 292 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | ||
| 283 | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | 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' })) } | ||
| @@ -341,12 +351,6 @@ | |||||
| 341 | <span class="k">tag</span> | 351 | <span class="k">tag</span> | ||
| 342 | </a> | 352 | </a> | ||
| 343 | </li> | 353 | </li> | ||
| 344 | - | <li data-k="file src/lib/util.go"> | |||
| 345 | - | <a href="/files/blob/main/src/lib/util.go" tabindex="-1"> | |||
| 346 | - | <span class="lbl">src/lib/util.go</span> | |||
| 347 | - | <span class="k">file</span> | |||
| 348 | - | </a> | |||
| 349 | - | </li> | |||
| 350 | </ul> | 354 | </ul> | ||
| 351 | <p class="panel-foot"> | 355 | <p class="panel-foot"> | ||
| 352 | <span> | 356 | <span> | ||
| @@ -384,7 +388,7 @@ | |||||
| 384 | 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()); | ||
| 385 | 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(); | ||
| 386 | 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()); | ||
| 387 | - | 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(); ermiteFinder.load(); |
| 388 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | 392 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | ||
| 389 | k-gh from:body -> document.querySelector('.brand')?.click(); | 393 | k-gh from:body -> document.querySelector('.brand')?.click(); | ||
| 390 | 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 +23 -91
| @@ -16,8 +16,10 @@ | |||||
| 16 | </script> | 16 | </script> | ||
| 17 | <script src="/-/static/js/ermite.HASH.js" defer> | 17 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 18 | </script> | 18 | </script> | ||
| 19 | + | <script src="/-/static/js/files.HASH.js" defer> | |||
| 20 | + | </script> | |||
| 19 | </head> | 21 | </head> | ||
| 20 | - | <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"> |
| 21 | <a class="skip" href="#main">skip to content</a> | 23 | <a class="skip" href="#main">skip to content</a> | ||
| 22 | <header class="hdr"> | 24 | <header class="hdr"> | ||
| 23 | <div class="hdr-top"> | 25 | <div class="hdr-top"> | ||
| @@ -83,9 +85,16 @@ | |||||
| 83 | <div class="work"> | 85 | <div class="work"> | ||
| 84 | <main id="main" tabindex="-1"> | 86 | <main id="main" tabindex="-1"> | ||
| 85 | <div class="filter"> | 87 | <div class="filter"> | ||
| 86 | - | <label for="tree-filter" class="sr">filter</label> | 88 | + | <label for="finder-q" class="sr">find a file</label> |
| 87 | - | <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> | |||
| 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> | |||
| 88 | </div> | 96 | </div> | ||
| 97 | + | <div id="tree-view"> | |||
| 89 | <table class="list tree" aria-label="Entries"> | 98 | <table class="list tree" aria-label="Entries"> | ||
| 90 | <thead> | 99 | <thead> | ||
| 91 | <tr> | 100 | <tr> | ||
| @@ -100,13 +109,13 @@ | |||||
| 100 | <th class="c-when">date</th> | 109 | <th class="c-when">date</th> | ||
| 101 | </tr> | 110 | </tr> | ||
| 102 | </thead> | 111 | </thead> | ||
| 103 | - | <tbody id="tree-rows" data-rows> | 112 | + | <tbody id="tree-rows" data-rows hx-on:click="ermiteTree.click(event)"> |
| 104 | <tr data-cursor> | 113 | <tr data-cursor> | ||
| 105 | <td class="cur" aria-hidden="true"> | 114 | <td class="cur" aria-hidden="true"> | ||
| 106 | </td> | 115 | </td> | ||
| 107 | - | <td class="c-kind" aria-hidden="true">▾</td> | 116 | + | <td class="c-kind" aria-hidden="true">▸</td> |
| 108 | <td class="c-name"> | 117 | <td class="c-name"> | ||
| 109 | - | <a href="/files/tree/main/docs">docs/</a> | 118 | + | <a href="/files/tree/main/docs" data-expand="/files/tree/main/docs?rows" aria-expanded="false">docs/</a> |
| 110 | </td> | 119 | </td> | ||
| 111 | <td class="c-size num dim">-</td> | 120 | <td class="c-size num dim">-</td> | ||
| 112 | <td class="c-sub dim"> | 121 | <td class="c-sub dim"> | ||
| @@ -120,9 +129,9 @@ | |||||
| 120 | <tr> | 129 | <tr> | ||
| 121 | <td class="cur" aria-hidden="true"> | 130 | <td class="cur" aria-hidden="true"> | ||
| 122 | </td> | 131 | </td> | ||
| 123 | - | <td class="c-kind" aria-hidden="true">▾</td> | 132 | + | <td class="c-kind" aria-hidden="true">▸</td> |
| 124 | <td class="c-name"> | 133 | <td class="c-name"> | ||
| 125 | - | <a href="/files/tree/main/src">src/</a> | 134 | + | <a href="/files/tree/main/src" data-expand="/files/tree/main/src?rows" aria-expanded="false">src/</a> |
| 126 | </td> | 135 | </td> | ||
| 127 | <td class="c-size num dim">-</td> | 136 | <td class="c-size num dim">-</td> | ||
| 128 | <td class="c-sub dim"> | 137 | <td class="c-sub dim"> | ||
| @@ -352,6 +361,7 @@ | |||||
| 352 | <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> | ||
| 353 | </article> | 362 | </article> | ||
| 354 | </section> | 363 | </section> | ||
| 364 | + | </div> | |||
| 355 | </main> | 365 | </main> | ||
| 356 | </div> | 366 | </div> | ||
| 357 | <footer class="foot"> | 367 | <footer class="foot"> | ||
| @@ -412,7 +422,7 @@ | |||||
| 412 | <dt> | 422 | <dt> | ||
| 413 | <kbd>/</kbd> | 423 | <kbd>/</kbd> | ||
| 414 | </dt> | 424 | </dt> | ||
| 415 | - | <dd>filter the list</dd> | 425 | + | <dd>filter the list; on Files, find a file in the repository</dd> |
| 416 | </div> | 426 | </div> | ||
| 417 | <div> | 427 | <div> | ||
| 418 | <dt> | 428 | <dt> | ||
| @@ -499,12 +509,12 @@ | |||||
| 499 | </section> | 509 | </section> | ||
| 500 | </div> | 510 | </div> | ||
| 501 | </dialog> | 511 | </dialog> | ||
| 502 | - | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 512 | + | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> |
| 503 | <div class="panel-head"> | 513 | <div class="panel-head"> | ||
| 504 | <label for="cmd-q" class="sr">go to</label> | 514 | <label for="cmd-q" class="sr">go to</label> | ||
| 505 | <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(); | ||
| 506 | - | 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)); |
| 507 | - | 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(); |
| 508 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | 518 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | ||
| 509 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | 519 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | ||
| 510 | 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' })) } | ||
| @@ -568,84 +578,6 @@ | |||||
| 568 | <span class="k">tag</span> | 578 | <span class="k">tag</span> | ||
| 569 | </a> | 579 | </a> | ||
| 570 | </li> | 580 | </li> | ||
| 571 | - | <li data-k="file readme.md"> | |||
| 572 | - | <a href="/files/blob/main/README.md" tabindex="-1"> | |||
| 573 | - | <span class="lbl">README.md</span> | |||
| 574 | - | <span class="k">file</span> | |||
| 575 | - | </a> | |||
| 576 | - | </li> | |||
| 577 | - | <li data-k="file big.txt"> | |||
| 578 | - | <a href="/files/blob/main/big.txt" tabindex="-1"> | |||
| 579 | - | <span class="lbl">big.txt</span> | |||
| 580 | - | <span class="k">file</span> | |||
| 581 | - | </a> | |||
| 582 | - | </li> | |||
| 583 | - | <li data-k="file data.bin"> | |||
| 584 | - | <a href="/files/blob/main/data.bin" tabindex="-1"> | |||
| 585 | - | <span class="lbl">data.bin</span> | |||
| 586 | - | <span class="k">file</span> | |||
| 587 | - | </a> | |||
| 588 | - | </li> | |||
| 589 | - | <li data-k="file empty.txt"> | |||
| 590 | - | <a href="/files/blob/main/empty.txt" tabindex="-1"> | |||
| 591 | - | <span class="lbl">empty.txt</span> | |||
| 592 | - | <span class="k">file</span> | |||
| 593 | - | </a> | |||
| 594 | - | </li> | |||
| 595 | - | <li data-k="file evil.go"> | |||
| 596 | - | <a href="/files/blob/main/evil.go" tabindex="-1"> | |||
| 597 | - | <span class="lbl">evil.go</span> | |||
| 598 | - | <span class="k">file</span> | |||
| 599 | - | </a> | |||
| 600 | - | </li> | |||
| 601 | - | <li data-k="file fake.jpg"> | |||
| 602 | - | <a href="/files/blob/main/fake.jpg" tabindex="-1"> | |||
| 603 | - | <span class="lbl">fake.jpg</span> | |||
| 604 | - | <span class="k">file</span> | |||
| 605 | - | </a> | |||
| 606 | - | </li> | |||
| 607 | - | <li data-k="file fake.png"> | |||
| 608 | - | <a href="/files/blob/main/fake.png" tabindex="-1"> | |||
| 609 | - | <span class="lbl">fake.png</span> | |||
| 610 | - | <span class="k">file</span> | |||
| 611 | - | </a> | |||
| 612 | - | </li> | |||
| 613 | - | <li data-k="file lines.txt"> | |||
| 614 | - | <a href="/files/blob/main/lines.txt" tabindex="-1"> | |||
| 615 | - | <span class="lbl">lines.txt</span> | |||
| 616 | - | <span class="k">file</span> | |||
| 617 | - | </a> | |||
| 618 | - | </li> | |||
| 619 | - | <li data-k="file logo.svg"> | |||
| 620 | - | <a href="/files/blob/main/logo.svg" tabindex="-1"> | |||
| 621 | - | <span class="lbl">logo.svg</span> | |||
| 622 | - | <span class="k">file</span> | |||
| 623 | - | </a> | |||
| 624 | - | </li> | |||
| 625 | - | <li data-k="file main.go"> | |||
| 626 | - | <a href="/files/blob/main/main.go" tabindex="-1"> | |||
| 627 | - | <span class="lbl">main.go</span> | |||
| 628 | - | <span class="k">file</span> | |||
| 629 | - | </a> | |||
| 630 | - | </li> | |||
| 631 | - | <li data-k="file notes.txt"> | |||
| 632 | - | <a href="/files/blob/main/notes.txt" tabindex="-1"> | |||
| 633 | - | <span class="lbl">notes.txt</span> | |||
| 634 | - | <span class="k">file</span> | |||
| 635 | - | </a> | |||
| 636 | - | </li> | |||
| 637 | - | <li data-k="file page.html"> | |||
| 638 | - | <a href="/files/blob/main/page.html" tabindex="-1"> | |||
| 639 | - | <span class="lbl">page.html</span> | |||
| 640 | - | <span class="k">file</span> | |||
| 641 | - | </a> | |||
| 642 | - | </li> | |||
| 643 | - | <li data-k="file pic.png"> | |||
| 644 | - | <a href="/files/blob/main/pic.png" tabindex="-1"> | |||
| 645 | - | <span class="lbl">pic.png</span> | |||
| 646 | - | <span class="k">file</span> | |||
| 647 | - | </a> | |||
| 648 | - | </li> | |||
| 649 | </ul> | 581 | </ul> | ||
| 650 | <p class="panel-foot"> | 582 | <p class="panel-foot"> | ||
| 651 | <span> | 583 | <span> | ||
| @@ -683,7 +615,7 @@ | |||||
| 683 | 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()); | ||
| 684 | 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(); | ||
| 685 | 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()); | ||
| 686 | - | 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(); ermiteFinder.load(); |
| 687 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | 619 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | ||
| 688 | k-gh from:body -> document.querySelector('.brand')?.click(); | 620 | k-gh from:body -> document.querySelector('.brand')?.click(); | ||
| 689 | 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 +19 -15
| @@ -16,8 +16,10 @@ | |||||
| 16 | </script> | 16 | </script> | ||
| 17 | <script src="/-/static/js/ermite.HASH.js" defer> | 17 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 18 | </script> | 18 | </script> | ||
| 19 | + | <script src="/-/static/js/files.HASH.js" defer> | |||
| 20 | + | </script> | |||
| 19 | </head> | 21 | </head> | ||
| 20 | - | <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"> |
| 21 | <a class="skip" href="#main">skip to content</a> | 23 | <a class="skip" href="#main">skip to content</a> | ||
| 22 | <header class="hdr"> | 24 | <header class="hdr"> | ||
| 23 | <div class="hdr-top"> | 25 | <div class="hdr-top"> | ||
| @@ -86,9 +88,16 @@ | |||||
| 86 | <div class="work"> | 88 | <div class="work"> | ||
| 87 | <main id="main" tabindex="-1"> | 89 | <main id="main" tabindex="-1"> | ||
| 88 | <div class="filter"> | 90 | <div class="filter"> | ||
| 89 | - | <label for="tree-filter" class="sr">filter</label> | 91 | + | <label for="finder-q" class="sr">find a file</label> |
| 90 | - | <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> | |||
| 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> | |||
| 91 | </div> | 99 | </div> | ||
| 100 | + | <div id="tree-view"> | |||
| 92 | <table class="list tree" aria-label="Entries"> | 101 | <table class="list tree" aria-label="Entries"> | ||
| 93 | <thead> | 102 | <thead> | ||
| 94 | <tr> | 103 | <tr> | ||
| @@ -103,7 +112,7 @@ | |||||
| 103 | <th class="c-when">date</th> | 112 | <th class="c-when">date</th> | ||
| 104 | </tr> | 113 | </tr> | ||
| 105 | </thead> | 114 | </thead> | ||
| 106 | - | <tbody id="tree-rows" data-rows> | 115 | + | <tbody id="tree-rows" data-rows hx-on:click="ermiteTree.click(event)"> |
| 107 | <tr data-cursor> | 116 | <tr data-cursor> | ||
| 108 | <td class="cur" aria-hidden="true"> | 117 | <td class="cur" aria-hidden="true"> | ||
| 109 | </td> | 118 | </td> | ||
| @@ -122,6 +131,7 @@ | |||||
| 122 | </tr> | 131 | </tr> | ||
| 123 | </tbody> | 132 | </tbody> | ||
| 124 | </table> | 133 | </table> | ||
| 134 | + | </div> | |||
| 125 | </main> | 135 | </main> | ||
| 126 | </div> | 136 | </div> | ||
| 127 | <footer class="foot"> | 137 | <footer class="foot"> | ||
| @@ -182,7 +192,7 @@ | |||||
| 182 | <dt> | 192 | <dt> | ||
| 183 | <kbd>/</kbd> | 193 | <kbd>/</kbd> | ||
| 184 | </dt> | 194 | </dt> | ||
| 185 | - | <dd>filter the list</dd> | 195 | + | <dd>filter the list; on Files, find a file in the repository</dd> |
| 186 | </div> | 196 | </div> | ||
| 187 | <div> | 197 | <div> | ||
| 188 | <dt> | 198 | <dt> | ||
| @@ -269,12 +279,12 @@ | |||||
| 269 | </section> | 279 | </section> | ||
| 270 | </div> | 280 | </div> | ||
| 271 | </dialog> | 281 | </dialog> | ||
| 272 | - | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 282 | + | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> |
| 273 | <div class="panel-head"> | 283 | <div class="panel-head"> | ||
| 274 | <label for="cmd-q" class="sr">go to</label> | 284 | <label for="cmd-q" class="sr">go to</label> | ||
| 275 | <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(); | ||
| 276 | - | 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)); |
| 277 | - | 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(); |
| 278 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | 288 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | ||
| 279 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | 289 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | ||
| 280 | 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' })) } | ||
| @@ -338,12 +348,6 @@ | |||||
| 338 | <span class="k">tag</span> | 348 | <span class="k">tag</span> | ||
| 339 | </a> | 349 | </a> | ||
| 340 | </li> | 350 | </li> | ||
| 341 | - | <li data-k="file x/y.txt"> | |||
| 342 | - | <a href="/files/blob/feature/x/x/y.txt" tabindex="-1"> | |||
| 343 | - | <span class="lbl">x/y.txt</span> | |||
| 344 | - | <span class="k">file</span> | |||
| 345 | - | </a> | |||
| 346 | - | </li> | |||
| 347 | </ul> | 351 | </ul> | ||
| 348 | <p class="panel-foot"> | 352 | <p class="panel-foot"> | ||
| 349 | <span> | 353 | <span> | ||
| @@ -381,7 +385,7 @@ | |||||
| 381 | 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()); | ||
| 382 | 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(); | ||
| 383 | 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()); | ||
| 384 | - | 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(); ermiteFinder.load(); |
| 385 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | 389 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | ||
| 386 | k-gh from:body -> document.querySelector('.brand')?.click(); | 390 | k-gh from:body -> document.querySelector('.brand')?.click(); | ||
| 387 | 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 +19 -21
| @@ -16,8 +16,10 @@ | |||||
| 16 | </script> | 16 | </script> | ||
| 17 | <script src="/-/static/js/ermite.HASH.js" defer> | 17 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 18 | </script> | 18 | </script> | ||
| 19 | + | <script src="/-/static/js/files.HASH.js" defer> | |||
| 20 | + | </script> | |||
| 19 | </head> | 21 | </head> | ||
| 20 | - | <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"> |
| 21 | <a class="skip" href="#main">skip to content</a> | 23 | <a class="skip" href="#main">skip to content</a> | ||
| 22 | <header class="hdr"> | 24 | <header class="hdr"> | ||
| 23 | <div class="hdr-top"> | 25 | <div class="hdr-top"> | ||
| @@ -86,9 +88,16 @@ | |||||
| 86 | <div class="work"> | 88 | <div class="work"> | ||
| 87 | <main id="main" tabindex="-1"> | 89 | <main id="main" tabindex="-1"> | ||
| 88 | <div class="filter"> | 90 | <div class="filter"> | ||
| 89 | - | <label for="tree-filter" class="sr">filter</label> | 91 | + | <label for="finder-q" class="sr">find a file</label> |
| 90 | - | <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)"> |
| 91 | </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"> | |||
| 92 | <table class="list tree" aria-label="Entries"> | 101 | <table class="list tree" aria-label="Entries"> | ||
| 93 | <thead> | 102 | <thead> | ||
| 94 | <tr> | 103 | <tr> | ||
| @@ -103,7 +112,7 @@ | |||||
| 103 | <th class="c-when">date</th> | 112 | <th class="c-when">date</th> | ||
| 104 | </tr> | 113 | </tr> | ||
| 105 | </thead> | 114 | </thead> | ||
| 106 | - | <tbody id="tree-rows" data-rows> | 115 | + | <tbody id="tree-rows" data-rows hx-on:click="ermiteTree.click(event)"> |
| 107 | <tr data-cursor> | 116 | <tr data-cursor> | ||
| 108 | <td class="cur" aria-hidden="true"> | 117 | <td class="cur" aria-hidden="true"> | ||
| 109 | </td> | 118 | </td> | ||
| @@ -147,6 +156,7 @@ | |||||
| 147 | <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> | ||
| 148 | </article> | 157 | </article> | ||
| 149 | </section> | 158 | </section> | ||
| 159 | + | </div> | |||
| 150 | </main> | 160 | </main> | ||
| 151 | </div> | 161 | </div> | ||
| 152 | <footer class="foot"> | 162 | <footer class="foot"> | ||
| @@ -207,7 +217,7 @@ | |||||
| 207 | <dt> | 217 | <dt> | ||
| 208 | <kbd>/</kbd> | 218 | <kbd>/</kbd> | ||
| 209 | </dt> | 219 | </dt> | ||
| 210 | - | <dd>filter the list</dd> | 220 | + | <dd>filter the list; on Files, find a file in the repository</dd> |
| 211 | </div> | 221 | </div> | ||
| 212 | <div> | 222 | <div> | ||
| 213 | <dt> | 223 | <dt> | ||
| @@ -294,12 +304,12 @@ | |||||
| 294 | </section> | 304 | </section> | ||
| 295 | </div> | 305 | </div> | ||
| 296 | </dialog> | 306 | </dialog> | ||
| 297 | - | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 307 | + | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> |
| 298 | <div class="panel-head"> | 308 | <div class="panel-head"> | ||
| 299 | <label for="cmd-q" class="sr">go to</label> | 309 | <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(); | 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(); | ||
| 301 | - | 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)); |
| 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(); | 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(); |
| 303 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | 313 | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | ||
| 304 | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | 314 | 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' })) } | 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' })) } | ||
| @@ -363,18 +373,6 @@ | |||||
| 363 | <span class="k">tag</span> | 373 | <span class="k">tag</span> | ||
| 364 | </a> | 374 | </a> | ||
| 365 | </li> | 375 | </li> | ||
| 366 | - | <li data-k="file docs/readme.md"> | |||
| 367 | - | <a href="/files/blob/main/docs/README.md" tabindex="-1"> | |||
| 368 | - | <span class="lbl">docs/README.md</span> | |||
| 369 | - | <span class="k">file</span> | |||
| 370 | - | </a> | |||
| 371 | - | </li> | |||
| 372 | - | <li data-k="file docs/guide.md"> | |||
| 373 | - | <a href="/files/blob/main/docs/guide.md" tabindex="-1"> | |||
| 374 | - | <span class="lbl">docs/guide.md</span> | |||
| 375 | - | <span class="k">file</span> | |||
| 376 | - | </a> | |||
| 377 | - | </li> | |||
| 378 | </ul> | 376 | </ul> | ||
| 379 | <p class="panel-foot"> | 377 | <p class="panel-foot"> | ||
| 380 | <span> | 378 | <span> | ||
| @@ -412,7 +410,7 @@ | |||||
| 412 | 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()); | ||
| 413 | 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(); | ||
| 414 | 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()); | ||
| 415 | - | 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(); ermiteFinder.load(); |
| 416 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | 414 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | ||
| 417 | k-gh from:body -> document.querySelector('.brand')?.click(); | 415 | k-gh from:body -> document.querySelector('.brand')?.click(); | ||
| 418 | 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 +255 -0
| @@ -4,10 +4,16 @@ package main | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "bytes" | 6 | "bytes" | ||
| 7 | + | "compress/gzip" | |||
| 8 | + | "encoding/json" | |||
| 9 | + | "fmt" | |||
| 7 | "image" | 10 | "image" | ||
| 8 | "image/color" | 11 | "image/color" | ||
| 9 | "image/png" | 12 | "image/png" | ||
| 13 | + | "io" | |||
| 10 | "net/http" | 14 | "net/http" | ||
| 15 | + | "os" | |||
| 16 | + | "path/filepath" | |||
| 11 | "regexp" | 17 | "regexp" | ||
| 12 | "strconv" | 18 | "strconv" | ||
| 13 | "strings" | 19 | "strings" | ||
| @@ -100,6 +106,78 @@ func TestWebFilesGolden(t *testing.T) { | |||||
| 100 | } | 106 | } | ||
| 101 | } | 107 | } | ||
| 102 | 108 | ||||
| 109 | + | // AC1, AC2, AC7: ?rows answers the entries of a directory as table rows only, | |||
| 110 | + | // for the Files view to insert under the directory; a private repository is | |||
| 111 | + | // the 404 of a missing one. | |||
| 112 | + | func TestWebTreeRows(t *testing.T) { | |||
| 113 | + | h := newHarness(t, harnessOpts{}) | |||
| 114 | + | h.webFilesRepo("files") | |||
| 115 | + | h.seed("secret-sauce", domain.VisibilityPrivate, "dir/a.txt") | |||
| 116 | + | ||||
| 117 | + | // The page links its directories to their rows. | |||
| 118 | + | _, _, page := h.get("/files/tree/main") | |||
| 119 | + | assert.Contains(t, page, `href="/files/tree/main/docs" data-expand="/files/tree/main/docs?rows" aria-expanded="false"`) | |||
| 120 | + | assert.Contains(t, page, `<script src="/-/static/js/files.`) | |||
| 121 | + | assert.NotContains(t, page, "main.go</span>", "the palette lists no file") | |||
| 122 | + | ||||
| 123 | + | status, hdr, body := h.get("/files/tree/main/docs?rows") | |||
| 124 | + | require.Equal(t, 200, status) | |||
| 125 | + | assert.Equal(t, "no-cache", hdr.Get("Cache-Control")) | |||
| 126 | + | assert.Contains(t, hdr.Get("Content-Type"), "text/html") | |||
| 127 | + | assert.NotContains(t, body, "<html") | |||
| 128 | + | assert.NotContains(t, body, "<table") | |||
| 129 | + | assert.Equal(t, 2, strings.Count(body, "<tr"), "one row per entry") | |||
| 130 | + | assert.Contains(t, body, `<a href="/files/blob/main/docs/README.md">README.md</a>`) | |||
| 131 | + | assert.Contains(t, body, `<a href="/files/blob/main/docs/guide.md">guide.md</a>`) | |||
| 132 | + | assert.NotContains(t, body, "data-cursor", "the cursor stays on the page") | |||
| 133 | + | assertNoInline(t, body) | |||
| 134 | + | ||||
| 135 | + | // A subdirectory row can be opened in turn. | |||
| 136 | + | _, _, body = h.get("/files/tree/main/src?rows") | |||
| 137 | + | assert.Contains(t, body, `href="/files/tree/main/src/lib" data-expand="/files/tree/main/src/lib?rows" aria-expanded="false"`) | |||
| 138 | + | assert.Contains(t, body, "▸") | |||
| 139 | + | _, _, body = h.get("/files/tree/main/src/lib?rows") | |||
| 140 | + | assert.Contains(t, body, `href="/files/blob/main/src/lib/util.go"`) | |||
| 141 | + | assert.NotContains(t, body, "data-expand") | |||
| 142 | + | ||||
| 143 | + | // Not found, and private as anonymous: the 404 page, nothing of the rows. | |||
| 144 | + | for _, p := range []string{"/files/tree/main/nope?rows", "/secret-sauce/tree/main/dir?rows", "/secret-sauce/tree/main?rows"} { | |||
| 145 | + | status, _, body = h.get(p) | |||
| 146 | + | assert.Equal(t, 404, status, p) | |||
| 147 | + | assert.NotContains(t, body, "a.txt", p) | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | // ?rows on a directory with more entries than are listed ends with the row | |||
| 152 | + | // that links to the directory page, which says the listing is cut. | |||
| 153 | + | func TestWebTreeRowsTruncated(t *testing.T) { | |||
| 154 | + | h := newHarness(t, harnessOpts{}) | |||
| 155 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name("wide"), Visibility: domain.VisibilityPublic}) | |||
| 156 | + | require.NoError(t, err) | |||
| 157 | + | wt := h.t.TempDir() | |||
| 158 | + | h.mustGit(wt, "init", "-q", "--initial-branch=main") | |||
| 159 | + | // The limit is domain.MaxTreeEntries: one commit with a few more. | |||
| 160 | + | n := domain.MaxTreeEntries + 3 | |||
| 161 | + | require.NoError(t, os.MkdirAll(filepath.Join(wt, "many"), 0o755)) | |||
| 162 | + | for i := range n { | |||
| 163 | + | require.NoError(t, os.WriteFile(filepath.Join(wt, "many", fmt.Sprintf("f%05d", i)), nil, 0o644)) | |||
| 164 | + | } | |||
| 165 | + | require.NoError(t, os.WriteFile(filepath.Join(wt, "small.txt"), []byte("x\n"), 0o644)) | |||
| 166 | + | h.mustGit(wt, "add", ".") | |||
| 167 | + | h.mustGit(wt, "commit", "-q", "-m", "wide") | |||
| 168 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/wide.git"), "main") | |||
| 169 | + | ||||
| 170 | + | status, _, body := h.get("/wide/tree/main/many?rows") | |||
| 171 | + | require.Equal(t, 200, status) | |||
| 172 | + | assert.Equal(t, domain.MaxTreeEntries+1, strings.Count(body, "<tr"), "the listed entries and the row that says there are more") | |||
| 173 | + | assert.Contains(t, body, `<tr class="tree-more"><td colspan="7"><a href="/wide/tree/main/many">more entries: open the directory</a></td></tr>`) | |||
| 174 | + | assert.Equal(t, 1, strings.Count(body, "tree-more")) | |||
| 175 | + | ||||
| 176 | + | // A directory that fits has no such row. | |||
| 177 | + | _, _, body = h.get("/wide/tree?rows") | |||
| 178 | + | assert.NotContains(t, body, "tree-more") | |||
| 179 | + | } | |||
| 180 | + | ||||
| 103 | // Ref names with a slash are found by their longest prefix, and a commit id | 181 | // Ref names with a slash are found by their longest prefix, and a commit id | ||
| 104 | // works where a ref does. | 182 | // works where a ref does. | ||
| 105 | func TestWebFilesRefs(t *testing.T) { | 183 | func TestWebFilesRefs(t *testing.T) { | ||
| @@ -248,6 +326,19 @@ func TestWebRaw(t *testing.T) { | |||||
| 248 | require.NoError(t, err) | 326 | require.NoError(t, err) | ||
| 249 | _ = resp.Body.Close() | 327 | _ = resp.Body.Close() | ||
| 250 | assert.Equal(t, http.StatusNotModified, resp.StatusCode) | 328 | assert.Equal(t, http.StatusNotModified, resp.StatusCode) | ||
| 329 | + | // A list of validators, weak ones and "*" match as well. | |||
| 330 | + | for _, inm := range []string{`"other", ` + etag, "W/" + etag, "*"} { | |||
| 331 | + | req.Header.Set("If-None-Match", inm) | |||
| 332 | + | resp, err = http.DefaultClient.Do(req) | |||
| 333 | + | require.NoError(t, err) | |||
| 334 | + | _ = resp.Body.Close() | |||
| 335 | + | assert.Equal(t, http.StatusNotModified, resp.StatusCode, inm) | |||
| 336 | + | } | |||
| 337 | + | req.Header.Set("If-None-Match", `"other", "another"`) | |||
| 338 | + | resp, err = http.DefaultClient.Do(req) | |||
| 339 | + | require.NoError(t, err) | |||
| 340 | + | _ = resp.Body.Close() | |||
| 341 | + | assert.Equal(t, http.StatusOK, resp.StatusCode, "no validator matches") | |||
| 251 | assert.Equal(t, http.StatusOK, h.status(http.MethodHead, "/files/raw/main/notes.txt", nil)) | 342 | assert.Equal(t, http.StatusOK, h.status(http.MethodHead, "/files/raw/main/notes.txt", nil)) | ||
| 252 | } | 343 | } | ||
| 253 | 344 | ||||
| @@ -331,3 +422,167 @@ func TestWebFilesContentSecurity(t *testing.T) { | |||||
| 331 | assert.Contains(t, sheet, ".chroma .kn { color: var(--syn-keyword)") | 422 | assert.Contains(t, sheet, ".chroma .kn { color: var(--syn-keyword)") | ||
| 332 | assert.NotRegexp(t, `\.chroma[^{]*\{[^}]*#[0-9a-fA-F]{3,6}`, sheet) | 423 | assert.NotRegexp(t, `\.chroma[^{]*\{[^}]*#[0-9a-fA-F]{3,6}`, sheet) | ||
| 333 | } | 424 | } | ||
| 425 | + | ||||
| 426 | + | // AC4: GET /<repo>/paths/<sha> lists the file paths of a commit as gzipped | |||
| 427 | + | // JSON, behind the access rule on every request, even when the list is | |||
| 428 | + | // already cached; a visitor revalidates (no-cache + ETag), the owner's copy is | |||
| 429 | + | // private. | |||
| 430 | + | func TestWebPaths(t *testing.T) { | |||
| 431 | + | c := newCLIHarness(t) | |||
| 432 | + | h := c.harness | |||
| 433 | + | h.webFilesRepo("files") | |||
| 434 | + | sha := h.bareGit("files", "rev-parse", "main") | |||
| 435 | + | ||||
| 436 | + | var want []string | |||
| 437 | + | for _, p := range strings.Split(h.bareGit("files", "ls-tree", "-r", "-z", "--name-only", "main"), "\x00") { | |||
| 438 | + | if p != "" { | |||
| 439 | + | want = append(want, p) | |||
| 440 | + | } | |||
| 441 | + | } | |||
| 442 | + | require.NotEmpty(t, want) | |||
| 443 | + | b := browser{h} | |||
| 444 | + | path := "/files/paths/" + sha | |||
| 445 | + | type list struct { | |||
| 446 | + | Paths []string `json:"paths"` | |||
| 447 | + | Truncated bool `json:"truncated"` | |||
| 448 | + | } | |||
| 449 | + | decodeList := func(t *testing.T, body string) list { | |||
| 450 | + | t.Helper() | |||
| 451 | + | var l list | |||
| 452 | + | require.NoError(t, json.Unmarshal([]byte(body), &l)) | |||
| 453 | + | return l | |||
| 454 | + | } | |||
| 455 | + | gunzip := func(t *testing.T, body string) string { | |||
| 456 | + | t.Helper() | |||
| 457 | + | zr, err := gzip.NewReader(strings.NewReader(body)) | |||
| 458 | + | require.NoError(t, err) | |||
| 459 | + | out, err := io.ReadAll(zr) | |||
| 460 | + | require.NoError(t, err) | |||
| 461 | + | return string(out) | |||
| 462 | + | } | |||
| 463 | + | ||||
| 464 | + | // Anonymous with gzip: the cached bytes as they are. The client of the | |||
| 465 | + | // harness would unzip by itself, so the encoding is asked for explicitly | |||
| 466 | + | // through a transport that does not. | |||
| 467 | + | raw := func(hdr map[string]string) page { | |||
| 468 | + | req, err := http.NewRequest(http.MethodGet, h.url(path), nil) | |||
| 469 | + | require.NoError(t, err) | |||
| 470 | + | for k, v := range hdr { | |||
| 471 | + | req.Header.Set(k, v) | |||
| 472 | + | } | |||
| 473 | + | resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req) | |||
| 474 | + | require.NoError(t, err) | |||
| 475 | + | defer func() { _ = resp.Body.Close() }() | |||
| 476 | + | out, err := io.ReadAll(resp.Body) | |||
| 477 | + | require.NoError(t, err) | |||
| 478 | + | return page{status: resp.StatusCode, hdr: resp.Header, body: string(out)} | |||
| 479 | + | } | |||
| 480 | + | gz := raw(map[string]string{"Accept-Encoding": "gzip"}) | |||
| 481 | + | require.Equal(t, 200, gz.status) | |||
| 482 | + | assert.Equal(t, "gzip", gz.hdr.Get("Content-Encoding")) | |||
| 483 | + | assert.Equal(t, "application/json", gz.hdr.Get("Content-Type")) | |||
| 484 | + | assert.Equal(t, "nosniff", gz.hdr.Get("X-Content-Type-Options")) | |||
| 485 | + | assert.Equal(t, "no-cache", gz.hdr.Get("Cache-Control")) | |||
| 486 | + | assert.Equal(t, "Accept-Encoding", gz.hdr.Get("Vary")) | |||
| 487 | + | etag := gz.hdr.Get("ETag") | |||
| 488 | + | assert.Equal(t, `"paths-`+sha+`-gz"`, etag) | |||
| 489 | + | got := decodeList(t, gunzip(t, gz.body)) | |||
| 490 | + | assert.Equal(t, want, got.Paths, "git ls-tree -r order") | |||
| 491 | + | assert.False(t, got.Truncated) | |||
| 492 | + | ||||
| 493 | + | // Without gzip: plain JSON, the same list. | |||
| 494 | + | plain := raw(nil) | |||
| 495 | + | require.Equal(t, 200, plain.status) | |||
| 496 | + | assert.Empty(t, plain.hdr.Get("Content-Encoding")) | |||
| 497 | + | assert.Equal(t, want, decodeList(t, plain.body).Paths) | |||
| 498 | + | assert.Equal(t, `"paths-`+sha+`"`, plain.hdr.Get("ETag"), "another variant, another validator") | |||
| 499 | + | ||||
| 500 | + | // Revalidation: 304, still through the access rule. The matrix of | |||
| 501 | + | // validators (variants, weak, "*") is in paths_test.go. | |||
| 502 | + | nm := raw(map[string]string{"If-None-Match": etag, "Accept-Encoding": "gzip"}) | |||
| 503 | + | assert.Equal(t, http.StatusNotModified, nm.status) | |||
| 504 | + | assert.Empty(t, nm.body) | |||
| 505 | + | assert.Equal(t, "no-cache", nm.hdr.Get("Cache-Control")) | |||
| 506 | + | ||||
| 507 | + | // Only a full commit id: abbreviations, refs, upper case and unknown ids. | |||
| 508 | + | for _, p := range []string{ | |||
| 509 | + | "/files/paths/" + sha[:7], "/files/paths/" + sha[:39], "/files/paths/main", "/files/paths/" + strings.ToUpper(sha), | |||
| 510 | + | "/files/paths/" + strings.Repeat("0", 40), "/files/paths/", "/never/paths/" + sha, | |||
| 511 | + | } { | |||
| 512 | + | assert.Equal(t, 404, b.get(p, nil).status, p) | |||
| 513 | + | } | |||
| 514 | + | ||||
| 515 | + | // Another repository does not serve a commit it does not have, though | |||
| 516 | + | // the list of that commit is cached. | |||
| 517 | + | h.seed("other", domain.VisibilityPublic, "x.txt") | |||
| 518 | + | assert.Equal(t, 404, b.get("/other/paths/"+sha, nil).status) | |||
| 519 | + | ||||
| 520 | + | // The owner: private, never stored; the list is the same. | |||
| 521 | + | token := c.signIn(t) | |||
| 522 | + | own := b.get(path, as(token)) | |||
| 523 | + | require.Equal(t, 200, own.status) | |||
| 524 | + | assert.Equal(t, "private, no-store", own.hdr.Get("Cache-Control")) | |||
| 525 | + | assert.Contains(t, own.hdr.Values("Vary"), "Cookie") | |||
| 526 | + | assert.Equal(t, want, decodeList(t, own.body).Paths) | |||
| 527 | + | ||||
| 528 | + | // The repository turns private: the list is cached on the server, and | |||
| 529 | + | // anonymous gets the 404 of a missing repository, as the owner still | |||
| 530 | + | // reads it. | |||
| 531 | + | priv := domain.VisibilityPrivate | |||
| 532 | + | _, err := h.forge.Repos.Update(h.ctx, domain.Root(), h.name("files"), app.UpdateParams{Visibility: &priv}) | |||
| 533 | + | require.NoError(t, err) | |||
| 534 | + | for _, hdr := range []map[string]string{nil, {"Accept-Encoding": "gzip"}, {"If-None-Match": etag}} { | |||
| 535 | + | p := b.get(path, hdr) | |||
| 536 | + | assert.Equal(t, 404, p.status) | |||
| 537 | + | assert.NotContains(t, p.body, "README.md") | |||
| 538 | + | assert.NotContains(t, p.hdr.Get("Content-Type"), "json") | |||
| 539 | + | } | |||
| 540 | + | assert.Equal(t, 200, b.get(path, as(token)).status) | |||
| 541 | + | } | |||
| 542 | + | ||||
| 543 | + | // AC3, AC4: the Files page carries the finder and the data it needs, on the | |||
| 544 | + | // commit the page shows, however the URL spelled it; the other pages carry | |||
| 545 | + | // the paths of their commit and no finder. | |||
| 546 | + | func TestWebFinderPage(t *testing.T) { | |||
| 547 | + | h := newHarness(t, harnessOpts{}) | |||
| 548 | + | h.webFilesRepo("files") | |||
| 549 | + | sha := h.bareGit("files", "rev-parse", "main") | |||
| 550 | + | attr := func(body, name string) string { | |||
| 551 | + | m := regexp.MustCompile(`<body[^>]*\b` + name + `="([^"]*)"`).FindStringSubmatch(body) | |||
| 552 | + | if m == nil { | |||
| 553 | + | return "<none>" | |||
| 554 | + | } | |||
| 555 | + | return m[1] | |||
| 556 | + | } | |||
| 557 | + | ||||
| 558 | + | for _, tt := range []struct{ name, path, paths, blob string }{ | |||
| 559 | + | {"branch", "/files/tree/main", "/files/paths/" + sha, "/files/blob/main"}, | |||
| 560 | + | {"subdirectory", "/files/tree/main/docs", "/files/paths/" + sha, "/files/blob/main"}, | |||
| 561 | + | {"slash ref", "/files/tree/feature/x", "/files/paths/" + h.bareGit("files", "rev-parse", "feature/x"), "/files/blob/feature/x"}, | |||
| 562 | + | {"tag", "/files/tree/v1.0", "/files/paths/" + sha, "/files/blob/v1.0"}, | |||
| 563 | + | {"full id", "/files/tree/" + sha, "/files/paths/" + sha, "/files/blob/" + sha}, | |||
| 564 | + | {"abbreviated id", "/files/tree/" + sha[:7], "/files/paths/" + sha, "/files/blob/" + sha[:7]}, | |||
| 565 | + | {"blob page", "/files/blob/main/main.go", "/files/paths/" + sha, "/files/blob/main"}, | |||
| 566 | + | {"blob at full id", "/files/blob/" + sha + "/main.go", "/files/paths/" + sha, "/files/blob/" + sha}, | |||
| 567 | + | {"blob at abbreviated id", "/files/blob/" + sha[:7] + "/main.go", "/files/paths/" + sha, "/files/blob/" + sha[:7]}, | |||
| 568 | + | } { | |||
| 569 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 570 | + | status, _, body := h.get(tt.path) | |||
| 571 | + | require.Equal(t, 200, status) | |||
| 572 | + | assert.Equal(t, tt.paths, attr(body, "data-paths")) | |||
| 573 | + | assert.Equal(t, tt.blob, attr(body, "data-blob")) | |||
| 574 | + | if strings.Contains(tt.path, "/tree/") { | |||
| 575 | + | assert.Contains(t, body, `id="finder-q"`) | |||
| 576 | + | assert.Contains(t, body, `id="finder-list"`) | |||
| 577 | + | assert.Contains(t, body, `placeholder="Find a file…"`) | |||
| 578 | + | } else { | |||
| 579 | + | assert.NotContains(t, body, `id="finder-q"`) | |||
| 580 | + | } | |||
| 581 | + | }) | |||
| 582 | + | } | |||
| 583 | + | // No commit to search: no data. | |||
| 584 | + | _, _, body := h.get("/nothing-here") | |||
| 585 | + | assert.Equal(t, "<none>", attr(body, "data-paths")) | |||
| 586 | + | _, _, body = h.get("/files/refs") | |||
| 587 | + | assert.Equal(t, "/files/paths/"+sha, attr(body, "data-paths")) | |||
| 588 | + | } | |||
M go.mod +1 -1
| @@ -12,7 +12,7 @@ tool ( | |||||
| 12 | ) | 12 | ) | ||
| 13 | 13 | ||||
| 14 | require ( | 14 | require ( | ||
| 15 | - | bnema.dev/ermite-api v0.0.0-20261011052945-e9394b717bc2 | 15 | + | bnema.dev/ermite-api v0.0.0-20261011182719-cb5699451db6 |
| 16 | bnema.dev/ermite-git v0.1.0 | 16 | bnema.dev/ermite-git v0.1.0 | ||
| 17 | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 | 17 | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 | ||
| 18 | connectrpc.com/connect v1.21.0 | 18 | connectrpc.com/connect v1.21.0 | ||
M go.sum +2 -2 generated file: collapsed
M internal/adapters/ermitegit/bench_test.go +4 -0
| @@ -163,6 +163,10 @@ func BenchmarkRepoReader(b *testing.B) { | |||||
| 163 | _, err = r.GetTree(ctx, t.name, domain.Revision{}, path(t.treeDir), true) | 163 | _, err = r.GetTree(ctx, t.name, domain.Revision{}, path(t.treeDir), true) | ||
| 164 | return | 164 | return | ||
| 165 | }}, | 165 | }}, | ||
| 166 | + | {"ListPaths", func(r ports.RepoReader) (err error) { | |||
| 167 | + | _, err = r.ListPaths(ctx, t.name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes) | |||
| 168 | + | return | |||
| 169 | + | }}, | |||
| 166 | {"OpenBlob/small", func(r ports.RepoReader) error { return drain(r, domain.Revision{}, path(t.file)) }}, | 170 | {"OpenBlob/small", func(r ports.RepoReader) error { return drain(r, domain.Revision{}, path(t.file)) }}, | ||
| 167 | {"OpenBlob/largest", func(r ports.RepoReader) error { return drain(r, domain.Revision{}, path(t.big)) }}, | 171 | {"OpenBlob/largest", func(r ports.RepoReader) error { return drain(r, domain.Revision{}, path(t.big)) }}, | ||
| 168 | {"ListCommits/page1", func(r ports.RepoReader) (err error) { | 172 | {"ListCommits/page1", func(r ports.RepoReader) (err error) { | ||
M internal/adapters/ermitegit/convert.go +98 -0
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "errors" | 8 | "errors" | ||
| 9 | "fmt" | 9 | "fmt" | ||
| 10 | "io" | 10 | "io" | ||
| 11 | + | "strings" | |||
| 11 | "time" | 12 | "time" | ||
| 12 | 13 | ||||
| 13 | git "bnema.dev/ermite-git" | 14 | git "bnema.dev/ermite-git" | ||
| @@ -326,6 +327,103 @@ func getTree(ctx context.Context, r *git.Repo, rev domain.Revision, path domain. | |||||
| 326 | return tree, nil | 327 | return tree, nil | ||
| 327 | } | 328 | } | ||
| 328 | 329 | ||||
| 330 | + | // pathChunk is the size of the blocks that hold the bytes of the paths. | |||
| 331 | + | const pathChunk = 32 << 10 | |||
| 332 | + | ||||
| 333 | + | // pathWalker lists the files below a tree, depth first, in tree order. | |||
| 334 | + | type pathWalker struct { | |||
| 335 | + | ctx context.Context | |||
| 336 | + | r *git.Repo | |||
| 337 | + | maxPaths int | |||
| 338 | + | maxBytes int | |||
| 339 | + | ||||
| 340 | + | // list collects the paths. Their bytes live in chunks: a chunk is filled | |||
| 341 | + | // by a strings.Builder, so a path is read back as a string without a copy, | |||
| 342 | + | // and a full chunk is left to the paths that point into it instead of | |||
| 343 | + | // being grown and copied. | |||
| 344 | + | list domain.PathList | |||
| 345 | + | chunk strings.Builder | |||
| 346 | + | prefix []byte // "dir/sub/" of the directory being read | |||
| 347 | + | bad error | |||
| 348 | + | } | |||
| 349 | + | ||||
| 350 | + | // listPaths lists the files and symbolic links of a revision recursively. | |||
| 351 | + | func listPaths(ctx context.Context, r *git.Repo, rev domain.Revision, maxPaths, maxBytes int) (domain.PathList, error) { | |||
| 352 | + | commit, root, err := commitTree(r, rev) | |||
| 353 | + | if err != nil { | |||
| 354 | + | return domain.PathList{}, err | |||
| 355 | + | } | |||
| 356 | + | treeID, err := domainID(root) | |||
| 357 | + | if err != nil { | |||
| 358 | + | return domain.PathList{}, err | |||
| 359 | + | } | |||
| 360 | + | w := &pathWalker{ctx: ctx, r: r, maxPaths: maxPaths, maxBytes: maxBytes} | |||
| 361 | + | w.list.CommitID, w.list.TreeID = commit, treeID | |||
| 362 | + | if err := w.walk(root); err != nil { | |||
| 363 | + | return domain.PathList{}, err | |||
| 364 | + | } | |||
| 365 | + | return w.list, nil | |||
| 366 | + | } | |||
| 367 | + | ||||
| 368 | + | // walk lists one directory. The recursion is inside the callback of ReadTree, | |||
| 369 | + | // where the entries of a subdirectory come at the place of the subdirectory, | |||
| 370 | + | // like in `git ls-tree -r`; the callback can only say stop, so the reasons to | |||
| 371 | + | // stop are kept in the walker. Every ReadTree takes its own pooled buffer, so | |||
| 372 | + | // the nesting is safe. | |||
| 373 | + | func (w *pathWalker) walk(tree git.ObjectID) error { | |||
| 374 | + | if err := w.ctx.Err(); err != nil { | |||
| 375 | + | return err | |||
| 376 | + | } | |||
| 377 | + | err := w.r.ReadTree(tree, func(e git.Entry) bool { | |||
| 378 | + | switch { | |||
| 379 | + | case e.Mode.IsGitlink(): | |||
| 380 | + | return true | |||
| 381 | + | case e.Mode.IsTree(): | |||
| 382 | + | mark := len(w.prefix) | |||
| 383 | + | w.prefix = append(w.prefix, e.Name...) | |||
| 384 | + | // The paths below a directory that is not addressable are not | |||
| 385 | + | // either: this also bounds the depth of the recursion. | |||
| 386 | + | if !domain.IsAddressablePathBytes(w.prefix) { | |||
| 387 | + | w.prefix = w.prefix[:mark] | |||
| 388 | + | return true | |||
| 389 | + | } | |||
| 390 | + | w.prefix = append(w.prefix, '/') | |||
| 391 | + | err := w.walk(e.ID) | |||
| 392 | + | w.prefix = w.prefix[:mark] | |||
| 393 | + | if err != nil { | |||
| 394 | + | w.bad = err | |||
| 395 | + | return false | |||
| 396 | + | } | |||
| 397 | + | return !w.list.Truncated | |||
| 398 | + | case e.Mode.IsRegular(), e.Mode.IsSymlink(): | |||
| 399 | + | return w.add(e.Name) | |||
| 400 | + | } | |||
| 401 | + | w.bad = fmt.Errorf("unknown mode %o of %q", uint32(e.Mode), e.Name) | |||
| 402 | + | return false | |||
| 403 | + | }) | |||
| 404 | + | if err == nil { | |||
| 405 | + | err = w.bad | |||
| 406 | + | } | |||
| 407 | + | return err | |||
| 408 | + | } | |||
| 409 | + | ||||
| 410 | + | // add lists the file name of the current directory, and says whether the walk | |||
| 411 | + | // goes on. PathList.Add filters the paths that are not addressable; their | |||
| 412 | + | // bytes stay in the chunk, which is rare enough not to matter. | |||
| 413 | + | func (w *pathWalker) add(name []byte) bool { | |||
| 414 | + | mark := len(w.prefix) | |||
| 415 | + | w.prefix = append(w.prefix, name...) | |||
| 416 | + | path := w.prefix | |||
| 417 | + | w.prefix = w.prefix[:mark] | |||
| 418 | + | if w.chunk.Cap()-w.chunk.Len() < len(path) { | |||
| 419 | + | w.chunk = strings.Builder{} | |||
| 420 | + | w.chunk.Grow(max(pathChunk, len(path))) | |||
| 421 | + | } | |||
| 422 | + | start := w.chunk.Len() | |||
| 423 | + | w.chunk.Write(path) | |||
| 424 | + | return w.list.Add(w.chunk.String()[start:], w.maxPaths, w.maxBytes) | |||
| 425 | + | } | |||
| 426 | + | ||||
| 329 | // fillLastCommits sets the last commit of the first MaxLastCommitEntries | 427 | // fillLastCommits sets the last commit of the first MaxLastCommitEntries | ||
| 330 | // entries with one walk. | 428 | // entries with one walk. | ||
| 331 | // | 429 | // | ||
M internal/adapters/ermitegit/ermitegit.go +18 -1
| @@ -94,12 +94,14 @@ type Routes struct { | |||||
| 94 | ListCommits Backend | 94 | ListCommits Backend | ||
| 95 | // ListCommitsPath is a page of the history of a file or a directory. | 95 | // ListCommitsPath is a page of the history of a file or a directory. | ||
| 96 | ListCommitsPath Backend | 96 | ListCommitsPath Backend | ||
| 97 | + | // ListPaths is the recursive list of the file paths of a revision. | |||
| 98 | + | ListPaths Backend | |||
| 97 | } | 99 | } | ||
| 98 | 100 | ||||
| 99 | // AllRoutes returns routes that send every method to b. The tests and the | 101 | // AllRoutes returns routes that send every method to b. The tests and the | ||
| 100 | // benchmarks use it to exercise a backend whatever the production routes are. | 102 | // benchmarks use it to exercise a backend whatever the production routes are. | ||
| 101 | func AllRoutes(b Backend) Routes { | 103 | func AllRoutes(b Backend) Routes { | ||
| 102 | - | return Routes{b, b, b, b, b, b, b, b, b} | 104 | + | return Routes{b, b, b, b, b, b, b, b, b, b} |
| 103 | } | 105 | } | ||
| 104 | 106 | ||||
| 105 | // Config configures an Adapter. | 107 | // Config configures an Adapter. | ||
| @@ -315,6 +317,21 @@ func (a *Adapter) ResolveRefPath(ctx context.Context, repo domain.RepoName, path | |||||
| 315 | }, cli) | 317 | }, cli) | ||
| 316 | } | 318 | } | ||
| 317 | 319 | ||||
| 320 | + | // ListPaths lists the file and symbolic link paths of a revision. | |||
| 321 | + | func (a *Adapter) ListPaths(ctx context.Context, repo domain.RepoName, rev domain.Revision, maxPaths, maxBytes int) (domain.PathList, error) { | |||
| 322 | + | cli := func() (domain.PathList, error) { return a.cli.ListPaths(ctx, repo, rev, maxPaths, maxBytes) } | |||
| 323 | + | if a.routes.ListPaths == CLI { | |||
| 324 | + | return cli() | |||
| 325 | + | } | |||
| 326 | + | return serve(ctx, a, repo, func(r *git.Repo) (domain.PathList, error) { | |||
| 327 | + | list, err := listPaths(ctx, r, rev, maxPaths, maxBytes) | |||
| 328 | + | if err != nil { | |||
| 329 | + | return domain.PathList{}, fmt.Errorf("paths of %q: %w", repo, err) | |||
| 330 | + | } | |||
| 331 | + | return list, nil | |||
| 332 | + | }, cli) | |||
| 333 | + | } | |||
| 334 | + | ||||
| 318 | // GetTree lists a directory. | 335 | // GetTree lists a directory. | ||
| 319 | func (a *Adapter) GetTree(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error) { | 336 | func (a *Adapter) GetTree(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error) { | ||
| 320 | cli := func() (domain.Tree, error) { return a.cli.GetTree(ctx, repo, rev, path, includeLastCommit) } | 337 | cli := func() (domain.Tree, error) { return a.cli.GetTree(ctx, repo, rev, path, includeLastCommit) } | ||
M internal/adapters/ermitegit/routes.go +17 -6
| @@ -12,7 +12,8 @@ package ermitegit | |||||
| 12 | // win: the constants GetTree, GetTreeLastCommits, ResolveRevisionAbbrev and | 12 | // win: the constants GetTree, GetTreeLastCommits, ResolveRevisionAbbrev and | ||
| 13 | // ListCommitsPath cover directories of any size and files of any history. A | 13 | // ListCommitsPath cover directories of any size and files of any history. A | ||
| 14 | // method that does not win stays on the CLI. GetCommit is not routable: the | 14 | // method that does not win stays on the CLI. GetCommit is not routable: the | ||
| 15 | - | // diff stays on git. | 15 | + | // diff stays on git. ListPaths has one shape: the fixture lists 131 files and |
| 16 | + | // lazygit 2,180. | |||
| 16 | // | 17 | // | ||
| 17 | // Warm is a repository that is open; cold closes it before every call, the | 18 | // Warm is a repository that is open; cold closes it before every call, the | ||
| 18 | // price of the first read after the idle TTL, an eviction or a restart (a | 19 | // price of the first read after the idle TTL, an eviction or a restart (a | ||
| @@ -67,6 +68,10 @@ package ermitegit | |||||
| 67 | // GetTree/dir+last fixture cold 5.4ms 1.8M 1455 316µs 247.6K 323 win | 68 | // GetTree/dir+last fixture cold 5.4ms 1.8M 1455 316µs 247.6K 323 win | ||
| 68 | // GetTree/dir+last lazygit warm 195.2ms 20.5M 17116 7.9ms 1.5M 855 win | 69 | // GetTree/dir+last lazygit warm 195.2ms 20.5M 17116 7.9ms 1.5M 855 win | ||
| 69 | // GetTree/dir+last lazygit cold 195.9ms 20.5M 17107 14.8ms 5.1M 6989 win | 70 | // GetTree/dir+last lazygit cold 195.9ms 20.5M 17107 14.8ms 5.1M 6989 win | ||
| 71 | + | // ListPaths fixture warm 760µs 118.4K 225 41µs 37.9K 30 win | |||
| 72 | + | // ListPaths fixture cold 771µs 118.4K 225 107µs 54.7K 160 win | |||
| 73 | + | // ListPaths lazygit warm 1.8ms 432.9K 2280 842µs 250.3K 477 win | |||
| 74 | + | // ListPaths lazygit cold 1.8ms 432.9K 2280 1.2ms 298.7K 749 win | |||
| 70 | // OpenBlob/small fixture warm 46µs 6.1K 25 17µs 5.8K 14 win | 75 | // OpenBlob/small fixture warm 46µs 6.1K 25 17µs 5.8K 14 win | ||
| 71 | // OpenBlob/small fixture cold 47µs 6.1K 25 87µs 27.7K 147 loses tba | 76 | // OpenBlob/small fixture cold 47µs 6.1K 25 87µs 27.7K 147 loses tba | ||
| 72 | // OpenBlob/small lazygit warm 51µs 5.4K 25 29µs 7.0K 42 loses ba | 77 | // OpenBlob/small lazygit warm 51µs 5.4K 25 29µs 7.0K 42 loses ba | ||
| @@ -103,11 +108,16 @@ package ermitegit | |||||
| 103 | // work after the open (ListRefs, GetTree, ResolveRefPath: 150 to 200 | 108 | // work after the open (ListRefs, GetTree, ResolveRefPath: 150 to 200 | ||
| 104 | // against 60 to 140), and in time too for the ones that cost a few | 109 | // against 60 to 140), and in time too for the ones that cost a few | ||
| 105 | // microseconds warm (ResolveRevision: 65 µs against 9 to 63 µs). | 110 | // microseconds warm (ResolveRevision: 65 µs against 9 to 63 µs). | ||
| 106 | - | // - Only the history of a revision (ListCommits page 1 and deep) wins every | 111 | + | // - Only the history of a revision (ListCommits page 1 and deep) and the |
| 107 | - | // line, so it is the only method routed. GetTree dir+last also wins every | 112 | + | // recursive path list (ListPaths) win every line, so they are the only |
| 108 | - | // line, but GetTreeLastCommits covers GetTree root+last too, which loses | 113 | + | // methods routed. ListPaths wins even cold because one walk replaces a |
| 109 | - | // cold allocations on lazygit (4204 against 3073): it is the nearest | 114 | + | // process and the parsing of its output (the bytes of the paths are kept in |
| 110 | - | // miss, and the first to flip once opening a repository allocates less. | 115 | + | // 32 KiB blocks, which is what brought the lazygit bytes under the CLI's: |
| 116 | + | // a single growing buffer lost them, 699K against 433K). GetTree dir+last | |||
| 117 | + | // also wins every line, but GetTreeLastCommits covers GetTree root+last | |||
| 118 | + | // too, which loses cold allocations on lazygit (4204 against 3073): it is | |||
| 119 | + | // the nearest miss, and the first to flip once opening a repository | |||
| 120 | + | // allocates less. | |||
| 111 | // - OpenBlob by size: below 1 MiB the small blob loses cold and, on lazygit, | 121 | // - OpenBlob by size: below 1 MiB the small blob loses cold and, on lazygit, | ||
| 112 | // warm bytes and allocations; above, it loses time. No size makes it win, | 122 | // warm bytes and allocations; above, it loses time. No size makes it win, | ||
| 113 | // so there is no size-based route. | 123 | // so there is no size-based route. | ||
| @@ -126,5 +136,6 @@ func DefaultRoutes() Routes { | |||||
| 126 | OpenBlob: CLI, | 136 | OpenBlob: CLI, | ||
| 127 | ListCommits: ErmiteGit, | 137 | ListCommits: ErmiteGit, | ||
| 128 | ListCommitsPath: CLI, | 138 | ListCommitsPath: CLI, | ||
| 139 | + | ListPaths: ErmiteGit, | |||
| 129 | } | 140 | } | ||
| 130 | } | 141 | } | ||
M internal/adapters/gitcli/read.go +48 -0
| @@ -288,6 +288,54 @@ func (g *Git) lookup(ctx context.Context, dir string, rev domain.Revision, path | |||||
| 288 | return obj, err | 288 | return obj, err | ||
| 289 | } | 289 | } | ||
| 290 | 290 | ||||
| 291 | + | // ListPaths lists the files and symbolic links of a revision with | |||
| 292 | + | // `git ls-tree -r -z`, in its order, up to the bounds of the port. | |||
| 293 | + | // | |||
| 294 | + | //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port | |||
| 295 | + | func (g *Git) ListPaths(ctx context.Context, repo domain.RepoName, rev domain.Revision, maxPaths, maxBytes int) (domain.PathList, error) { | |||
| 296 | + | dir, err := g.repoDir(repo) | |||
| 297 | + | if err != nil { | |||
| 298 | + | return domain.PathList{}, err | |||
| 299 | + | } | |||
| 300 | + | obj, err := g.lookup(ctx, dir, rev, domain.TreePath{}) | |||
| 301 | + | if err != nil { | |||
| 302 | + | return domain.PathList{}, fmt.Errorf("paths of %q: %w", repo, err) | |||
| 303 | + | } | |||
| 304 | + | treeID, err := domain.ParseObjectID(obj.info.ID) | |||
| 305 | + | if err != nil { | |||
| 306 | + | return domain.PathList{}, fmt.Errorf("paths of %q: %w", repo, err) | |||
| 307 | + | } | |||
| 308 | + | list := domain.PathList{CommitID: obj.commit, TreeID: treeID} | |||
| 309 | + | err = g.pipe(ctx, readArgs(dir, "ls-tree", "-r", "-z", treeID.String()), func(r *bufio.Reader) error { | |||
| 310 | + | for { | |||
| 311 | + | tok, err := readToken(r) | |||
| 312 | + | if errors.Is(err, io.EOF) { | |||
| 313 | + | return nil | |||
| 314 | + | } | |||
| 315 | + | if err != nil { | |||
| 316 | + | return err | |||
| 317 | + | } | |||
| 318 | + | meta, path, ok := strings.Cut(tok, "\t") | |||
| 319 | + | if !ok { | |||
| 320 | + | return fmt.Errorf("malformed ls-tree entry %q", tok) | |||
| 321 | + | } | |||
| 322 | + | // meta is "<mode> <type> <id>": a submodule is a commit. | |||
| 323 | + | _, rest, _ := strings.Cut(meta, " ") | |||
| 324 | + | if typ, _, _ := strings.Cut(rest, " "); typ == "commit" { | |||
| 325 | + | continue | |||
| 326 | + | } | |||
| 327 | + | // A copy: path would keep the whole "<mode> <type> <id>\t" entry alive. | |||
| 328 | + | if !list.Add(strings.Clone(path), maxPaths, maxBytes) { | |||
| 329 | + | return errStop | |||
| 330 | + | } | |||
| 331 | + | } | |||
| 332 | + | }) | |||
| 333 | + | if err != nil { | |||
| 334 | + | return domain.PathList{}, fmt.Errorf("paths of %q: %w", repo, err) | |||
| 335 | + | } | |||
| 336 | + | return list, nil | |||
| 337 | + | } | |||
| 338 | + | ||||
| 291 | // GetTree lists a directory with `git ls-tree -z -l`. | 339 | // GetTree lists a directory with `git ls-tree -z -l`. | ||
| 292 | // | 340 | // | ||
| 293 | //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port | 341 | //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port | ||
M internal/adapters/rpc/api.go +1 -1
| @@ -27,7 +27,7 @@ const MaxRequestBytes = 1 << 20 | |||||
| 27 | 27 | ||||
| 28 | // Features are the optional capabilities of the contract that this server | 28 | // Features are the optional capabilities of the contract that this server | ||
| 29 | // implements, as GetVersion lists them. | 29 | // implements, as GetVersion lists them. | ||
| 30 | - | var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session"} | 30 | + | var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"} |
| 31 | 31 | ||||
| 32 | // Authenticator turns a token secret into a principal, or | 32 | // Authenticator turns a token secret into a principal, or | ||
| 33 | // domain.ErrUnauthenticated. app.Auth fits. | 33 | // domain.ErrUnauthenticated. app.Auth fits. | ||
M internal/adapters/rpc/git.go +18 -0
| @@ -85,6 +85,24 @@ func (s *gitService) GetTree(ctx context.Context, req *connect.Request[pb.GetTre | |||||
| 85 | return connect.NewResponse(out), nil | 85 | return connect.NewResponse(out), nil | ||
| 86 | } | 86 | } | ||
| 87 | 87 | ||||
| 88 | + | // ListPaths lists the paths of every file of a revision. | |||
| 89 | + | func (s *gitService) ListPaths(ctx context.Context, req *connect.Request[pb.ListPathsRequest]) (*connect.Response[pb.ListPathsResponse], error) { | |||
| 90 | + | name, rev, err := target(req.Msg.GetRepo(), req.Msg.GetRevision()) | |||
| 91 | + | if err != nil { | |||
| 92 | + | return nil, connectError(ctx, err) | |||
| 93 | + | } | |||
| 94 | + | list, err := s.browse.ListPaths(ctx, caller(ctx), name, rev) | |||
| 95 | + | if err != nil { | |||
| 96 | + | return nil, connectError(ctx, err) | |||
| 97 | + | } | |||
| 98 | + | return connect.NewResponse(&pb.ListPathsResponse{ | |||
| 99 | + | CommitId: list.CommitID.String(), | |||
| 100 | + | TreeId: list.TreeID.String(), | |||
| 101 | + | Paths: list.Paths, | |||
| 102 | + | Truncated: list.Truncated, | |||
| 103 | + | }), nil | |||
| 104 | + | } | |||
| 105 | + | ||||
| 88 | // GetBlob streams the blob in chunks of BlobChunkSize. The first message | 106 | // GetBlob streams the blob in chunks of BlobChunkSize. The first message | ||
| 89 | // carries the metadata and the first chunk. | 107 | // carries the metadata and the first chunk. | ||
| 90 | func (s *gitService) GetBlob(ctx context.Context, req *connect.Request[pb.GetBlobRequest], stream *connect.ServerStream[pb.GetBlobResponse]) error { | 108 | func (s *gitService) GetBlob(ctx context.Context, req *connect.Request[pb.GetBlobRequest], stream *connect.ServerStream[pb.GetBlobResponse]) error { | ||
M internal/adapters/web/ASSETS.md +1 -0
| @@ -9,6 +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`), 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 | | | |||
| 12 | | `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 | | | ||
| 13 | | `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 | | | ||
| 14 | | `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` | | ||
M internal/adapters/web/assets.go +2 -1
| @@ -23,7 +23,7 @@ const StaticPrefix = "/-/static/" | |||||
| 23 | // Assets are the URLs of the assets a page loads. Their names carry a hash of | 23 | // Assets are the URLs of the assets a page loads. Their names carry a hash of | ||
| 24 | // the content, so they can be cached for ever. | 24 | // the content, so they can be cached for ever. | ||
| 25 | type Assets struct { | 25 | type Assets struct { | ||
| 26 | - | CSS, HTMX, Live, SSE, App, Icon string | 26 | + | CSS, HTMX, Live, SSE, App, Files, Icon string |
| 27 | // Font is the UI font, preloaded by every page. | 27 | // Font is the UI font, preloaded by every page. | ||
| 28 | Font string | 28 | Font string | ||
| 29 | // Rules is the speculation rules file named by the Speculation-Rules header. | 29 | // Rules is the speculation rules file named by the Speculation-Rules header. | ||
| @@ -52,6 +52,7 @@ var scripts = []struct { | |||||
| 52 | {"hx-live.min.js", func(a *Assets, u string) { a.Live = u }}, | 52 | {"hx-live.min.js", func(a *Assets, u string) { a.Live = u }}, | ||
| 53 | {"hx-sse.min.js", func(a *Assets, u string) { a.SSE = u }}, | 53 | {"hx-sse.min.js", func(a *Assets, u string) { a.SSE = u }}, | ||
| 54 | {"ermite.js", func(a *Assets, u string) { a.App = u }}, | 54 | {"ermite.js", func(a *Assets, u string) { a.App = u }}, | ||
| 55 | + | {"files.js", func(a *Assets, u string) { a.Files = u }}, | |||
| 55 | } | 56 | } | ||
| 56 | 57 | ||||
| 57 | // sheetAssets are the files the stylesheet references as url(<dir>/<file>): | 58 | // sheetAssets are the files the stylesheet references as url(<dir>/<file>): | ||
M internal/adapters/web/blob.go +3 -0
| @@ -185,6 +185,9 @@ func (s *Server) blob(ctx context.Context, r *http.Request) (templ.Component, in | |||||
| 185 | } | 185 | } | ||
| 186 | 186 | ||||
| 187 | l := s.repoLayout(ctx, d, "tree") | 187 | l := s.repoLayout(ctx, d, "tree") | ||
| 188 | + | // A blob stream does not name its commit: an abbreviated id is resolved | |||
| 189 | + | // for the finder. | |||
| 190 | + | s.resolveCommit(ctx, d.name(), &t) | |||
| 188 | pinRef(&l, t) | 191 | pinRef(&l, t) | ||
| 189 | l.Crumbs = pathCrumbs(d.name(), "blob", t) | 192 | l.Crumbs = pathCrumbs(d.name(), "blob", t) | ||
| 190 | l.Title = visibleText(t.path) + " · " + d.name() + " · " + s.instance | 193 | l.Title = visibleText(t.path) + " · " + d.name() + " · " + s.instance | ||
A internal/adapters/web/jstest/dom.mjs +203 -0
| @@ -0,0 +1,203 @@ | |||||
| 1 | + | // A DOM just big enough for the glue of static/js/files.js: elements with | |||
| 2 | + | // attributes, dataset, children and the few selectors the script uses | |||
| 3 | + | // ("tag.class[attr][attr=v]:not([attr])", optionally after ":scope >" or | |||
| 4 | + | // "#id >"). Not a test file: the tests import it. | |||
| 5 | + | import assert from 'node:assert/strict'; | |||
| 6 | + | ||||
| 7 | + | const kebab = (k) => 'data-' + k.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase()); | |||
| 8 | + | ||||
| 9 | + | export class Text { | |||
| 10 | + | constructor(data) { | |||
| 11 | + | this.nodeType = 3; | |||
| 12 | + | this.data = data; | |||
| 13 | + | this.parent = null; | |||
| 14 | + | } | |||
| 15 | + | remove() { | |||
| 16 | + | if (!this.parent) return; | |||
| 17 | + | this.parent.nodes.splice(this.parent.nodes.indexOf(this), 1); | |||
| 18 | + | this.parent = null; | |||
| 19 | + | } | |||
| 20 | + | get textContent() { return this.data; } | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // A compound selector is checked on one element: tag, .class, #id, [attr], | |||
| 24 | + | // [attr=v], :not([attr]). `hidden` is a property of the element, as in a page. | |||
| 25 | + | function compound(sel) { | |||
| 26 | + | const re = /([a-z][a-z0-9]*)|\.([\w-]+)|#([\w-]+)|:not\(\[([\w-]+)\]\)|\[([\w-]+)(?:=(?:"([^"]*)"|([^\]]*)))?\]/y; | |||
| 27 | + | const want = { tag: null, cls: [], id: null, not: [], attrs: [] }; | |||
| 28 | + | while (re.lastIndex < sel.length) { | |||
| 29 | + | const m = re.exec(sel); | |||
| 30 | + | assert.ok(m, 'unsupported selector: ' + sel); | |||
| 31 | + | if (m[1]) want.tag = m[1].toUpperCase(); | |||
| 32 | + | else if (m[2]) want.cls.push(m[2]); | |||
| 33 | + | else if (m[3]) want.id = m[3]; | |||
| 34 | + | else if (m[4]) want.not.push(m[4]); | |||
| 35 | + | else want.attrs.push([m[5], m[6] ?? m[7]]); | |||
| 36 | + | } | |||
| 37 | + | return (el) => { | |||
| 38 | + | if (want.tag && el.tagName !== want.tag) return false; | |||
| 39 | + | if (want.id && el.id !== want.id) return false; | |||
| 40 | + | if (!want.cls.every((c) => el.className.split(/\s+/).includes(c))) return false; | |||
| 41 | + | if (want.not.some((a) => el.hasAttribute(a))) return false; | |||
| 42 | + | return want.attrs.every(([a, v]) => (v === undefined ? el.hasAttribute(a) : el.getAttribute(a) === v)); | |||
| 43 | + | }; | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | export class El { | |||
| 47 | + | constructor(tag) { | |||
| 48 | + | this.nodeType = 1; | |||
| 49 | + | this.tagName = tag.toUpperCase(); | |||
| 50 | + | this.attrs = new Map(); | |||
| 51 | + | this.nodes = []; | |||
| 52 | + | this.parent = null; | |||
| 53 | + | this.hidden = false; | |||
| 54 | + | this.className = ''; | |||
| 55 | + | this.id = ''; | |||
| 56 | + | this.value = ''; | |||
| 57 | + | this.href = ''; | |||
| 58 | + | this.tabIndex = 0; | |||
| 59 | + | this.text = ''; | |||
| 60 | + | this.style = { props: {}, setProperty(k, v) { this.props[k] = v; } }; | |||
| 61 | + | this.dataset = new Proxy({}, { | |||
| 62 | + | get: (_, k) => this.getAttribute(kebab(k)) ?? undefined, | |||
| 63 | + | set: (_, k, v) => { this.setAttribute(kebab(k), v); return true; }, | |||
| 64 | + | has: (_, k) => this.hasAttribute(kebab(k)), | |||
| 65 | + | }); | |||
| 66 | + | } | |||
| 67 | + | setAttribute(k, v) { this.attrs.set(k, String(v)); } | |||
| 68 | + | getAttribute(k) { return this.attrs.has(k) ? this.attrs.get(k) : null; } | |||
| 69 | + | hasAttribute(k) { return k === 'hidden' ? this.hidden : this.attrs.has(k); } | |||
| 70 | + | removeAttribute(k) { this.attrs.delete(k); } | |||
| 71 | + | ||||
| 72 | + | get children() { return this.nodes.filter((n) => n.nodeType === 1); } | |||
| 73 | + | get parentElement() { return this.parent; } | |||
| 74 | + | get nextElementSibling() { | |||
| 75 | + | if (!this.parent) return null; | |||
| 76 | + | const sib = this.parent.children; | |||
| 77 | + | return sib[sib.indexOf(this) + 1] || null; | |||
| 78 | + | } | |||
| 79 | + | get previousElementSibling() { | |||
| 80 | + | if (!this.parent) return null; | |||
| 81 | + | const sib = this.parent.children; | |||
| 82 | + | return sib[sib.indexOf(this) - 1] || null; | |||
| 83 | + | } | |||
| 84 | + | get textContent() { return this.text + this.nodes.map((n) => n.textContent).join(''); } | |||
| 85 | + | set textContent(v) { | |||
| 86 | + | this.nodes.forEach((n) => { n.parent = null; }); | |||
| 87 | + | this.nodes = []; | |||
| 88 | + | this.text = String(v); | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | adopt(list) { | |||
| 92 | + | return list.map((n) => { | |||
| 93 | + | const c = typeof n === 'string' ? new Text(n) : n; | |||
| 94 | + | c.remove(); | |||
| 95 | + | c.parent = this; | |||
| 96 | + | return c; | |||
| 97 | + | }); | |||
| 98 | + | } | |||
| 99 | + | append(...list) { this.nodes.push(...this.adopt(list)); } | |||
| 100 | + | replaceChildren(...list) { | |||
| 101 | + | this.nodes.forEach((n) => { n.parent = null; }); | |||
| 102 | + | this.nodes = []; | |||
| 103 | + | this.append(...list); | |||
| 104 | + | } | |||
| 105 | + | after(...list) { | |||
| 106 | + | const p = this.parent; | |||
| 107 | + | const added = p.adopt(list); | |||
| 108 | + | p.nodes.splice(p.nodes.indexOf(this) + 1, 0, ...added); | |||
| 109 | + | } | |||
| 110 | + | remove() { | |||
| 111 | + | if (!this.parent) return; | |||
| 112 | + | this.parent.nodes.splice(this.parent.nodes.indexOf(this), 1); | |||
| 113 | + | this.parent = null; | |||
| 114 | + | } | |||
| 115 | + | closest(sel) { | |||
| 116 | + | const ok = compound(sel); | |||
| 117 | + | for (let e = this; e; e = e.parent) if (ok(e)) return e; | |||
| 118 | + | return null; | |||
| 119 | + | } | |||
| 120 | + | matches(sel) { return compound(sel)(this); } | |||
| 121 | + | ||||
| 122 | + | // querySelectorAll: ":scope > c" and "#id > c" look at the children of the | |||
| 123 | + | // scope or of the element with that id; anything else at the descendants. | |||
| 124 | + | querySelectorAll(sel) { | |||
| 125 | + | const m = /^(:scope|#[\w-]+)\s*>\s*(.+)$/.exec(sel); | |||
| 126 | + | const ok = compound(m ? m[2] : sel); | |||
| 127 | + | if (m) { | |||
| 128 | + | const scope = m[1] === ':scope' ? this : this.find((e) => e.id === m[1].slice(1)); | |||
| 129 | + | return scope ? scope.children.filter(ok) : []; | |||
| 130 | + | } | |||
| 131 | + | const out = []; | |||
| 132 | + | const walk = (e) => e.children.forEach((c) => { if (ok(c)) out.push(c); walk(c); }); | |||
| 133 | + | walk(this); | |||
| 134 | + | return out; | |||
| 135 | + | } | |||
| 136 | + | querySelector(sel) { return this.querySelectorAll(sel)[0] || null; } | |||
| 137 | + | find(pred) { | |||
| 138 | + | for (const c of this.children) { | |||
| 139 | + | if (pred(c)) return c; | |||
| 140 | + | const d = c.find(pred); | |||
| 141 | + | if (d) return d; | |||
| 142 | + | } | |||
| 143 | + | return null; | |||
| 144 | + | } | |||
| 145 | + | scrollIntoView() {} | |||
| 146 | + | click() { dom.clicked.push(this); } | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | export const dom = { clicked: [], body: null, holdFrames: false, frames: new Map(), frameID: 0 }; | |||
| 150 | + | ||||
| 151 | + | // flushFrames runs the frames held since the last flush. | |||
| 152 | + | export function flushFrames() { | |||
| 153 | + | const fs = [...dom.frames.values()]; | |||
| 154 | + | dom.frames.clear(); | |||
| 155 | + | fs.forEach((f) => f()); | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | // installDom puts the globals files.js expects. parseHTML(text) answers the | |||
| 159 | + | // nodes that a template's innerHTML makes. | |||
| 160 | + | export function installDom({ parseHTML } = {}) { | |||
| 161 | + | dom.body = new El('body'); | |||
| 162 | + | dom.clicked = []; | |||
| 163 | + | const doc = { | |||
| 164 | + | body: dom.body, | |||
| 165 | + | createElement(tag) { | |||
| 166 | + | const e = new El(tag); | |||
| 167 | + | if (tag === 'template') { | |||
| 168 | + | e.content = new El('#fragment'); | |||
| 169 | + | Object.defineProperty(e, 'innerHTML', { set(text) { e.content.replaceChildren(...parseHTML(text)); } }); | |||
| 170 | + | } | |||
| 171 | + | return e; | |||
| 172 | + | }, | |||
| 173 | + | createTextNode: (data) => new Text(data), | |||
| 174 | + | getElementById: (id) => dom.body.find((e) => e.id === id), | |||
| 175 | + | querySelectorAll: (sel) => dom.body.querySelectorAll(sel), | |||
| 176 | + | querySelector: (sel) => dom.body.querySelector(sel), | |||
| 177 | + | }; | |||
| 178 | + | globalThis.window = globalThis; | |||
| 179 | + | globalThis.document = doc; | |||
| 180 | + | globalThis.location = { href: '' }; | |||
| 181 | + | // A frame runs at once, unless a test holds the frames (dom.holdFrames) to | |||
| 182 | + | // act between a keystroke and its frame, as in a browser; flushFrames runs them. | |||
| 183 | + | globalThis.requestAnimationFrame = (f) => { | |||
| 184 | + | if (!dom.holdFrames) { f(); return 0; } | |||
| 185 | + | dom.frames.set(++dom.frameID, f); | |||
| 186 | + | return dom.frameID; | |||
| 187 | + | }; | |||
| 188 | + | globalThis.cancelAnimationFrame = (id) => { dom.frames.delete(id); }; | |||
| 189 | + | return doc; | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | // el makes an element: el('li', {id, cls, attrs}, ...children). | |||
| 193 | + | export function el(tag, { id = '', cls = '', attrs = {}, ...props } = {}, ...children) { | |||
| 194 | + | const e = new El(tag); | |||
| 195 | + | e.id = id; | |||
| 196 | + | e.className = cls; | |||
| 197 | + | Object.assign(e, props); | |||
| 198 | + | for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v); | |||
| 199 | + | e.append(...children); | |||
| 200 | + | return e; | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | export const settle = () => new Promise((r) => setTimeout(r, 0)); | |||
A internal/adapters/web/jstest/files.test.mjs +344 -0
| @@ -0,0 +1,344 @@ | |||||
| 1 | + | // Tests of the pure core of the file finder (static/js/files.js). Run with | |||
| 2 | + | // node --test 'internal/adapters/web/jstest/*.test.mjs' | |||
| 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('search: rejects what is not a subsequence', () => { | |||
| 29 | + | assert.deepEqual(names(top('zzz')), []); | |||
| 30 | + | assert.deepEqual(names(top('gnirt')), [], 'order matters'); | |||
| 31 | + | assert.equal(top('zzz').all.length, 0); | |||
| 32 | + | assert.deepEqual(names(top('')), []); | |||
| 33 | + | assert.deepEqual(names(top(' ')), []); | |||
| 34 | + | assert.equal(top('q').all.length, 0, 'a character no path has'); | |||
| 35 | + | }); | |||
| 36 | + | ||||
| 37 | + | test('search: ranking', () => { | |||
| 38 | + | const r = names(top('tree')); | |||
| 39 | + | assert.ok(r.indexOf('internal/adapters/web/tree.go') >= 0); | |||
| 40 | + | assert.ok(r.indexOf('internal/adapters/web/tree.go') < r.indexOf('internal/adapters/web/static/css/30-list.css'), | |||
| 41 | + | 'basename match beats a scattered one: ' + r.join(', ')); | |||
| 42 | + | // Equal scores: the shorter path first, then the path. | |||
| 43 | + | assert.ok(r.indexOf('internal/adapters/web/tree.go') < r.indexOf('internal/adapters/web/tree.templ'), r.join(', ')); | |||
| 44 | + | assert.ok(r.indexOf('internal/adapters/web/tree.templ') < r.indexOf('internal/adapters/web/tree_templ.go'), r.join(', ')); | |||
| 45 | + | assert.ok(r.indexOf('internal/adapters/web/tree.templ') < r.indexOf('src/deeply/nested/dir/with/a/treehouse.txt'), r.join(', ')); | |||
| 46 | + | }); | |||
| 47 | + | ||||
| 48 | + | test('search: wbtree matches web/tree.templ', () => { | |||
| 49 | + | const r = names(top('wbtree')); | |||
| 50 | + | assert.ok(r.includes('internal/adapters/web/tree.templ'), r.join(', ')); | |||
| 51 | + | assert.ok(r.includes('internal/adapters/web/tree.go')); | |||
| 52 | + | }); | |||
| 53 | + | ||||
| 54 | + | test('search: basename prefix beats a deep match', () => { | |||
| 55 | + | const x = F.index(['a/b/c/d/e/main_helper.go', 'x/m/a/i/n.go', 'main.go', 'pkg/domain/admin.go']); | |||
| 56 | + | const r = F.search(x, 'main').hits.map((h) => x.paths[h.i]); | |||
| 57 | + | assert.equal(r[0], 'main.go'); | |||
| 58 | + | assert.ok(r.indexOf('a/b/c/d/e/main_helper.go') < r.indexOf('x/m/a/i/n.go'), r.join(', ')); | |||
| 59 | + | assert.ok(r.indexOf('a/b/c/d/e/main_helper.go') < r.indexOf('pkg/domain/admin.go'), r.join(', ')); | |||
| 60 | + | }); | |||
| 61 | + | ||||
| 62 | + | test('search: camelCase and separators earn bonuses', () => { | |||
| 63 | + | const x = F.index(['pkg/fooBarBaz.go', 'pkg/foobarbaz.go', 'pkg/fxoxoxbxa.go']); | |||
| 64 | + | const r = F.search(x, 'fbb').hits.map((h) => x.paths[h.i]); | |||
| 65 | + | assert.equal(r[0], 'pkg/fooBarBaz.go', r.join(', ')); | |||
| 66 | + | }); | |||
| 67 | + | ||||
| 68 | + | test('search: several terms', () => { | |||
| 69 | + | const r = names(top('web tree')); | |||
| 70 | + | assert.ok(r.length > 0); | |||
| 71 | + | assert.ok(r.slice(0, 3).every((p) => p.includes('web') && p.includes('tree')), r.join(', ')); | |||
| 72 | + | assert.ok(!r.includes('README.md')); | |||
| 73 | + | // Every term must match. | |||
| 74 | + | assert.deepEqual(names(top('web zzz')), []); | |||
| 75 | + | // The order of the terms is free. | |||
| 76 | + | assert.deepEqual(new Set(names(top('tree web'))), new Set(names(top('web tree')))); | |||
| 77 | + | }); | |||
| 78 | + | ||||
| 79 | + | test('search: top k, ties by length then path', () => { | |||
| 80 | + | const ps = []; | |||
| 81 | + | for (let i = 0; i < 200; i++) ps.push('d/file' + i + '.txt'); | |||
| 82 | + | const x = F.index(ps); | |||
| 83 | + | const r = F.search(x, 'file', 50); | |||
| 84 | + | assert.equal(r.hits.length, 50); | |||
| 85 | + | assert.equal(r.all.length, 200); | |||
| 86 | + | const got = r.hits.map((h) => x.paths[h.i]); | |||
| 87 | + | for (let i = 1; i < got.length; i++) { | |||
| 88 | + | const a = got[i - 1]; | |||
| 89 | + | const b = got[i]; | |||
| 90 | + | assert.ok(a.length < b.length || (a.length === b.length && a < b), a + ' before ' + b); | |||
| 91 | + | } | |||
| 92 | + | assert.equal(got[0], 'd/file0.txt'); | |||
| 93 | + | }); | |||
| 94 | + | ||||
| 95 | + | test('search: incremental results equal a cold search', () => { | |||
| 96 | + | const big = []; | |||
| 97 | + | let seed = 7; | |||
| 98 | + | const rnd = () => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff; | |||
| 99 | + | const words = ['web', 'tree', 'file', 'test', 'main', 'util', 'core', 'index', 'view', 'model']; | |||
| 100 | + | for (let i = 0; i < 3000; i++) { | |||
| 101 | + | const depth = 1 + Math.floor(rnd() * 4); | |||
| 102 | + | const segs = []; | |||
| 103 | + | for (let d = 0; d < depth; d++) segs.push(words[Math.floor(rnd() * words.length)] + (rnd() < 0.3 ? 'Case' : '') + Math.floor(rnd() * 20)); | |||
| 104 | + | big.push(segs.join('/') + ['.go', '.js', '.md'][Math.floor(rnd() * 3)]); | |||
| 105 | + | } | |||
| 106 | + | const x = F.index(big); | |||
| 107 | + | const queries = ['t', 'tr', 'tre', 'tree', 'tree w', 'tree we', 'tree web', 'tre web', 'tree web m']; | |||
| 108 | + | let prev = null; | |||
| 109 | + | for (const q of queries) { | |||
| 110 | + | const warm = F.search(x, q, 50, prev); | |||
| 111 | + | const cold = F.search(x, q, 50, null); | |||
| 112 | + | assert.deepEqual(warm.hits, cold.hits, q); | |||
| 113 | + | assert.deepEqual(Array.from(warm.all), Array.from(cold.all), q); | |||
| 114 | + | prev = warm; | |||
| 115 | + | } | |||
| 116 | + | // A query that does not extend the previous one searches everything. | |||
| 117 | + | const a = F.search(x, 'tree', 50, null); | |||
| 118 | + | assert.deepEqual(F.search(x, 'web', 50, a).hits, F.search(x, 'web', 50, null).hits); | |||
| 119 | + | assert.deepEqual(F.search(x, 'tre', 50, a).hits, F.search(x, 'tre', 50, null).hits, 'shortening'); | |||
| 120 | + | // A result of another index is not a source of candidates. | |||
| 121 | + | const y = F.index(['zzz']); | |||
| 122 | + | assert.deepEqual(F.search(y, 'z', 50, a).hits.length, 1); | |||
| 123 | + | }); | |||
| 124 | + | ||||
| 125 | + | test('positions', () => { | |||
| 126 | + | const p = 'internal/adapters/web/tree.go'; | |||
| 127 | + | const x = F.index([p]); | |||
| 128 | + | 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]); | |||
| 129 | + | const y = F.index(['internal/adapters/web/tree.templ']); | |||
| 130 | + | const pos = F.positions(y, 0, 'wbtree'); | |||
| 131 | + | const s = y.paths[0]; | |||
| 132 | + | assert.equal(pos.map((i) => s[i]).join(''), 'wbtree'); | |||
| 133 | + | assert.deepEqual(pos, [...pos].sort((a, b) => a - b)); | |||
| 134 | + | assert.equal(pos[0], s.indexOf('web'), 'w of the web directory'); | |||
| 135 | + | // Two terms. | |||
| 136 | + | const z = F.index(['src/web/tree.go']); | |||
| 137 | + | const both = F.positions(z, 0, 'web tree'); | |||
| 138 | + | assert.equal(both.map((i) => z.paths[0][i]).join(''), 'webtree'); | |||
| 139 | + | }); | |||
| 140 | + | ||||
| 141 | + | // Case is folded for A-Z only, and a position is an index of the original | |||
| 142 | + | // string; both are seen through search and positions. | |||
| 143 | + | test('non-ASCII paths: ASCII-only folding, positions index the original', () => { | |||
| 144 | + | const cases = [ | |||
| 145 | + | // path, query, the characters the query marks (null: no match) | |||
| 146 | + | ['İndex/Straße.go', 'index', null], // İ is not an ASCII capital I | |||
| 147 | + | ['İndex/Straße.go', 'ndex', 'ndex'], | |||
| 148 | + | ['İndex/Straße.go', 'stra', 'Stra'], | |||
| 149 | + | ['İndex/Straße.go', 'strasse', null], // ß is not ss | |||
| 150 | + | ['ABC/Def.TXT', 'abc/def.txt', 'ABC/Def.TXT'], | |||
| 151 | + | ['README.md', 'readme', 'README'], | |||
| 152 | + | ['README.md', 'README', 'README'], | |||
| 153 | + | ]; | |||
| 154 | + | for (const [path, q, want] of cases) { | |||
| 155 | + | const x = F.index([path]); | |||
| 156 | + | const r = F.search(x, q); | |||
| 157 | + | const pos = F.positions(x, 0, q); | |||
| 158 | + | if (want === null) { | |||
| 159 | + | assert.equal(r.hits.length, 0, `${q} in ${path}`); | |||
| 160 | + | assert.deepEqual(pos, [], `${q} in ${path}: nothing is claimed`); | |||
| 161 | + | } else { | |||
| 162 | + | assert.equal(r.hits.length, 1, `${q} in ${path}`); | |||
| 163 | + | assert.equal(pos.map((i) => path[i]).join(''), want, `${q} in ${path}`); | |||
| 164 | + | } | |||
| 165 | + | } | |||
| 166 | + | assert.equal(F.positions(F.index(['İndex/Straße.go']), 0, 'ndex')[0], 1, 'the İ before it counts as one'); | |||
| 167 | + | }); | |||
| 168 | + | ||||
| 169 | + | test('positions of a long path (greedy)', () => { | |||
| 170 | + | const long = 'a/'.repeat(600) + 'target.txt'; | |||
| 171 | + | const x = F.index([long]); | |||
| 172 | + | const r = F.search(x, 'target'); | |||
| 173 | + | assert.equal(r.hits.length, 1); | |||
| 174 | + | const pos = F.positions(x, 0, 'target'); | |||
| 175 | + | assert.equal(pos.map((i) => long[i]).join(''), 'target'); | |||
| 176 | + | }); | |||
| 177 | + | ||||
| 178 | + | test('visible', () => { | |||
| 179 | + | assert.equal(F.visible('a\u202eb'), 'a<U+202E>b'); | |||
| 180 | + | assert.equal(F.visible('x\u200by'), 'x<U+200B>y'); | |||
| 181 | + | assert.equal(F.visible('a\u0007b'), 'a<U+0007>b'); | |||
| 182 | + | assert.equal(F.visible('\u3164'), '<U+3164>'); | |||
| 183 | + | assert.equal(F.visible('\u2028'), '<U+2028>'); | |||
| 184 | + | assert.equal(F.visible('plain/ünï.go'), 'plain/ünï.go'); | |||
| 185 | + | assert.equal(F.visible('tab\tname'), 'tab<U+0009>name', 'the finder shows a control character; Go refuses such paths anyway'); | |||
| 186 | + | }); | |||
| 187 | + | ||||
| 188 | + | test('performance on 100,000 paths (AC6)', () => { | |||
| 189 | + | const dirs = ['internal', 'cmd', 'pkg', 'src', 'lib', 'docs', 'test', 'web', 'adapters', 'domain', 'app', 'static']; | |||
| 190 | + | const names = ['tree', 'blob', 'commit', 'diff', 'index', 'main', 'util', 'handler', 'service', 'reader', 'writer', 'config']; | |||
| 191 | + | const exts = ['.go', '.js', '.md', '.templ', '.css', '.txt']; | |||
| 192 | + | const paths = []; | |||
| 193 | + | for (let i = 0; paths.length < 100000; i++) { | |||
| 194 | + | const d = [dirs[i % 12], dirs[(i * 7) % 12], dirs[(i * 13 + 5) % 12], 'pkg' + (i % 311)]; | |||
| 195 | + | paths.push(d.join('/') + '/' + names[(i * 5) % 12] + '_' + (i % 977) + names[(i >> 3) % 12] + exts[i % 6]); | |||
| 196 | + | } | |||
| 197 | + | let t0 = performance.now(); | |||
| 198 | + | const x = F.index(paths); | |||
| 199 | + | const build = performance.now() - t0; | |||
| 200 | + | ||||
| 201 | + | const timings = []; | |||
| 202 | + | let prev = null; | |||
| 203 | + | let worst = 0; | |||
| 204 | + | for (const q of ['t', 'tr', 'tre', 'tree', 'tree h', 'wbtree', 'internal handler', 'zzzq']) { | |||
| 205 | + | t0 = performance.now(); | |||
| 206 | + | const r = F.search(x, q, 50, prev); | |||
| 207 | + | const ms = performance.now() - t0; | |||
| 208 | + | worst = Math.max(worst, ms); | |||
| 209 | + | timings.push(q + ': ' + ms.toFixed(1) + ' ms (' + r.all.length + ' matches)'); | |||
| 210 | + | prev = r; | |||
| 211 | + | } | |||
| 212 | + | // The cold query of a single character is the worst case: every path matches. | |||
| 213 | + | t0 = performance.now(); | |||
| 214 | + | F.search(x, 'e'); | |||
| 215 | + | const cold = performance.now() - t0; | |||
| 216 | + | t0 = performance.now(); | |||
| 217 | + | F.search(x, 'inthand'); | |||
| 218 | + | const cold2 = performance.now() - t0; | |||
| 219 | + | t0 = performance.now(); | |||
| 220 | + | const r = F.search(x, 'tree h'); | |||
| 221 | + | for (const h of r.hits) F.positions(x, h.i, 'tree h'); | |||
| 222 | + | const withPos = performance.now() - t0; | |||
| 223 | + | console.log(`AC6 index of ${paths.length} paths: ${build.toFixed(1)} ms (target < 150, asserted < 1500)`); | |||
| 224 | + | console.log(`AC6 queries (incremental): ${timings.join('; ')}`); | |||
| 225 | + | 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)`); | |||
| 226 | + | assert.ok(build < 1500, 'index ' + build); | |||
| 227 | + | assert.ok(Math.max(worst, cold, cold2, withPos) < 300, 'query ' + Math.max(worst, cold, cold2, withPos)); | |||
| 228 | + | }); | |||
| 229 | + | ||||
| 230 | + | // ---- a brute-force reference of the scoring -------------------------------- | |||
| 231 | + | ||||
| 232 | + | // The reference tries every placement of the term in the path and scores it by | |||
| 233 | + | // the documented rules; the DP of files.js must find the same best score. | |||
| 234 | + | function refBonus(orig, j) { | |||
| 235 | + | if (j === 0) return 10; | |||
| 236 | + | const p = orig[j - 1]; | |||
| 237 | + | const c = orig[j]; | |||
| 238 | + | if (p === '/') return 10; | |||
| 239 | + | if ('_-. '.includes(p)) return 8; | |||
| 240 | + | const upper = c >= 'A' && c <= 'Z'; | |||
| 241 | + | const lower = c >= 'a' && c <= 'z'; | |||
| 242 | + | if (p >= 'a' && p <= 'z' && upper) return 7; | |||
| 243 | + | if (p >= '0' && p <= '9' && (upper || lower)) return 7; | |||
| 244 | + | return 0; | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | function refPlacement(orig, pos) { | |||
| 248 | + | let score = 0; | |||
| 249 | + | pos.forEach((j, i) => { | |||
| 250 | + | const b = refBonus(orig, j); | |||
| 251 | + | score += 16 + (i === 0 ? 2 * b : b); | |||
| 252 | + | if (i > 0) { | |||
| 253 | + | const gap = j - pos[i - 1] - 1; | |||
| 254 | + | score += gap === 0 ? Math.max(b, 4) : -(gap + 2); | |||
| 255 | + | } | |||
| 256 | + | }); | |||
| 257 | + | return score; | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | // refBest is the best score of the placements of t at or after from (null: none). | |||
| 261 | + | function refBest(orig, lower, t, from) { | |||
| 262 | + | let best = null; | |||
| 263 | + | const go = (i, start, pos) => { | |||
| 264 | + | if (i === t.length) { | |||
| 265 | + | const sc = refPlacement(orig, pos); | |||
| 266 | + | if (best === null || sc > best) best = sc; | |||
| 267 | + | return; | |||
| 268 | + | } | |||
| 269 | + | for (let j = start; j < lower.length; j++) if (lower[j] === t[i]) go(i + 1, j + 1, [...pos, j]); | |||
| 270 | + | }; | |||
| 271 | + | go(0, from, []); | |||
| 272 | + | return best; | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | function refTerm(orig, t, isLast) { | |||
| 276 | + | const lower = orig.replace(/[A-Z]/g, (c) => c.toLowerCase()); | |||
| 277 | + | const all = refBest(orig, lower, t, 0); | |||
| 278 | + | if (all === null) return null; | |||
| 279 | + | if (!isLast) return all; | |||
| 280 | + | const base = orig.lastIndexOf('/') + 1; | |||
| 281 | + | let best = all; | |||
| 282 | + | if (base === 0) best += 24; | |||
| 283 | + | else { | |||
| 284 | + | const r = refBest(orig, lower, t, base); | |||
| 285 | + | if (r !== null && r + 24 > best) best = r + 24; | |||
| 286 | + | } | |||
| 287 | + | if (lower.startsWith(t, base)) best += 16; | |||
| 288 | + | return best; | |||
| 289 | + | } | |||
| 290 | + | ||||
| 291 | + | test('search and positions agree with a brute-force reference', () => { | |||
| 292 | + | let seed = 12345; | |||
| 293 | + | const rnd = (n) => { | |||
| 294 | + | seed = (Math.imul(seed, 1103515245) + 12345) & 0x7fffffff; | |||
| 295 | + | return Math.floor((seed / 0x80000000) * n); | |||
| 296 | + | }; | |||
| 297 | + | const alphabet = 'abcabcABC//..__-1'; | |||
| 298 | + | const paths = new Set(); | |||
| 299 | + | while (paths.size < 3000) { | |||
| 300 | + | let p = ''; | |||
| 301 | + | for (let n = 1 + rnd(14); n > 0; n--) p += alphabet[rnd(alphabet.length)]; | |||
| 302 | + | paths.add(p); | |||
| 303 | + | } | |||
| 304 | + | const list = Array.from(paths); | |||
| 305 | + | const x = F.index(list); | |||
| 306 | + | const queries = []; | |||
| 307 | + | for (let n = 0; n < 40; n++) { | |||
| 308 | + | let q = ''; | |||
| 309 | + | for (let k = 1 + rnd(3); k > 0; k--) q += 'abc/._-1'[rnd(8)]; | |||
| 310 | + | queries.push(q); | |||
| 311 | + | } | |||
| 312 | + | for (let n = 0; n < 20; n++) queries.push('ab'[rnd(2)] + 'c/.'[rnd(3)] + ' ' + 'abc'[rnd(3)] + 'a_'[rnd(2)]); | |||
| 313 | + | let compared = 0; | |||
| 314 | + | for (const q of queries) { | |||
| 315 | + | const terms = q.split(/\s+/).filter(Boolean); | |||
| 316 | + | const res = F.search(x, q, list.length); | |||
| 317 | + | const got = new Map(res.hits.map((h) => [h.i, h.score])); | |||
| 318 | + | for (let i = 0; i < list.length; i++) { | |||
| 319 | + | let total = 0; | |||
| 320 | + | for (let n = 0; n < terms.length && total !== null; n++) { | |||
| 321 | + | const sc = refTerm(list[i], terms[n], n === terms.length - 1); | |||
| 322 | + | total = sc === null ? null : total + sc; | |||
| 323 | + | } | |||
| 324 | + | if (total === null) { | |||
| 325 | + | assert.ok(!got.has(i), `${q}: ${list[i]} cannot match`); | |||
| 326 | + | continue; | |||
| 327 | + | } | |||
| 328 | + | assert.equal(got.get(i), total, `${q}: score of ${list[i]}`); | |||
| 329 | + | compared++; | |||
| 330 | + | if (terms.length === 1) { | |||
| 331 | + | // The positions are a placement of the term that scores the best. | |||
| 332 | + | const pos = F.positions(x, i, q); | |||
| 333 | + | assert.equal(pos.map((j) => list[i][j]).join('').toLowerCase(), terms[0], `${q}: characters of ${list[i]}`); | |||
| 334 | + | const lower = list[i].toLowerCase(); | |||
| 335 | + | const base = list[i].lastIndexOf('/') + 1; | |||
| 336 | + | const all = refBest(list[i], lower, terms[0], 0); | |||
| 337 | + | const inBase = base === 0 ? null : refBest(list[i], lower, terms[0], base); | |||
| 338 | + | const want = inBase !== null && inBase + 24 > all ? inBase : all; | |||
| 339 | + | assert.equal(refPlacement(list[i], pos), want, `${q}: placement of ${list[i]}`); | |||
| 340 | + | } | |||
| 341 | + | } | |||
| 342 | + | } | |||
| 343 | + | assert.ok(compared > 2000, 'compared ' + compared); | |||
| 344 | + | }); | |||
A internal/adapters/web/jstest/finder.test.mjs +269 -0
| @@ -0,0 +1,269 @@ | |||||
| 1 | + | // Tests of the DOM glue of the file finder and of the ":" palette | |||
| 2 | + | // (static/js/files.js) on the small DOM of dom.mjs. Run with | |||
| 3 | + | // node --test 'internal/adapters/web/jstest/*.test.mjs' | |||
| 4 | + | import assert from 'node:assert/strict'; | |||
| 5 | + | import { test } from 'node:test'; | |||
| 6 | + | import { dom, el, flushFrames, installDom, settle } from './dom.mjs'; | |||
| 7 | + | ||||
| 8 | + | installDom(); | |||
| 9 | + | await import('../static/js/files.js'); | |||
| 10 | + | const F = window.ermiteFinder; | |||
| 11 | + | ||||
| 12 | + | const PATHS = [ | |||
| 13 | + | 'README.md', | |||
| 14 | + | 'cmd/ermite/main.go', | |||
| 15 | + | 'internal/adapters/web/tree.go', | |||
| 16 | + | 'internal/adapters/web/tree.templ', | |||
| 17 | + | 'docs/web/tree-view.md', | |||
| 18 | + | 'src/treehouse.txt', | |||
| 19 | + | ]; | |||
| 20 | + | for (let i = 0; i < 1194; i++) PATHS.push('gen/f' + i + '.dat'); // 1,200 in all | |||
| 21 | + | ||||
| 22 | + | // The path list answers when the test says so (once: the finder keeps it). | |||
| 23 | + | let release; | |||
| 24 | + | const gate = new Promise((r) => { release = r; }); | |||
| 25 | + | let fetches = 0; | |||
| 26 | + | globalThis.fetch = async () => { | |||
| 27 | + | fetches++; | |||
| 28 | + | await gate; | |||
| 29 | + | return { ok: true, json: async () => ({ paths: PATHS, truncated: true }) }; | |||
| 30 | + | }; | |||
| 31 | + | ||||
| 32 | + | // page builds the parts of the Files view and of the palette that files.js | |||
| 33 | + | // reaches, around a query field and a tree of three rows. | |||
| 34 | + | function page() { | |||
| 35 | + | const rows = ['r1', 'r2', 'r3'].map((n) => el('tr', { attrs: { 'data-nm': n } })); | |||
| 36 | + | rows[1].setAttribute('data-cursor', ''); | |||
| 37 | + | const q = el('input', { id: 'finder-q' }); | |||
| 38 | + | const cmdQ = el('input', { id: 'cmd-q' }); | |||
| 39 | + | const treeRows = el('tbody', { id: 'tree-rows', attrs: { 'data-rows': '' } }, ...rows); | |||
| 40 | + | const finderList = el('ul', { id: 'finder-list' }); | |||
| 41 | + | const cmdList = el('ul', { id: 'cmd-list' }, el('li', { attrs: { 'data-k': 'section' }, cls: 'entry' })); | |||
| 42 | + | const finder = el('div', { id: 'finder', hidden: true }, el('p', { id: 'finder-status' }), finderList); | |||
| 43 | + | const treeView = el('div', { id: 'tree-view' }, treeRows); | |||
| 44 | + | dom.body.replaceChildren(q, cmdQ, treeView, finder, cmdList); | |||
| 45 | + | dom.body.setAttribute('data-paths', '/paths.json'); | |||
| 46 | + | dom.body.setAttribute('data-blob', '/blob/main'); | |||
| 47 | + | dom.clicked = []; | |||
| 48 | + | return { rows, q, cmdQ, treeRows, finderList, cmdList, finder, treeView }; | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | const key = (k, extra = {}) => { | |||
| 52 | + | const e = { key: k, isComposing: false, prevented: false, preventDefault() { this.prevented = true; }, ...extra }; | |||
| 53 | + | return e; | |||
| 54 | + | }; | |||
| 55 | + | const status = () => document.getElementById('finder-status').textContent; | |||
| 56 | + | const hrefs = (list) => list.children.map((li) => li.querySelector('a').href); | |||
| 57 | + | ||||
| 58 | + | test('Enter before the results opens the top hit once they come', async () => { | |||
| 59 | + | const p = page(); | |||
| 60 | + | p.q.value = 'tree'; | |||
| 61 | + | F.input(p.q); | |||
| 62 | + | assert.equal(p.finder.hidden, false, 'the results replace the tree at once'); | |||
| 63 | + | F.key(p.q, key('Enter')); | |||
| 64 | + | assert.equal(dom.clicked.length, 0, 'nothing to open yet'); | |||
| 65 | + | release(); | |||
| 66 | + | await settle(); | |||
| 67 | + | assert.equal(p.finderList.children.length > 0, true); | |||
| 68 | + | assert.equal(dom.clicked.length, 1); | |||
| 69 | + | assert.equal(dom.clicked[0].href, hrefs(p.finderList)[0]); | |||
| 70 | + | assert.match(dom.clicked[0].href, /^\/blob\/main\/.*tree/); | |||
| 71 | + | assert.equal(fetches, 1); | |||
| 72 | + | }); | |||
| 73 | + | ||||
| 74 | + | test('the status counts the files and names the real size of a truncated list', async () => { | |||
| 75 | + | const p = page(); | |||
| 76 | + | p.q.value = 'tree'; | |||
| 77 | + | F.input(p.q); | |||
| 78 | + | await settle(); | |||
| 79 | + | assert.equal(status(), '4 files match · only the first 1,200 files are searched'); | |||
| 80 | + | p.q.value = 'zzzz'; | |||
| 81 | + | F.input(p.q); | |||
| 82 | + | await settle(); | |||
| 83 | + | assert.match(status(), /^no file matches · /); | |||
| 84 | + | }); | |||
| 85 | + | ||||
| 86 | + | test('an Enter is dropped when the field changed since', async () => { | |||
| 87 | + | const p = page(); | |||
| 88 | + | p.q.value = 'tree'; | |||
| 89 | + | F.input(p.q); | |||
| 90 | + | F.key(p.q, key('Enter')); // pending on "tree" | |||
| 91 | + | p.q.value = 'tre'; | |||
| 92 | + | F.input(p.q); | |||
| 93 | + | await settle(); | |||
| 94 | + | assert.equal(p.finderList.children.length > 0, true, 'the results of "tre" are shown'); | |||
| 95 | + | assert.equal(dom.clicked.length, 0); | |||
| 96 | + | }); | |||
| 97 | + | ||||
| 98 | + | test('Enter on current results opens the cursor row, whichever way the cursor moved', async () => { | |||
| 99 | + | const p = page(); | |||
| 100 | + | p.q.value = 'tree'; | |||
| 101 | + | F.input(p.q); | |||
| 102 | + | await settle(); | |||
| 103 | + | const items = p.finderList.children; | |||
| 104 | + | assert.equal(items[0].getAttribute('aria-selected'), 'true'); | |||
| 105 | + | assert.equal(p.q.getAttribute('aria-activedescendant'), items[0].id); | |||
| 106 | + | // j/k (keys.templ) move data-cursor while the field is blurred. | |||
| 107 | + | items[0].removeAttribute('data-cursor'); | |||
| 108 | + | items[2].setAttribute('data-cursor', ''); | |||
| 109 | + | const e = key('Enter'); | |||
| 110 | + | F.key(p.q, e); | |||
| 111 | + | assert.equal(e.prevented, true); | |||
| 112 | + | assert.equal(items[2].getAttribute('aria-selected'), 'true'); | |||
| 113 | + | assert.equal(items[0].getAttribute('aria-selected'), 'false'); | |||
| 114 | + | assert.equal(p.q.getAttribute('aria-activedescendant'), items[2].id); | |||
| 115 | + | assert.equal(dom.clicked[0].href, items[2].querySelector('a').href); | |||
| 116 | + | // The arrows move from there. | |||
| 117 | + | F.key(p.q, key('ArrowUp')); | |||
| 118 | + | assert.equal(p.q.getAttribute('aria-activedescendant'), items[1].id); | |||
| 119 | + | assert.ok(items[1].hasAttribute('data-cursor') && !items[2].hasAttribute('data-cursor')); | |||
| 120 | + | }); | |||
| 121 | + | ||||
| 122 | + | test('an input method composing does not drive the finder', async () => { | |||
| 123 | + | const p = page(); | |||
| 124 | + | p.q.value = 'tree'; | |||
| 125 | + | F.input(p.q); | |||
| 126 | + | await settle(); | |||
| 127 | + | for (const k of ['Enter', 'Escape', 'ArrowDown']) { | |||
| 128 | + | const e = key(k, { isComposing: true }); | |||
| 129 | + | F.key(p.q, e); | |||
| 130 | + | assert.equal(e.prevented, false, k); | |||
| 131 | + | } | |||
| 132 | + | assert.equal(p.q.value, 'tree'); | |||
| 133 | + | assert.equal(dom.clicked.length, 0); | |||
| 134 | + | }); | |||
| 135 | + | ||||
| 136 | + | test('Escape clears the field and puts the cursor back on the tree', async () => { | |||
| 137 | + | const cases = [ | |||
| 138 | + | { name: 'the row that had it', hide: false, want: 'r2' }, | |||
| 139 | + | { name: 'the first visible row when it is hidden', hide: true, want: 'r1' }, | |||
| 140 | + | ]; | |||
| 141 | + | for (const c of cases) { | |||
| 142 | + | const p = page(); | |||
| 143 | + | p.q.value = 'tree'; | |||
| 144 | + | F.input(p.q); | |||
| 145 | + | await settle(); | |||
| 146 | + | assert.equal(p.treeView.hidden, true); | |||
| 147 | + | assert.ok(!p.rows[1].hasAttribute('data-cursor'), 'the tree gives up its cursor to the results'); | |||
| 148 | + | assert.ok(p.finderList.hasAttribute('data-rows')); | |||
| 149 | + | if (c.hide) p.rows[1].hidden = true; | |||
| 150 | + | F.key(p.q, key('Escape')); | |||
| 151 | + | assert.equal(p.q.value, '', c.name); | |||
| 152 | + | assert.equal(p.finder.hidden, true); | |||
| 153 | + | assert.equal(p.treeView.hidden, false); | |||
| 154 | + | assert.equal(p.finderList.children.length, 0); | |||
| 155 | + | const got = p.rows.filter((r) => r.hasAttribute('data-cursor')).map((r) => r.getAttribute('data-nm')); | |||
| 156 | + | assert.deepEqual(got, [c.want], c.name); | |||
| 157 | + | assert.ok(p.treeRows.hasAttribute('data-rows') && !p.finderList.hasAttribute('data-rows')); | |||
| 158 | + | } | |||
| 159 | + | }); | |||
| 160 | + | ||||
| 161 | + | test('label marks a surrogate pair whole', () => { | |||
| 162 | + | for (const pos of [[2], [3], [2, 3]]) { | |||
| 163 | + | const lbl = F.label('a/😀b', pos); | |||
| 164 | + | const marks = lbl.querySelectorAll('mark'); | |||
| 165 | + | assert.deepEqual(marks.map((m) => m.textContent), ['😀'], JSON.stringify(pos)); | |||
| 166 | + | assert.equal(lbl.textContent, 'a/😀b'); | |||
| 167 | + | } | |||
| 168 | + | const lbl = F.label('a/😀b', [4]); | |||
| 169 | + | assert.deepEqual(lbl.querySelectorAll('mark').map((m) => m.textContent), ['b']); | |||
| 170 | + | assert.equal(F.label('x\u202ey', [0]).querySelectorAll('mark').length, 0, 'no highlight on a marked path'); | |||
| 171 | + | assert.equal(F.label('x\u202ey', [0]).textContent, 'x<U+202E>y'); | |||
| 172 | + | }); | |||
| 173 | + | ||||
| 174 | + | test('the palette lists the file hits under its entries', async () => { | |||
| 175 | + | const p = page(); | |||
| 176 | + | p.cmdQ.value = 'tree'; | |||
| 177 | + | F.palette(p.cmdQ); | |||
| 178 | + | await settle(); | |||
| 179 | + | const hits = p.cmdList.children.filter((li) => li.className === 'finder-hit'); | |||
| 180 | + | assert.equal(hits.length, 4); | |||
| 181 | + | assert.equal(p.cmdList.children[0].className, 'entry', 'the entries stay first'); | |||
| 182 | + | assert.deepEqual(hits.map((li) => li.querySelector('a').href).sort(), [ | |||
| 183 | + | '/blob/main/docs/web/tree-view.md', | |||
| 184 | + | '/blob/main/internal/adapters/web/tree.go', | |||
| 185 | + | '/blob/main/internal/adapters/web/tree.templ', | |||
| 186 | + | '/blob/main/src/treehouse.txt', | |||
| 187 | + | ]); | |||
| 188 | + | assert.equal(hits[0].querySelector('a').tabIndex, -1); | |||
| 189 | + | assert.equal(hits[0].querySelector('.k').textContent, 'file'); | |||
| 190 | + | assert.equal(p.cmdList.children.filter((li) => li.hasAttribute('data-cursor')).length, 1, 'one cursor'); | |||
| 191 | + | ||||
| 192 | + | // A longer query narrows the hits; an emptied field removes them. | |||
| 193 | + | p.cmdQ.value = 'treeh'; | |||
| 194 | + | F.palette(p.cmdQ); | |||
| 195 | + | await settle(); | |||
| 196 | + | assert.deepEqual(hrefs({ children: p.cmdList.children.filter((li) => li.className === 'finder-hit') }), ['/blob/main/src/treehouse.txt']); | |||
| 197 | + | p.cmdQ.value = ''; | |||
| 198 | + | F.palette(p.cmdQ); | |||
| 199 | + | assert.equal(p.cmdList.children.length, 1); | |||
| 200 | + | }); | |||
| 201 | + | ||||
| 202 | + | test('Enter typed before the frame of the query runs opens the top hit', async () => { | |||
| 203 | + | const p = page(); | |||
| 204 | + | release(); | |||
| 205 | + | await F.load(); // the path list is loaded: only the frame is pending | |||
| 206 | + | dom.holdFrames = true; | |||
| 207 | + | try { | |||
| 208 | + | p.q.value = 'maingo'; | |||
| 209 | + | F.input(p.q); | |||
| 210 | + | const e = key('Enter'); | |||
| 211 | + | F.key(p.q, e); | |||
| 212 | + | assert.equal(e.prevented, true); | |||
| 213 | + | assert.equal(dom.clicked.length, 0, 'nothing to open before the frame'); | |||
| 214 | + | flushFrames(); | |||
| 215 | + | await settle(); | |||
| 216 | + | assert.deepEqual(dom.clicked.map((a) => a.href), ['/blob/main/cmd/ermite/main.go']); | |||
| 217 | + | } finally { | |||
| 218 | + | dom.holdFrames = false; | |||
| 219 | + | } | |||
| 220 | + | }); | |||
| 221 | + | ||||
| 222 | + | test('the palette keeps one search per frame and drops a search when it closes', async () => { | |||
| 223 | + | const p = page(); | |||
| 224 | + | release(); | |||
| 225 | + | await F.load(); | |||
| 226 | + | dom.holdFrames = true; | |||
| 227 | + | try { | |||
| 228 | + | for (const q of ['t', 'tr', 'tre', 'tree']) { | |||
| 229 | + | p.cmdQ.value = q; | |||
| 230 | + | F.palette(p.cmdQ); | |||
| 231 | + | } | |||
| 232 | + | assert.equal(dom.frames.size, 1, 'the keystrokes of a frame share one search'); | |||
| 233 | + | flushFrames(); | |||
| 234 | + | await settle(); | |||
| 235 | + | assert.equal(p.cmdList.children.filter((li) => li.className === 'finder-hit').length, 4); | |||
| 236 | + | ||||
| 237 | + | p.cmdQ.value = 'readme'; | |||
| 238 | + | F.palette(p.cmdQ); | |||
| 239 | + | F.paletteClose(); | |||
| 240 | + | assert.equal(dom.frames.size, 0, 'closing cancels the pending frame'); | |||
| 241 | + | ||||
| 242 | + | // A search past its frame, still awaiting the list, is dropped at close. | |||
| 243 | + | F.palette(p.cmdQ); | |||
| 244 | + | flushFrames(); | |||
| 245 | + | F.paletteClose(); | |||
| 246 | + | await settle(); | |||
| 247 | + | assert.equal(p.cmdList.children.filter((li) => li.className === 'finder-hit').length, 0); | |||
| 248 | + | } finally { | |||
| 249 | + | dom.holdFrames = false; | |||
| 250 | + | } | |||
| 251 | + | }); | |||
| 252 | + | ||||
| 253 | + | test('the palette drops the results of a query that is stale', async () => { | |||
| 254 | + | const p = page(); | |||
| 255 | + | p.cmdQ.value = 'tree'; | |||
| 256 | + | F.palette(p.cmdQ); | |||
| 257 | + | p.cmdQ.value = 'readme'; // typed before the search of "tree" finished | |||
| 258 | + | F.palette(p.cmdQ); | |||
| 259 | + | await settle(); | |||
| 260 | + | const got = p.cmdList.children.filter((li) => li.className === 'finder-hit').map((li) => li.querySelector('a').href); | |||
| 261 | + | assert.deepEqual(got, ['/blob/main/README.md']); | |||
| 262 | + | ||||
| 263 | + | // A field changed without a new call: the pending search is dropped. | |||
| 264 | + | p.cmdQ.value = 'main'; | |||
| 265 | + | F.palette(p.cmdQ); | |||
| 266 | + | p.cmdQ.value = 'zzzz'; | |||
| 267 | + | await settle(); | |||
| 268 | + | assert.deepEqual(p.cmdList.children.filter((li) => li.className === 'finder-hit'), []); | |||
| 269 | + | }); | |||
A internal/adapters/web/jstest/tree.test.mjs +177 -0
| @@ -0,0 +1,177 @@ | |||||
| 1 | + | // Tests of the tree rows of static/js/files.js (window.ermiteTree) on a tiny | |||
| 2 | + | // hand-rolled DOM. Run with | |||
| 3 | + | // node --test 'internal/adapters/web/jstest/*.test.mjs' | |||
| 4 | + | import assert from 'node:assert/strict'; | |||
| 5 | + | import { test } from 'node:test'; | |||
| 6 | + | import { El, el, installDom } from './dom.mjs'; | |||
| 7 | + | ||||
| 8 | + | // A row is a name: a trailing "/" makes a directory with a link, "!x" a div | |||
| 9 | + | // that is not a row, "more" the tree-more row of a truncated directory. | |||
| 10 | + | function row(name, depth) { | |||
| 11 | + | const tr = el('tr', { cls: name === 'more' ? 'tree-more' : '', attrs: { 'data-depth': depth } }); | |||
| 12 | + | tr.name = name; | |||
| 13 | + | tr.append(el('td', { cls: 'c-kind', text: '▸' })); | |||
| 14 | + | if (name.endsWith('/')) { | |||
| 15 | + | tr.append(el('a', { href: '/tree/' + name, attrs: { 'data-expand': '/rows/' + name, 'aria-expanded': 'false' } })); | |||
| 16 | + | } | |||
| 17 | + | return tr; | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | installDom({ | |||
| 21 | + | parseHTML: (text) => text.split(',').filter(Boolean).map((n) => (n[0] === '!' ? new El('div') : row(n, 0))), | |||
| 22 | + | }); | |||
| 23 | + | await import('../static/js/files.js'); | |||
| 24 | + | const T = window.ermiteTree; | |||
| 25 | + | ||||
| 26 | + | // fetches: URL -> resolve function, to answer a request when the test says so. | |||
| 27 | + | const pending = new Map(); | |||
| 28 | + | globalThis.fetch = (url) => new Promise((resolve) => { | |||
| 29 | + | pending.set(url, (text, ok = true) => resolve({ ok, status: ok ? 200 : 500, text: async () => text })); | |||
| 30 | + | }); | |||
| 31 | + | const answer = async (url, text, ok) => { | |||
| 32 | + | pending.get(url)(text, ok); | |||
| 33 | + | await new Promise((r) => setTimeout(r, 0)); | |||
| 34 | + | }; | |||
| 35 | + | ||||
| 36 | + | // click sends a click on the link of tr; it answers whether the page's own | |||
| 37 | + | // navigation was kept (no preventDefault). | |||
| 38 | + | const click = (tr, mods = {}) => { | |||
| 39 | + | const a = tr.querySelector('a[data-expand]'); | |||
| 40 | + | let prevented = false; | |||
| 41 | + | T.click({ target: a, button: 0, preventDefault() { prevented = true; }, ...mods }); | |||
| 42 | + | return !prevented; | |||
| 43 | + | }; | |||
| 44 | + | const kindOf = (tr) => tr.querySelector('.c-kind').textContent; | |||
| 45 | + | ||||
| 46 | + | function table(...names) { | |||
| 47 | + | return el('tbody', {}, ...names.map((n) => row(n, 0))); | |||
| 48 | + | } | |||
| 49 | + | const byName = (body, n) => body.children.find((r) => r.name === n); | |||
| 50 | + | const shown = (body) => body.children.filter((r) => !r.hidden).map((r) => r.name); | |||
| 51 | + | ||||
| 52 | + | test('a directory opens in place and closes again without a request', async () => { | |||
| 53 | + | const body = table('P/', 'f'); | |||
| 54 | + | click(body.children[0]); | |||
| 55 | + | assert.equal(body.children[0].getAttribute('aria-busy'), 'true'); | |||
| 56 | + | await answer('/rows/P/', 'a,b'); | |||
| 57 | + | assert.deepEqual(shown(body), ['P/', 'a', 'b', 'f']); | |||
| 58 | + | assert.equal(byName(body, 'a').dataset.depth, '1'); | |||
| 59 | + | assert.equal(kindOf(body.children[0]), '▾'); | |||
| 60 | + | click(body.children[0]); | |||
| 61 | + | assert.deepEqual(shown(body), ['P/', 'f']); | |||
| 62 | + | pending.clear(); | |||
| 63 | + | click(body.children[0]); | |||
| 64 | + | assert.deepEqual(shown(body), ['P/', 'a', 'b', 'f']); | |||
| 65 | + | assert.equal(pending.size, 0, 'no new request'); | |||
| 66 | + | }); | |||
| 67 | + | ||||
| 68 | + | test('TS-001/TS-002: a directory that loads under a closed ancestor comes in hidden', async () => { | |||
| 69 | + | pending.clear(); | |||
| 70 | + | const body = table('P/', 'f'); | |||
| 71 | + | const P = body.children[0]; | |||
| 72 | + | click(P); | |||
| 73 | + | await answer('/rows/P/', 'C/,x'); | |||
| 74 | + | const C = byName(body, 'C/'); | |||
| 75 | + | click(C); // C starts loading | |||
| 76 | + | assert.equal(C.getAttribute('aria-busy'), 'true'); | |||
| 77 | + | C.setAttribute('data-cursor', ''); | |||
| 78 | + | click(P); // P closes meanwhile | |||
| 79 | + | assert.deepEqual(shown(body), ['P/', 'f']); | |||
| 80 | + | assert.ok(P.hasAttribute('data-cursor'), 'the cursor of a hidden row moves to the closed row'); | |||
| 81 | + | assert.ok(!C.hasAttribute('data-cursor')); | |||
| 82 | + | ||||
| 83 | + | await answer('/rows/C/', 'c1,c2'); | |||
| 84 | + | assert.deepEqual(shown(body), ['P/', 'f'], 'no visible row under a closed directory'); | |||
| 85 | + | assert.deepEqual(body.children.map((r) => r.name), ['P/', 'C/', 'c1', 'c2', 'x', 'f']); | |||
| 86 | + | assert.equal(byName(body, 'c1').hidden, true); | |||
| 87 | + | assert.equal(C.getAttribute('aria-busy'), null); | |||
| 88 | + | assert.equal(C.querySelector('a[data-expand]').getAttribute('aria-expanded'), 'true', 'C is open and loaded'); | |||
| 89 | + | assert.equal(kindOf(C), '▾'); | |||
| 90 | + | ||||
| 91 | + | pending.clear(); | |||
| 92 | + | click(P); // P opens: C's rows show with it | |||
| 93 | + | assert.deepEqual(shown(body), ['P/', 'C/', 'c1', 'c2', 'x', 'f']); | |||
| 94 | + | assert.equal(pending.size, 0, 'C was not fetched again'); | |||
| 95 | + | }); | |||
| 96 | + | ||||
| 97 | + | test('a closed directory under a closed ancestor stays closed when the ancestor opens', async () => { | |||
| 98 | + | pending.clear(); | |||
| 99 | + | const body = table('P/'); | |||
| 100 | + | click(body.children[0]); | |||
| 101 | + | await answer('/rows/P/', 'C/'); | |||
| 102 | + | const C = byName(body, 'C/'); | |||
| 103 | + | click(C); | |||
| 104 | + | await answer('/rows/C/', 'c1'); | |||
| 105 | + | click(C); // C closed | |||
| 106 | + | click(body.children[0]); | |||
| 107 | + | click(body.children[0]); | |||
| 108 | + | assert.deepEqual(shown(body), ['P/', 'C/']); | |||
| 109 | + | }); | |||
| 110 | + | ||||
| 111 | + | test('TS-006: only rows are kept; no row falls back to navigating', async () => { | |||
| 112 | + | pending.clear(); | |||
| 113 | + | const body = table('P/'); | |||
| 114 | + | click(body.children[0]); | |||
| 115 | + | await answer('/rows/P/', '!junk,a'); | |||
| 116 | + | assert.deepEqual(body.children.map((r) => r.name), ['P/', 'a']); | |||
| 117 | + | ||||
| 118 | + | const other = table('Q/'); | |||
| 119 | + | globalThis.location.href = ''; | |||
| 120 | + | click(other.children[0]); | |||
| 121 | + | await answer('/rows/Q/', '!junk'); | |||
| 122 | + | assert.equal(globalThis.location.href, '/tree/Q/'); | |||
| 123 | + | assert.deepEqual(other.children.map((r) => r.name), ['Q/']); | |||
| 124 | + | }); | |||
| 125 | + | ||||
| 126 | + | test('a modified or non-left click keeps the link navigation', () => { | |||
| 127 | + | pending.clear(); | |||
| 128 | + | const cases = [ | |||
| 129 | + | { ctrlKey: true }, { metaKey: true }, { shiftKey: true }, { altKey: true }, { button: 1 }, { button: 2 }, | |||
| 130 | + | ]; | |||
| 131 | + | for (const mods of cases) { | |||
| 132 | + | const body = table('P/'); | |||
| 133 | + | assert.equal(click(body.children[0], mods), true, JSON.stringify(mods) + ' is not intercepted'); | |||
| 134 | + | assert.equal(pending.size, 0, 'no request'); | |||
| 135 | + | assert.equal(body.children[0].getAttribute('aria-busy'), null); | |||
| 136 | + | } | |||
| 137 | + | assert.equal(click(table('P/').children[0]), false, 'a plain left click is intercepted'); | |||
| 138 | + | }); | |||
| 139 | + | ||||
| 140 | + | test('a second click while the rows are loading is ignored', async () => { | |||
| 141 | + | pending.clear(); | |||
| 142 | + | const body = table('P/'); | |||
| 143 | + | let requests = 0; | |||
| 144 | + | const fetch0 = globalThis.fetch; | |||
| 145 | + | globalThis.fetch = (url) => { requests++; return fetch0(url); }; | |||
| 146 | + | click(body.children[0]); | |||
| 147 | + | click(body.children[0]); | |||
| 148 | + | assert.equal(requests, 1); | |||
| 149 | + | await answer('/rows/P/', 'a'); | |||
| 150 | + | globalThis.fetch = fetch0; | |||
| 151 | + | assert.deepEqual(shown(body), ['P/', 'a']); | |||
| 152 | + | }); | |||
| 153 | + | ||||
| 154 | + | test('a failed request navigates to the directory', async () => { | |||
| 155 | + | pending.clear(); | |||
| 156 | + | const body = table('P/'); | |||
| 157 | + | globalThis.location.href = ''; | |||
| 158 | + | click(body.children[0]); | |||
| 159 | + | await answer('/rows/P/', 'oops', false); | |||
| 160 | + | assert.equal(globalThis.location.href, '/tree/P/'); | |||
| 161 | + | assert.deepEqual(body.children.map((r) => r.name), ['P/']); | |||
| 162 | + | }); | |||
| 163 | + | ||||
| 164 | + | test('the tree-more row has a depth and follows its directory', async () => { | |||
| 165 | + | pending.clear(); | |||
| 166 | + | const body = table('P/', 'f'); | |||
| 167 | + | click(body.children[0]); | |||
| 168 | + | await answer('/rows/P/', 'a,more'); | |||
| 169 | + | const more = byName(body, 'more'); | |||
| 170 | + | assert.equal(more.dataset.depth, '1'); | |||
| 171 | + | assert.equal(more.style.props['--depth'], '1'); | |||
| 172 | + | click(body.children[0]); | |||
| 173 | + | assert.equal(more.hidden, true, 'hidden with its directory'); | |||
| 174 | + | assert.deepEqual(shown(body), ['P/', 'f']); | |||
| 175 | + | click(body.children[0]); | |||
| 176 | + | assert.equal(more.hidden, false, 'shown with it again'); | |||
| 177 | + | }); | |||
M internal/adapters/web/keys.templ +1 -1
| @@ -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(); ermiteFinder.load(); |
| 39 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | 39 | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | ||
| 40 | k-gh from:body -> document.querySelector('.brand')?.click(); | 40 | k-gh from:body -> document.querySelector('.brand')?.click(); | ||
| 41 | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | 41 | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | ||
M internal/adapters/web/keys_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/overlays.templ +5 -5
| @@ -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> | ||
| @@ -84,9 +84,9 @@ templ refItem(r RefItem) { | |||||
| 84 | } | 84 | } | ||
| 85 | 85 | ||||
| 86 | // OverlayCmd is the ":" command line: one filterable list mixing sections, | 86 | // OverlayCmd is the ":" command line: one filterable list mixing sections, | ||
| 87 | - | // refs, files of this directory and repos. | 87 | + | // refs and repos. |
| 88 | templ OverlayCmd(items []CmdItem) { | 88 | templ OverlayCmd(items []CmdItem) { | ||
| 89 | - | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 89 | + | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> |
| 90 | <div class="panel-head"> | 90 | <div class="panel-head"> | ||
| 91 | <label for="cmd-q" class="sr">go to</label> | 91 | <label for="cmd-q" class="sr">go to</label> | ||
| 92 | <input | 92 | <input | ||
| @@ -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 +3 -3 generated file: collapsed
A internal/adapters/web/pathcache.go +195 -0
| @@ -0,0 +1,195 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "container/list" | |||
| 7 | + | "context" | |||
| 8 | + | "runtime/debug" | |||
| 9 | + | "sync" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/zerowrap" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | const ( | |||
| 15 | + | // pathCacheBytes is the budget of the cache of path lists. | |||
| 16 | + | pathCacheBytes = 64 << 20 | |||
| 17 | + | // pathBuilds is how many path lists are built at the same time, for all | |||
| 18 | + | // commits and repositories: a build walks a whole tree and holds the | |||
| 19 | + | // list in memory. | |||
| 20 | + | pathBuilds = 4 | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | // pathCache keeps the gzipped JSON path lists of commits, least recently used | |||
| 24 | + | // out first, within a budget of bytes. A commit id names its content, so an | |||
| 25 | + | // entry is right for every repository and every visitor; who may read it is | |||
| 26 | + | // decided before the cache is asked (Server.paths). | |||
| 27 | + | // | |||
| 28 | + | // Concurrent misses for one commit of one repository share a single build, | |||
| 29 | + | // and only a few builds run at a time. | |||
| 30 | + | // | |||
| 31 | + | // A list over a quarter of the budget is not kept (one big list would push | |||
| 32 | + | // out most of the others): it is built again for each request that needs it. | |||
| 33 | + | type pathCache struct { | |||
| 34 | + | mu sync.Mutex | |||
| 35 | + | budget int | |||
| 36 | + | size int | |||
| 37 | + | lru *list.List // of *pathEntry, most recently used first | |||
| 38 | + | byID map[string]*list.Element | |||
| 39 | + | building map[string]*pathBuild // by repository and commit | |||
| 40 | + | slots chan struct{} // one token per build running | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | type pathEntry struct { | |||
| 44 | + | id string | |||
| 45 | + | body []byte | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // pathBuild is a build in progress; done is closed when body and err are set. | |||
| 49 | + | type pathBuild struct { | |||
| 50 | + | waiters int // callers that joined it, for the tests; guarded by pathCache.mu | |||
| 51 | + | done chan struct{} | |||
| 52 | + | body []byte | |||
| 53 | + | err error | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func newPathCache(budget, builds int) *pathCache { | |||
| 57 | + | return &pathCache{ | |||
| 58 | + | budget: budget, lru: list.New(), | |||
| 59 | + | byID: map[string]*list.Element{}, building: map[string]*pathBuild{}, | |||
| 60 | + | slots: make(chan struct{}, builds), | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func (c *pathCache) getLocked(id string) ([]byte, bool) { | |||
| 65 | + | el, ok := c.byID[id] | |||
| 66 | + | if !ok { | |||
| 67 | + | return nil, false | |||
| 68 | + | } | |||
| 69 | + | c.lru.MoveToFront(el) | |||
| 70 | + | return el.Value.(*pathEntry).body, true | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // putLocked stores a list, evicting the least recently used ones to stay in | |||
| 74 | + | // budget. A list over a quarter of the budget is not stored. | |||
| 75 | + | func (c *pathCache) putLocked(id string, body []byte) { | |||
| 76 | + | if len(body) > c.budget/4 { | |||
| 77 | + | return | |||
| 78 | + | } | |||
| 79 | + | if el, ok := c.byID[id]; ok { | |||
| 80 | + | c.size -= len(el.Value.(*pathEntry).body) | |||
| 81 | + | el.Value.(*pathEntry).body = body | |||
| 82 | + | c.size += len(body) | |||
| 83 | + | c.lru.MoveToFront(el) | |||
| 84 | + | } else { | |||
| 85 | + | c.byID[id] = c.lru.PushFront(&pathEntry{id: id, body: body}) | |||
| 86 | + | c.size += len(body) | |||
| 87 | + | } | |||
| 88 | + | for c.size > c.budget { | |||
| 89 | + | el := c.lru.Back() | |||
| 90 | + | e := el.Value.(*pathEntry) | |||
| 91 | + | c.lru.Remove(el) | |||
| 92 | + | delete(c.byID, e.id) | |||
| 93 | + | c.size -= len(e.body) | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | // load returns the list of a commit: from the cache, or from build, which | |||
| 98 | + | // runs once however many callers of a repository ask at the same time. The | |||
| 99 | + | // build runs in its own goroutine, so no caller is held by it: each one, | |||
| 100 | + | // the one that started it included, waits for the build or for its own ctx. | |||
| 101 | + | // | |||
| 102 | + | // Only pathBuilds builds run at a time, whatever their commits: a caller that | |||
| 103 | + | // would start one waits for a free slot, or for its ctx. The build keeps the | |||
| 104 | + | // slot until it ends, even if every caller has left. | |||
| 105 | + | // | |||
| 106 | + | // Builds in progress are keyed by repository and commit, though the cache is | |||
| 107 | + | // keyed by commit only: a failure of one repository (the commit is missing | |||
| 108 | + | // there, its access rule changed) must not be handed to the requests of | |||
| 109 | + | // another one that would have succeeded. | |||
| 110 | + | func (c *pathCache) load(ctx context.Context, repo, id string, build func() ([]byte, error)) ([]byte, error) { | |||
| 111 | + | key := repo + "\x00" + id // a repository name has no NUL | |||
| 112 | + | f, body, ok := c.join(key, id) | |||
| 113 | + | if ok { | |||
| 114 | + | return body, nil | |||
| 115 | + | } | |||
| 116 | + | if f == nil { | |||
| 117 | + | // Nothing to join: take a slot, then look again, since the commit | |||
| 118 | + | // may have been built while this caller waited. | |||
| 119 | + | select { | |||
| 120 | + | case c.slots <- struct{}{}: | |||
| 121 | + | case <-ctx.Done(): | |||
| 122 | + | return nil, ctx.Err() | |||
| 123 | + | } | |||
| 124 | + | if f, body, ok = c.joinOrStart(ctx, key, id, build); ok { | |||
| 125 | + | return body, nil | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | select { | |||
| 129 | + | case <-f.done: | |||
| 130 | + | return f.body, f.err | |||
| 131 | + | case <-ctx.Done(): | |||
| 132 | + | return nil, ctx.Err() | |||
| 133 | + | } | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | // join returns the list of a commit if it is cached, or the build in progress | |||
| 137 | + | // for it. | |||
| 138 | + | func (c *pathCache) join(key, id string) (f *pathBuild, body []byte, ok bool) { | |||
| 139 | + | c.mu.Lock() | |||
| 140 | + | defer c.mu.Unlock() | |||
| 141 | + | if b, ok := c.getLocked(id); ok { | |||
| 142 | + | return nil, b, true | |||
| 143 | + | } | |||
| 144 | + | f = c.building[key] | |||
| 145 | + | if f != nil { | |||
| 146 | + | f.waiters++ | |||
| 147 | + | } | |||
| 148 | + | return f, nil, false | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | // joinOrStart is join for a caller that holds a slot: it starts the build when | |||
| 152 | + | // there is still none, and gives the slot back when it does not need it. | |||
| 153 | + | func (c *pathCache) joinOrStart(ctx context.Context, key, id string, build func() ([]byte, error)) (f *pathBuild, body []byte, ok bool) { | |||
| 154 | + | c.mu.Lock() | |||
| 155 | + | defer c.mu.Unlock() | |||
| 156 | + | if b, ok := c.getLocked(id); ok { | |||
| 157 | + | <-c.slots | |||
| 158 | + | return nil, b, true | |||
| 159 | + | } | |||
| 160 | + | if f, ok := c.building[key]; ok { | |||
| 161 | + | <-c.slots | |||
| 162 | + | f.waiters++ | |||
| 163 | + | return f, nil, false | |||
| 164 | + | } | |||
| 165 | + | f = &pathBuild{done: make(chan struct{}), waiters: 1} | |||
| 166 | + | c.building[key] = f | |||
| 167 | + | go c.run(ctx, key, id, f, build) | |||
| 168 | + | return f, nil, false | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | // run executes a build and publishes it. A panic of build is logged and | |||
| 172 | + | // handed to the waiters as errBuildPanicked: it cannot propagate, since | |||
| 173 | + | // nobody above this goroutine could recover it and it would end the process. | |||
| 174 | + | func (c *pathCache) run(ctx context.Context, key, id string, f *pathBuild, build func() ([]byte, error)) { | |||
| 175 | + | defer func() { | |||
| 176 | + | if r := recover(); r != nil { | |||
| 177 | + | f.body, f.err = nil, errBuildPanicked | |||
| 178 | + | zerowrap.Ctx(ctx).Error().Interface("panic", r).Str("stack", string(debug.Stack())).Msg("path list build panicked") | |||
| 179 | + | } | |||
| 180 | + | c.finish(key, id, f) | |||
| 181 | + | }() | |||
| 182 | + | f.body, f.err = build() | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | // finish publishes a build: stored when it worked, then released to the waiters. | |||
| 186 | + | func (c *pathCache) finish(key, id string, f *pathBuild) { | |||
| 187 | + | c.mu.Lock() | |||
| 188 | + | delete(c.building, key) | |||
| 189 | + | if f.err == nil { | |||
| 190 | + | c.putLocked(id, f.body) | |||
| 191 | + | } | |||
| 192 | + | c.mu.Unlock() | |||
| 193 | + | <-c.slots // free before the waiters wake: one of them may start the next build | |||
| 194 | + | close(f.done) | |||
| 195 | + | } | |||
A internal/adapters/web/pathcache_test.go +311 -0
| @@ -0,0 +1,311 @@ | |||||
| 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 | + | // store puts a list in the cache the way a finished build does. | |||
| 19 | + | func (c *pathCache) store(id string, body []byte) { | |||
| 20 | + | c.mu.Lock() | |||
| 21 | + | defer c.mu.Unlock() | |||
| 22 | + | c.putLocked(id, body) | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // has says whether a list is cached, marking it as used. | |||
| 26 | + | func (c *pathCache) has(id string) bool { | |||
| 27 | + | c.mu.Lock() | |||
| 28 | + | defer c.mu.Unlock() | |||
| 29 | + | _, ok := c.getLocked(id) | |||
| 30 | + | return ok | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func TestPathCacheEvictsLeastRecentlyUsed(t *testing.T) { | |||
| 34 | + | c := newPathCache(100, 1) | |||
| 35 | + | blob := func(n int) []byte { return make([]byte, n) } | |||
| 36 | + | c.store("a", blob(25)) | |||
| 37 | + | c.store("b", blob(25)) | |||
| 38 | + | c.store("e", blob(25)) | |||
| 39 | + | require.True(t, c.has("a")) // a is now the most recent | |||
| 40 | + | c.store("c", blob(25)) // 100 fit | |||
| 41 | + | c.store("d", blob(10)) // 110 > 100: b, the least recent, goes | |||
| 42 | + | assert.False(t, c.has("b"), "b was evicted") | |||
| 43 | + | assert.True(t, c.has("a") && c.has("c") && c.has("d") && c.has("e")) | |||
| 44 | + | assert.Equal(t, 85, c.size) | |||
| 45 | + | ||||
| 46 | + | // Replacing an entry accounts for the old size. | |||
| 47 | + | c.store("a", blob(10)) | |||
| 48 | + | assert.Equal(t, 70, c.size) | |||
| 49 | + | ||||
| 50 | + | // A big one pushes out as many as needed. | |||
| 51 | + | c.store("f", blob(25)) | |||
| 52 | + | c.store("g", blob(25)) | |||
| 53 | + | assert.LessOrEqual(t, c.size, 100) | |||
| 54 | + | assert.True(t, c.has("g")) | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // A list over a quarter of the budget is not kept, since it would push out the | |||
| 58 | + | // others; one at the limit is. | |||
| 59 | + | func TestPathCacheSkipsOversizedEntries(t *testing.T) { | |||
| 60 | + | c := newPathCache(100, 1) | |||
| 61 | + | var builds atomic.Int32 | |||
| 62 | + | big := func(n int) func() ([]byte, error) { | |||
| 63 | + | return func() ([]byte, error) { builds.Add(1); return make([]byte, n), nil } | |||
| 64 | + | } | |||
| 65 | + | for range 2 { | |||
| 66 | + | b, err := c.load(context.Background(), "r", "big", big(26)) | |||
| 67 | + | require.NoError(t, err) | |||
| 68 | + | assert.Len(t, b, 26, "the caller still gets the list") | |||
| 69 | + | } | |||
| 70 | + | assert.EqualValues(t, 2, builds.Load(), "not cached, so built again") | |||
| 71 | + | assert.False(t, c.has("big")) | |||
| 72 | + | assert.Zero(t, c.size) | |||
| 73 | + | ||||
| 74 | + | for range 2 { | |||
| 75 | + | _, err := c.load(context.Background(), "r", "fits", big(25)) | |||
| 76 | + | require.NoError(t, err) | |||
| 77 | + | } | |||
| 78 | + | assert.EqualValues(t, 3, builds.Load(), "at a quarter: kept") | |||
| 79 | + | assert.Equal(t, 25, c.size) | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func TestPathCacheConcurrent(t *testing.T) { | |||
| 83 | + | c := newPathCache(1000, 1) | |||
| 84 | + | var wg sync.WaitGroup | |||
| 85 | + | for g := range 8 { | |||
| 86 | + | wg.Add(1) | |||
| 87 | + | go func() { | |||
| 88 | + | defer wg.Done() | |||
| 89 | + | for i := range 500 { | |||
| 90 | + | c.store(fmt.Sprint((g+i)%17), make([]byte, 10+i%90)) | |||
| 91 | + | c.has(fmt.Sprint(i % 17)) | |||
| 92 | + | } | |||
| 93 | + | }() | |||
| 94 | + | } | |||
| 95 | + | wg.Wait() | |||
| 96 | + | assert.LessOrEqual(t, c.size, 1000) | |||
| 97 | + | sum := 0 | |||
| 98 | + | for _, el := range c.byID { | |||
| 99 | + | sum += len(el.Value.(*pathEntry).body) | |||
| 100 | + | } | |||
| 101 | + | assert.Equal(t, sum, c.size, "the size is the sum of the entries") | |||
| 102 | + | assert.Equal(t, len(c.byID), c.lru.Len()) | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // waiters is the number of callers waiting for the build of a key, -1 when | |||
| 106 | + | // none is in flight. | |||
| 107 | + | func waiters(c *pathCache, repo, id string) int { | |||
| 108 | + | c.mu.Lock() | |||
| 109 | + | defer c.mu.Unlock() | |||
| 110 | + | if f := c.building[repo+"\x00"+id]; f != nil { | |||
| 111 | + | return f.waiters | |||
| 112 | + | } | |||
| 113 | + | return -1 | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | func building(c *pathCache, n int) bool { | |||
| 117 | + | c.mu.Lock() | |||
| 118 | + | defer c.mu.Unlock() | |||
| 119 | + | return len(c.building) == n | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // Concurrent misses for one commit build once; a failure is shared and not | |||
| 123 | + | // stored; a waiter that gives up leaves the build running. | |||
| 124 | + | func TestPathCacheLoadBuildsOnce(t *testing.T) { | |||
| 125 | + | c := newPathCache(1000, 4) | |||
| 126 | + | var builds atomic.Int32 | |||
| 127 | + | release := make(chan struct{}) | |||
| 128 | + | build := func() ([]byte, error) { | |||
| 129 | + | builds.Add(1) | |||
| 130 | + | <-release | |||
| 131 | + | return []byte("list"), nil | |||
| 132 | + | } | |||
| 133 | + | const callers = 10 | |||
| 134 | + | var wg sync.WaitGroup | |||
| 135 | + | got := make([][]byte, callers) | |||
| 136 | + | for i := range got { | |||
| 137 | + | wg.Add(1) | |||
| 138 | + | go func() { | |||
| 139 | + | defer wg.Done() | |||
| 140 | + | b, err := c.load(context.Background(), "r", "x", build) | |||
| 141 | + | assert.NoError(t, err) | |||
| 142 | + | got[i] = b | |||
| 143 | + | }() | |||
| 144 | + | } | |||
| 145 | + | // Every caller waits for the one build before it is released: none can | |||
| 146 | + | // find the list cached, so one build serving all ten shows they joined it. | |||
| 147 | + | require.Eventually(t, func() bool { return waiters(c, "r", "x") == callers }, 5*time.Second, time.Millisecond) | |||
| 148 | + | // A waiter whose context ends does not stop the others. | |||
| 149 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 150 | + | cancel() | |||
| 151 | + | _, err := c.load(ctx, "r", "x", build) | |||
| 152 | + | assert.ErrorIs(t, err, context.Canceled) | |||
| 153 | + | close(release) | |||
| 154 | + | wg.Wait() | |||
| 155 | + | assert.EqualValues(t, 1, builds.Load()) | |||
| 156 | + | for _, b := range got { | |||
| 157 | + | assert.Equal(t, "list", string(b)) | |||
| 158 | + | } | |||
| 159 | + | assert.True(t, c.has("x")) | |||
| 160 | + | ||||
| 161 | + | boom := errors.New("boom") | |||
| 162 | + | _, err = c.load(context.Background(), "r", "y", func() ([]byte, error) { return nil, boom }) | |||
| 163 | + | assert.ErrorIs(t, err, boom) | |||
| 164 | + | assert.False(t, c.has("y"), "a failure is not cached") | |||
| 165 | + | b, err := c.load(context.Background(), "r", "y", func() ([]byte, error) { return []byte("ok"), nil }) | |||
| 166 | + | require.NoError(t, err) | |||
| 167 | + | assert.Equal(t, "ok", string(b), "the next request builds again") | |||
| 168 | + | assert.Empty(t, c.building) | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | // The builds of all commits share the slots: with one slot, a load of another | |||
| 172 | + | // commit waits for the running build, and leaves with its ctx if it is | |||
| 173 | + | // cancelled first. The slot is the build's, not the caller's. | |||
| 174 | + | func TestPathCacheLoadBoundsBuilds(t *testing.T) { | |||
| 175 | + | c := newPathCache(1000, 1) | |||
| 176 | + | started, release := make(chan struct{}), make(chan struct{}) | |||
| 177 | + | first := make(chan error, 1) | |||
| 178 | + | go func() { | |||
| 179 | + | _, err := c.load(context.Background(), "r", "a", func() ([]byte, error) { close(started); <-release; return []byte("a"), nil }) | |||
| 180 | + | first <- err | |||
| 181 | + | }() | |||
| 182 | + | <-started | |||
| 183 | + | ||||
| 184 | + | // A second commit waits for the slot... | |||
| 185 | + | var secondBuilt atomic.Bool | |||
| 186 | + | second := make(chan error, 1) | |||
| 187 | + | go func() { | |||
| 188 | + | _, err := c.load(context.Background(), "r", "b", func() ([]byte, error) { secondBuilt.Store(true); return []byte("b"), nil }) | |||
| 189 | + | second <- err | |||
| 190 | + | }() | |||
| 191 | + | time.Sleep(30 * time.Millisecond) | |||
| 192 | + | assert.False(t, secondBuilt.Load(), "no slot, no build") | |||
| 193 | + | select { | |||
| 194 | + | case <-second: | |||
| 195 | + | t.Fatal("the second load did not wait for a slot") | |||
| 196 | + | default: | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | // ...or leaves with its ctx. | |||
| 200 | + | ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) | |||
| 201 | + | defer cancel() | |||
| 202 | + | _, err := c.load(ctx, "r", "c", func() ([]byte, error) { t.Error("built without a slot"); return nil, nil }) | |||
| 203 | + | assert.ErrorIs(t, err, context.DeadlineExceeded) | |||
| 204 | + | ||||
| 205 | + | // A caller of the running build is not held by the slots. | |||
| 206 | + | _, err = c.load(ctx, "r", "a", nil) | |||
| 207 | + | assert.ErrorIs(t, err, context.DeadlineExceeded, "it joins the build and leaves with its ctx") | |||
| 208 | + | ||||
| 209 | + | close(release) | |||
| 210 | + | require.NoError(t, <-first) | |||
| 211 | + | require.NoError(t, <-second) | |||
| 212 | + | assert.True(t, secondBuilt.Load()) | |||
| 213 | + | assert.True(t, c.has("a") && c.has("b")) | |||
| 214 | + | assert.Empty(t, c.building) | |||
| 215 | + | assert.Empty(t, c.slots, "every slot is back") | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | // The slot of a build is kept after its callers leave, and given back when it | |||
| 219 | + | // ends, whether it fails or panics. | |||
| 220 | + | func TestPathCacheSlotOutlivesCallers(t *testing.T) { | |||
| 221 | + | c := newPathCache(1000, 1) | |||
| 222 | + | started, release := make(chan struct{}), make(chan struct{}) | |||
| 223 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 224 | + | done := make(chan error, 1) | |||
| 225 | + | go func() { | |||
| 226 | + | _, err := c.load(ctx, "r", "a", func() ([]byte, error) { close(started); <-release; panic("boom") }) | |||
| 227 | + | done <- err | |||
| 228 | + | }() | |||
| 229 | + | <-started | |||
| 230 | + | cancel() | |||
| 231 | + | assert.ErrorIs(t, <-done, context.Canceled) | |||
| 232 | + | assert.Len(t, c.slots, 1, "the build still holds its slot") | |||
| 233 | + | close(release) | |||
| 234 | + | require.Eventually(t, func() bool { return len(c.slots) == 0 && building(c, 0) }, time.Second, time.Millisecond) | |||
| 235 | + | b, err := c.load(context.Background(), "r", "b", func() ([]byte, error) { return []byte("ok"), nil }) | |||
| 236 | + | require.NoError(t, err) | |||
| 237 | + | assert.Equal(t, "ok", string(b)) | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | // A build that panics is handed to its waiters as an error, leaves nothing | |||
| 241 | + | // in flight or cached, and does not take the process down. | |||
| 242 | + | func TestPathCacheLoadPanic(t *testing.T) { | |||
| 243 | + | c := newPathCache(1000, 4) | |||
| 244 | + | started, release := make(chan struct{}), make(chan struct{}) | |||
| 245 | + | var once sync.Once | |||
| 246 | + | build := func() ([]byte, error) { | |||
| 247 | + | once.Do(func() { close(started) }) | |||
| 248 | + | <-release | |||
| 249 | + | panic("boom") | |||
| 250 | + | } | |||
| 251 | + | errs := make(chan error, 2) | |||
| 252 | + | for range 2 { | |||
| 253 | + | go func() { | |||
| 254 | + | _, err := c.load(context.Background(), "r", "x", build) | |||
| 255 | + | errs <- err | |||
| 256 | + | }() | |||
| 257 | + | } | |||
| 258 | + | <-started | |||
| 259 | + | require.Eventually(t, func() bool { c.mu.Lock(); defer c.mu.Unlock(); return len(c.building) == 1 }, time.Second, time.Millisecond) | |||
| 260 | + | close(release) | |||
| 261 | + | for range 2 { | |||
| 262 | + | assert.ErrorIs(t, <-errs, errBuildPanicked) | |||
| 263 | + | } | |||
| 264 | + | assert.Empty(t, c.building) | |||
| 265 | + | assert.False(t, c.has("x"), "a panic is not cached") | |||
| 266 | + | ||||
| 267 | + | b, err := c.load(context.Background(), "r", "x", func() ([]byte, error) { return []byte("ok"), nil }) | |||
| 268 | + | require.NoError(t, err) | |||
| 269 | + | assert.Equal(t, "ok", string(b), "the next request builds again") | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | // The caller that starts a build is released with its context too, and the | |||
| 273 | + | // build goes on for the others. | |||
| 274 | + | func TestPathCacheLoadStarterLeaves(t *testing.T) { | |||
| 275 | + | c := newPathCache(1000, 4) | |||
| 276 | + | release := make(chan struct{}) | |||
| 277 | + | build := func() ([]byte, error) { <-release; return []byte("list"), nil } | |||
| 278 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 279 | + | done := make(chan error, 1) | |||
| 280 | + | go func() { _, err := c.load(ctx, "r", "x", build); done <- err }() | |||
| 281 | + | require.Eventually(t, func() bool { c.mu.Lock(); defer c.mu.Unlock(); return len(c.building) == 1 }, time.Second, time.Millisecond) | |||
| 282 | + | cancel() | |||
| 283 | + | select { | |||
| 284 | + | case err := <-done: | |||
| 285 | + | assert.ErrorIs(t, err, context.Canceled) | |||
| 286 | + | case <-time.After(time.Second): | |||
| 287 | + | t.Fatal("the starter is held by its build") | |||
| 288 | + | } | |||
| 289 | + | close(release) | |||
| 290 | + | require.Eventually(t, func() bool { return c.has("x") }, time.Second, time.Millisecond) | |||
| 291 | + | assert.Empty(t, c.building) | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | // A failure belongs to the repository that built it: another repository | |||
| 295 | + | // asking for the same commit builds its own. | |||
| 296 | + | func TestPathCacheLoadFailureStaysInRepo(t *testing.T) { | |||
| 297 | + | c := newPathCache(1000, 4) | |||
| 298 | + | started, release := make(chan struct{}), make(chan struct{}) | |||
| 299 | + | boom := errors.New("boom") | |||
| 300 | + | failing := make(chan error, 1) | |||
| 301 | + | go func() { | |||
| 302 | + | _, err := c.load(context.Background(), "a", "x", func() ([]byte, error) { close(started); <-release; return nil, boom }) | |||
| 303 | + | failing <- err | |||
| 304 | + | }() | |||
| 305 | + | <-started | |||
| 306 | + | b, err := c.load(context.Background(), "b", "x", func() ([]byte, error) { return []byte("list"), nil }) | |||
| 307 | + | require.NoError(t, err) | |||
| 308 | + | assert.Equal(t, "list", string(b)) | |||
| 309 | + | close(release) | |||
| 310 | + | assert.ErrorIs(t, <-failing, boom) | |||
| 311 | + | } | |||
A internal/adapters/web/paths.go +187 -0
| @@ -0,0 +1,187 @@ | |||||
| 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 (answered before the list is loaded, so it never | |||
| 37 | + | // waits for a build); the owner's copy is private and never stored. The ETag | |||
| 38 | + | // differs for the gzip and the plain variant. | |||
| 39 | + | func (s *Server) paths(w http.ResponseWriter, r *http.Request) { | |||
| 40 | + | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | |||
| 41 | + | defer cancel() | |||
| 42 | + | repo, sha := r.PathValue("repo"), r.PathValue("sha") | |||
| 43 | + | if !isFullID(sha) { | |||
| 44 | + | s.fail(ctx, w, r, errNotFound) | |||
| 45 | + | return | |||
| 46 | + | } | |||
| 47 | + | res, err := s.cfg.Git.ResolveRevision(ctx, connect.NewRequest(&pb.ResolveRevisionRequest{Repo: repo, Revision: sha})) | |||
| 48 | + | if err != nil { | |||
| 49 | + | s.fail(ctx, w, r, err) | |||
| 50 | + | return | |||
| 51 | + | } | |||
| 52 | + | // A ref can be named like a commit id: only the commit itself is served, | |||
| 53 | + | // since the cache is keyed by it. | |||
| 54 | + | if res.Msg.GetCommitId() != sha { | |||
| 55 | + | s.fail(ctx, w, r, errNotFound) | |||
| 56 | + | return | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // The validator names the variant that would be served: the gzip bytes | |||
| 60 | + | // and the plain JSON are different representations. | |||
| 61 | + | gz := acceptsGzip(r.Header.Get("Accept-Encoding")) | |||
| 62 | + | etag := `"paths-` + sha + `"` | |||
| 63 | + | if gz { | |||
| 64 | + | etag = `"paths-` + sha + `-gz"` | |||
| 65 | + | } | |||
| 66 | + | // Revalidated every time, as raw is: a repository can turn private, and a | |||
| 67 | + | // shared cache must not keep serving its files. The ETag keeps it cheap. | |||
| 68 | + | validators := func(h http.Header) { | |||
| 69 | + | h.Set("Vary", "Accept-Encoding") | |||
| 70 | + | h.Set("ETag", etag) | |||
| 71 | + | cacheHeaders(ctx, h, "no-cache", sessionCache) | |||
| 72 | + | } | |||
| 73 | + | // Access is settled: a revalidation is answered without the list, so it | |||
| 74 | + | // never waits for a build. | |||
| 75 | + | if etagMatches(r.Header.Get("If-None-Match"), etag) { | |||
| 76 | + | validators(w.Header()) | |||
| 77 | + | w.WriteHeader(http.StatusNotModified) | |||
| 78 | + | return | |||
| 79 | + | } | |||
| 80 | + | body, err := s.pathCache.load(ctx, repo, sha, func() ([]byte, error) { return s.buildPaths(ctx, repo, sha) }) | |||
| 81 | + | if err != nil { | |||
| 82 | + | s.fail(ctx, w, r, err) | |||
| 83 | + | return | |||
| 84 | + | } | |||
| 85 | + | // Authorized, not modified and loaded: all that is left is to send it. | |||
| 86 | + | validators(w.Header()) | |||
| 87 | + | if err := writePathList(w, r, body, gz); err != nil { | |||
| 88 | + | s.fail(ctx, w, r, err) | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | // writePathList sends a list (gzipped JSON, as the cache keeps it): as it is | |||
| 93 | + | // to a client that accepts gzip, unzipped to the others. The validators are | |||
| 94 | + | // already set. It fails only before anything is written. | |||
| 95 | + | func writePathList(w http.ResponseWriter, r *http.Request, body []byte, gz bool) error { | |||
| 96 | + | var plain *gzip.Reader | |||
| 97 | + | if !gz { | |||
| 98 | + | var err error | |||
| 99 | + | if plain, err = gzip.NewReader(bytes.NewReader(body)); err != nil { | |||
| 100 | + | return err | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | h := w.Header() | |||
| 104 | + | h.Set("Content-Type", "application/json") | |||
| 105 | + | h.Set("X-Content-Type-Options", "nosniff") | |||
| 106 | + | if gz { | |||
| 107 | + | h.Set("Content-Encoding", "gzip") | |||
| 108 | + | h.Set("Content-Length", strconv.Itoa(len(body))) | |||
| 109 | + | w.WriteHeader(http.StatusOK) | |||
| 110 | + | if r.Method != http.MethodHead { | |||
| 111 | + | _, _ = w.Write(body) | |||
| 112 | + | } | |||
| 113 | + | return nil | |||
| 114 | + | } | |||
| 115 | + | w.WriteHeader(http.StatusOK) | |||
| 116 | + | if r.Method != http.MethodHead { | |||
| 117 | + | _, _ = io.Copy(w, plain) | |||
| 118 | + | } | |||
| 119 | + | return nil | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // buildPaths lists the paths of a commit and returns them as gzipped JSON. | |||
| 123 | + | // The build is shared by the requests that wait for it, so it does not stop | |||
| 124 | + | // when the request that started it ends. | |||
| 125 | + | func (s *Server) buildPaths(ctx context.Context, repo, sha string) ([]byte, error) { | |||
| 126 | + | ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), requestTimeout) | |||
| 127 | + | defer cancel() | |||
| 128 | + | res, err := s.cfg.Git.ListPaths(ctx, connect.NewRequest(&pb.ListPathsRequest{Repo: repo, Revision: sha})) | |||
| 129 | + | if err != nil { | |||
| 130 | + | return nil, err | |||
| 131 | + | } | |||
| 132 | + | if res.Msg.GetCommitId() != sha { | |||
| 133 | + | return nil, errNotFound | |||
| 134 | + | } | |||
| 135 | + | paths := res.Msg.GetPaths() | |||
| 136 | + | if paths == nil { | |||
| 137 | + | paths = []string{} | |||
| 138 | + | } | |||
| 139 | + | var buf bytes.Buffer | |||
| 140 | + | zw, err := gzip.NewWriterLevel(&buf, gzip.BestSpeed) | |||
| 141 | + | if err != nil { | |||
| 142 | + | return nil, err | |||
| 143 | + | } | |||
| 144 | + | enc := json.NewEncoder(zw) | |||
| 145 | + | enc.SetEscapeHTML(false) // a path is not HTML; "<", ">" and "&" stay as they are | |||
| 146 | + | if err := enc.Encode(pathList{Paths: paths, Truncated: res.Msg.GetTruncated()}); err != nil { | |||
| 147 | + | return nil, err | |||
| 148 | + | } | |||
| 149 | + | if err := zw.Close(); err != nil { | |||
| 150 | + | return nil, err | |||
| 151 | + | } | |||
| 152 | + | // Not buf.Bytes(): the buffer's spare capacity would stay in the cache | |||
| 153 | + | // without being counted in its budget. (bytes.Clone may round the | |||
| 154 | + | // capacity up, so the copy is exact.) | |||
| 155 | + | body := make([]byte, buf.Len()) | |||
| 156 | + | copy(body, buf.Bytes()) | |||
| 157 | + | return body, nil | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | // acceptsGzip reports whether an Accept-Encoding header allows gzip. | |||
| 161 | + | func acceptsGzip(h string) bool { | |||
| 162 | + | for f := range strings.SplitSeq(h, ",") { | |||
| 163 | + | name, params, _ := strings.Cut(strings.TrimSpace(f), ";") | |||
| 164 | + | if !strings.EqualFold(strings.TrimSpace(name), "gzip") { | |||
| 165 | + | continue | |||
| 166 | + | } | |||
| 167 | + | if q, ok := strings.CutPrefix(strings.ReplaceAll(strings.TrimSpace(params), " ", ""), "q="); ok { | |||
| 168 | + | if v, err := strconv.ParseFloat(q, 64); err == nil && v <= 0 { | |||
| 169 | + | return false | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | return true | |||
| 173 | + | } | |||
| 174 | + | return false | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | // etagMatches reports whether an If-None-Match header lists etag (weak | |||
| 178 | + | // comparison, or "*"). | |||
| 179 | + | func etagMatches(header, etag string) bool { | |||
| 180 | + | for f := range strings.SplitSeq(header, ",") { | |||
| 181 | + | f = strings.TrimSpace(f) | |||
| 182 | + | if f == "*" || strings.TrimPrefix(f, "W/") == etag { | |||
| 183 | + | return true | |||
| 184 | + | } | |||
| 185 | + | } | |||
| 186 | + | return false | |||
| 187 | + | } | |||
A internal/adapters/web/paths_test.go +173 -0
| @@ -0,0 +1,173 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bytes" | |||
| 7 | + | "compress/gzip" | |||
| 8 | + | "context" | |||
| 9 | + | "io" | |||
| 10 | + | "net/http" | |||
| 11 | + | "net/http/httptest" | |||
| 12 | + | "strings" | |||
| 13 | + | "sync/atomic" | |||
| 14 | + | "testing" | |||
| 15 | + | ||||
| 16 | + | "connectrpc.com/connect" | |||
| 17 | + | "github.com/stretchr/testify/assert" | |||
| 18 | + | "github.com/stretchr/testify/require" | |||
| 19 | + | ||||
| 20 | + | pb "bnema.dev/ermite-api/gen/ermite" | |||
| 21 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |||
| 22 | + | ) | |||
| 23 | + | ||||
| 24 | + | // pathsGit is a Git client that resolves any revision to itself and counts | |||
| 25 | + | // the lists it builds. | |||
| 26 | + | type pathsGit struct { | |||
| 27 | + | ermiteconnect.GitServiceClient | |||
| 28 | + | lists atomic.Int32 | |||
| 29 | + | paths []string // the list; a.go and b/c.go when nil | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func (g *pathsGit) ResolveRevision(_ context.Context, req *connect.Request[pb.ResolveRevisionRequest]) (*connect.Response[pb.ResolveRevisionResponse], error) { | |||
| 33 | + | return connect.NewResponse(&pb.ResolveRevisionResponse{CommitId: req.Msg.GetRevision()}), nil | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func (g *pathsGit) ListPaths(_ context.Context, req *connect.Request[pb.ListPathsRequest]) (*connect.Response[pb.ListPathsResponse], error) { | |||
| 37 | + | g.lists.Add(1) | |||
| 38 | + | paths := g.paths | |||
| 39 | + | if paths == nil { | |||
| 40 | + | paths = []string{"a.go", "b/c.go"} | |||
| 41 | + | } | |||
| 42 | + | return connect.NewResponse(&pb.ListPathsResponse{CommitId: req.Msg.GetRevision(), Paths: paths}), nil | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // A revalidation is answered after the access check and before the list is | |||
| 46 | + | // loaded: it never builds. Each variant has its own validator. | |||
| 47 | + | func TestPathsRevalidationDoesNotBuild(t *testing.T) { | |||
| 48 | + | git := &pathsGit{} | |||
| 49 | + | srv, err := New(Config{ | |||
| 50 | + | Repos: failing{}, Git: git, | |||
| 51 | + | PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1", | |||
| 52 | + | }) | |||
| 53 | + | require.NoError(t, err) | |||
| 54 | + | sha := strings.Repeat("a", 40) | |||
| 55 | + | get := func(hdr map[string]string) *httptest.ResponseRecorder { | |||
| 56 | + | req := httptest.NewRequest(http.MethodGet, "/r/paths/"+sha, nil) | |||
| 57 | + | for k, v := range hdr { | |||
| 58 | + | req.Header.Set(k, v) | |||
| 59 | + | } | |||
| 60 | + | rec := httptest.NewRecorder() | |||
| 61 | + | srv.ServeHTTP(rec, req) | |||
| 62 | + | return rec | |||
| 63 | + | } | |||
| 64 | + | gzTag, plainTag := `"paths-`+sha+`-gz"`, `"paths-`+sha+`"` | |||
| 65 | + | ||||
| 66 | + | for _, tt := range []struct{ name, ae, inm string }{ | |||
| 67 | + | {"gzip", "gzip", gzTag}, | |||
| 68 | + | {"identity", "", plainTag}, | |||
| 69 | + | {"weak", "gzip", "W/" + gzTag}, | |||
| 70 | + | {"any", "", "*"}, | |||
| 71 | + | } { | |||
| 72 | + | rec := get(map[string]string{"Accept-Encoding": tt.ae, "If-None-Match": tt.inm}) | |||
| 73 | + | assert.Equal(t, http.StatusNotModified, rec.Code, tt.name) | |||
| 74 | + | assert.Empty(t, rec.Body.String(), tt.name) | |||
| 75 | + | assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control"), tt.name) | |||
| 76 | + | assert.Equal(t, "Accept-Encoding", rec.Header().Get("Vary"), tt.name) | |||
| 77 | + | assert.NotEmpty(t, rec.Header().Get("ETag"), tt.name) | |||
| 78 | + | } | |||
| 79 | + | assert.Zero(t, git.lists.Load(), "a 304 never builds") | |||
| 80 | + | ||||
| 81 | + | // The validator of the other variant does not match. | |||
| 82 | + | assert.Equal(t, http.StatusOK, get(map[string]string{"Accept-Encoding": "gzip", "If-None-Match": plainTag}).Code) | |||
| 83 | + | assert.Equal(t, http.StatusOK, get(map[string]string{"If-None-Match": gzTag}).Code) | |||
| 84 | + | assert.EqualValues(t, 1, git.lists.Load(), "built once, then cached") | |||
| 85 | + | ||||
| 86 | + | rec := get(map[string]string{"Accept-Encoding": "gzip"}) | |||
| 87 | + | assert.Equal(t, gzTag, rec.Header().Get("ETag")) | |||
| 88 | + | assert.Equal(t, "gzip", rec.Header().Get("Content-Encoding")) | |||
| 89 | + | zr, err := gzip.NewReader(rec.Body) | |||
| 90 | + | require.NoError(t, err) | |||
| 91 | + | out, err := io.ReadAll(zr) | |||
| 92 | + | require.NoError(t, err) | |||
| 93 | + | assert.JSONEq(t, `{"paths":["a.go","b/c.go"],"truncated":false}`, string(out)) | |||
| 94 | + | ||||
| 95 | + | rec = get(nil) | |||
| 96 | + | assert.Equal(t, plainTag, rec.Header().Get("ETag")) | |||
| 97 | + | assert.Empty(t, rec.Header().Get("Content-Encoding")) | |||
| 98 | + | assert.JSONEq(t, `{"paths":["a.go","b/c.go"],"truncated":false}`, rec.Body.String()) | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | // The cached list holds no spare capacity. | |||
| 102 | + | func TestBuildPathsCachesATightBody(t *testing.T) { | |||
| 103 | + | srv, err := New(Config{Repos: failing{}, Git: &pathsGit{}, PublicURL: "https://forge.example"}) | |||
| 104 | + | require.NoError(t, err) | |||
| 105 | + | body, err := srv.buildPaths(context.Background(), "r", strings.Repeat("a", 40)) | |||
| 106 | + | require.NoError(t, err) | |||
| 107 | + | assert.Equal(t, len(body), cap(body)) | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func TestAcceptsGzip(t *testing.T) { | |||
| 111 | + | for _, tt := range []struct { | |||
| 112 | + | header string | |||
| 113 | + | want bool | |||
| 114 | + | }{ | |||
| 115 | + | {"", false}, | |||
| 116 | + | {"gzip", true}, | |||
| 117 | + | {"GZip", true}, | |||
| 118 | + | {"deflate, gzip", true}, | |||
| 119 | + | {"deflate, br", false}, | |||
| 120 | + | {"gzip;q=0", false}, | |||
| 121 | + | {"gzip; q=0", false}, | |||
| 122 | + | {"gzip;q=0.0", false}, | |||
| 123 | + | {"gzip ; q = 0", false}, | |||
| 124 | + | {"gzip;q=0.5", true}, | |||
| 125 | + | {"gzip;q=1", true}, | |||
| 126 | + | {"identity, gzip;q=0", false}, | |||
| 127 | + | {"gzip;q=0, deflate", false}, | |||
| 128 | + | {"gzip;q=bad", true}, | |||
| 129 | + | {"x-gzip", false}, | |||
| 130 | + | // A wildcard is not read: the finder asks for gzip by name, and the | |||
| 131 | + | // plain body is always right. | |||
| 132 | + | {"*", false}, | |||
| 133 | + | } { | |||
| 134 | + | assert.Equal(t, tt.want, acceptsGzip(tt.header), "%q", tt.header) | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | func TestEtagMatches(t *testing.T) { | |||
| 139 | + | const tag = `"paths-x"` | |||
| 140 | + | for _, tt := range []struct { | |||
| 141 | + | header string | |||
| 142 | + | want bool | |||
| 143 | + | }{ | |||
| 144 | + | {"", false}, | |||
| 145 | + | {tag, true}, | |||
| 146 | + | {"W/" + tag, true}, | |||
| 147 | + | {`"other"`, false}, | |||
| 148 | + | {`"a", ` + tag, true}, | |||
| 149 | + | {`"a",` + tag + `,"b"`, true}, | |||
| 150 | + | {`"a", W/` + tag, true}, | |||
| 151 | + | {`"a", "b"`, false}, | |||
| 152 | + | {"*", true}, | |||
| 153 | + | {`"a", *`, true}, | |||
| 154 | + | {`"paths-x-gz"`, false}, | |||
| 155 | + | {`w/` + tag, false}, | |||
| 156 | + | } { | |||
| 157 | + | assert.Equal(t, tt.want, etagMatches(tt.header, tag), "%q", tt.header) | |||
| 158 | + | } | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | // A path is not HTML: "<", ">" and "&" are not escaped in the JSON. | |||
| 162 | + | func TestBuildPathsDoesNotEscapeHTML(t *testing.T) { | |||
| 163 | + | git := &pathsGit{paths: []string{"a&b/<c>.go"}} | |||
| 164 | + | srv, err := New(Config{Repos: failing{}, Git: git, PublicURL: "https://forge.example"}) | |||
| 165 | + | require.NoError(t, err) | |||
| 166 | + | body, err := srv.buildPaths(context.Background(), "r", strings.Repeat("a", 40)) | |||
| 167 | + | require.NoError(t, err) | |||
| 168 | + | zr, err := gzip.NewReader(bytes.NewReader(body)) | |||
| 169 | + | require.NoError(t, err) | |||
| 170 | + | out, err := io.ReadAll(zr) | |||
| 171 | + | require.NoError(t, err) | |||
| 172 | + | assert.Contains(t, string(out), `"a&b/<c>.go"`) | |||
| 173 | + | } | |||
M internal/adapters/web/raw.go +1 -1
| @@ -60,7 +60,7 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) { | |||||
| 60 | // private, and a shared cache must not keep serving its files (AC2). The | 60 | // private, and a shared cache must not keep serving its files (AC2). The | ||
| 61 | // ETag keeps the revalidation cheap. | 61 | // ETag keeps the revalidation cheap. | ||
| 62 | cacheHeaders(ctx, h, "no-cache", sessionCache) | 62 | cacheHeaders(ctx, h, "no-cache", sessionCache) | ||
| 63 | - | if match := r.Header.Get("If-None-Match"); match != "" && h.Get("ETag") != "" && match == h.Get("ETag") { | 63 | + | if etag := h.Get("ETag"); etag != "" && etagMatches(r.Header.Get("If-None-Match"), etag) { |
| 64 | w.WriteHeader(http.StatusNotModified) | 64 | w.WriteHeader(http.StatusNotModified) | ||
| 65 | return | 65 | return | ||
| 66 | } | 66 | } | ||
M internal/adapters/web/refs.go +45 -1
| @@ -3,9 +3,12 @@ | |||||
| 3 | package web | 3 | package web | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | + | "context" | |||
| 6 | "net/url" | 7 | "net/url" | ||
| 7 | "strings" | 8 | "strings" | ||
| 8 | 9 | ||||
| 10 | + | "connectrpc.com/connect" | |||
| 11 | + | ||||
| 9 | pb "bnema.dev/ermite-api/gen/ermite" | 12 | pb "bnema.dev/ermite-api/gen/ermite" | ||
| 10 | ) | 13 | ) | ||
| 11 | 14 | ||||
| @@ -77,6 +80,32 @@ type target struct { | |||||
| 77 | path string | 80 | path string | ||
| 78 | // detached is true when the URL names a commit and not a ref. | 81 | // detached is true when the URL names a commit and not a ref. | ||
| 79 | detached bool | 82 | detached bool | ||
| 83 | + | // commit is the full id of the commit when the page has learnt it from | |||
| 84 | + | // the API and rev is an abbreviation; "" otherwise. | |||
| 85 | + | commit string | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // commitID is the full id of the commit of the target when it is known, and | |||
| 89 | + | // rev otherwise. | |||
| 90 | + | func (t target) commitID() string { | |||
| 91 | + | if t.commit != "" { | |||
| 92 | + | return t.commit | |||
| 93 | + | } | |||
| 94 | + | return t.rev | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | // resolveCommit sets the full id of a target named by an abbreviated commit id, | |||
| 98 | + | // with one ResolveRevision. The page renders without it (it only lacks the file | |||
| 99 | + | // finder), so a failure is not one. | |||
| 100 | + | func (s *Server) resolveCommit(ctx context.Context, repo string, t *target) { | |||
| 101 | + | if !t.detached || isFullID(t.rev) { | |||
| 102 | + | return | |||
| 103 | + | } | |||
| 104 | + | res, err := s.cfg.Git.ResolveRevision(ctx, connect.NewRequest(&pb.ResolveRevisionRequest{Repo: repo, Revision: t.rev})) | |||
| 105 | + | if err != nil { | |||
| 106 | + | return | |||
| 107 | + | } | |||
| 108 | + | t.commit = res.Msg.GetCommitId() | |||
| 80 | } | 109 | } | ||
| 81 | 110 | ||||
| 82 | // locate reads "<ref>[/<path>]" or "<commit>[/<path>]". The ref is the | 111 | // locate reads "<ref>[/<path>]" or "<commit>[/<path>]". The ref is the | ||
| @@ -132,13 +161,14 @@ func (d *repoData) isBranchHead(t target) bool { | |||||
| 132 | 161 | ||||
| 133 | // pinRef points the ref switcher of the layout at what a page shows: a | 162 | // pinRef points the ref switcher of the layout at what a page shows: a | ||
| 134 | // branch or a tag, which is then the current one of the lists, or a commit, | 163 | // branch or a tag, which is then the current one of the lists, or a commit, | ||
| 135 | - | // which makes none current. | 164 | + | // which makes none current. It points the file finder at the commit too. |
| 136 | func pinRef(l *Layout, t target) { | 165 | func pinRef(l *Layout, t target) { | ||
| 137 | if t.detached { | 166 | if t.detached { | ||
| 138 | l.Ref, l.RefPath, l.Short, l.SHA = "detached", "", short(t.rev), "" | 167 | l.Ref, l.RefPath, l.Short, l.SHA = "detached", "", short(t.rev), "" | ||
| 139 | if len(t.rev) == 40 { | 168 | if len(t.rev) == 40 { | ||
| 140 | l.SHA = t.rev | 169 | l.SHA = t.rev | ||
| 141 | } | 170 | } | ||
| 171 | + | l.setFinder(t.commitID(), t.rev) | |||
| 142 | for i := range l.Heads { | 172 | for i := range l.Heads { | ||
| 143 | l.Heads[i].Current = false | 173 | l.Heads[i].Current = false | ||
| 144 | } | 174 | } | ||
| @@ -148,6 +178,7 @@ func pinRef(l *Layout, t target) { | |||||
| 148 | return | 178 | return | ||
| 149 | } | 179 | } | ||
| 150 | l.Ref, l.RefPath, l.SHA, l.Short = t.name, t.name, t.rev, short(t.rev) | 180 | l.Ref, l.RefPath, l.SHA, l.Short = t.name, t.name, t.rev, short(t.rev) | ||
| 181 | + | l.setFinder(t.rev, t.name) | |||
| 151 | for i := range l.Heads { | 182 | for i := range l.Heads { | ||
| 152 | l.Heads[i].Current = l.Heads[i].Name == t.name | 183 | l.Heads[i].Current = l.Heads[i].Name == t.name | ||
| 153 | } | 184 | } | ||
| @@ -156,6 +187,19 @@ func pinRef(l *Layout, t target) { | |||||
| 156 | } | 187 | } | ||
| 157 | } | 188 | } | ||
| 158 | 189 | ||||
| 190 | + | // setFinder points the file finder at a commit: the list of its paths when sha | |||
| 191 | + | // is a full commit id, and the blob pages of ref (a ref name or a commit id) | |||
| 192 | + | // its results open. Without a full id there is nothing to search and the | |||
| 193 | + | // page has no finder data. | |||
| 194 | + | func (l *Layout) setFinder(sha, ref string) { | |||
| 195 | + | l.PathsURL, l.BlobBase = "", "" | |||
| 196 | + | if l.Repo == "" || ref == "" || !isFullID(sha) { | |||
| 197 | + | return | |||
| 198 | + | } | |||
| 199 | + | l.PathsURL = repoHref(l.Repo, "paths", sha) | |||
| 200 | + | l.BlobBase = repoHref(l.Repo, "blob", ref) | |||
| 201 | + | } | |||
| 202 | + | ||||
| 159 | func hasName(items []RefItem, name string) bool { | 203 | func hasName(items []RefItem, name string) bool { | ||
| 160 | for _, it := range items { | 204 | for _, it := range items { | ||
| 161 | if it.Name == name { | 205 | 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 | 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(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 +11 -1
| @@ -20,8 +20,18 @@ templ Shell(l Layout, rail templ.Component) { | |||||
| 20 | <script src={ l.Assets.Live } defer></script> | 20 | <script src={ l.Assets.Live } defer></script> | ||
| 21 | <script src={ l.Assets.SSE } defer></script> | 21 | <script src={ l.Assets.SSE } defer></script> | ||
| 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 | </head> | 24 | </head> | ||
| 24 | - | <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 | + | > | |||
| 25 | <a class="skip" href="#main">skip to content</a> | 35 | <a class="skip" href="#main">skip to content</a> | ||
| 26 | @Header(l) | 36 | @Header(l) | ||
| 27 | <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 +174 -125 generated file: collapsed
M internal/adapters/web/static/css/25-panel.css +3 -0
| @@ -54,6 +54,9 @@ | |||||
| 54 | & li[data-cursor] a { background: var(--bg-strong); color: var(--ink-hi); } | 54 | & li[data-cursor] a { background: var(--bg-strong); color: var(--ink-hi); } | ||
| 55 | & .lbl { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); font-size: var(--fs-s); } | 55 | & .lbl { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); font-size: var(--fs-s); } | ||
| 56 | & .k { flex: none; font-size: var(--fs-xs); color: var(--ink-mute); } | 56 | & .k { flex: none; font-size: var(--fs-xs); color: var(--ink-mute); } | ||
| 57 | + | & .dir { color: var(--ink-dim); } | |||
| 58 | + | & strong { color: var(--ink-hi); font-weight: 700; } | |||
| 59 | + | & mark { background: none; color: var(--teal); font-weight: 700; text-decoration: underline; } | |||
| 57 | } | 60 | } | ||
| 58 | 61 | ||||
| 59 | .keys { | 62 | .keys { | ||
M internal/adapters/web/static/css/30-list.css +10 -1
| @@ -38,7 +38,10 @@ | |||||
| 38 | } | 38 | } | ||
| 39 | .list.tree { | 39 | .list.tree { | ||
| 40 | & .c-kind { width: 36px; color: var(--ink-mute); padding-right: 0; text-align: center; } | 40 | & .c-kind { width: 36px; color: var(--ink-mute); padding-right: 0; text-align: center; } | ||
| 41 | - | & .c-name { width: 32%; font-family: var(--mono); font-size: var(--fs-s); } | 41 | + | /* --depth is set by files.js on the rows opened in place */ |
| 42 | + | & .c-name { width: 32%; font-family: var(--mono); font-size: var(--fs-s); padding-inline-start: calc(var(--s3) + var(--depth, 0) * 1.25ch); } | |||
| 43 | + | & tr[aria-busy=true] .c-kind { opacity: .4; } | |||
| 44 | + | & .tree-more td { padding-inline-start: calc(var(--s3) + 36px + var(--depth, 0) * 1.25ch); color: var(--ink-dim); font-size: var(--fs-s); } | |||
| 42 | & .c-size { width: 90px; text-align: right; } | 45 | & .c-size { width: 90px; text-align: right; } | ||
| 43 | & .c-when { width: 100px; } | 46 | & .c-when { width: 100px; } | ||
| 44 | & .c-sub { width: auto; } | 47 | & .c-sub { width: auto; } | ||
| @@ -71,6 +74,12 @@ | |||||
| 71 | &:focus-within::after { display: none; } | 74 | &:focus-within::after { display: none; } | ||
| 72 | } | 75 | } | ||
| 73 | 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 | + | ||||
| 74 | /* section headings inside a page */ | 83 | /* section headings inside a page */ | ||
| 75 | .rule-h { | 84 | .rule-h { | ||
| 76 | display: flex; align-items: center; gap: var(--s2); margin-top: var(--s5); | 85 | display: flex; align-items: center; gap: var(--s2); margin-top: var(--s5); | ||
A internal/adapters/web/static/js/files.js +688 -0
| @@ -0,0 +1,688 @@ | |||||
| 1 | + | // files.js: the Files view. A directory opens in place: its entries are | |||
| 2 | + | // fetched once as table rows (the tree page with ?rows), inserted under it | |||
| 3 | + | // and kept in the DOM, so closing and opening it again needs no request. | |||
| 4 | + | // | |||
| 5 | + | // Rows opened in place carry data-depth and the --depth custom property | |||
| 6 | + | // (set through the CSSOM: the CSP forbids style attributes). Rows of the | |||
| 7 | + | // page itself have depth 0. Nothing here touches the document at load; the | |||
| 8 | + | // handlers are declared in the templates (hx-on:click on #tree-rows). | |||
| 9 | + | window.ermiteTree = (() => { | |||
| 10 | + | const depthOf = (tr) => Number(tr.dataset.depth || 0); | |||
| 11 | + | const linkOf = (tr) => tr.querySelector('a[data-expand]'); | |||
| 12 | + | const isOpen = (a) => a.getAttribute('aria-expanded') === 'true'; | |||
| 13 | + | // A directory is loaded when rows deeper than it follow it. | |||
| 14 | + | const isLoaded = (tr) => { const n = tr.nextElementSibling; return !!n && depthOf(n) > depthOf(tr); }; | |||
| 15 | + | ||||
| 16 | + | function setOpen(tr, a, open) { | |||
| 17 | + | a.setAttribute('aria-expanded', open ? 'true' : 'false'); | |||
| 18 | + | const kind = tr.querySelector('.c-kind'); | |||
| 19 | + | if (kind) kind.textContent = open ? '▾' : '▸'; | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // collapse hides every row under tr. The cursor of a row it hides moves to | |||
| 23 | + | // tr, so it never rests on a row that cannot be seen. | |||
| 24 | + | function collapse(tr, a) { | |||
| 25 | + | setOpen(tr, a, false); | |||
| 26 | + | const d = depthOf(tr); | |||
| 27 | + | for (let r = tr.nextElementSibling; r && depthOf(r) > d; r = r.nextElementSibling) { | |||
| 28 | + | r.hidden = true; | |||
| 29 | + | if (r.hasAttribute('data-cursor')) { | |||
| 30 | + | r.removeAttribute('data-cursor'); | |||
| 31 | + | tr.setAttribute('data-cursor', ''); | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // expand shows the rows under tr that no collapsed directory between hides. | |||
| 37 | + | function expand(tr, a) { | |||
| 38 | + | setOpen(tr, a, true); | |||
| 39 | + | const d = depthOf(tr); | |||
| 40 | + | let closedAt = Infinity; // depth of the collapsed directory whose rows are skipped | |||
| 41 | + | for (let r = tr.nextElementSibling; r && depthOf(r) > d; r = r.nextElementSibling) { | |||
| 42 | + | const rd = depthOf(r); | |||
| 43 | + | if (rd <= closedAt) closedAt = Infinity; | |||
| 44 | + | r.hidden = rd > closedAt; | |||
| 45 | + | const la = linkOf(r); | |||
| 46 | + | if (closedAt === Infinity && la && !isOpen(la)) closedAt = rd; | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | async function load(tr, a) { | |||
| 51 | + | tr.setAttribute('aria-busy', 'true'); | |||
| 52 | + | try { | |||
| 53 | + | const res = await fetch(a.dataset.expand, { headers: { Accept: 'text/html' } }); | |||
| 54 | + | if (!res.ok) throw new Error('HTTP ' + res.status); | |||
| 55 | + | const t = document.createElement('template'); | |||
| 56 | + | t.innerHTML = await res.text(); | |||
| 57 | + | const d = depthOf(tr) + 1; | |||
| 58 | + | const rows = Array.from(t.content.children).filter((r) => r.tagName === 'TR'); | |||
| 59 | + | if (!rows.length) throw new Error('no rows'); | |||
| 60 | + | // An ancestor closed while the request was out: tr is hidden, and its | |||
| 61 | + | // rows come in hidden, for the expand of that ancestor to show. | |||
| 62 | + | const covered = tr.hidden; | |||
| 63 | + | for (const r of rows) { | |||
| 64 | + | r.dataset.depth = String(d); | |||
| 65 | + | r.style.setProperty('--depth', String(d)); | |||
| 66 | + | if (covered) r.hidden = true; | |||
| 67 | + | } | |||
| 68 | + | tr.after(...rows); | |||
| 69 | + | tr.removeAttribute('aria-busy'); | |||
| 70 | + | if (covered) setOpen(tr, a, true); | |||
| 71 | + | else expand(tr, a); | |||
| 72 | + | } catch (e) { | |||
| 73 | + | location.href = a.href; | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | function toggle(tr) { | |||
| 78 | + | const a = linkOf(tr); | |||
| 79 | + | if (!a || tr.getAttribute('aria-busy') === 'true') return; | |||
| 80 | + | if (isOpen(a)) collapse(tr, a); | |||
| 81 | + | else if (isLoaded(tr)) expand(tr, a); | |||
| 82 | + | else load(tr, a); | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | return { | |||
| 86 | + | // click opens or closes the directory under a plain left click; any | |||
| 87 | + | // modified or other click keeps the link's own navigation. | |||
| 88 | + | click(event) { | |||
| 89 | + | const a = event.target.closest('a[data-expand]'); | |||
| 90 | + | if (!a || event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return; | |||
| 91 | + | event.preventDefault(); | |||
| 92 | + | toggle(a.closest('tr')); | |||
| 93 | + | }, | |||
| 94 | + | }; | |||
| 95 | + | })(); | |||
| 96 | + | ||||
| 97 | + | // ermiteFinder: the fuzzy finder of the Files view and the file hits of the | |||
| 98 | + | // ":" palette. The core (index, search, positions, visible) is pure and runs | |||
| 99 | + | // in Node; the DOM glue below it runs only from the handlers declared in the | |||
| 100 | + | // templates. A path reaches the page through textContent, never as HTML. | |||
| 101 | + | window.ermiteFinder = (() => { | |||
| 102 | + | // ---- core --------------------------------------------------------------- | |||
| 103 | + | ||||
| 104 | + | const NEG = -1e9; | |||
| 105 | + | const MAXLEN = 1024; // longer paths are scored on their greedy match | |||
| 106 | + | const B_OTHER = 1 << 30; | |||
| 107 | + | ||||
| 108 | + | // BIT is the mask bit of an ASCII code: a-z 0-25, digit 26, "/" 27, "." 28, | |||
| 109 | + | // "-" and "_" 29, anything else 30. | |||
| 110 | + | const BIT = new Uint32Array(128).fill(B_OTHER); | |||
| 111 | + | for (let c = 97; c <= 122; c++) BIT[c] = 1 << (c - 97); | |||
| 112 | + | for (let c = 48; c <= 57; c++) BIT[c] = 1 << 26; | |||
| 113 | + | BIT[47] = 1 << 27; | |||
| 114 | + | BIT[46] = 1 << 28; | |||
| 115 | + | BIT[45] = BIT[95] = 1 << 29; | |||
| 116 | + | const bitOf = (c) => (c < 128 ? BIT[c] : B_OTHER); | |||
| 117 | + | ||||
| 118 | + | const UPPER = /[A-Z]/; | |||
| 119 | + | // fold lowercases A-Z only: the length is kept, so a position in the folded | |||
| 120 | + | // string is a position in the original. | |||
| 121 | + | const fold = (s) => (UPPER.test(s) ? s.replace(/[A-Z]/g, (c) => String.fromCharCode(c.charCodeAt(0) | 32)) : s); | |||
| 122 | + | ||||
| 123 | + | // index prepares the paths for search. | |||
| 124 | + | function index(paths) { | |||
| 125 | + | const n = paths.length; | |||
| 126 | + | const lower = new Array(n); | |||
| 127 | + | const mask = new Uint32Array(n); | |||
| 128 | + | const base = new Int32Array(n); | |||
| 129 | + | for (let i = 0; i < n; i++) { | |||
| 130 | + | const p = paths[i]; | |||
| 131 | + | const l = fold(p); | |||
| 132 | + | lower[i] = l; | |||
| 133 | + | let m = 0; | |||
| 134 | + | for (let j = 0; j < l.length; j++) m |= bitOf(l.charCodeAt(j)); | |||
| 135 | + | mask[i] = m; | |||
| 136 | + | base[i] = p.lastIndexOf('/') + 1; | |||
| 137 | + | } | |||
| 138 | + | return { paths, lower, mask, base }; | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | // bonusAt rewards a match at a boundary: after "/" or at the start, after a | |||
| 142 | + | // separator, at a camelCase hump or a letter after a digit. | |||
| 143 | + | function bonusAt(orig, j) { | |||
| 144 | + | if (j === 0) return 10; | |||
| 145 | + | const p = orig.charCodeAt(j - 1); | |||
| 146 | + | if (p === 47) return 10; | |||
| 147 | + | if (p === 95 || p === 45 || p === 46 || p === 32) return 8; | |||
| 148 | + | const c = orig.charCodeAt(j); | |||
| 149 | + | const upper = c >= 65 && c <= 90; | |||
| 150 | + | if (p >= 97 && p <= 122 && upper) return 7; | |||
| 151 | + | if (p >= 48 && p <= 57 && (upper || (c >= 97 && c <= 122))) return 7; | |||
| 152 | + | return 0; | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | // Scratch space of matchTerm. | |||
| 156 | + | let sLo = new Int32Array(64); | |||
| 157 | + | let sHi = new Int32Array(64); | |||
| 158 | + | let sOut = new Int32Array(64); | |||
| 159 | + | let sPtr = new Int32Array(0); | |||
| 160 | + | let rowA = new Int32Array(MAXLEN + 2); | |||
| 161 | + | let rowB = new Int32Array(MAXLEN + 2); | |||
| 162 | + | ||||
| 163 | + | // matchTerm scores the best placement of the characters of t in s (the | |||
| 164 | + | // folded path; orig is the path as it is), starting at from or later: NEG | |||
| 165 | + | // when there is none. With trace, the positions of the best placement are | |||
| 166 | + | // left in sOut[0..t.length). It is the DP of fzf v2: a matched character is | |||
| 167 | + | // worth 16 plus its boundary bonus (doubled for the first), one that follows | |||
| 168 | + | // the previous match gains max(bonus, 4), and a gap costs 3 to open and 1 | |||
| 169 | + | // per further skipped character. | |||
| 170 | + | function matchTerm(orig, s, t, from, trace) { | |||
| 171 | + | const n = s.length; | |||
| 172 | + | const m = t.length; | |||
| 173 | + | if (sLo.length < m) { | |||
| 174 | + | sLo = new Int32Array(m * 2); | |||
| 175 | + | sHi = new Int32Array(m * 2); | |||
| 176 | + | sOut = new Int32Array(m * 2); | |||
| 177 | + | } | |||
| 178 | + | let pos = from - 1; | |||
| 179 | + | for (let i = 0; i < m; i++) { | |||
| 180 | + | pos = s.indexOf(t[i], pos + 1); | |||
| 181 | + | if (pos < 0) return NEG; | |||
| 182 | + | sLo[i] = pos; | |||
| 183 | + | } | |||
| 184 | + | pos = n; | |||
| 185 | + | for (let i = m - 1; i >= 0; i--) { | |||
| 186 | + | pos = s.lastIndexOf(t[i], pos - 1); | |||
| 187 | + | sHi[i] = pos; | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | if (n > MAXLEN) { | |||
| 191 | + | // The greedy match, scored by the same rules. | |||
| 192 | + | let score = 0; | |||
| 193 | + | for (let i = 0; i < m; i++) { | |||
| 194 | + | const j = sLo[i]; | |||
| 195 | + | const b = bonusAt(orig, j); | |||
| 196 | + | score += 16 + (i === 0 ? 2 * b : b); | |||
| 197 | + | if (i > 0) { | |||
| 198 | + | const gap = j - sLo[i - 1] - 1; | |||
| 199 | + | score += gap === 0 ? Math.max(b, 4) : -(gap + 2); | |||
| 200 | + | } | |||
| 201 | + | if (trace) sOut[i] = j; | |||
| 202 | + | } | |||
| 203 | + | return score; | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | const width = n + 1; | |||
| 207 | + | if (trace && sPtr.length < m * width) sPtr = new Int32Array(m * width); | |||
| 208 | + | let prev = rowA; | |||
| 209 | + | let cur = rowB; | |||
| 210 | + | for (let i = 0; i < m; i++) { | |||
| 211 | + | cur.fill(NEG, 0, width); | |||
| 212 | + | const c = t.charCodeAt(i); | |||
| 213 | + | const hi = sHi[i]; | |||
| 214 | + | let rm = NEG; // best of prev[k] + k over the k <= j-2 seen so far | |||
| 215 | + | let rmK = -1; | |||
| 216 | + | let kk = i > 0 ? sLo[i - 1] : 0; | |||
| 217 | + | for (let j = sLo[i]; j <= hi; j++) { | |||
| 218 | + | if (s.charCodeAt(j) !== c) continue; | |||
| 219 | + | const b = bonusAt(orig, j); | |||
| 220 | + | let sc = 16 + (i === 0 ? 2 * b : b); | |||
| 221 | + | let fromK = -1; | |||
| 222 | + | if (i > 0) { | |||
| 223 | + | let best = NEG; | |||
| 224 | + | if (j > 0 && prev[j - 1] > NEG) { | |||
| 225 | + | best = prev[j - 1] + (b > 4 ? b : 4); | |||
| 226 | + | fromK = j - 1; | |||
| 227 | + | } | |||
| 228 | + | for (; kk <= j - 2; kk++) { | |||
| 229 | + | if (prev[kk] > NEG && prev[kk] + kk > rm) { | |||
| 230 | + | rm = prev[kk] + kk; | |||
| 231 | + | rmK = kk; | |||
| 232 | + | } | |||
| 233 | + | } | |||
| 234 | + | if (rm > NEG && rm - j - 1 > best) { | |||
| 235 | + | best = rm - j - 1; | |||
| 236 | + | fromK = rmK; | |||
| 237 | + | } | |||
| 238 | + | if (best <= NEG) continue; | |||
| 239 | + | sc += best; | |||
| 240 | + | } | |||
| 241 | + | cur[j] = sc; | |||
| 242 | + | if (trace) sPtr[i * width + j] = fromK; | |||
| 243 | + | } | |||
| 244 | + | const tmp = prev; | |||
| 245 | + | prev = cur; | |||
| 246 | + | cur = tmp; | |||
| 247 | + | } | |||
| 248 | + | // prev is the row of the last character. | |||
| 249 | + | let bestScore = NEG; | |||
| 250 | + | let bestJ = -1; | |||
| 251 | + | for (let j = sLo[m - 1]; j <= sHi[m - 1]; j++) { | |||
| 252 | + | if (prev[j] > bestScore) { | |||
| 253 | + | bestScore = prev[j]; | |||
| 254 | + | bestJ = j; | |||
| 255 | + | } | |||
| 256 | + | } | |||
| 257 | + | if (trace && bestJ >= 0) { | |||
| 258 | + | for (let i = m - 1, j = bestJ; i >= 0; i--) { | |||
| 259 | + | sOut[i] = j; | |||
| 260 | + | j = sPtr[i * width + j]; | |||
| 261 | + | } | |||
| 262 | + | } | |||
| 263 | + | return bestScore; | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | // termScore is matchTerm plus, for the last term, the path bonuses: 24 when | |||
| 267 | + | // its match lies in the basename, 16 when the basename starts with it. With | |||
| 268 | + | // trace, the winning positions are in `got`. | |||
| 269 | + | let got = []; | |||
| 270 | + | function termScore(orig, s, t, base, isLast, trace) { | |||
| 271 | + | if (trace) got = []; | |||
| 272 | + | let best = matchTerm(orig, s, t, 0, trace); | |||
| 273 | + | if (best <= NEG) return NEG; | |||
| 274 | + | if (trace) got = Array.from(sOut.subarray(0, t.length)); | |||
| 275 | + | if (!isLast) return best; | |||
| 276 | + | if (base === 0) { | |||
| 277 | + | best += 24; | |||
| 278 | + | } else { | |||
| 279 | + | const r = matchTerm(orig, s, t, base, trace); | |||
| 280 | + | if (r > NEG && r + 24 > best) { | |||
| 281 | + | best = r + 24; | |||
| 282 | + | if (trace) got = Array.from(sOut.subarray(0, t.length)); | |||
| 283 | + | } | |||
| 284 | + | } | |||
| 285 | + | if (s.startsWith(t, base)) best += 16; | |||
| 286 | + | return best; | |||
| 287 | + | } | |||
| 288 | + | ||||
| 289 | + | const termsOf = (query) => fold(query).split(/\s+/).filter(Boolean); | |||
| 290 | + | ||||
| 291 | + | // search returns the k best paths for the query, and `all`, the indices of | |||
| 292 | + | // every path that matches. prev, the result of an earlier search of the same | |||
| 293 | + | // index, narrows the candidates when the new query extends it. | |||
| 294 | + | function search(idx, query, k = 50, prev = null) { | |||
| 295 | + | const terms = termsOf(query); | |||
| 296 | + | const res = { q: query, terms, hits: [], all: new Int32Array(0), idx }; | |||
| 297 | + | if (!terms.length) return res; | |||
| 298 | + | let qmask = 0; | |||
| 299 | + | for (const t of terms) for (let j = 0; j < t.length; j++) qmask |= bitOf(t.charCodeAt(j)); | |||
| 300 | + | ||||
| 301 | + | let cand = null; | |||
| 302 | + | if (prev && prev.idx === idx && prev.terms.length > 0 && prev.terms.length <= terms.length | |||
| 303 | + | && prev.terms.every((t, n) => terms[n].startsWith(t))) cand = prev.all; | |||
| 304 | + | const count = cand ? cand.length : idx.paths.length; | |||
| 305 | + | const { lower, mask } = idx; | |||
| 306 | + | const all = new Int32Array(count); | |||
| 307 | + | let na = 0; | |||
| 308 | + | for (let c = 0; c < count; c++) { | |||
| 309 | + | const i = cand ? cand[c] : c; | |||
| 310 | + | if ((mask[i] & qmask) !== qmask) continue; | |||
| 311 | + | const s = lower[i]; | |||
| 312 | + | let ok = true; | |||
| 313 | + | for (const t of terms) { | |||
| 314 | + | let pos = -1; | |||
| 315 | + | for (let n = 0; n < t.length; n++) { | |||
| 316 | + | pos = s.indexOf(t[n], pos + 1); | |||
| 317 | + | if (pos < 0) { ok = false; break; } | |||
| 318 | + | } | |||
| 319 | + | if (!ok) break; | |||
| 320 | + | } | |||
| 321 | + | if (ok) all[na++] = i; | |||
| 322 | + | } | |||
| 323 | + | res.all = all.subarray(0, na); | |||
| 324 | + | ||||
| 325 | + | // The k best, by sorted insertion: score desc, length asc, path asc. | |||
| 326 | + | const { paths, base } = idx; | |||
| 327 | + | const topI = new Int32Array(k); | |||
| 328 | + | const topS = new Int32Array(k); | |||
| 329 | + | let nt = 0; | |||
| 330 | + | const last = terms.length - 1; | |||
| 331 | + | for (let c = 0; c < na; c++) { | |||
| 332 | + | const i = all[c]; | |||
| 333 | + | const orig = paths[i]; | |||
| 334 | + | const s = lower[i]; | |||
| 335 | + | let score = 0; | |||
| 336 | + | for (let n = 0; n <= last; n++) { | |||
| 337 | + | const sc = termScore(orig, s, terms[n], base[i], n === last, false); | |||
| 338 | + | if (sc <= NEG) { score = NEG; break; } | |||
| 339 | + | score += sc; | |||
| 340 | + | } | |||
| 341 | + | if (score <= NEG) continue; | |||
| 342 | + | if (nt === k) { | |||
| 343 | + | const w = topI[k - 1]; | |||
| 344 | + | if (score < topS[k - 1]) continue; | |||
| 345 | + | if (score === topS[k - 1]) { | |||
| 346 | + | const a = paths[w]; | |||
| 347 | + | if (orig.length > a.length || (orig.length === a.length && orig >= a)) continue; | |||
| 348 | + | } | |||
| 349 | + | } | |||
| 350 | + | let at = nt < k ? nt : k - 1; | |||
| 351 | + | while (at > 0) { | |||
| 352 | + | const o = topI[at - 1]; | |||
| 353 | + | const os = topS[at - 1]; | |||
| 354 | + | const a = paths[o]; | |||
| 355 | + | if (os > score || (os === score && (a.length < orig.length || (a.length === orig.length && a < orig)))) break; | |||
| 356 | + | topI[at] = o; | |||
| 357 | + | topS[at] = os; | |||
| 358 | + | at--; | |||
| 359 | + | } | |||
| 360 | + | topI[at] = i; | |||
| 361 | + | topS[at] = score; | |||
| 362 | + | if (nt < k) nt++; | |||
| 363 | + | } | |||
| 364 | + | for (let h = 0; h < nt; h++) res.hits.push({ i: topI[h], score: topS[h] }); | |||
| 365 | + | return res; | |||
| 366 | + | } | |||
| 367 | + | ||||
| 368 | + | // positions are the indices of path i that the query matches, ascending. | |||
| 369 | + | function positions(idx, i, query) { | |||
| 370 | + | const terms = termsOf(query); | |||
| 371 | + | const orig = idx.paths[i]; | |||
| 372 | + | const s = idx.lower[i]; | |||
| 373 | + | const set = new Set(); | |||
| 374 | + | for (let n = 0; n < terms.length; n++) { | |||
| 375 | + | termScore(orig, s, terms[n], idx.base[i], n === terms.length - 1, true); | |||
| 376 | + | for (const p of got) set.add(p); | |||
| 377 | + | } | |||
| 378 | + | return Array.from(set).sort((a, b) => a - b); | |||
| 379 | + | } | |||
| 380 | + | ||||
| 381 | + | // visible replaces the characters that read as nothing (the set of | |||
| 382 | + | // invisible in text.go) by their <U+XXXX> marker. | |||
| 383 | + | const INVISIBLE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\u115F\u1160\u3164\uFFA0]/gu; | |||
| 384 | + | const visible = (s) => s.replace(INVISIBLE, (c) => '<U+' + c.codePointAt(0).toString(16).toUpperCase().padStart(4, '0') + '>'); | |||
| 385 | + | ||||
| 386 | + | // ---- DOM glue ----------------------------------------------------------- | |||
| 387 | + | ||||
| 388 | + | const $ = (id) => document.getElementById(id); | |||
| 389 | + | let loading = null; // the promise of {idx, truncated}; null before the first load and after a failure | |||
| 390 | + | let rendered = null; // the result the finder list shows | |||
| 391 | + | let frame = 0; | |||
| 392 | + | let treeCursor = null; // the row of the tree that had the cursor when the results came | |||
| 393 | + | let pendingEnter = null; // the value of the field at an Enter pressed before its results | |||
| 394 | + | ||||
| 395 | + | // load fetches the path list of the page once and indexes it. | |||
| 396 | + | function load() { | |||
| 397 | + | const url = document.body.dataset.paths; | |||
| 398 | + | if (!url) return Promise.resolve(null); | |||
| 399 | + | if (!loading) { | |||
| 400 | + | const p = fetch(url, { headers: { Accept: 'application/json' } }) | |||
| 401 | + | .then((res) => { | |||
| 402 | + | if (!res.ok) throw new Error('HTTP ' + res.status); | |||
| 403 | + | return res.json(); | |||
| 404 | + | }) | |||
| 405 | + | .then((j) => ({ idx: index(j.paths || []), truncated: !!j.truncated })); | |||
| 406 | + | loading = p; | |||
| 407 | + | p.catch(() => { if (loading === p) loading = null; }); // a failure is retried at the next use | |||
| 408 | + | } | |||
| 409 | + | return loading; | |||
| 410 | + | } | |||
| 411 | + | ||||
| 412 | + | // The server's JSON never carries a lone surrogate, so encodeURIComponent | |||
| 413 | + | // cannot throw here. | |||
| 414 | + | const blobHref = (path) => document.body.dataset.blob + '/' + path.split('/').map(encodeURIComponent).join('/'); | |||
| 415 | + | ||||
| 416 | + | const isHigh = (c) => c >= 0xd800 && c <= 0xdbff; | |||
| 417 | + | const isLow = (c) => c >= 0xdc00 && c <= 0xdfff; | |||
| 418 | + | ||||
| 419 | + | // runs appends text[from, to) to parent, the marked characters in <mark>. A | |||
| 420 | + | // surrogate pair is marked whole or not at all, so it is never split. | |||
| 421 | + | function runs(parent, text, from, to, marks) { | |||
| 422 | + | const marked = (j) => { | |||
| 423 | + | if (marks.has(j)) return true; | |||
| 424 | + | const c = text.charCodeAt(j); | |||
| 425 | + | if (isLow(c) && isHigh(text.charCodeAt(j - 1))) return marks.has(j - 1); | |||
| 426 | + | if (isHigh(c) && isLow(text.charCodeAt(j + 1))) return marks.has(j + 1); | |||
| 427 | + | return false; | |||
| 428 | + | }; | |||
| 429 | + | for (let j = from; j < to;) { | |||
| 430 | + | const m = marked(j); | |||
| 431 | + | let e = j; | |||
| 432 | + | while (e < to && marked(e) === m) e++; | |||
| 433 | + | const part = text.slice(j, e); | |||
| 434 | + | if (m) { | |||
| 435 | + | const mk = document.createElement('mark'); | |||
| 436 | + | mk.textContent = part; | |||
| 437 | + | parent.append(mk); | |||
| 438 | + | } else { | |||
| 439 | + | parent.append(document.createTextNode(part)); | |||
| 440 | + | } | |||
| 441 | + | j = e; | |||
| 442 | + | } | |||
| 443 | + | } | |||
| 444 | + | ||||
| 445 | + | // label is the span of a path: the directory dimmed, the name strong, the | |||
| 446 | + | // matched characters marked. A path with an invisible character shows its | |||
| 447 | + | // markers and no highlight. | |||
| 448 | + | function label(path, pos) { | |||
| 449 | + | const lbl = document.createElement('span'); | |||
| 450 | + | lbl.className = 'lbl'; | |||
| 451 | + | const shown = visible(path); | |||
| 452 | + | if (shown !== path) { | |||
| 453 | + | lbl.textContent = shown; | |||
| 454 | + | return lbl; | |||
| 455 | + | } | |||
| 456 | + | const cut = path.lastIndexOf('/') + 1; | |||
| 457 | + | const marks = new Set(pos); | |||
| 458 | + | if (cut > 0) { | |||
| 459 | + | const dir = document.createElement('span'); | |||
| 460 | + | dir.className = 'dir'; | |||
| 461 | + | runs(dir, path, 0, cut, marks); | |||
| 462 | + | lbl.append(dir); | |||
| 463 | + | } | |||
| 464 | + | const name = document.createElement('strong'); | |||
| 465 | + | runs(name, path, cut, path.length, marks); | |||
| 466 | + | lbl.append(name); | |||
| 467 | + | return lbl; | |||
| 468 | + | } | |||
| 469 | + | ||||
| 470 | + | // hitLink is the link of path i of the result: its label, out of the tab order. | |||
| 471 | + | function hitLink(res, i) { | |||
| 472 | + | const path = res.idx.paths[i]; | |||
| 473 | + | const a = document.createElement('a'); | |||
| 474 | + | a.href = blobHref(path); | |||
| 475 | + | a.tabIndex = -1; | |||
| 476 | + | a.append(label(path, positions(res.idx, i, res.q))); | |||
| 477 | + | return a; | |||
| 478 | + | } | |||
| 479 | + | ||||
| 480 | + | function option(res, hit, n) { | |||
| 481 | + | const li = document.createElement('li'); | |||
| 482 | + | li.setAttribute('role', 'option'); | |||
| 483 | + | li.setAttribute('aria-selected', 'false'); | |||
| 484 | + | li.id = 'finder-o' + n; | |||
| 485 | + | li.append(hitLink(res, hit.i)); | |||
| 486 | + | return li; | |||
| 487 | + | } | |||
| 488 | + | ||||
| 489 | + | const status = (text) => { $('finder-status').textContent = text; }; | |||
| 490 | + | ||||
| 491 | + | // syncActive makes the option that has data-cursor the active one: j/k | |||
| 492 | + | // (keys.templ) move the cursor while the field is blurred. | |||
| 493 | + | function syncActive(el) { | |||
| 494 | + | const cur = $('finder-list').querySelector(':scope > li[data-cursor]'); | |||
| 495 | + | if (cur && cur.getAttribute('aria-selected') !== 'true') setActive(el, cur); | |||
| 496 | + | return cur; | |||
| 497 | + | } | |||
| 498 | + | ||||
| 499 | + | // setActive makes li the active option (null: none) of the listbox. | |||
| 500 | + | function setActive(el, li) { | |||
| 501 | + | $('finder-list').querySelectorAll(':scope > li[aria-selected="true"]').forEach((o) => o.setAttribute('aria-selected', 'false')); | |||
| 502 | + | if (li) { | |||
| 503 | + | li.setAttribute('aria-selected', 'true'); | |||
| 504 | + | el.setAttribute('aria-activedescendant', li.id); | |||
| 505 | + | } else { | |||
| 506 | + | el.removeAttribute('aria-activedescendant'); | |||
| 507 | + | } | |||
| 508 | + | } | |||
| 509 | + | ||||
| 510 | + | // showTree: no query, the directory again, with j/k and Enter on its rows. | |||
| 511 | + | function showTree(el) { | |||
| 512 | + | rendered = null; | |||
| 513 | + | pendingEnter = null; | |||
| 514 | + | $('finder').hidden = true; | |||
| 515 | + | $('tree-view').hidden = false; | |||
| 516 | + | el.setAttribute('aria-expanded', 'false'); | |||
| 517 | + | setActive(el, null); | |||
| 518 | + | const list = $('finder-list'); | |||
| 519 | + | list.removeAttribute('data-rows'); | |||
| 520 | + | list.replaceChildren(); | |||
| 521 | + | const rows = $('tree-rows'); | |||
| 522 | + | rows.setAttribute('data-rows', ''); | |||
| 523 | + | const keep = treeCursor; | |||
| 524 | + | treeCursor = null; | |||
| 525 | + | if (!rows.querySelector(':scope > tr[data-cursor]')) { | |||
| 526 | + | const row = keep && keep.parentElement === rows && !keep.hidden ? keep : rows.querySelector(':scope > tr:not([hidden])'); | |||
| 527 | + | row?.setAttribute('data-cursor', ''); | |||
| 528 | + | } | |||
| 529 | + | } | |||
| 530 | + | ||||
| 531 | + | // showResults: j/k and Enter act on the results, not on the rows behind. | |||
| 532 | + | function showResults(el) { | |||
| 533 | + | $('finder').hidden = false; | |||
| 534 | + | $('tree-view').hidden = true; | |||
| 535 | + | el.setAttribute('aria-expanded', 'true'); | |||
| 536 | + | const rows = $('tree-rows'); | |||
| 537 | + | rows.removeAttribute('data-rows'); | |||
| 538 | + | rows.querySelectorAll(':scope > tr[data-cursor]').forEach((r) => { | |||
| 539 | + | treeCursor = r; | |||
| 540 | + | r.removeAttribute('data-cursor'); | |||
| 541 | + | }); | |||
| 542 | + | $('finder-list').setAttribute('data-rows', ''); | |||
| 543 | + | } | |||
| 544 | + | ||||
| 545 | + | async function run(el) { | |||
| 546 | + | const q = el.value; | |||
| 547 | + | if (pendingEnter !== null && pendingEnter !== q) pendingEnter = null; // the field moved on | |||
| 548 | + | if (!q.trim()) return showTree(el); | |||
| 549 | + | showResults(el); | |||
| 550 | + | if (!rendered) status('loading the file list…'); | |||
| 551 | + | let loaded; | |||
| 552 | + | try { | |||
| 553 | + | loaded = await load(); | |||
| 554 | + | } catch (e) { | |||
| 555 | + | if (el.value === q) { | |||
| 556 | + | pendingEnter = null; | |||
| 557 | + | status('could not load the file list'); | |||
| 558 | + | } | |||
| 559 | + | return; | |||
| 560 | + | } | |||
| 561 | + | if (el.value !== q) return; // the field changed during the wait | |||
| 562 | + | if (!loaded) { | |||
| 563 | + | pendingEnter = null; | |||
| 564 | + | status('file search is not available here'); | |||
| 565 | + | return; | |||
| 566 | + | } | |||
| 567 | + | const res = search(loaded.idx, q, 50, rendered); | |||
| 568 | + | const items = res.hits.map((h, n) => option(res, h, n)); | |||
| 569 | + | const list = $('finder-list'); | |||
| 570 | + | list.replaceChildren(...items); | |||
| 571 | + | items[0]?.setAttribute('data-cursor', ''); | |||
| 572 | + | setActive(el, items[0]); | |||
| 573 | + | rendered = res; | |||
| 574 | + | let msg = res.all.length === 0 ? 'no file matches' : res.all.length + (res.all.length === 1 ? ' file matches' : ' files match'); | |||
| 575 | + | if (loaded.truncated) msg += ' · only the first ' + loaded.idx.paths.length.toLocaleString('en') + ' files are searched'; | |||
| 576 | + | status(msg); | |||
| 577 | + | const open = pendingEnter === q; | |||
| 578 | + | pendingEnter = null; | |||
| 579 | + | if (open) items[0]?.querySelector('a').click(); | |||
| 580 | + | } | |||
| 581 | + | ||||
| 582 | + | // The palette keeps its own frame and previous result: the field of the | |||
| 583 | + | // dialog and the finder are searched independently. | |||
| 584 | + | let pFrame = 0; | |||
| 585 | + | let pPrev = null; | |||
| 586 | + | let pGen = 0; // bumped at close: a search still loading then is dropped | |||
| 587 | + | const clearHits = () => document.querySelectorAll('#cmd-list > li.finder-hit').forEach((li) => li.remove()); | |||
| 588 | + | ||||
| 589 | + | // palette lists the file hits of the ":" field under its entries, once per frame. | |||
| 590 | + | function palette(el) { | |||
| 591 | + | if (!document.body.dataset.paths) return; | |||
| 592 | + | clearHits(); | |||
| 593 | + | if (pFrame) cancelAnimationFrame(pFrame); | |||
| 594 | + | pFrame = 0; | |||
| 595 | + | if (!el.value.trim()) { | |||
| 596 | + | pPrev = null; | |||
| 597 | + | return; | |||
| 598 | + | } | |||
| 599 | + | pFrame = requestAnimationFrame(() => { pFrame = 0; paletteRun(el); }); | |||
| 600 | + | } | |||
| 601 | + | ||||
| 602 | + | // paletteClose drops the frame and the result kept for the next keystroke. | |||
| 603 | + | function paletteClose() { | |||
| 604 | + | if (pFrame) cancelAnimationFrame(pFrame); | |||
| 605 | + | pFrame = 0; | |||
| 606 | + | pPrev = null; | |||
| 607 | + | pGen++; | |||
| 608 | + | } | |||
| 609 | + | ||||
| 610 | + | async function paletteRun(el) { | |||
| 611 | + | const q = el.value; | |||
| 612 | + | const gen = pGen; | |||
| 613 | + | let loaded; | |||
| 614 | + | try { | |||
| 615 | + | loaded = await load(); | |||
| 616 | + | } catch (e) { | |||
| 617 | + | return; | |||
| 618 | + | } | |||
| 619 | + | if (el.value !== q || gen !== pGen || !loaded) return; // the field changed or the dialog closed during the wait | |||
| 620 | + | const res = search(loaded.idx, q, 20, pPrev); | |||
| 621 | + | pPrev = res; | |||
| 622 | + | clearHits(); | |||
| 623 | + | const list = $('cmd-list'); | |||
| 624 | + | for (const h of res.hits) { | |||
| 625 | + | const li = document.createElement('li'); | |||
| 626 | + | li.className = 'finder-hit'; | |||
| 627 | + | const a = hitLink(res, h.i); | |||
| 628 | + | const k = document.createElement('span'); | |||
| 629 | + | k.className = 'k'; | |||
| 630 | + | k.textContent = 'file'; | |||
| 631 | + | a.append(k); | |||
| 632 | + | li.append(a); | |||
| 633 | + | list.append(li); | |||
| 634 | + | } | |||
| 635 | + | const shown = Array.from(list.querySelectorAll(':scope > li:not([hidden])')); | |||
| 636 | + | if (shown.length && !shown.some((li) => li.hasAttribute('data-cursor'))) { | |||
| 637 | + | list.querySelectorAll(':scope > li[data-cursor]').forEach((li) => li.removeAttribute('data-cursor')); | |||
| 638 | + | shown[0].setAttribute('data-cursor', ''); | |||
| 639 | + | } | |||
| 640 | + | } | |||
| 641 | + | ||||
| 642 | + | // input runs the search of the field, once per frame. | |||
| 643 | + | function input(el) { | |||
| 644 | + | if (frame) cancelAnimationFrame(frame); | |||
| 645 | + | if (!el.value.trim()) { | |||
| 646 | + | frame = 0; | |||
| 647 | + | run(el); | |||
| 648 | + | return; | |||
| 649 | + | } | |||
| 650 | + | frame = requestAnimationFrame(() => { frame = 0; run(el); }); | |||
| 651 | + | } | |||
| 652 | + | ||||
| 653 | + | return { | |||
| 654 | + | index, search, positions, visible, label, load, palette, paletteClose, input, | |||
| 655 | + | // key moves the cursor of the results, opens one, or clears the field. | |||
| 656 | + | key(el, e) { | |||
| 657 | + | if (e.isComposing) return; // the Enter or Escape of an input method is its own | |||
| 658 | + | const results = !$('finder').hidden; | |||
| 659 | + | if (e.key === 'Escape') { | |||
| 660 | + | el.value = ''; | |||
| 661 | + | input(el); | |||
| 662 | + | return; | |||
| 663 | + | } | |||
| 664 | + | if (!results) { | |||
| 665 | + | // A query whose first frame has not run yet: open its top hit once it does. | |||
| 666 | + | if (e.key === 'Enter' && el.value.trim()) { | |||
| 667 | + | e.preventDefault(); | |||
| 668 | + | pendingEnter = el.value; | |||
| 669 | + | } | |||
| 670 | + | return; | |||
| 671 | + | } | |||
| 672 | + | const cur = syncActive(el); | |||
| 673 | + | if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { | |||
| 674 | + | e.preventDefault(); | |||
| 675 | + | const next = e.key === 'ArrowDown' ? cur?.nextElementSibling : cur?.previousElementSibling; | |||
| 676 | + | if (!next) return; | |||
| 677 | + | cur.removeAttribute('data-cursor'); | |||
| 678 | + | next.setAttribute('data-cursor', ''); | |||
| 679 | + | setActive(el, next); | |||
| 680 | + | next.scrollIntoView({ block: 'nearest' }); | |||
| 681 | + | } else if (e.key === 'Enter') { | |||
| 682 | + | e.preventDefault(); | |||
| 683 | + | if (rendered && rendered.q === el.value) cur?.querySelector('a')?.click(); | |||
| 684 | + | else pendingEnter = el.value; // the results are stale: run opens the top hit | |||
| 685 | + | } | |||
| 686 | + | }, | |||
| 687 | + | }; | |||
| 688 | + | })(); | |||
M internal/adapters/web/tree.go +24 -12
| @@ -12,14 +12,14 @@ import ( | |||||
| 12 | pb "bnema.dev/ermite-api/gen/ermite" | 12 | pb "bnema.dev/ermite-api/gen/ermite" | ||
| 13 | ) | 13 | ) | ||
| 14 | 14 | ||||
| 15 | - | // maxFileTargets bounds the files of a directory offered in the ":" list. | |||
| 16 | - | const maxFileTargets = 200 | |||
| 17 | - | ||||
| 18 | // TreeRow is one entry of a directory. | 15 | // TreeRow is one entry of a directory. | ||
| 19 | type TreeRow struct { | 16 | type TreeRow struct { | ||
| 20 | - | Glyph string // ▾ directory, @ symbolic link, + submodule, · file | 17 | + | Glyph string // ▸ directory, @ symbolic link, + submodule, · file |
| 21 | - | Name string | 18 | + | Name string |
| 22 | - | Href string // "" for a submodule | 19 | + | Href string // "" for a submodule |
| 20 | + | // Expand is the URL of the rows of a directory (Href?rows); "" for any | |||
| 21 | + | // other entry. | |||
| 22 | + | Expand string | |||
| 23 | Size string | 23 | Size string | ||
| 24 | Subject string | 24 | Subject string | ||
| 25 | CHref string | 25 | CHref string | ||
| @@ -35,6 +35,9 @@ type TreePage struct { | |||||
| 35 | Readme *Readme | 35 | Readme *Readme | ||
| 36 | // Notes say what the listing leaves out. | 36 | // Notes say what the listing leaves out. | ||
| 37 | Notes []string | 37 | Notes []string | ||
| 38 | + | // More, for the rows of a directory expanded in place, is the page of the | |||
| 39 | + | // directory when it has more entries than are listed; "" otherwise. | |||
| 40 | + | More string | |||
| 38 | } | 41 | } | ||
| 39 | 42 | ||||
| 40 | // tree lists a directory of a ref, with the last commit of each entry, and | 43 | // tree lists a directory of a ref, with the last commit of each entry, and | ||
| @@ -57,14 +60,15 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in | |||||
| 57 | entries := res.Msg.GetEntries() | 60 | entries := res.Msg.GetEntries() | ||
| 58 | now := s.cfg.Now() | 61 | now := s.cfg.Now() | ||
| 59 | 62 | ||||
| 63 | + | rowsOnly := r.URL.Query().Has("rows") | |||
| 60 | p := TreePage{} | 64 | p := TreePage{} | ||
| 61 | - | l := s.repoLayout(ctx, d, "tree") | |||
| 62 | for i, e := range entries { | 65 | for i, e := range entries { | ||
| 63 | - | row := TreeRow{Glyph: "·", Name: visibleText(e.GetName()), Size: humanSize(e.GetSize()), Cursor: i == 0} | 66 | + | row := TreeRow{Glyph: "·", Name: visibleText(e.GetName()), Size: humanSize(e.GetSize()), Cursor: i == 0 && !rowsOnly} |
| 64 | switch e.GetKind() { | 67 | switch e.GetKind() { | ||
| 65 | case pb.EntryKind_ENTRY_KIND_DIRECTORY: | 68 | case pb.EntryKind_ENTRY_KIND_DIRECTORY: | ||
| 66 | - | row.Glyph, row.Name, row.Size = "▾", row.Name+"/", "-" | 69 | + | row.Glyph, row.Name, row.Size = "▸", row.Name+"/", "-" |
| 67 | row.Href = repoHref(d.name(), "tree", t.name, e.GetPath()) | 70 | row.Href = repoHref(d.name(), "tree", t.name, e.GetPath()) | ||
| 71 | + | row.Expand = row.Href + "?rows" | |||
| 68 | case pb.EntryKind_ENTRY_KIND_SYMLINK: | 72 | case pb.EntryKind_ENTRY_KIND_SYMLINK: | ||
| 69 | row.Glyph = "@" | 73 | row.Glyph = "@" | ||
| 70 | row.Href = repoHref(d.name(), "blob", t.name, e.GetPath()) | 74 | row.Href = repoHref(d.name(), "blob", t.name, e.GetPath()) | ||
| @@ -72,9 +76,6 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in | |||||
| 72 | row.Glyph, row.Size = "+", "-" | 76 | row.Glyph, row.Size = "+", "-" | ||
| 73 | default: | 77 | default: | ||
| 74 | row.Href = repoHref(d.name(), "blob", t.name, e.GetPath()) | 78 | row.Href = repoHref(d.name(), "blob", t.name, e.GetPath()) | ||
| 75 | - | if len(l.Cmd) < maxFileTargets { | |||
| 76 | - | l.Cmd = append(l.Cmd, CmdItem{Kind: "file", Label: visibleText(e.GetPath()), Href: row.Href}) | |||
| 77 | - | } | |||
| 78 | } | 79 | } | ||
| 79 | if c := e.GetLastCommit(); c != nil { | 80 | if c := e.GetLastCommit(); c != nil { | ||
| 80 | last := commitRow(d.name(), c, now) | 81 | last := commitRow(d.name(), c, now) | ||
| @@ -82,6 +83,13 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in | |||||
| 82 | } | 83 | } | ||
| 83 | p.Rows = append(p.Rows, row) | 84 | p.Rows = append(p.Rows, row) | ||
| 84 | } | 85 | } | ||
| 86 | + | if rowsOnly { | |||
| 87 | + | // The rows alone, for a directory opened in place (files.js). | |||
| 88 | + | if res.Msg.GetTruncated() { | |||
| 89 | + | p.More = repoHref(d.name(), "tree", t.name, t.path) | |||
| 90 | + | } | |||
| 91 | + | return TreeRows(p), http.StatusOK, nil | |||
| 92 | + | } | |||
| 85 | if res.Msg.GetTruncated() { | 93 | if res.Msg.GetTruncated() { | ||
| 86 | p.Notes = append(p.Notes, "this directory has more entries than are listed here: the first "+itoa(len(entries))+" are shown.") | 94 | p.Notes = append(p.Notes, "this directory has more entries than are listed here: the first "+itoa(len(entries))+" are shown.") | ||
| 87 | } | 95 | } | ||
| @@ -96,6 +104,10 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in | |||||
| 96 | p.Readme = rd.readme | 104 | p.Readme = rd.readme | ||
| 97 | } | 105 | } | ||
| 98 | 106 | ||||
| 107 | + | l := s.repoLayout(ctx, d, "tree") | |||
| 108 | + | // The finder searches the commit this page lists, whichever way the URL | |||
| 109 | + | // spelled it (an abbreviated id included): the listing knows it. | |||
| 110 | + | t.commit = res.Msg.GetCommitId() | |||
| 99 | pinRef(&l, t) | 111 | pinRef(&l, t) | ||
| 100 | l.Crumbs = pathCrumbs(d.name(), "tree", t) | 112 | l.Crumbs = pathCrumbs(d.name(), "tree", t) | ||
| 101 | if t.path == "" { | 113 | if t.path == "" { | ||
M internal/adapters/web/tree.templ +57 -27
| @@ -2,52 +2,82 @@ | |||||
| 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> | 31 | + | <div id="tree-view"> |
| 25 | - | for _, r := range p.Rows { | 32 | + | <table class="list tree" aria-label="Entries"> |
| 26 | - | @TreeRowView(r) | 33 | + | <thead> |
| 27 | - | } | 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 | - | </tbody> | 35 | + | </thead> |
| 29 | - | </table> | 36 | + | <tbody id="tree-rows" data-rows hx-on:click="ermiteTree.click(event)"> |
| 30 | - | if len(p.Rows) == 0 { | 37 | + | @TreeRows(p) |
| 31 | - | <p class="lede dim">this directory is empty.</p> | 38 | + | </tbody> |
| 32 | - | } | 39 | + | </table> |
| 33 | - | for _, n := range p.Notes { | 40 | + | if len(p.Rows) == 0 { |
| 34 | - | <p class="note">{ n }</p> | 41 | + | <p class="lede dim">this directory is empty.</p> |
| 35 | - | } | 42 | + | } |
| 36 | - | if p.Readme != nil { | 43 | + | for _, n := range p.Notes { |
| 37 | - | @ReadmeCard(*p.Readme) | 44 | + | <p class="note">{ n }</p> |
| 38 | - | } | 45 | + | } |
| 46 | + | if p.Readme != nil { | |||
| 47 | + | @ReadmeCard(*p.Readme) | |||
| 48 | + | } | |||
| 49 | + | </div> | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // TreeRows is the rows of a directory alone: what files.js inserts under a | |||
| 54 | + | // directory it opens in place. When the directory has more entries than are | |||
| 55 | + | // listed, the last row links to its page. | |||
| 56 | + | templ TreeRows(p TreePage) { | |||
| 57 | + | for _, r := range p.Rows { | |||
| 58 | + | @TreeRowView(r) | |||
| 59 | + | } | |||
| 60 | + | if p.More != "" { | |||
| 61 | + | <tr class="tree-more"><td colspan="7"><a href={ templ.SafeURL(p.More) }>more entries: open the directory</a></td></tr> | |||
| 39 | } | 62 | } | ||
| 40 | } | 63 | } | ||
| 41 | 64 | ||||
| 42 | - | // TreeRowView is one entry of a directory. Glyph: ▾ directory, @ symbolic | 65 | + | // TreeRowView is one entry of a directory. Glyph: ▸ directory (▾ once opened |
| 43 | - | // link, + submodule, · file. | 66 | + | // in place), @ symbolic link, + submodule, · file. A directory link carries |
| 67 | + | // data-expand, the URL of its rows. | |||
| 44 | templ TreeRowView(r TreeRow) { | 68 | templ TreeRowView(r TreeRow) { | ||
| 45 | <tr data-cursor?={ r.Cursor }> | 69 | <tr data-cursor?={ r.Cursor }> | ||
| 46 | <td class="cur" aria-hidden="true"></td> | 70 | <td class="cur" aria-hidden="true"></td> | ||
| 47 | <td class="c-kind" aria-hidden="true">{ r.Glyph }</td> | 71 | <td class="c-kind" aria-hidden="true">{ r.Glyph }</td> | ||
| 48 | <td class="c-name"> | 72 | <td class="c-name"> | ||
| 49 | if r.Href != "" { | 73 | if r.Href != "" { | ||
| 50 | - | <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a> | 74 | + | <a |
| 75 | + | href={ templ.SafeURL(r.Href) } | |||
| 76 | + | if r.Expand != "" { | |||
| 77 | + | data-expand={ r.Expand } | |||
| 78 | + | aria-expanded="false" | |||
| 79 | + | } | |||
| 80 | + | >{ r.Name }</a> | |||
| 51 | } else { | 81 | } else { | ||
| 52 | { r.Name } | 82 | { r.Name } | ||
| 53 | } | 83 | } | ||
M internal/adapters/web/tree_templ.go +153 -78 generated file: collapsed
M internal/adapters/web/view.go +4 -0
| @@ -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 | 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, pathBuilds), |
| 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)) | ||
M internal/app/browse.go +9 -0
| @@ -174,6 +174,15 @@ func (b *Browse) GetTree(ctx context.Context, p domain.Principal, repo domain.Re | |||||
| 174 | return b.reader.GetTree(ctx, repo, rev, path, includeLastCommit) | 174 | return b.reader.GetTree(ctx, repo, rev, path, includeLastCommit) | ||
| 175 | } | 175 | } | ||
| 176 | 176 | ||||
| 177 | + | // ListPaths lists the paths of every file and symbolic link of a revision, at | |||
| 178 | + | // most domain.MaxPathEntries paths and domain.MaxPathBytes bytes of paths. | |||
| 179 | + | func (b *Browse) ListPaths(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.PathList, error) { | |||
| 180 | + | if err := b.authorize(ctx, p, repo); err != nil { | |||
| 181 | + | return domain.PathList{}, err | |||
| 182 | + | } | |||
| 183 | + | return b.reader.ListPaths(ctx, repo, rev, domain.MaxPathEntries, domain.MaxPathBytes) | |||
| 184 | + | } | |||
| 185 | + | ||||
| 177 | // OpenBlob returns the description of a file and a reader of its content. The | 186 | // OpenBlob returns the description of a file and a reader of its content. The | ||
| 178 | // caller closes the reader. | 187 | // caller closes the reader. | ||
| 179 | func (b *Browse) OpenBlob(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) { | 188 | func (b *Browse) OpenBlob(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) { | ||
M internal/app/browse_test.go +6 -0
| @@ -76,6 +76,12 @@ func browseOps() []browseOp { | |||||
| 76 | }, func(r *mocks.RepoReader) { | 76 | }, func(r *mocks.RepoReader) { | ||
| 77 | r.EXPECT().GetTree(mock.Anything, mock.Anything, mock.Anything, mock.Anything, true).Return(domain.Tree{}, nil) | 77 | r.EXPECT().GetTree(mock.Anything, mock.Anything, mock.Anything, mock.Anything, true).Return(domain.Tree{}, nil) | ||
| 78 | }}, | 78 | }}, | ||
| 79 | + | {"ListPaths", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | |||
| 80 | + | _, err := b.ListPaths(ctx, p, repo, domain.Revision{}) | |||
| 81 | + | return err | |||
| 82 | + | }, func(r *mocks.RepoReader) { | |||
| 83 | + | r.EXPECT().ListPaths(mock.Anything, mock.Anything, mock.Anything, domain.MaxPathEntries, domain.MaxPathBytes).Return(domain.PathList{}, nil) | |||
| 84 | + | }}, | |||
| 79 | {"OpenBlob", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | 85 | {"OpenBlob", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | ||
| 80 | _, rc, err := b.OpenBlob(ctx, p, repo, domain.Revision{}, root) | 86 | _, rc, err := b.OpenBlob(ctx, p, repo, domain.Revision{}, root) | ||
| 81 | if rc != nil { | 87 | if rc != nil { | ||
M internal/domain/git.go +79 -9
| @@ -155,20 +155,51 @@ func ParseTreePath(s string) (TreePath, error) { | |||||
| 155 | if s == "" { | 155 | if s == "" { | ||
| 156 | return TreePath{}, nil | 156 | return TreePath{}, nil | ||
| 157 | } | 157 | } | ||
| 158 | + | if why := treePathProblem(s); why != "" { | |||
| 159 | + | return TreePath{}, fmt.Errorf("%w: %q: %s", ErrInvalidPath, truncate(s), why) | |||
| 160 | + | } | |||
| 161 | + | return TreePath{s: s}, nil | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | // IsAddressablePath reports whether s is a non-root path that ParseTreePath | |||
| 165 | + | // accepts as it is (no trailing slash). It allocates nothing. | |||
| 166 | + | func IsAddressablePath(s string) bool { | |||
| 167 | + | return s != "" && treePathProblem(s) == "" | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | // IsAddressablePathBytes is IsAddressablePath for a path held in bytes; it | |||
| 171 | + | // spares the copy into a string. | |||
| 172 | + | func IsAddressablePathBytes(b []byte) bool { | |||
| 173 | + | return len(b) > 0 && len(b) <= maxTreePathLen && utf8.Valid(b) && pathShapeProblem(b) == "" | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | // treePathProblem returns why s cannot be a non-root tree path, or "". | |||
| 177 | + | func treePathProblem(s string) string { | |||
| 158 | if len(s) > maxTreePathLen || !utf8.ValidString(s) { | 178 | if len(s) > maxTreePathLen || !utf8.ValidString(s) { | ||
| 159 | - | return TreePath{}, fmt.Errorf("%w: %q: too long or not valid UTF-8", ErrInvalidPath, truncate(s)) | 179 | + | return "too long or not valid UTF-8" |
| 160 | } | 180 | } | ||
| 161 | - | for _, c := range s { | 181 | + | return pathShapeProblem(s) |
| 162 | - | if c < 0x20 || c == 0x7f { | 182 | + | } |
| 163 | - | return TreePath{}, fmt.Errorf("%w: %q: control character", ErrInvalidPath, truncate(s)) | 183 | + | |
| 184 | + | // pathShapeProblem checks the control characters and the components of a path | |||
| 185 | + | // that is valid UTF-8 and short enough. The bytes of a multi-byte character | |||
| 186 | + | // are all 0x80 or more, so it looks at bytes. | |||
| 187 | + | func pathShapeProblem[S ~string | ~[]byte](s S) string { | |||
| 188 | + | start := 0 | |||
| 189 | + | for i := 0; i <= len(s); i++ { | |||
| 190 | + | if i < len(s) && s[i] != '/' { | |||
| 191 | + | if c := s[i]; c < 0x20 || c == 0x7f { | |||
| 192 | + | return "control character" | |||
| 193 | + | } | |||
| 194 | + | continue | |||
| 164 | } | 195 | } | ||
| 165 | - | } | 196 | + | n := i - start |
| 166 | - | for part := range strings.SplitSeq(s, "/") { | 197 | + | if n == 0 || (n == 1 && s[start] == '.') || (n == 2 && s[start] == '.' && s[start+1] == '.') { |
| 167 | - | if part == "" || part == "." || part == ".." { | 198 | + | return "empty, . or .. component" |
| 168 | - | return TreePath{}, fmt.Errorf("%w: %q: empty, . or .. component", ErrInvalidPath, truncate(s)) | |||
| 169 | } | 199 | } | ||
| 200 | + | start = i + 1 | |||
| 170 | } | 201 | } | ||
| 171 | - | return TreePath{s: s}, nil | 202 | + | return "" |
| 172 | } | 203 | } | ||
| 173 | 204 | ||||
| 174 | func truncate(s string) string { | 205 | func truncate(s string) string { | ||
| @@ -325,6 +356,45 @@ type Tree struct { | |||||
| 325 | // MaxTreeEntries bounds the entries of a listed directory. | 356 | // MaxTreeEntries bounds the entries of a listed directory. | ||
| 326 | const MaxTreeEntries = 5000 | 357 | const MaxTreeEntries = 5000 | ||
| 327 | 358 | ||||
| 359 | + | // MaxPathEntries bounds the paths ListPaths returns. | |||
| 360 | + | const MaxPathEntries = 100_000 | |||
| 361 | + | ||||
| 362 | + | // MaxPathBytes bounds the total bytes of the paths ListPaths returns. | |||
| 363 | + | const MaxPathBytes = 8 << 20 | |||
| 364 | + | ||||
| 365 | + | // PathList is the recursive list of the file paths of a revision. | |||
| 366 | + | type PathList struct { | |||
| 367 | + | // CommitID is the commit the revision resolved to. | |||
| 368 | + | CommitID ObjectID | |||
| 369 | + | // TreeID is the root tree of that commit. | |||
| 370 | + | TreeID ObjectID | |||
| 371 | + | // Paths are the paths of the files and symbolic links, in tree order (the | |||
| 372 | + | // order of `git ls-tree -r`). | |||
| 373 | + | Paths []string | |||
| 374 | + | // Truncated is true when more paths exist than the ones in Paths. | |||
| 375 | + | Truncated bool | |||
| 376 | + | ||||
| 377 | + | size int // bytes of Paths, kept by Add | |||
| 378 | + | } | |||
| 379 | + | ||||
| 380 | + | // Add lists p, the next path of a walk in tree order, within the bounds of | |||
| 381 | + | // ListPaths: at most maxPaths paths and maxBytes bytes of paths. A path that | |||
| 382 | + | // is not addressable (IsAddressablePath) is left out. When a bound is reached | |||
| 383 | + | // p is not listed, Truncated is set, and Add returns false: the walk can stop. | |||
| 384 | + | // Otherwise it returns true. The bytes of p are kept, not copied. | |||
| 385 | + | func (l *PathList) Add(p string, maxPaths, maxBytes int) bool { | |||
| 386 | + | if !IsAddressablePath(p) { | |||
| 387 | + | return true | |||
| 388 | + | } | |||
| 389 | + | if len(l.Paths) >= maxPaths || l.size+len(p) > maxBytes { | |||
| 390 | + | l.Truncated = true | |||
| 391 | + | return false | |||
| 392 | + | } | |||
| 393 | + | l.size += len(p) | |||
| 394 | + | l.Paths = append(l.Paths, p) | |||
| 395 | + | return true | |||
| 396 | + | } | |||
| 397 | + | ||||
| 328 | // MaxLastCommitEntries bounds the entries of one directory that get a last | 398 | // MaxLastCommitEntries bounds the entries of one directory that get a last | ||
| 329 | // commit: each costs a history lookup. | 399 | // commit: each costs a history lookup. | ||
| 330 | const MaxLastCommitEntries = 200 | 400 | const MaxLastCommitEntries = 200 | ||
M internal/domain/git_test.go +72 -3
| @@ -73,10 +73,79 @@ func TestParseTreePath(t *testing.T) { | |||||
| 73 | require.NoError(t, err) | 73 | require.NoError(t, err) | ||
| 74 | assert.Equal(t, "a/b", p.String()) | 74 | assert.Equal(t, "a/b", p.String()) | ||
| 75 | assert.Equal(t, "a/b/c", p.Join("c")) | 75 | assert.Equal(t, "a/b/c", p.Join("c")) | ||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // ParseTreePath and IsAddressablePath share one rule. The only differences | |||
| 79 | + | // are the root ("" is a valid path to parse, and not addressable) and the | |||
| 80 | + | // trailing slash (ParseTreePath drops one, IsAddressablePath wants the path | |||
| 81 | + | // as it is). | |||
| 82 | + | func TestTreePathRule(t *testing.T) { | |||
| 83 | + | long := strings.Repeat("a", 4096) | |||
| 84 | + | for _, tt := range []struct { | |||
| 85 | + | path string | |||
| 86 | + | ok bool // parses, and is addressable | |||
| 87 | + | }{ | |||
| 88 | + | {"a", true}, {"a/b.txt", true}, {"dir/ünï.go", true}, {"a b/c", true}, {".github/ci.yml", true}, {long, true}, | |||
| 89 | + | {"/a", false}, {"a//b", false}, {"./a", false}, {"a/..", false}, {"..", false}, {".", false}, | |||
| 90 | + | {"a/../b", false}, {"tab\tname", false}, {"a\nb", false}, {"a\x00b", false}, {"a\x7fb", false}, | |||
| 91 | + | {"\xff", false}, {"a/\xfe/b", false}, {long + "a", false}, {"//", false}, | |||
| 92 | + | } { | |||
| 93 | + | _, err := domain.ParseTreePath(tt.path) | |||
| 94 | + | assert.Equal(t, tt.ok, err == nil, "ParseTreePath(%q)", tt.path) | |||
| 95 | + | if !tt.ok { | |||
| 96 | + | assert.ErrorIs(t, err, domain.ErrInvalidPath, "%q", tt.path) | |||
| 97 | + | } | |||
| 98 | + | assert.Equal(t, tt.ok, domain.IsAddressablePath(tt.path), "IsAddressablePath(%q)", tt.path) | |||
| 99 | + | assert.Equal(t, tt.ok, domain.IsAddressablePathBytes([]byte(tt.path)), "IsAddressablePathBytes(%q)", tt.path) | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | // The exceptions. | |||
| 103 | + | _, err := domain.ParseTreePath("") | |||
| 104 | + | assert.NoError(t, err, "the root parses") | |||
| 105 | + | assert.False(t, domain.IsAddressablePath(""), "the root is not addressable") | |||
| 106 | + | assert.False(t, domain.IsAddressablePathBytes(nil)) | |||
| 107 | + | _, err = domain.ParseTreePath("a/") | |||
| 108 | + | assert.NoError(t, err, "one trailing slash is dropped") | |||
| 109 | + | assert.False(t, domain.IsAddressablePath("a/"), "an addressable path has none") | |||
| 110 | + | assert.False(t, domain.IsAddressablePathBytes([]byte("a/"))) | |||
| 111 | + | _, err = domain.ParseTreePath("/") | |||
| 112 | + | assert.NoError(t, err, `"/" is the root with its trailing slash dropped`) | |||
| 113 | + | assert.False(t, domain.IsAddressablePath("/")) | |||
| 114 | + | } | |||
| 76 | 115 | ||||
| 77 | - | for _, bad := range []string{"/a", "a//b", "./a", "a/..", "..", ".", "a\x00b", "a\nb", "\xff", strings.Repeat("a", 4097), "//"} { | 116 | + | func TestPathListAdd(t *testing.T) { |
| 78 | - | _, err := domain.ParseTreePath(bad) | 117 | + | for _, tt := range []struct { |
| 79 | - | assert.ErrorIs(t, err, domain.ErrInvalidPath, "%q", bad) | 118 | + | name string |
| 119 | + | adds []string | |||
| 120 | + | maxPaths, maxBytes int | |||
| 121 | + | want []string | |||
| 122 | + | wantStopAt int // index of the Add that returned false, -1 for none | |||
| 123 | + | truncated bool | |||
| 124 | + | }{ | |||
| 125 | + | {"all fit", []string{"a", "b/c"}, 10, 100, []string{"a", "b/c"}, -1, false}, | |||
| 126 | + | {"not addressable are skipped", []string{"a", "", "b//c", "d/", "e\nf", "g"}, 10, 100, []string{"a", "g"}, -1, false}, | |||
| 127 | + | {"skipped paths do not count", []string{"", "..", "a"}, 1, 1, []string{"a"}, -1, false}, | |||
| 128 | + | {"exactly maxPaths", []string{"a", "b"}, 2, 100, []string{"a", "b"}, -1, false}, | |||
| 129 | + | {"over maxPaths", []string{"a", "b", "c"}, 2, 100, []string{"a", "b"}, 2, true}, | |||
| 130 | + | {"exactly maxBytes", []string{"ab", "cd"}, 10, 4, []string{"ab", "cd"}, -1, false}, | |||
| 131 | + | {"over maxBytes", []string{"ab", "cde", "f"}, 10, 4, []string{"ab"}, 1, true}, | |||
| 132 | + | {"nothing fits", []string{"abc"}, 10, 2, nil, 0, true}, | |||
| 133 | + | {"no paths allowed", []string{"a"}, 0, 100, nil, 0, true}, | |||
| 134 | + | {"unaddressable past the limit is no truncation", []string{"a", "b/"}, 1, 100, []string{"a"}, -1, false}, | |||
| 135 | + | } { | |||
| 136 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 137 | + | var l domain.PathList | |||
| 138 | + | stop := -1 | |||
| 139 | + | for i, p := range tt.adds { | |||
| 140 | + | if !l.Add(p, tt.maxPaths, tt.maxBytes) { | |||
| 141 | + | stop = i | |||
| 142 | + | break | |||
| 143 | + | } | |||
| 144 | + | } | |||
| 145 | + | assert.Equal(t, tt.want, l.Paths) | |||
| 146 | + | assert.Equal(t, tt.wantStopAt, stop) | |||
| 147 | + | assert.Equal(t, tt.truncated, l.Truncated) | |||
| 148 | + | }) | |||
| 80 | } | 149 | } | ||
| 81 | } | 150 | } | ||
| 82 | 151 | ||||
M internal/ports/mocks/RepoReader.go +84 -0
| @@ -283,6 +283,90 @@ func (_c *RepoReader_ListCommits_Call) RunAndReturn(run func(ctx context.Context | |||||
| 283 | return _c | 283 | return _c | ||
| 284 | } | 284 | } | ||
| 285 | 285 | ||||
| 286 | + | // ListPaths provides a mock function for the type RepoReader | |||
| 287 | + | func (_mock *RepoReader) ListPaths(ctx context.Context, repo domain.RepoName, rev domain.Revision, maxPaths int, maxBytes int) (domain.PathList, error) { | |||
| 288 | + | ret := _mock.Called(ctx, repo, rev, maxPaths, maxBytes) | |||
| 289 | + | ||||
| 290 | + | if len(ret) == 0 { | |||
| 291 | + | panic("no return value specified for ListPaths") | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | var r0 domain.PathList | |||
| 295 | + | var r1 error | |||
| 296 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.Revision, int, int) (domain.PathList, error)); ok { | |||
| 297 | + | return returnFunc(ctx, repo, rev, maxPaths, maxBytes) | |||
| 298 | + | } | |||
| 299 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.Revision, int, int) domain.PathList); ok { | |||
| 300 | + | r0 = returnFunc(ctx, repo, rev, maxPaths, maxBytes) | |||
| 301 | + | } else { | |||
| 302 | + | r0 = ret.Get(0).(domain.PathList) | |||
| 303 | + | } | |||
| 304 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.Revision, int, int) error); ok { | |||
| 305 | + | r1 = returnFunc(ctx, repo, rev, maxPaths, maxBytes) | |||
| 306 | + | } else { | |||
| 307 | + | r1 = ret.Error(1) | |||
| 308 | + | } | |||
| 309 | + | return r0, r1 | |||
| 310 | + | } | |||
| 311 | + | ||||
| 312 | + | // RepoReader_ListPaths_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListPaths' | |||
| 313 | + | type RepoReader_ListPaths_Call struct { | |||
| 314 | + | *mock.Call | |||
| 315 | + | } | |||
| 316 | + | ||||
| 317 | + | // ListPaths is a helper method to define mock.On call | |||
| 318 | + | // - ctx context.Context | |||
| 319 | + | // - repo domain.RepoName | |||
| 320 | + | // - rev domain.Revision | |||
| 321 | + | // - maxPaths int | |||
| 322 | + | // - maxBytes int | |||
| 323 | + | func (_e *RepoReader_Expecter) ListPaths(ctx any, repo any, rev any, maxPaths any, maxBytes any) *RepoReader_ListPaths_Call { | |||
| 324 | + | return &RepoReader_ListPaths_Call{Call: _e.mock.On("ListPaths", ctx, repo, rev, maxPaths, maxBytes)} | |||
| 325 | + | } | |||
| 326 | + | ||||
| 327 | + | func (_c *RepoReader_ListPaths_Call) Run(run func(ctx context.Context, repo domain.RepoName, rev domain.Revision, maxPaths int, maxBytes int)) *RepoReader_ListPaths_Call { | |||
| 328 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 329 | + | var arg0 context.Context | |||
| 330 | + | if args[0] != nil { | |||
| 331 | + | arg0 = args[0].(context.Context) | |||
| 332 | + | } | |||
| 333 | + | var arg1 domain.RepoName | |||
| 334 | + | if args[1] != nil { | |||
| 335 | + | arg1 = args[1].(domain.RepoName) | |||
| 336 | + | } | |||
| 337 | + | var arg2 domain.Revision | |||
| 338 | + | if args[2] != nil { | |||
| 339 | + | arg2 = args[2].(domain.Revision) | |||
| 340 | + | } | |||
| 341 | + | var arg3 int | |||
| 342 | + | if args[3] != nil { | |||
| 343 | + | arg3 = args[3].(int) | |||
| 344 | + | } | |||
| 345 | + | var arg4 int | |||
| 346 | + | if args[4] != nil { | |||
| 347 | + | arg4 = args[4].(int) | |||
| 348 | + | } | |||
| 349 | + | run( | |||
| 350 | + | arg0, | |||
| 351 | + | arg1, | |||
| 352 | + | arg2, | |||
| 353 | + | arg3, | |||
| 354 | + | arg4, | |||
| 355 | + | ) | |||
| 356 | + | }) | |||
| 357 | + | return _c | |||
| 358 | + | } | |||
| 359 | + | ||||
| 360 | + | func (_c *RepoReader_ListPaths_Call) Return(pathList domain.PathList, err error) *RepoReader_ListPaths_Call { | |||
| 361 | + | _c.Call.Return(pathList, err) | |||
| 362 | + | return _c | |||
| 363 | + | } | |||
| 364 | + | ||||
| 365 | + | func (_c *RepoReader_ListPaths_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, rev domain.Revision, maxPaths int, maxBytes int) (domain.PathList, error)) *RepoReader_ListPaths_Call { | |||
| 366 | + | _c.Call.Return(run) | |||
| 367 | + | return _c | |||
| 368 | + | } | |||
| 369 | + | ||||
| 286 | // ListRefs provides a mock function for the type RepoReader | 370 | // ListRefs provides a mock function for the type RepoReader | ||
| 287 | func (_mock *RepoReader) ListRefs(ctx context.Context, repo domain.RepoName) ([]domain.Ref, error) { | 371 | func (_mock *RepoReader) ListRefs(ctx context.Context, repo domain.RepoName) ([]domain.Ref, error) { | ||
| 288 | ret := _mock.Called(ctx, repo) | 372 | ret := _mock.Called(ctx, repo) | ||
M internal/ports/ports.go +5 -0
| @@ -156,6 +156,11 @@ type RepoReader interface { | |||||
| 156 | // domain.MaxLastCommitEntries entries get their last commit. It returns | 156 | // domain.MaxLastCommitEntries entries get their last commit. It returns | ||
| 157 | // domain.ErrNotDirectory if path names something else. | 157 | // domain.ErrNotDirectory if path names something else. | ||
| 158 | GetTree(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error) | 158 | GetTree(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error) | ||
| 159 | + | // ListPaths lists the file and symlink paths of rev recursively in tree | |||
| 160 | + | // order, at most maxPaths paths and maxBytes bytes of paths (PathList.Truncated | |||
| 161 | + | // tells), skipping submodules and paths that are not addressable | |||
| 162 | + | // (domain.IsAddressablePath). | |||
| 163 | + | ListPaths(ctx context.Context, repo domain.RepoName, rev domain.Revision, maxPaths, maxBytes int) (domain.PathList, error) | |||
| 159 | // OpenBlob returns the description of the file at path and a reader of its | 164 | // OpenBlob returns the description of the file at path and a reader of its | ||
| 160 | // content, which streams without holding the whole file in memory. The | 165 | // content, which streams without holding the whole file in memory. The | ||
| 161 | // caller must close the reader. It returns domain.ErrNotFile if path names | 166 | // caller must close the reader. It returns domain.ErrNotFile if path names | ||
M internal/readertest/compare.go +15 -0
| @@ -264,6 +264,17 @@ func Compare(t *testing.T, want, got ports.RepoReader, repo domain.RepoName, dir | |||||
| 264 | } | 264 | } | ||
| 265 | } | 265 | } | ||
| 266 | }) | 266 | }) | ||
| 267 | + | t.Run("ListPaths", func(t *testing.T) { | |||
| 268 | + | for _, rev := range revs { | |||
| 269 | + | for _, lim := range [...][2]int{{domain.MaxPathEntries, domain.MaxPathBytes}, {5, domain.MaxPathBytes}, {domain.MaxPathEntries, 60}} { | |||
| 270 | + | w, werr := want.ListPaths(ctx, repo, rev, lim[0], lim[1]) | |||
| 271 | + | g, gerr := got.ListPaths(ctx, repo, rev, lim[0], lim[1]) | |||
| 272 | + | if assert.Equal(t, errClass(werr), errClass(gerr), "paths at %q %v", rev, lim) { | |||
| 273 | + | assert.Equal(t, w, g, "paths at %q %v", rev, lim) | |||
| 274 | + | } | |||
| 275 | + | } | |||
| 276 | + | } | |||
| 277 | + | }) | |||
| 267 | t.Run("OpenBlob", func(t *testing.T) { | 278 | t.Run("OpenBlob", func(t *testing.T) { | ||
| 268 | read := func(r ports.RepoReader, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, []byte, error) { | 279 | read := func(r ports.RepoReader, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, []byte, error) { | ||
| 269 | info, rc, err := r.OpenBlob(ctx, repo, rev, path) | 280 | info, rc, err := r.OpenBlob(ctx, repo, rev, path) | ||
| @@ -338,6 +349,10 @@ func Compare(t *testing.T, want, got ports.RepoReader, repo domain.RepoName, dir | |||||
| 338 | _, err := r.GetTree(ctx, nope, domain.Revision{}, domain.TreePath{}, false) | 349 | _, err := r.GetTree(ctx, nope, domain.Revision{}, domain.TreePath{}, false) | ||
| 339 | return err | 350 | return err | ||
| 340 | }, | 351 | }, | ||
| 352 | + | "ListPaths": func(r ports.RepoReader) error { | |||
| 353 | + | _, err := r.ListPaths(ctx, nope, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes) | |||
| 354 | + | return err | |||
| 355 | + | }, | |||
| 341 | "OpenBlob": func(r ports.RepoReader) error { | 356 | "OpenBlob": func(r ports.RepoReader) error { | ||
| 342 | _, _, err := r.OpenBlob(ctx, nope, domain.Revision{}, domain.TreePath{}) | 357 | _, _, err := r.OpenBlob(ctx, nope, domain.Revision{}, domain.TreePath{}) | ||
| 343 | return err | 358 | return err | ||
M internal/readertest/conformance.go +108 -0
| @@ -46,6 +46,7 @@ func Conformance(t *testing.T, mk Maker) { | |||||
| 46 | {"HostileRevisionAndPathStayLiteral", testHostileRevisionAndPathStayLiteral}, | 46 | {"HostileRevisionAndPathStayLiteral", testHostileRevisionAndPathStayLiteral}, | ||
| 47 | {"ResolveRevisionObjectIDBeatsRefs", testResolveRevisionObjectIDBeatsRefs}, | 47 | {"ResolveRevisionObjectIDBeatsRefs", testResolveRevisionObjectIDBeatsRefs}, | ||
| 48 | {"GetTreeIsBounded", testGetTreeIsBounded}, | 48 | {"GetTreeIsBounded", testGetTreeIsBounded}, | ||
| 49 | + | {"ListPaths", testListPaths}, | |||
| 49 | } { | 50 | } { | ||
| 50 | t.Run(tc.name, func(t *testing.T) { tc.fn(t, mk) }) | 51 | t.Run(tc.name, func(t *testing.T) { tc.fn(t, mk) }) | ||
| 51 | } | 52 | } | ||
| @@ -701,3 +702,110 @@ func testGetTreeIsBounded(t *testing.T, mk Maker) { | |||||
| 701 | require.NoError(t, err) | 702 | require.NoError(t, err) | ||
| 702 | assert.False(t, small.Truncated) | 703 | assert.False(t, small.Truncated) | ||
| 703 | } | 704 | } | ||
| 705 | + | ||||
| 706 | + | func testListPaths(t *testing.T, mk Maker) { | |||
| 707 | + | ctx := context.Background() | |||
| 708 | + | g, data := mk(t) | |||
| 709 | + | f := buildPaths(t, data) | |||
| 710 | + | ||||
| 711 | + | // git ls-tree -r in order, minus the submodule and the path no address can | |||
| 712 | + | // carry. | |||
| 713 | + | var want []string | |||
| 714 | + | for _, rec := range strings.Split(strings.TrimSuffix(GitOut(t, f.Bare, "ls-tree", "-r", "-z", "HEAD"), "\x00"), "\x00") { | |||
| 715 | + | meta, p, _ := strings.Cut(rec, "\t") | |||
| 716 | + | if !strings.Contains(meta, " commit ") && !strings.Contains(p, "\t") { | |||
| 717 | + | want = append(want, p) | |||
| 718 | + | } | |||
| 719 | + | } | |||
| 720 | + | require.Equal(t, []string{ | |||
| 721 | + | "a-b.txt", "a/b.txt", "a/b/c.txt", "d/e/f/deep.txt", "héllo wörld.txt", "link", "run.sh", "z.txt", | |||
| 722 | + | }, want, "the fixture") | |||
| 723 | + | ||||
| 724 | + | got, err := g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes) | |||
| 725 | + | require.NoError(t, err) | |||
| 726 | + | assert.Equal(t, want, got.Paths) | |||
| 727 | + | assert.False(t, got.Truncated) | |||
| 728 | + | assert.Equal(t, GitOut(t, f.Bare, "rev-parse", "HEAD"), got.CommitID.String()) | |||
| 729 | + | assert.Equal(t, GitOut(t, f.Bare, "rev-parse", "HEAD^{tree}"), got.TreeID.String()) | |||
| 730 | + | ||||
| 731 | + | t.Run("revisions", func(t *testing.T) { | |||
| 732 | + | for _, s := range []string{"main", "v1", "refs/tags/v1", f.Git("rev-parse", "HEAD"), f.Git("rev-parse", "HEAD")[:10]} { | |||
| 733 | + | list, err := g.ListPaths(ctx, f.Name, Rev(t, s), domain.MaxPathEntries, domain.MaxPathBytes) | |||
| 734 | + | require.NoError(t, err, s) | |||
| 735 | + | assert.Equal(t, want, list.Paths, s) | |||
| 736 | + | } | |||
| 737 | + | // The first commit has no submodule: the same files. | |||
| 738 | + | first, err := g.ListPaths(ctx, f.Name, Rev(t, f.Git("rev-parse", "HEAD~1")), domain.MaxPathEntries, domain.MaxPathBytes) | |||
| 739 | + | require.NoError(t, err) | |||
| 740 | + | assert.Equal(t, want, first.Paths) | |||
| 741 | + | assert.Equal(t, GitOut(t, f.Bare, "rev-parse", "HEAD~1^{tree}"), first.TreeID.String()) | |||
| 742 | + | assert.NotEqual(t, got.TreeID, first.TreeID) | |||
| 743 | + | }) | |||
| 744 | + | t.Run("not found", func(t *testing.T) { | |||
| 745 | + | for _, s := range []string{"nope", "refs/heads/nope", strings.Repeat("ab", 20), "deadbeef"} { | |||
| 746 | + | _, err := g.ListPaths(ctx, f.Name, Rev(t, s), domain.MaxPathEntries, domain.MaxPathBytes) | |||
| 747 | + | assert.ErrorIs(t, err, domain.ErrNotFound, s) | |||
| 748 | + | } | |||
| 749 | + | _, err := g.ListPaths(ctx, RepoName(t, "no-such-repo"), domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes) | |||
| 750 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |||
| 751 | + | }) | |||
| 752 | + | t.Run("empty repository", func(t *testing.T) { | |||
| 753 | + | e := NewFixture(t, data, "empty-paths") | |||
| 754 | + | _, err := g.ListPaths(ctx, e.Name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes) | |||
| 755 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |||
| 756 | + | }) | |||
| 757 | + | t.Run("bounds", func(t *testing.T) { | |||
| 758 | + | list, err := g.ListPaths(ctx, f.Name, domain.Revision{}, 3, domain.MaxPathBytes) | |||
| 759 | + | require.NoError(t, err) | |||
| 760 | + | assert.Equal(t, want[:3], list.Paths) | |||
| 761 | + | assert.True(t, list.Truncated) | |||
| 762 | + | assert.Equal(t, got.CommitID, list.CommitID) | |||
| 763 | + | assert.Equal(t, got.TreeID, list.TreeID) | |||
| 764 | + | ||||
| 765 | + | list, err = g.ListPaths(ctx, f.Name, domain.Revision{}, len(want), domain.MaxPathBytes) | |||
| 766 | + | require.NoError(t, err) | |||
| 767 | + | assert.Equal(t, want, list.Paths) | |||
| 768 | + | assert.False(t, list.Truncated, "exactly maxPaths paths is not truncated") | |||
| 769 | + | ||||
| 770 | + | sum := len(want[0]) + len(want[1]) + len(want[2]) | |||
| 771 | + | list, err = g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, sum-1) | |||
| 772 | + | require.NoError(t, err) | |||
| 773 | + | assert.Equal(t, want[:2], list.Paths) | |||
| 774 | + | assert.True(t, list.Truncated) | |||
| 775 | + | ||||
| 776 | + | list, err = g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, sum) | |||
| 777 | + | require.NoError(t, err) | |||
| 778 | + | assert.Equal(t, want[:3], list.Paths, "bytes up to maxBytes fit") | |||
| 779 | + | assert.True(t, list.Truncated) | |||
| 780 | + | ||||
| 781 | + | total := 0 | |||
| 782 | + | for _, p := range want { | |||
| 783 | + | total += len(p) | |||
| 784 | + | } | |||
| 785 | + | list, err = g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, total) | |||
| 786 | + | require.NoError(t, err) | |||
| 787 | + | assert.Equal(t, want, list.Paths) | |||
| 788 | + | assert.False(t, list.Truncated, "exactly maxBytes bytes is not truncated") | |||
| 789 | + | }) | |||
| 790 | + | t.Run("cancelled context", func(t *testing.T) { | |||
| 791 | + | ctx, cancel := context.WithCancel(ctx) | |||
| 792 | + | cancel() | |||
| 793 | + | _, err := g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes) | |||
| 794 | + | assert.ErrorIs(t, err, context.Canceled) | |||
| 795 | + | }) | |||
| 796 | + | t.Run("a large tree", func(t *testing.T) { | |||
| 797 | + | w := NewFixture(t, data, "paths-wide") | |||
| 798 | + | for i := range 300 { | |||
| 799 | + | w.Write(fmt.Sprintf("d%02d/e%d/f%03d.txt", i%17, i%5, i), "x") | |||
| 800 | + | } | |||
| 801 | + | w.Commit("wide") | |||
| 802 | + | order := lines(GitOut(t, w.Bare, "ls-tree", "-r", "--name-only", "HEAD")) | |||
| 803 | + | list, err := g.ListPaths(ctx, w.Name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes) | |||
| 804 | + | require.NoError(t, err) | |||
| 805 | + | assert.Equal(t, order, list.Paths) | |||
| 806 | + | list, err = g.ListPaths(ctx, w.Name, domain.Revision{}, 100, domain.MaxPathBytes) | |||
| 807 | + | require.NoError(t, err) | |||
| 808 | + | assert.Equal(t, order[:100], list.Paths) | |||
| 809 | + | assert.True(t, list.Truncated) | |||
| 810 | + | }) | |||
| 811 | + | } | |||
M internal/readertest/fixtures.go +18 -0
| @@ -63,6 +63,7 @@ func Cases() []Case { | |||||
| 63 | {"refs", buildRefs}, | 63 | {"refs", buildRefs}, | ||
| 64 | {"dangling ref", buildDangling}, | 64 | {"dangling ref", buildDangling}, | ||
| 65 | {"submodule", buildSubmodule}, | 65 | {"submodule", buildSubmodule}, | ||
| 66 | + | {"paths", buildPaths}, | |||
| 66 | {"wide directory", buildWide}, | 67 | {"wide directory", buildWide}, | ||
| 67 | {"detached HEAD", func(t *testing.T, data string) *Fixture { | 68 | {"detached HEAD", func(t *testing.T, data string) *Fixture { | ||
| 68 | h := NewHistory(t, data) | 69 | h := NewHistory(t, data) | ||
| @@ -265,6 +266,23 @@ func buildSubmodule(t *testing.T, data string) *Fixture { | |||||
| 265 | return f | 266 | return f | ||
| 266 | } | 267 | } | ||
| 267 | 268 | ||||
| 269 | + | // buildPaths has what ListPaths must order and skip: nested directories whose | |||
| 270 | + | // names sort around "/" (a/b/c.txt, a/b.txt, a-b.txt), an executable, a | |||
| 271 | + | // symlink, a submodule, and a name with a tab that no address can carry. The | |||
| 272 | + | // tag v1 points at the commit. | |||
| 273 | + | func buildPaths(t *testing.T, data string) *Fixture { | |||
| 274 | + | f := NewFixture(t, data, "paths") | |||
| 275 | + | f.Write("a/b/c.txt", "c\n").Write("a/b.txt", "b\n").Write("a-b.txt", "ab\n").Write("z.txt", "z\n") | |||
| 276 | + | f.Write("run.sh", "#!/bin/sh\n").Chmod("run.sh", 0o755).Symlink("z.txt", "link") | |||
| 277 | + | f.Write("tab\tname.txt", "t\n").Write("d/e/f/deep.txt", "d\n").Write("héllo wörld.txt", "h\n") | |||
| 278 | + | f.Commit("base") | |||
| 279 | + | f.Git("update-index", "--add", "--cacheinfo", "160000,"+strings.Repeat("ab", 20)+",vendor/mod") | |||
| 280 | + | f.Git("commit", "--quiet", "-m", "add submodule") | |||
| 281 | + | f.Git("tag", "v1") | |||
| 282 | + | f.Maintain() | |||
| 283 | + | return f | |||
| 284 | + | } | |||
| 285 | + | ||||
| 268 | // buildWide has a directory over MaxTreeEntries entries and one over | 286 | // buildWide has a directory over MaxTreeEntries entries and one over | ||
| 269 | // MaxLastCommitEntries, with commits that touch some of them. | 287 | // MaxLastCommitEntries, with commits that touch some of them. | ||
| 270 | func buildWide(t *testing.T, data string) *Fixture { | 288 | func buildWide(t *testing.T, data string) *Fixture { | ||