detached d5cc8fe

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
commit
d5cc8febcf1bf6790c7be46896184d8616221f6f
author
bnema · · 2026-10-12 05:27 UTC
merge of
bfab294 + 53b75ef · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
refs
tag: v0.6.0
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 squash37 # openssh provides ssh-keygen, which the tests use to check that the squash
38 # commits verify (git verify-commit, ADR 0022). The tests fail without it when CI is set.38 # commits verify (git verify-commit, ADR 0022). The tests fail without it when CI is set.
39+ # nodejs runs the tests of the file finder (internal/adapters/web/jstest).
39 - name: System packages40 - name: System packages
40- run: pacman -Syu --noconfirm --needed git gcc openssh41+ run: pacman -Syu --noconfirm --needed git gcc openssh nodejs
41 - uses: actions/checkout@v642 - uses: actions/checkout@v6
42 # Job containers reach Forgejo through the Gordon edge on the host loopback,43 # Job containers reach Forgejo through the Gordon edge on the host loopback,
43 # in plain HTTP on :8088 (see vps-infra homelab/igor/ci-runner).44 # in plain HTTP on :8088 (see vps-infra homelab/igor/ci-runner).
@@ -66,6 +67,9 @@ jobs:
66 - run: go test -race ./...67 - run: go test -race ./...
67 - name: Architecture68 - name: Architecture
68 run: go tool hexcheck -fail-on warn ./...69 run: go tool hexcheck -fail-on warn ./...
70+ # The core of the file finder (static/js/files.js) runs in Node.
71+ - name: JavaScript tests
72+ run: node --test 'internal/adapters/web/jstest/*.test.mjs'
69 73
70 proto:74 proto:
71 name: Protobuf and generated code75 name: Protobuf and generated code
M 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 ## Run7 ## 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 and105 // 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 err125 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 err133 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 &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</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 &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</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 &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</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 &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</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 &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</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 &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</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 id181 // 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-e9394b717bc215+ bnema.dev/ermite-api v0.0.0-20261011182719-cb5699451db6
16 bnema.dev/ermite-git v0.1.016 bnema.dev/ermite-git v0.1.0
17 bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c317 bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3
18 connectrpc.com/connect v1.21.018 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 return164 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, nil327 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 MaxLastCommitEntries427 // 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 Backend94 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 Backend96 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 the101 // 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 and12 // win: the constants GetTree, GetTreeLastCommits, ResolveRevisionAbbrev and
13 // ListCommitsPath cover directories of any size and files of any history. A13 // 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: the14 // 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, the18 // 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 (a19 // 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 win68 // 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 win69 // 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 win70 // 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 win75 // 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 tba76 // 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 ba77 // 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 200108 // 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 few109 // 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 every111+// - 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 every112+// recursive path list (ListPaths) win every line, so they are the only
108-// line, but GetTreeLastCommits covers GetTree root+last too, which loses113+// methods routed. ListPaths wins even cold because one walk replaces a
109-// cold allocations on lazygit (4204 against 3073): it is the nearest114+// 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, err288 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 port341 //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 server28 // 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, or32 // 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), nil85 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 message106 // 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 of23 // 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 string26+ 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 string28 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.instance193 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 &amp; tags, merge requests</dd></div>19 <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd></div>
20 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>20 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>
@@ -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 <input92 <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). The60 // 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 return65 return
66 }66 }
M internal/adapters/web/refs.go +45 -1
@@ -3,9 +3,12 @@
3 package web3 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 string80 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 bool82 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 the111 // 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: a162 // 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.rev169 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 = false173 l.Heads[i].Current = false
144 }174 }
@@ -148,6 +178,7 @@ func pinRef(l *Layout, t target) {
148 return178 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.name183 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, def99 l.Ref, l.RefPath = def, def
100 l.SHA, l.Short = d.head.GetCommitId(), short(d.head.GetCommitId())100 l.SHA, l.Short = d.head.GetCommitId(), short(d.head.GetCommitId())
101+ l.setFinder(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, · file17+ Glyph string // ▸ directory, @ symbolic link, + submodule, · file
21- Name string18+ Name string
22- Href string // "" for a submodule19+ 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 string23 Size string
24 Subject string24 Subject string
25 CHref string25 CHref string
@@ -35,6 +35,9 @@ type TreePage struct {
35 Readme *Readme35 Readme *Readme
36 // Notes say what the listing leaves out.36 // Notes say what the listing leaves out.
37 Notes []string37 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, and43 // 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.readme104 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 web3 package web
4 4
5-// Tree is a directory: the filter, the entries with their last commit, the5+// Tree is a directory: the file finder, the entries with their last commit, the
6 // notes on what is left out, and the README of the directory below.6 // notes on what is left out, and the README of the directory below.
7 templ Tree(l Layout, p TreePage) {7 templ Tree(l Layout, p TreePage) {
8 @Shell(l, nil) {8 @Shell(l, nil) {
9 <div class="filter">9 <div class="filter">
10- <label for="tree-filter" class="sr">filter</label>10+ <label for="finder-q" class="sr">find a file</label>
11 <input11 <input
12- id="tree-filter"12+ id="finder-q"
13 type="search"13 type="search"
14 data-filter14 data-filter
15- placeholder="Filter this directory…"15+ role="combobox"
16+ aria-autocomplete="list"
17+ aria-controls="finder-list"
18+ aria-expanded="false"
19+ placeholder="Find a file…"
16 autocomplete="off"20 autocomplete="off"
17- hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')"21+ spellcheck="false"
22+ hx-on:focus="ermiteFinder.load()"
23+ hx-on:input="ermiteFinder.input(this)"
24+ hx-on:keydown="ermiteFinder.key(this, event)"
18 />25 />
19 </div>26 </div>
20- <table class="list tree" aria-label="Entries">27+ <div id="finder" hidden>
21- <thead>28+ <p id="finder-status" class="dim" aria-live="polite"></p>
22- <tr><th class="cur"></th><th class="c-kind"></th><th class="c-name">name</th><th class="c-size">size</th><th class="c-sub">last commit</th><th class="c-who">author</th><th class="c-when">date</th></tr>29+ <ul id="finder-list" class="cmd-list" role="listbox" aria-label="Files"></ul>
23- </thead>30+ </div>
24- <tbody id="tree-rows" data-rows>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, @ symbolic65+// 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 bool56 Live bool
57 // LiveURL is the stream the page follows, with its query.57 // LiveURL is the stream the page follows, with its query.
58 LiveURL string58 LiveURL string
59+ // PathsURL is the list of the file paths of the commit the page shows
60+ // (/{repo}/paths/{sha}) and BlobBase the URL its file links start with;
61+ // both are "" when the page has no commit to search.
62+ PathsURL, BlobBase string
59 }63 }
60 64
61 // Link is a plain label + href pair.65 // Link is a plain label + href pair.
M internal/adapters/web/web.go +6 -2
@@ -85,7 +85,9 @@ type Server struct {
85 origin string // scheme://host of the public URL, what a POST must come from85 origin string // scheme://host of the public URL, what a POST must come from
86 assets *assetSet86 assets *assetSet
87 md *markdown87 md *markdown
88- mux *http.ServeMux88+ // pathCache holds the path lists the file finder fetches.
89+ pathCache *pathCache
90+ mux *http.ServeMux
89 }91 }
90 92
91 // New builds the web handler.93 // New builds the web handler.
@@ -109,7 +111,8 @@ func New(cfg Config) (*Server, error) {
109 }111 }
110 s := &Server{112 s := &Server{
111 cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown(),113 cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown(),
112- origin: originOf(u),114+ pathCache: newPathCache(pathCacheBytes, pathBuilds),
115+ origin: originOf(u),
113 }116 }
114 s.mux = s.routes()117 s.mux = s.routes()
115 return s, nil118 return s, nil
@@ -137,6 +140,7 @@ func (s *Server) routes() *http.ServeMux {
137 mux.HandleFunc("GET /{repo}/tree/{rest...}", s.page(s.tree))140 mux.HandleFunc("GET /{repo}/tree/{rest...}", s.page(s.tree))
138 mux.HandleFunc("GET /{repo}/blob/{rest...}", s.page(s.blob))141 mux.HandleFunc("GET /{repo}/blob/{rest...}", s.page(s.blob))
139 mux.HandleFunc("GET /{repo}/raw/{rest...}", s.raw)142 mux.HandleFunc("GET /{repo}/raw/{rest...}", s.raw)
143+ mux.HandleFunc("GET /{repo}/paths/{sha}", s.paths)
140 if s.cfg.MergeRequests != nil {144 if s.cfg.MergeRequests != nil {
141 mux.HandleFunc("GET /{repo}/merge-requests", s.page(s.mrList))145 mux.HandleFunc("GET /{repo}/merge-requests", s.page(s.mrList))
142 mux.HandleFunc("GET /{repo}/merge-requests/{$}", s.page(s.mrList))146 mux.HandleFunc("GET /{repo}/merge-requests/{$}", s.page(s.mrList))
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. The186 // 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{}, nil156 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}, nil202+ 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 = 5000357 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 last398 // 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 = 200400 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 _c283 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 RepoReader370 // 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 returns156 // 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 its164 // 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. The165 // content, which streams without holding the whole file in memory. The
161 // caller must close the reader. It returns domain.ErrNotFile if path names166 // 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 err350 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 err358 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 f266 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 over286 // 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 {

Keyboard shortcuts

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

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

Branches 2

Tags 44

↑↓ moveenter openesc close