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:
3636 steps:
3737 # openssh provides ssh-keygen, which the tests use to check that the squash
3838 # commits verify (git verify-commit, ADR 0022). The tests fail without it when CI is set.
39+ # nodejs runs the tests of the file finder (internal/adapters/web/jstest).
3940 - name: System packages
40- run: pacman -Syu --noconfirm --needed git gcc openssh
41+ run: pacman -Syu --noconfirm --needed git gcc openssh nodejs
4142 - uses: actions/checkout@v6
4243 # Job containers reach Forgejo through the Gordon edge on the host loopback,
4344 # in plain HTTP on :8088 (see vps-infra homelab/igor/ci-runner).
@@ -66,6 +67,9 @@ jobs:
6667 - run: go test -race ./...
6768 - name: Architecture
6869 run: go tool hexcheck -fail-on warn ./...
70+ # The core of the file finder (static/js/files.js) runs in Node.
71+ - name: JavaScript tests
72+ run: node --test 'internal/adapters/web/jstest/*.test.mjs'
6973
7074 proto:
7175 name: Protobuf and generated code
M README.md +1 -1
@@ -2,7 +2,7 @@
22
33 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.
44
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.
66
77 ## Run
88
M cmd/ermite/api_test.go +39 -1
@@ -68,6 +68,40 @@ func newAccessRig(t *testing.T) *accessRig {
6868 // every principal in a stable order.
6969 var allPrincipals = []string{pAnon, pRoot, pReader, pLimited, pNoScope, pBad}
7070
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+
71105 // TestAC2ReadAccess: every read RPC on a public repository, a private one and
72106 // one inside the limited token's list.
73107 func TestAC2ReadAccess(t *testing.T) {
@@ -90,6 +124,10 @@ func TestAC2ReadAccess(t *testing.T) {
90124 _, err := c.git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{Repo: repo}))
91125 return err
92126 },
127+ "ListPaths": func(c *clients, repo string) error {
128+ _, err := c.git.ListPaths(ctx, connect.NewRequest(&pb.ListPathsRequest{Repo: repo}))
129+ return err
130+ },
93131 "GetBlob": func(c *clients, repo string) error {
94132 _, err := readBlob(ctx, c, &pb.GetBlobRequest{Repo: repo, Path: "README.md"})
95133 return err
@@ -158,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
158196 for _, who := range allPrincipals {
159197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
160198 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)
162200 assert.NotEmpty(t, resp.Msg.GetVersion())
163201 }
164202 })
M cmd/ermite/cli_e2e_test.go +1 -1
@@ -324,7 +324,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
324324 ver := decode[versionOut](t, res.stdout)
325325 assert.Equal(t, c.url, ver.Server.URL)
326326 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)
328328 assert.Equal(t, []string{}, ver.Server.MissingFeatures)
329329 stdout, stderr, code = c.bin(t, secret, "version")
330330 require.Equal(t, 0, code, stderr)
M cmd/ermite/testdata/web/blob-binary.html +8 -6
@@ -16,8 +16,10 @@
1616 </script>
1717 <script src="/-/static/js/ermite.HASH.js" defer>
1818 </script>
19+<script src="/-/static/js/files.HASH.js" defer>
20+</script>
1921 </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">
2123 <a class="skip" href="#main">skip to content</a>
2224 <header class="hdr">
2325 <div class="hdr-top">
@@ -173,7 +175,7 @@
173175 <dt>
174176 <kbd>/</kbd>
175177 </dt>
176-<dd>filter the list</dd>
178+<dd>filter the list; on Files, find a file in the repository</dd>
177179 </div>
178180 <div>
179181 <dt>
@@ -260,12 +262,12 @@
260262 </section>
261263 </div>
262264 </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()">
264266 <div class="panel-head">
265267 <label for="cmd-q" class="sr">go to</label>
266268 <input id="cmd-q" type="search" placeholder="Go to 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));
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();
269+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
270+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
269271 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
270272 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
271273 if (step) { event.preventDefault(); let n = 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 @@
366368 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
367369 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
368370 k-refs from:body -> 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();
370372 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
371373 k-gh from:body -> document.querySelector('.brand')?.click();
372374 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 @@
1616 </script>
1717 <script src="/-/static/js/ermite.HASH.js" defer>
1818 </script>
19+<script src="/-/static/js/files.HASH.js" defer>
20+</script>
1921 </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">
2123 <a class="skip" href="#main">skip to content</a>
2224 <header class="hdr">
2325 <div class="hdr-top">
@@ -243,7 +245,7 @@
243245 <dt>
244246 <kbd>/</kbd>
245247 </dt>
246-<dd>filter the list</dd>
248+<dd>filter the list; on Files, find a file in the repository</dd>
247249 </div>
248250 <div>
249251 <dt>
@@ -330,12 +332,12 @@
330332 </section>
331333 </div>
332334 </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()">
334336 <div class="panel-head">
335337 <label for="cmd-q" class="sr">go to</label>
336338 <input id="cmd-q" type="search" placeholder="Go to 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));
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();
339+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
340+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
339341 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
340342 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
341343 if (step) { event.preventDefault(); let n = 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 @@
436438 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
437439 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
438440 k-refs from:body -> 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();
440442 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
441443 k-gh from:body -> document.querySelector('.brand')?.click();
442444 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 @@
1616 </script>
1717 <script src="/-/static/js/ermite.HASH.js" defer>
1818 </script>
19+<script src="/-/static/js/files.HASH.js" defer>
20+</script>
1921 </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">
2123 <a class="skip" href="#main">skip to content</a>
2224 <header class="hdr">
2325 <div class="hdr-top">
@@ -173,7 +175,7 @@
173175 <dt>
174176 <kbd>/</kbd>
175177 </dt>
176-<dd>filter the list</dd>
178+<dd>filter the list; on Files, find a file in the repository</dd>
177179 </div>
178180 <div>
179181 <dt>
@@ -260,12 +262,12 @@
260262 </section>
261263 </div>
262264 </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()">
264266 <div class="panel-head">
265267 <label for="cmd-q" class="sr">go to</label>
266268 <input id="cmd-q" type="search" placeholder="Go to 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));
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();
269+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
270+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
269271 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
270272 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
271273 if (step) { event.preventDefault(); let n = 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 @@
366368 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
367369 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
368370 k-refs from:body -> 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();
370372 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
371373 k-gh from:body -> document.querySelector('.brand')?.click();
372374 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 @@
1616 </script>
1717 <script src="/-/static/js/ermite.HASH.js" defer>
1818 </script>
19+<script src="/-/static/js/files.HASH.js" defer>
20+</script>
1921 </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">
2123 <a class="skip" href="#main">skip to content</a>
2224 <header class="hdr">
2325 <div class="hdr-top">
@@ -175,7 +177,7 @@
175177 <dt>
176178 <kbd>/</kbd>
177179 </dt>
178-<dd>filter the list</dd>
180+<dd>filter the list; on Files, find a file in the repository</dd>
179181 </div>
180182 <div>
181183 <dt>
@@ -262,12 +264,12 @@
262264 </section>
263265 </div>
264266 </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()">
266268 <div class="panel-head">
267269 <label for="cmd-q" class="sr">go to</label>
268270 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
269- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
270- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
271+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
272+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
271273 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
272274 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
273275 if (step) { event.preventDefault(); let n = 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 @@
368370 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
369371 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
370372 k-refs from:body -> 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();
372374 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
373375 k-gh from:body -> document.querySelector('.brand')?.click();
374376 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 @@
1616 </script>
1717 <script src="/-/static/js/ermite.HASH.js" defer>
1818 </script>
19+<script src="/-/static/js/files.HASH.js" defer>
20+</script>
1921 </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">
2123 <a class="skip" href="#main">skip to content</a>
2224 <header class="hdr">
2325 <div class="hdr-top">
@@ -240,7 +242,7 @@
240242 <dt>
241243 <kbd>/</kbd>
242244 </dt>
243-<dd>filter the list</dd>
245+<dd>filter the list; on Files, find a file in the repository</dd>
244246 </div>
245247 <div>
246248 <dt>
@@ -327,12 +329,12 @@
327329 </section>
328330 </div>
329331 </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()">
331333 <div class="panel-head">
332334 <label for="cmd-q" class="sr">go to</label>
333335 <input id="cmd-q" type="search" placeholder="Go to 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));
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();
336+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
337+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
336338 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
337339 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
338340 if (step) { event.preventDefault(); let n = 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 @@
433435 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
434436 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
435437 k-refs from:body -> 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();
437439 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
438440 k-gh from:body -> document.querySelector('.brand')?.click();
439441 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 @@
1616 </script>
1717 <script src="/-/static/js/ermite.HASH.js" defer>
1818 </script>
19+<script src="/-/static/js/files.HASH.js" defer>
20+</script>
1921 </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">
2123 <a class="skip" href="#main">skip to content</a>
2224 <header class="hdr">
2325 <div class="hdr-top">
@@ -198,7 +200,7 @@
198200 <dt>
199201 <kbd>/</kbd>
200202 </dt>
201-<dd>filter the list</dd>
203+<dd>filter the list; on Files, find a file in the repository</dd>
202204 </div>
203205 <div>
204206 <dt>
@@ -285,12 +287,12 @@
285287 </section>
286288 </div>
287289 </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()">
289291 <div class="panel-head">
290292 <label for="cmd-q" class="sr">go to</label>
291293 <input id="cmd-q" type="search" placeholder="Go to 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));
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();
294+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
295+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
294296 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
295297 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
296298 if (step) { event.preventDefault(); let n = 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 @@
391393 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
392394 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
393395 k-refs from:body -> 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();
395397 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
396398 k-gh from:body -> document.querySelector('.brand')?.click();
397399 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 @@
1616 </script>
1717 <script src="/-/static/js/ermite.HASH.js" defer>
1818 </script>
19+<script src="/-/static/js/files.HASH.js" defer>
20+</script>
1921 </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">
2123 <a class="skip" href="#main">skip to content</a>
2224 <header class="hdr">
2325 <div class="hdr-top">
@@ -193,7 +195,7 @@
193195 <dt>
194196 <kbd>/</kbd>
195197 </dt>
196-<dd>filter the list</dd>
198+<dd>filter the list; on Files, find a file in the repository</dd>
197199 </div>
198200 <div>
199201 <dt>
@@ -280,12 +282,12 @@
280282 </section>
281283 </div>
282284 </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()">
284286 <div class="panel-head">
285287 <label for="cmd-q" class="sr">go to</label>
286288 <input id="cmd-q" type="search" placeholder="Go to 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));
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();
289+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
290+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
289291 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
290292 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
291293 if (step) { event.preventDefault(); let n = 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 @@
386388 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
387389 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
388390 k-refs from:body -> 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();
390392 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
391393 k-gh from:body -> document.querySelector('.brand')?.click();
392394 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 @@
1616 </script>
1717 <script src="/-/static/js/ermite.HASH.js" defer>
1818 </script>
19+<script src="/-/static/js/files.HASH.js" defer>
20+</script>
1921 </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">
2123 <a class="skip" href="#main">skip to content</a>
2224 <header class="hdr">
2325 <div class="hdr-top">
@@ -173,7 +175,7 @@
173175 <dt>
174176 <kbd>/</kbd>
175177 </dt>
176-<dd>filter the list</dd>
178+<dd>filter the list; on Files, find a file in the repository</dd>
177179 </div>
178180 <div>
179181 <dt>
@@ -260,12 +262,12 @@
260262 </section>
261263 </div>
262264 </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()">
264266 <div class="panel-head">
265267 <label for="cmd-q" class="sr">go to</label>
266268 <input id="cmd-q" type="search" placeholder="Go to 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));
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();
269+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
270+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
269271 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
270272 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
271273 if (step) { event.preventDefault(); let n = 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 @@
366368 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
367369 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
368370 k-refs from:body -> 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();
370372 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
371373 k-gh from:body -> document.querySelector('.brand')?.click();
372374 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 @@
1616 </script>
1717 <script src="/-/static/js/ermite.HASH.js" defer>
1818 </script>
19+<script src="/-/static/js/files.HASH.js" defer>
20+</script>
1921 </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">
2123 <a class="skip" href="#main">skip to content</a>
2224 <header class="hdr">
2325 <div class="hdr-top">
@@ -240,7 +242,7 @@
240242 <dt>
241243 <kbd>/</kbd>
242244 </dt>
243-<dd>filter the list</dd>
245+<dd>filter the list; on Files, find a file in the repository</dd>
244246 </div>
245247 <div>
246248 <dt>
@@ -327,12 +329,12 @@
327329 </section>
328330 </div>
329331 </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()">
331333 <div class="panel-head">
332334 <label for="cmd-q" class="sr">go to</label>
333335 <input id="cmd-q" type="search" placeholder="Go to 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));
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();
336+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
337+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
336338 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
337339 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
338340 if (step) { event.preventDefault(); let n = 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 @@
433435 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
434436 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
435437 k-refs from:body -> 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();
437439 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
438440 k-gh from:body -> document.querySelector('.brand')?.click();
439441 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 @@
1616 </script>
1717 <script src="/-/static/js/ermite.HASH.js" defer>
1818 </script>
19+<script src="/-/static/js/files.HASH.js" defer>
20+</script>
1921 </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">
2123 <a class="skip" href="#main">skip to content</a>
2224 <header class="hdr">
2325 <div class="hdr-top">
@@ -210,7 +212,7 @@
210212 <dt>
211213 <kbd>/</kbd>
212214 </dt>
213-<dd>filter the list</dd>
215+<dd>filter the list; on Files, find a file in the repository</dd>
214216 </div>
215217 <div>
216218 <dt>
@@ -297,12 +299,12 @@
297299 </section>
298300 </div>
299301 </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()">
301303 <div class="panel-head">
302304 <label for="cmd-q" class="sr">go to</label>
303305 <input id="cmd-q" type="search" placeholder="Go to 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));
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();
306+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
307+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
306308 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
307309 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
308310 if (step) { event.preventDefault(); let n = 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 @@
403405 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
404406 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
405407 k-refs from:body -> 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();
407409 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
408410 k-gh from:body -> document.querySelector('.brand')?.click();
409411 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 @@
1616 </script>
1717 <script src="/-/static/js/ermite.HASH.js" defer>
1818 </script>
19+<script src="/-/static/js/files.HASH.js" defer>
20+</script>
1921 </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">
2123 <a class="skip" href="#main">skip to content</a>
2224 <header class="hdr">
2325 <div class="hdr-top">
@@ -205,7 +207,7 @@
205207 <dt>
206208 <kbd>/</kbd>
207209 </dt>
208-<dd>filter the list</dd>
210+<dd>filter the list; on Files, find a file in the repository</dd>
209211 </div>
210212 <div>
211213 <dt>
@@ -292,12 +294,12 @@
292294 </section>
293295 </div>
294296 </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()">
296298 <div class="panel-head">
297299 <label for="cmd-q" class="sr">go to</label>
298300 <input id="cmd-q" type="search" placeholder="Go to 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));
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();
301+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
302+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
301303 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
302304 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
303305 if (step) { event.preventDefault(); let n = 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 @@
398400 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
399401 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
400402 k-refs from:body -> 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();
402404 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
403405 k-gh from:body -> document.querySelector('.brand')?.click();
404406 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 @@
1616 </script>
1717 <script src="/-/static/js/ermite.HASH.js" defer>
1818 </script>
19+<script src="/-/static/js/files.HASH.js" defer>
20+</script>
1921 </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">
2123 <a class="skip" href="#main">skip to content</a>
2224 <header class="hdr">
2325 <div class="hdr-top">
@@ -244,7 +246,7 @@
244246 <dt>
245247 <kbd>/</kbd>
246248 </dt>
247-<dd>filter the list</dd>
249+<dd>filter the list; on Files, find a file in the repository</dd>
248250 </div>
249251 <div>
250252 <dt>
@@ -331,12 +333,12 @@
331333 </section>
332334 </div>
333335 </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()">
335337 <div class="panel-head">
336338 <label for="cmd-q" class="sr">go to</label>
337339 <input id="cmd-q" type="search" placeholder="Go to 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));
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();
340+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
341+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
340342 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
341343 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
342344 if (step) { event.preventDefault(); let n = 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 @@
437439 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
438440 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
439441 k-refs from:body -> 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();
441443 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
442444 k-gh from:body -> document.querySelector('.brand')?.click();
443445 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 @@
1616 </script>
1717 <script src="/-/static/js/ermite.HASH.js" defer>
1818 </script>
19+<script src="/-/static/js/files.HASH.js" defer>
20+</script>
1921 </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">
2123 <a class="skip" href="#main">skip to content</a>
2224 <header class="hdr">
2325 <div class="hdr-top">
@@ -365,7 +367,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
365367 <dt>
366368 <kbd>/</kbd>
367369 </dt>
368-<dd>filter the list</dd>
370+<dd>filter the list; on Files, find a file in the repository</dd>
369371 </div>
370372 <div>
371373 <dt>
@@ -452,12 +454,12 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
452454 </section>
453455 </div>
454456 </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()">
456458 <div class="panel-head">
457459 <label for="cmd-q" class="sr">go to</label>
458460 <input id="cmd-q" type="search" placeholder="Go to 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));
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();
461+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
462+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
461463 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
462464 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
463465 if (step) { event.preventDefault(); let n = 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>
558560 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
559561 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
560562 k-refs from:body -> 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();
562564 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
563565 k-gh from:body -> document.querySelector('.brand')?.click();
564566 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 @@
1616 </script>
1717 <script src="/-/static/js/ermite.HASH.js" defer>
1818 </script>
19+<script src="/-/static/js/files.HASH.js" defer>
20+</script>
1921 </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">
2123 <a class="skip" href="#main">skip to content</a>
2224 <header class="hdr">
2325 <div class="hdr-top">
@@ -365,7 +367,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
365367 <dt>
366368 <kbd>/</kbd>
367369 </dt>
368-<dd>filter the list</dd>
370+<dd>filter the list; on Files, find a file in the repository</dd>
369371 </div>
370372 <div>
371373 <dt>
@@ -452,12 +454,12 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
452454 </section>
453455 </div>
454456 </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()">
456458 <div class="panel-head">
457459 <label for="cmd-q" class="sr">go to</label>
458460 <input id="cmd-q" type="search" placeholder="Go to 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));
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();
461+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
462+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
461463 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
462464 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
463465 if (step) { event.preventDefault(); let n = 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>
558560 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
559561 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
560562 k-refs from:body -> 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();
562564 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
563565 k-gh from:body -> document.querySelector('.brand')?.click();
564566 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 @@
1616 </script>
1717 <script src="/-/static/js/ermite.HASH.js" defer>
1818 </script>
19+<script src="/-/static/js/files.HASH.js" defer>
20+</script>
1921 </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">
2123 <a class="skip" href="#main">skip to content</a>
2224 <header class="hdr">
2325 <div class="hdr-top">
@@ -270,7 +272,7 @@
270272 <dt>
271273 <kbd>/</kbd>
272274 </dt>
273-<dd>filter the list</dd>
275+<dd>filter the list; on Files, find a file in the repository</dd>
274276 </div>
275277 <div>
276278 <dt>
@@ -357,12 +359,12 @@
357359 </section>
358360 </div>
359361 </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()">
361363 <div class="panel-head">
362364 <label for="cmd-q" class="sr">go to</label>
363365 <input id="cmd-q" type="search" placeholder="Go to 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));
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();
366+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
367+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
366368 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
367369 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
368370 if (step) { event.preventDefault(); let n = 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 @@
463465 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
464466 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
465467 k-refs from:body -> 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();
467469 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
468470 k-gh from:body -> document.querySelector('.brand')?.click();
469471 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 @@
1616 </script>
1717 <script src="/-/static/js/ermite.HASH.js" defer>
1818 </script>
19+<script src="/-/static/js/files.HASH.js" defer>
20+</script>
1921 </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">
2123 <a class="skip" href="#main">skip to content</a>
2224 <header class="hdr">
2325 <div class="hdr-top">
@@ -586,7 +588,7 @@
586588 <dt>
587589 <kbd>/</kbd>
588590 </dt>
589-<dd>filter the list</dd>
591+<dd>filter the list; on Files, find a file in the repository</dd>
590592 </div>
591593 <div>
592594 <dt>
@@ -673,12 +675,12 @@
673675 </section>
674676 </div>
675677 </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()">
677679 <div class="panel-head">
678680 <label for="cmd-q" class="sr">go to</label>
679681 <input id="cmd-q" type="search" placeholder="Go to 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));
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();
682+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
683+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
682684 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
683685 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
684686 if (step) { event.preventDefault(); let n = 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 @@
779781 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
780782 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
781783 k-refs from:body -> 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();
783785 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
784786 k-gh from:body -> document.querySelector('.brand')?.click();
785787 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 @@
1616 </script>
1717 <script src="/-/static/js/ermite.HASH.js" defer>
1818 </script>
19+<script src="/-/static/js/files.HASH.js" defer>
20+</script>
1921 </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">
2123 <a class="skip" href="#main">skip to content</a>
2224 <header class="hdr">
2325 <div class="hdr-top">
@@ -379,7 +381,7 @@
379381 <dt>
380382 <kbd>/</kbd>
381383 </dt>
382-<dd>filter the list</dd>
384+<dd>filter the list; on Files, find a file in the repository</dd>
383385 </div>
384386 <div>
385387 <dt>
@@ -466,12 +468,12 @@
466468 </section>
467469 </div>
468470 </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()">
470472 <div class="panel-head">
471473 <label for="cmd-q" class="sr">go to</label>
472474 <input id="cmd-q" type="search" placeholder="Go to 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));
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();
475+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
476+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
475477 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
476478 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
477479 if (step) { event.preventDefault(); let n = 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 @@
572574 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
573575 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
574576 k-refs from:body -> 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();
576578 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
577579 k-gh from:body -> document.querySelector('.brand')?.click();
578580 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 @@
1616 </script>
1717 <script src="/-/static/js/ermite.HASH.js" defer>
1818 </script>
19+<script src="/-/static/js/files.HASH.js" defer>
20+</script>
1921 </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">
2123 <a class="skip" href="#main">skip to content</a>
2224 <header class="hdr">
2325 <div class="hdr-top">
@@ -202,7 +204,7 @@
202204 <dt>
203205 <kbd>/</kbd>
204206 </dt>
205-<dd>filter the list</dd>
207+<dd>filter the list; on Files, find a file in the repository</dd>
206208 </div>
207209 <div>
208210 <dt>
@@ -281,12 +283,12 @@
281283 </section>
282284 </div>
283285 </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()">
285287 <div class="panel-head">
286288 <label for="cmd-q" class="sr">go to</label>
287289 <input id="cmd-q" type="search" placeholder="Go to 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));
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();
290+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
291+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
290292 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
291293 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
292294 if (step) { event.preventDefault(); let n = 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 @@
381383 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
382384 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
383385 k-refs from:body -> 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();
385387 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
386388 k-gh from:body -> document.querySelector('.brand')?.click();
387389 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 @@
1616 </script>
1717 <script src="/-/static/js/ermite.HASH.js" defer>
1818 </script>
19+<script src="/-/static/js/files.HASH.js" defer>
20+</script>
1921 </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">
2123 <a class="skip" href="#main">skip to content</a>
2224 <header class="hdr">
2325 <div class="hdr-top">
@@ -217,7 +219,7 @@
217219 <dt>
218220 <kbd>/</kbd>
219221 </dt>
220-<dd>filter the list</dd>
222+<dd>filter the list; on Files, find a file in the repository</dd>
221223 </div>
222224 <div>
223225 <dt>
@@ -296,12 +298,12 @@
296298 </section>
297299 </div>
298300 </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()">
300302 <div class="panel-head">
301303 <label for="cmd-q" class="sr">go to</label>
302304 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
303- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
304- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
305+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
306+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
305307 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
306308 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
307309 if (step) { event.preventDefault(); let n = 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 @@
396398 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
397399 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
398400 k-refs from:body -> 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();
400402 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
401403 k-gh from:body -> document.querySelector('.brand')?.click();
402404 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 @@
1616 </script>
1717 <script src="/-/static/js/ermite.HASH.js" defer>
1818 </script>
19+<script src="/-/static/js/files.HASH.js" defer>
20+</script>
1921 </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">
2123 <a class="skip" href="#main">skip to content</a>
2224 <header class="hdr">
2325 <div class="hdr-top">
@@ -1004,7 +1006,7 @@
10041006 <dt>
10051007 <kbd>/</kbd>
10061008 </dt>
1007-<dd>filter the list</dd>
1009+<dd>filter the list; on Files, find a file in the repository</dd>
10081010 </div>
10091011 <div>
10101012 <dt>
@@ -1083,12 +1085,12 @@
10831085 </section>
10841086 </div>
10851087 </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()">
10871089 <div class="panel-head">
10881090 <label for="cmd-q" class="sr">go to</label>
10891091 <input id="cmd-q" type="search" placeholder="Go to 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));
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();
1092+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
1093+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
10921094 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
10931095 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
10941096 if (step) { event.preventDefault(); let n = 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 @@
11831185 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
11841186 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
11851187 k-refs from:body -> 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();
11871189 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
11881190 k-gh from:body -> document.querySelector('.brand')?.click();
11891191 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 @@
1616 </script>
1717 <script src="/-/static/js/ermite.HASH.js" defer>
1818 </script>
19+<script src="/-/static/js/files.HASH.js" defer>
20+</script>
1921 </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">
2123 <a class="skip" href="#main">skip to content</a>
2224 <header class="hdr">
2325 <div class="hdr-top">
@@ -349,7 +351,7 @@
349351 <dt>
350352 <kbd>/</kbd>
351353 </dt>
352-<dd>filter the list</dd>
354+<dd>filter the list; on Files, find a file in the repository</dd>
353355 </div>
354356 <div>
355357 <dt>
@@ -436,12 +438,12 @@
436438 </section>
437439 </div>
438440 </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()">
440442 <div class="panel-head">
441443 <label for="cmd-q" class="sr">go to</label>
442444 <input id="cmd-q" type="search" placeholder="Go to 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));
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();
445+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
446+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
445447 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
446448 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
447449 if (step) { event.preventDefault(); let n = 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 @@
548550 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
549551 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
550552 k-refs from:body -> 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();
552554 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
553555 k-gh from:body -> document.querySelector('.brand')?.click();
554556 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
M cmd/ermite/testdata/web/empty.html +7 -5
@@ -16,6 +16,8 @@
1616 </script>
1717 <script src="/-/static/js/ermite.HASH.js" defer>
1818 </script>
19+<script src="/-/static/js/files.HASH.js" defer>
20+</script>
1921 </head>
2022 <body data-ref="" data-sha="" data-sel="" data-nav="">
2123 <a class="skip" href="#main">skip to content</a>
@@ -126,7 +128,7 @@
126128 <dt>
127129 <kbd>/</kbd>
128130 </dt>
129-<dd>filter the list</dd>
131+<dd>filter the list; on Files, find a file in the repository</dd>
130132 </div>
131133 <div>
132134 <dt>
@@ -178,12 +180,12 @@
178180 </div>
179181 </dl>
180182 </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()">
182184 <div class="panel-head">
183185 <label for="cmd-q" class="sr">go to</label>
184186 <input id="cmd-q" type="search" placeholder="Go to 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));
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();
187+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
188+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
187189 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
188190 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
189191 if (step) { event.preventDefault(); let n = 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 @@
242244 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
243245 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
244246 k-refs from:body -> 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();
246248 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
247249 k-gh from:body -> document.querySelector('.brand')?.click();
248250 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
M cmd/ermite/testdata/web/home.html +7 -5
@@ -16,6 +16,8 @@
1616 </script>
1717 <script src="/-/static/js/ermite.HASH.js" defer>
1818 </script>
19+<script src="/-/static/js/files.HASH.js" defer>
20+</script>
1921 </head>
2022 <body data-ref="" data-sha="" data-sel="" data-nav="">
2123 <a class="skip" href="#main">skip to content</a>
@@ -156,7 +158,7 @@
156158 <dt>
157159 <kbd>/</kbd>
158160 </dt>
159-<dd>filter the list</dd>
161+<dd>filter the list; on Files, find a file in the repository</dd>
160162 </div>
161163 <div>
162164 <dt>
@@ -208,12 +210,12 @@
208210 </div>
209211 </dl>
210212 </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()">
212214 <div class="panel-head">
213215 <label for="cmd-q" class="sr">go to</label>
214216 <input id="cmd-q" type="search" placeholder="Go to 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));
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();
217+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
218+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
217219 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
218220 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
219221 if (step) { event.preventDefault(); let n = 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 @@
278280 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
279281 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
280282 k-refs from:body -> 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();
282284 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
283285 k-gh from:body -> document.querySelector('.brand')?.click();
284286 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 @@
1616 </script>
1717 <script src="/-/static/js/ermite.HASH.js" defer>
1818 </script>
19+<script src="/-/static/js/files.HASH.js" defer>
20+</script>
1921 </head>
2022 <body data-ref="" data-sha="" data-sel="" data-nav="">
2123 <a class="skip" href="#main">skip to content</a>
@@ -115,7 +117,7 @@
115117 <dt>
116118 <kbd>/</kbd>
117119 </dt>
118-<dd>filter the list</dd>
120+<dd>filter the list; on Files, find a file in the repository</dd>
119121 </div>
120122 <div>
121123 <dt>
@@ -167,12 +169,12 @@
167169 </div>
168170 </dl>
169171 </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()">
171173 <div class="panel-head">
172174 <label for="cmd-q" class="sr">go to</label>
173175 <input id="cmd-q" type="search" placeholder="Go to 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));
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();
176+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
177+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
176178 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
177179 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
178180 if (step) { event.preventDefault(); let n = 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 @@
225227 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
226228 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
227229 k-refs from:body -> 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();
229231 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
230232 k-gh from:body -> document.querySelector('.brand')?.click();
231233 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 @@
1616 </script>
1717 <script src="/-/static/js/ermite.HASH.js" defer>
1818 </script>
19+<script src="/-/static/js/files.HASH.js" defer>
20+</script>
1921 </head>
2022 <body data-ref="" data-sha="" data-sel="" data-nav="">
2123 <a class="skip" href="#main">skip to content</a>
@@ -115,7 +117,7 @@
115117 <dt>
116118 <kbd>/</kbd>
117119 </dt>
118-<dd>filter the list</dd>
120+<dd>filter the list; on Files, find a file in the repository</dd>
119121 </div>
120122 <div>
121123 <dt>
@@ -167,12 +169,12 @@
167169 </div>
168170 </dl>
169171 </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()">
171173 <div class="panel-head">
172174 <label for="cmd-q" class="sr">go to</label>
173175 <input id="cmd-q" type="search" placeholder="Go to 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));
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();
176+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
177+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
176178 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
177179 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
178180 if (step) { event.preventDefault(); let n = 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 @@
225227 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
226228 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
227229 k-refs from:body -> 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();
229231 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
230232 k-gh from:body -> document.querySelector('.brand')?.click();
231233 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
M cmd/ermite/testdata/web/refs.html +8 -6
@@ -16,8 +16,10 @@
1616 </script>
1717 <script src="/-/static/js/ermite.HASH.js" defer>
1818 </script>
19+<script src="/-/static/js/files.HASH.js" defer>
20+</script>
1921 </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">
2123 <a class="skip" href="#main">skip to content</a>
2224 <header class="hdr">
2325 <div class="hdr-top">
@@ -240,7 +242,7 @@
240242 <dt>
241243 <kbd>/</kbd>
242244 </dt>
243-<dd>filter the list</dd>
245+<dd>filter the list; on Files, find a file in the repository</dd>
244246 </div>
245247 <div>
246248 <dt>
@@ -331,12 +333,12 @@
331333 </section>
332334 </div>
333335 </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()">
335337 <div class="panel-head">
336338 <label for="cmd-q" class="sr">go to</label>
337339 <input id="cmd-q" type="search" placeholder="Go to 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));
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();
340+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
341+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
340342 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
341343 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
342344 if (step) { event.preventDefault(); let n = 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 @@
449451 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
450452 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
451453 k-refs from:body -> 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();
453455 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
454456 k-gh from:body -> document.querySelector('.brand')?.click();
455457 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
M cmd/ermite/testdata/web/summary.html +8 -6
@@ -16,8 +16,10 @@
1616 </script>
1717 <script src="/-/static/js/ermite.HASH.js" defer>
1818 </script>
19+<script src="/-/static/js/files.HASH.js" defer>
20+</script>
1921 </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">
2123 <a class="skip" href="#main">skip to content</a>
2224 <header class="hdr">
2325 <div class="hdr-top">
@@ -258,7 +260,7 @@
258260 <dt>
259261 <kbd>/</kbd>
260262 </dt>
261-<dd>filter the list</dd>
263+<dd>filter the list; on Files, find a file in the repository</dd>
262264 </div>
263265 <div>
264266 <dt>
@@ -349,12 +351,12 @@
349351 </section>
350352 </div>
351353 </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()">
353355 <div class="panel-head">
354356 <label for="cmd-q" class="sr">go to</label>
355357 <input id="cmd-q" type="search" placeholder="Go to 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));
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();
358+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
359+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
358360 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
359361 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
360362 if (step) { event.preventDefault(); let n = 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 @@
467469 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
468470 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
469471 k-refs from:body -> 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();
471473 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
472474 k-gh from:body -> document.querySelector('.brand')?.click();
473475 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 @@
1616 </script>
1717 <script src="/-/static/js/ermite.HASH.js" defer>
1818 </script>
19+<script src="/-/static/js/files.HASH.js" defer>
20+</script>
1921 </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">
2123 <a class="skip" href="#main">skip to content</a>
2224 <header class="hdr">
2325 <div class="hdr-top">
@@ -89,9 +91,16 @@
8991 <div class="work">
9092 <main id="main" tabindex="-1">
9193 <div class="filter">
92-<label for="tree-filter" class="sr">filter</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])')">
94+<label for="finder-q" class="sr">find a file</label>
95+<input id="finder-q" type="search" data-filter role="combobox" aria-autocomplete="list" aria-controls="finder-list" aria-expanded="false" placeholder="Find a file…" autocomplete="off" spellcheck="false" hx-on:focus="ermiteFinder.load()" hx-on:input="ermiteFinder.input(this)" hx-on:keydown="ermiteFinder.key(this, event)">
96+</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>
94102 </div>
103+<div id="tree-view">
95104 <table class="list tree" aria-label="Entries">
96105 <thead>
97106 <tr>
@@ -106,7 +115,7 @@
106115 <th class="c-when">date</th>
107116 </tr>
108117 </thead>
109-<tbody id="tree-rows" data-rows>
118+<tbody id="tree-rows" data-rows hx-on:click="ermiteTree.click(event)">
110119 <tr data-cursor>
111120 <td class="cur" aria-hidden="true">
112121 </td>
@@ -125,6 +134,7 @@
125134 </tr>
126135 </tbody>
127136 </table>
137+</div>
128138 </main>
129139 </div>
130140 <footer class="foot">
@@ -185,7 +195,7 @@
185195 <dt>
186196 <kbd>/</kbd>
187197 </dt>
188-<dd>filter the list</dd>
198+<dd>filter the list; on Files, find a file in the repository</dd>
189199 </div>
190200 <div>
191201 <dt>
@@ -272,12 +282,12 @@
272282 </section>
273283 </div>
274284 </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()">
276286 <div class="panel-head">
277287 <label for="cmd-q" class="sr">go to</label>
278288 <input id="cmd-q" type="search" placeholder="Go to 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));
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();
289+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
290+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
281291 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
282292 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
283293 if (step) { event.preventDefault(); let n = 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 @@
341351 <span class="k">tag</span>
342352 </a>
343353 </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>
350354 </ul>
351355 <p class="panel-foot">
352356 <span>
@@ -384,7 +388,7 @@
384388 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
385389 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
386390 k-refs from:body -> 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();
388392 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
389393 k-gh from:body -> document.querySelector('.brand')?.click();
390394 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 @@
1616 </script>
1717 <script src="/-/static/js/ermite.HASH.js" defer>
1818 </script>
19+<script src="/-/static/js/files.HASH.js" defer>
20+</script>
1921 </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">
2123 <a class="skip" href="#main">skip to content</a>
2224 <header class="hdr">
2325 <div class="hdr-top">
@@ -83,9 +85,16 @@
8385 <div class="work">
8486 <main id="main" tabindex="-1">
8587 <div class="filter">
86-<label for="tree-filter" class="sr">filter</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])')">
88+<label for="finder-q" class="sr">find a file</label>
89+<input id="finder-q" type="search" data-filter role="combobox" aria-autocomplete="list" aria-controls="finder-list" aria-expanded="false" placeholder="Find a file…" autocomplete="off" spellcheck="false" hx-on:focus="ermiteFinder.load()" hx-on:input="ermiteFinder.input(this)" hx-on:keydown="ermiteFinder.key(this, event)">
90+</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>
8896 </div>
97+<div id="tree-view">
8998 <table class="list tree" aria-label="Entries">
9099 <thead>
91100 <tr>
@@ -100,13 +109,13 @@
100109 <th class="c-when">date</th>
101110 </tr>
102111 </thead>
103-<tbody id="tree-rows" data-rows>
112+<tbody id="tree-rows" data-rows hx-on:click="ermiteTree.click(event)">
104113 <tr data-cursor>
105114 <td class="cur" aria-hidden="true">
106115 </td>
107-<td class="c-kind" aria-hidden="true">▾</td>
116+<td class="c-kind" aria-hidden="true">▸</td>
108117 <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>
110119 </td>
111120 <td class="c-size num dim">-</td>
112121 <td class="c-sub dim">
@@ -120,9 +129,9 @@
120129 <tr>
121130 <td class="cur" aria-hidden="true">
122131 </td>
123-<td class="c-kind" aria-hidden="true">▾</td>
132+<td class="c-kind" aria-hidden="true">▸</td>
124133 <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>
126135 </td>
127136 <td class="c-size num dim">-</td>
128137 <td class="c-sub dim">
@@ -352,6 +361,7 @@
352361 <p>See <a href="/files/blob/main/docs/guide.md">the guide</a>.</p>
353362 </article>
354363 </section>
364+</div>
355365 </main>
356366 </div>
357367 <footer class="foot">
@@ -412,7 +422,7 @@
412422 <dt>
413423 <kbd>/</kbd>
414424 </dt>
415-<dd>filter the list</dd>
425+<dd>filter the list; on Files, find a file in the repository</dd>
416426 </div>
417427 <div>
418428 <dt>
@@ -499,12 +509,12 @@
499509 </section>
500510 </div>
501511 </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()">
503513 <div class="panel-head">
504514 <label for="cmd-q" class="sr">go to</label>
505515 <input id="cmd-q" type="search" placeholder="Go to 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));
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();
516+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
517+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
508518 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
509519 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
510520 if (step) { event.preventDefault(); let n = 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 @@
568578 <span class="k">tag</span>
569579 </a>
570580 </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>
649581 </ul>
650582 <p class="panel-foot">
651583 <span>
@@ -683,7 +615,7 @@
683615 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
684616 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
685617 k-refs from:body -> 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();
687619 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
688620 k-gh from:body -> document.querySelector('.brand')?.click();
689621 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 @@
1616 </script>
1717 <script src="/-/static/js/ermite.HASH.js" defer>
1818 </script>
19+<script src="/-/static/js/files.HASH.js" defer>
20+</script>
1921 </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">
2123 <a class="skip" href="#main">skip to content</a>
2224 <header class="hdr">
2325 <div class="hdr-top">
@@ -86,9 +88,16 @@
8688 <div class="work">
8789 <main id="main" tabindex="-1">
8890 <div class="filter">
89-<label for="tree-filter" class="sr">filter</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])')">
91+<label for="finder-q" class="sr">find a file</label>
92+<input id="finder-q" type="search" data-filter role="combobox" aria-autocomplete="list" aria-controls="finder-list" aria-expanded="false" placeholder="Find a file…" autocomplete="off" spellcheck="false" hx-on:focus="ermiteFinder.load()" hx-on:input="ermiteFinder.input(this)" hx-on:keydown="ermiteFinder.key(this, event)">
93+</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>
9199 </div>
100+<div id="tree-view">
92101 <table class="list tree" aria-label="Entries">
93102 <thead>
94103 <tr>
@@ -103,7 +112,7 @@
103112 <th class="c-when">date</th>
104113 </tr>
105114 </thead>
106-<tbody id="tree-rows" data-rows>
115+<tbody id="tree-rows" data-rows hx-on:click="ermiteTree.click(event)">
107116 <tr data-cursor>
108117 <td class="cur" aria-hidden="true">
109118 </td>
@@ -122,6 +131,7 @@
122131 </tr>
123132 </tbody>
124133 </table>
134+</div>
125135 </main>
126136 </div>
127137 <footer class="foot">
@@ -182,7 +192,7 @@
182192 <dt>
183193 <kbd>/</kbd>
184194 </dt>
185-<dd>filter the list</dd>
195+<dd>filter the list; on Files, find a file in the repository</dd>
186196 </div>
187197 <div>
188198 <dt>
@@ -269,12 +279,12 @@
269279 </section>
270280 </div>
271281 </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()">
273283 <div class="panel-head">
274284 <label for="cmd-q" class="sr">go to</label>
275285 <input id="cmd-q" type="search" placeholder="Go to 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));
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();
286+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
287+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
278288 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
279289 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
280290 if (step) { event.preventDefault(); let n = 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 @@
338348 <span class="k">tag</span>
339349 </a>
340350 </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>
347351 </ul>
348352 <p class="panel-foot">
349353 <span>
@@ -381,7 +385,7 @@
381385 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
382386 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
383387 k-refs from:body -> 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();
385389 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
386390 k-gh from:body -> document.querySelector('.brand')?.click();
387391 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 @@
1616 </script>
1717 <script src="/-/static/js/ermite.HASH.js" defer>
1818 </script>
19+<script src="/-/static/js/files.HASH.js" defer>
20+</script>
1921 </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">
2123 <a class="skip" href="#main">skip to content</a>
2224 <header class="hdr">
2325 <div class="hdr-top">
@@ -86,9 +88,16 @@
8688 <div class="work">
8789 <main id="main" tabindex="-1">
8890 <div class="filter">
89-<label for="tree-filter" class="sr">filter</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])')">
91+<label for="finder-q" class="sr">find a file</label>
92+<input id="finder-q" type="search" data-filter role="combobox" aria-autocomplete="list" aria-controls="finder-list" aria-expanded="false" placeholder="Find a file…" autocomplete="off" spellcheck="false" hx-on:focus="ermiteFinder.load()" hx-on:input="ermiteFinder.input(this)" hx-on:keydown="ermiteFinder.key(this, event)">
9193 </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">
92101 <table class="list tree" aria-label="Entries">
93102 <thead>
94103 <tr>
@@ -103,7 +112,7 @@
103112 <th class="c-when">date</th>
104113 </tr>
105114 </thead>
106-<tbody id="tree-rows" data-rows>
115+<tbody id="tree-rows" data-rows hx-on:click="ermiteTree.click(event)">
107116 <tr data-cursor>
108117 <td class="cur" aria-hidden="true">
109118 </td>
@@ -147,6 +156,7 @@
147156 <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>
148157 </article>
149158 </section>
159+</div>
150160 </main>
151161 </div>
152162 <footer class="foot">
@@ -207,7 +217,7 @@
207217 <dt>
208218 <kbd>/</kbd>
209219 </dt>
210-<dd>filter the list</dd>
220+<dd>filter the list; on Files, find a file in the repository</dd>
211221 </div>
212222 <div>
213223 <dt>
@@ -294,12 +304,12 @@
294304 </section>
295305 </div>
296306 </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()">
298308 <div class="panel-head">
299309 <label for="cmd-q" class="sr">go to</label>
300310 <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
301- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
302- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
311+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
312+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
303313 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
304314 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
305315 if (step) { event.preventDefault(); let n = 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 @@
363373 <span class="k">tag</span>
364374 </a>
365375 </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>
378376 </ul>
379377 <p class="panel-foot">
380378 <span>
@@ -412,7 +410,7 @@
412410 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
413411 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
414412 k-refs from:body -> 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();
416414 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
417415 k-gh from:body -> document.querySelector('.brand')?.click();
418416 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
44
55 import (
66 "bytes"
7+ "compress/gzip"
8+ "encoding/json"
9+ "fmt"
710 "image"
811 "image/color"
912 "image/png"
13+ "io"
1014 "net/http"
15+ "os"
16+ "path/filepath"
1117 "regexp"
1218 "strconv"
1319 "strings"
@@ -100,6 +106,78 @@ func TestWebFilesGolden(t *testing.T) {
100106 }
101107 }
102108
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+
103181 // Ref names with a slash are found by their longest prefix, and a commit id
104182 // works where a ref does.
105183 func TestWebFilesRefs(t *testing.T) {
@@ -248,6 +326,19 @@ func TestWebRaw(t *testing.T) {
248326 require.NoError(t, err)
249327 _ = resp.Body.Close()
250328 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")
251342 assert.Equal(t, http.StatusOK, h.status(http.MethodHead, "/files/raw/main/notes.txt", nil))
252343 }
253344
@@ -331,3 +422,167 @@ func TestWebFilesContentSecurity(t *testing.T) {
331422 assert.Contains(t, sheet, ".chroma .kn { color: var(--syn-keyword)")
332423 assert.NotRegexp(t, `\.chroma[^{]*\{[^}]*#[0-9a-fA-F]{3,6}`, sheet)
333424 }
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 (
1212 )
1313
1414 require (
15- bnema.dev/ermite-api v0.0.0-20261011052945-e9394b717bc2
15+ bnema.dev/ermite-api v0.0.0-20261011182719-cb5699451db6
1616 bnema.dev/ermite-git v0.1.0
1717 bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3
1818 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) {
163163 _, err = r.GetTree(ctx, t.name, domain.Revision{}, path(t.treeDir), true)
164164 return
165165 }},
166+ {"ListPaths", func(r ports.RepoReader) (err error) {
167+ _, err = r.ListPaths(ctx, t.name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes)
168+ return
169+ }},
166170 {"OpenBlob/small", func(r ports.RepoReader) error { return drain(r, domain.Revision{}, path(t.file)) }},
167171 {"OpenBlob/largest", func(r ports.RepoReader) error { return drain(r, domain.Revision{}, path(t.big)) }},
168172 {"ListCommits/page1", func(r ports.RepoReader) (err error) {
M internal/adapters/ermitegit/convert.go +98 -0
@@ -8,6 +8,7 @@ import (
88 "errors"
99 "fmt"
1010 "io"
11+ "strings"
1112 "time"
1213
1314 git "bnema.dev/ermite-git"
@@ -326,6 +327,103 @@ func getTree(ctx context.Context, r *git.Repo, rev domain.Revision, path domain.
326327 return tree, nil
327328 }
328329
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+
329427 // fillLastCommits sets the last commit of the first MaxLastCommitEntries
330428 // entries with one walk.
331429 //
M internal/adapters/ermitegit/ermitegit.go +18 -1
@@ -94,12 +94,14 @@ type Routes struct {
9494 ListCommits Backend
9595 // ListCommitsPath is a page of the history of a file or a directory.
9696 ListCommitsPath Backend
97+ // ListPaths is the recursive list of the file paths of a revision.
98+ ListPaths Backend
9799 }
98100
99101 // AllRoutes returns routes that send every method to b. The tests and the
100102 // benchmarks use it to exercise a backend whatever the production routes are.
101103 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}
103105 }
104106
105107 // Config configures an Adapter.
@@ -315,6 +317,21 @@ func (a *Adapter) ResolveRefPath(ctx context.Context, repo domain.RepoName, path
315317 }, cli)
316318 }
317319
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+
318335 // GetTree lists a directory.
319336 func (a *Adapter) GetTree(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error) {
320337 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
1212 // win: the constants GetTree, GetTreeLastCommits, ResolveRevisionAbbrev and
1313 // ListCommitsPath cover directories of any size and files of any history. A
1414 // 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.
1617 //
1718 // Warm is a repository that is open; cold closes it before every call, the
1819 // price of the first read after the idle TTL, an eviction or a restart (a
@@ -67,6 +68,10 @@ package ermitegit
6768 // GetTree/dir+last fixture cold 5.4ms 1.8M 1455 316µs 247.6K 323 win
6869 // GetTree/dir+last lazygit warm 195.2ms 20.5M 17116 7.9ms 1.5M 855 win
6970 // 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
7075 // OpenBlob/small fixture warm 46µs 6.1K 25 17µs 5.8K 14 win
7176 // OpenBlob/small fixture cold 47µs 6.1K 25 87µs 27.7K 147 loses tba
7277 // OpenBlob/small lazygit warm 51µs 5.4K 25 29µs 7.0K 42 loses ba
@@ -103,11 +108,16 @@ package ermitegit
103108 // work after the open (ListRefs, GetTree, ResolveRefPath: 150 to 200
104109 // against 60 to 140), and in time too for the ones that cost a few
105110 // microseconds warm (ResolveRevision: 65 µs against 9 to 63 µs).
106-// - Only the history of a revision (ListCommits page 1 and deep) wins every
107-// line, so it is the only method routed. GetTree dir+last also wins every
108-// line, but GetTreeLastCommits covers GetTree root+last too, which loses
109-// cold allocations on lazygit (4204 against 3073): it is the nearest
110-// miss, and the first to flip once opening a repository allocates less.
111+// - Only the history of a revision (ListCommits page 1 and deep) and the
112+// recursive path list (ListPaths) win every line, so they are the only
113+// methods routed. ListPaths wins even cold because one walk replaces a
114+// process and the parsing of its output (the bytes of the paths are kept in
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.
111121 // - OpenBlob by size: below 1 MiB the small blob loses cold and, on lazygit,
112122 // warm bytes and allocations; above, it loses time. No size makes it win,
113123 // so there is no size-based route.
@@ -126,5 +136,6 @@ func DefaultRoutes() Routes {
126136 OpenBlob: CLI,
127137 ListCommits: ErmiteGit,
128138 ListCommitsPath: CLI,
139+ ListPaths: ErmiteGit,
129140 }
130141 }
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
288288 return obj, err
289289 }
290290
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+
291339 // GetTree lists a directory with `git ls-tree -z -l`.
292340 //
293341 //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
2727
2828 // Features are the optional capabilities of the contract that this server
2929 // 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"}
3131
3232 // Authenticator turns a token secret into a principal, or
3333 // 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
8585 return connect.NewResponse(out), nil
8686 }
8787
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+
88106 // GetBlob streams the blob in chunks of BlobChunkSize. The first message
89107 // carries the metadata and the first chunk.
90108 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.
99 | `static/js/hx-live.min.js` | htmx 4 `hx-live` extension | `mockups/assets/htmx/ext/hx-live.min.js` | `adf9b90949d57baf20dc2eaf1d1fd46c81347ac82c090a5e2912ce6b37726734` |
1010 | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` |
1111 | `static/js/ermite.js` | key sequences to `k-*` events | `mockups/d/js/ermite.js` | |
12+| `static/js/files.js` | the Files view: directories open in place (`window.ermiteTree`), 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 | |
1213 | `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 | |
1314 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |
1415 | `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/"
2323 // Assets are the URLs of the assets a page loads. Their names carry a hash of
2424 // the content, so they can be cached for ever.
2525 type Assets struct {
26- CSS, HTMX, Live, SSE, App, Icon string
26+ CSS, HTMX, Live, SSE, App, Files, Icon string
2727 // Font is the UI font, preloaded by every page.
2828 Font string
2929 // Rules is the speculation rules file named by the Speculation-Rules header.
@@ -52,6 +52,7 @@ var scripts = []struct {
5252 {"hx-live.min.js", func(a *Assets, u string) { a.Live = u }},
5353 {"hx-sse.min.js", func(a *Assets, u string) { a.SSE = u }},
5454 {"ermite.js", func(a *Assets, u string) { a.App = u }},
55+ {"files.js", func(a *Assets, u string) { a.Files = u }},
5556 }
5657
5758 // 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
185185 }
186186
187187 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)
188191 pinRef(&l, t)
189192 l.Crumbs = pathCrumbs(d.name(), "blob", t)
190193 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() {
3535 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
3636 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
3737 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
38- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
38+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load();
3939 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
4040 k-gh from:body -> document.querySelector('.brand')?.click();
4141 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
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() {
1414 <div><dt><kbd>j</kbd> <kbd>k</kbd></dt><dd>move the row cursor down / up</dd></div>
1515 <div><dt><kbd>enter</kbd></dt><dd>open the row under the cursor</dd></div>
1616 <div><dt><kbd>g g</kbd> <kbd>G</kbd></dt><dd>first / last row</dd></div>
17- <div><dt><kbd>/</kbd></dt><dd>filter the list</dd></div>
17+ <div><dt><kbd>/</kbd></dt><dd>filter the list; on Files, find a file in the repository</dd></div>
1818 <div><dt><kbd>b</kbd></dt><dd>{ "switch" } branch or tag</dd></div>
1919 <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd></div>
2020 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>
@@ -84,9 +84,9 @@ templ refItem(r RefItem) {
8484 }
8585
8686 // OverlayCmd is the ":" command line: one filterable list mixing sections,
87-// refs, files of this directory and repos.
87+// refs and repos.
8888 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()">
9090 <div class="panel-head">
9191 <label for="cmd-q" class="sr">go to</label>
9292 <input
@@ -97,8 +97,8 @@ templ OverlayCmd(items []CmdItem) {
9797 spellcheck="false"
9898 aria-controls="cmd-list"
9999 hx-on:input="let t = this.value.toLowerCase().trim();
100- q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
101- let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')"
100+ q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
101+ let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)"
102102 hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
103103 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
104104 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
M internal/adapters/web/overlays_templ.go +3 -3
@@ -34,7 +34,7 @@ func OverlayHelp() templ.Component {
3434 templ_7745c5c3_Var1 = templ.NopComponent
3535 }
3636 ctx = templ.ClearChildren(ctx)
37- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<dialog id=\"panel-help\" class=\"panel modal\" aria-label=\"Keyboard shortcuts\" hx-on:keydown=\"event.key === 'Escape' && (event.preventDefault(), this.close())\" hx-on:click=\"event.target === this && this.close()\"><div class=\"panel-head\"><h2>Keyboard shortcuts</h2><button type=\"button\" class=\"panel-x\" aria-label=\"Close\" hx-on:click=\"closest('dialog').close()\"><kbd>esc</kbd> close</button></div><dl class=\"keys\"><div><dt><kbd>ctrl k</kbd> <kbd>:</kbd></dt><dd>go to a section, branch, tag, file or repository</dd></div><div><dt><kbd>?</kbd></dt><dd>this list</dd></div><div><dt><kbd>j</kbd> <kbd>k</kbd></dt><dd>move the row cursor down / up</dd></div><div><dt><kbd>enter</kbd></dt><dd>open the row under the cursor</dd></div><div><dt><kbd>g g</kbd> <kbd>G</kbd></dt><dd>first / last row</dd></div><div><dt><kbd>/</kbd></dt><dd>filter the list</dd></div><div><dt><kbd>b</kbd></dt><dd>")
37+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<dialog id=\"panel-help\" class=\"panel modal\" aria-label=\"Keyboard shortcuts\" hx-on:keydown=\"event.key === 'Escape' && (event.preventDefault(), this.close())\" hx-on:click=\"event.target === this && this.close()\"><div class=\"panel-head\"><h2>Keyboard shortcuts</h2><button type=\"button\" class=\"panel-x\" aria-label=\"Close\" hx-on:click=\"closest('dialog').close()\"><kbd>esc</kbd> close</button></div><dl class=\"keys\"><div><dt><kbd>ctrl k</kbd> <kbd>:</kbd></dt><dd>go to a section, branch, tag, file or repository</dd></div><div><dt><kbd>?</kbd></dt><dd>this list</dd></div><div><dt><kbd>j</kbd> <kbd>k</kbd></dt><dd>move the row cursor down / up</dd></div><div><dt><kbd>enter</kbd></dt><dd>open the row under the cursor</dd></div><div><dt><kbd>g g</kbd> <kbd>G</kbd></dt><dd>first / last row</dd></div><div><dt><kbd>/</kbd></dt><dd>filter the list; on Files, find a file in the repository</dd></div><div><dt><kbd>b</kbd></dt><dd>")
3838 if templ_7745c5c3_Err != nil {
3939 return templ_7745c5c3_Err
4040 }
@@ -214,7 +214,7 @@ func refItem(r RefItem) templ.Component {
214214 }
215215
216216 // OverlayCmd is the ":" command line: one filterable list mixing sections,
217-// refs, files of this directory and repos.
217+// refs and repos.
218218 func OverlayCmd(items []CmdItem) templ.Component {
219219 return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
220220 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
@@ -236,7 +236,7 @@ func OverlayCmd(items []CmdItem) templ.Component {
236236 templ_7745c5c3_Var9 = templ.NopComponent
237237 }
238238 ctx = templ.ClearChildren(ctx)
239- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<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()\"><div class=\"panel-head\"><label for=\"cmd-q\" class=\"sr\">go to</label> <input id=\"cmd-q\" type=\"search\" placeholder=\"Go to a section, branch, tag, file or repository…\" autocomplete=\"off\" spellcheck=\"false\" aria-controls=\"cmd-list\" hx-on:input=\"let t = this.value.toLowerCase().trim();\n q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));\n 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();\n let i = vis.findIndex(li => li.hasAttribute('data-cursor'));\n let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;\n if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }\n else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }\"> <button type=\"button\" class=\"panel-x\" aria-label=\"Close\" hx-on:click=\"closest('dialog').close()\"><kbd>esc</kbd></button></div><ul id=\"cmd-list\" class=\"cmd-list\" aria-label=\"Targets\">")
239+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<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()\"><div class=\"panel-head\"><label for=\"cmd-q\" class=\"sr\">go to</label> <input id=\"cmd-q\" type=\"search\" placeholder=\"Go to a section, branch, tag, file or repository…\" autocomplete=\"off\" spellcheck=\"false\" aria-controls=\"cmd-list\" hx-on:input=\"let t = this.value.toLowerCase().trim();\n q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));\n 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();\n let i = vis.findIndex(li => li.hasAttribute('data-cursor'));\n let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;\n if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }\n else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }\"> <button type=\"button\" class=\"panel-x\" aria-label=\"Close\" hx-on:click=\"closest('dialog').close()\"><kbd>esc</kbd></button></div><ul id=\"cmd-list\" class=\"cmd-list\" aria-label=\"Targets\">")
240240 if templ_7745c5c3_Err != nil {
241241 return templ_7745c5c3_Err
242242 }
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) {
6060 // private, and a shared cache must not keep serving its files (AC2). The
6161 // ETag keeps the revalidation cheap.
6262 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) {
6464 w.WriteHeader(http.StatusNotModified)
6565 return
6666 }
M internal/adapters/web/refs.go +45 -1
@@ -3,9 +3,12 @@
33 package web
44
55 import (
6+ "context"
67 "net/url"
78 "strings"
89
10+ "connectrpc.com/connect"
11+
912 pb "bnema.dev/ermite-api/gen/ermite"
1013 )
1114
@@ -77,6 +80,32 @@ type target struct {
7780 path string
7881 // detached is true when the URL names a commit and not a ref.
7982 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()
80109 }
81110
82111 // locate reads "<ref>[/<path>]" or "<commit>[/<path>]". The ref is the
@@ -132,13 +161,14 @@ func (d *repoData) isBranchHead(t target) bool {
132161
133162 // pinRef points the ref switcher of the layout at what a page shows: a
134163 // 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.
136165 func pinRef(l *Layout, t target) {
137166 if t.detached {
138167 l.Ref, l.RefPath, l.Short, l.SHA = "detached", "", short(t.rev), ""
139168 if len(t.rev) == 40 {
140169 l.SHA = t.rev
141170 }
171+ l.setFinder(t.commitID(), t.rev)
142172 for i := range l.Heads {
143173 l.Heads[i].Current = false
144174 }
@@ -148,6 +178,7 @@ func pinRef(l *Layout, t target) {
148178 return
149179 }
150180 l.Ref, l.RefPath, l.SHA, l.Short = t.name, t.name, t.rev, short(t.rev)
181+ l.setFinder(t.rev, t.name)
151182 for i := range l.Heads {
152183 l.Heads[i].Current = l.Heads[i].Name == t.name
153184 }
@@ -156,6 +187,19 @@ func pinRef(l *Layout, t target) {
156187 }
157188 }
158189
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+
159203 func hasName(items []RefItem, name string) bool {
160204 for _, it := range items {
161205 if it.Name == name {
M internal/adapters/web/repo.go +1 -0
@@ -98,6 +98,7 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La
9898 def = d.head.GetShortName()
9999 l.Ref, l.RefPath = def, def
100100 l.SHA, l.Short = d.head.GetCommitId(), short(d.head.GetCommitId())
101+ l.setFinder(l.SHA, def)
101102 }
102103 mk := func(key, label, href, id string) Section {
103104 return Section{Key: key, Label: label, Href: href, Current: section == id}
M internal/adapters/web/shell.templ +11 -1
@@ -20,8 +20,18 @@ templ Shell(l Layout, rail templ.Component) {
2020 <script src={ l.Assets.Live } defer></script>
2121 <script src={ l.Assets.SSE } defer></script>
2222 <script src={ l.Assets.App } defer></script>
23+ <script src={ l.Assets.Files } defer></script>
2324 </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+ >
2535 <a class="skip" href="#main">skip to content</a>
2636 @Header(l)
2737 <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 @@
5454 & li[data-cursor] a { background: var(--bg-strong); color: var(--ink-hi); }
5555 & .lbl { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); font-size: var(--fs-s); }
5656 & .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; }
5760 }
5861
5962 .keys {
M internal/adapters/web/static/css/30-list.css +10 -1
@@ -38,7 +38,10 @@
3838 }
3939 .list.tree {
4040 & .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); }
4245 & .c-size { width: 90px; text-align: right; }
4346 & .c-when { width: 100px; }
4447 & .c-sub { width: auto; }
@@ -71,6 +74,12 @@
7174 &:focus-within::after { display: none; }
7275 }
7376
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+
7483 /* section headings inside a page */
7584 .rule-h {
7685 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 (
1212 pb "bnema.dev/ermite-api/gen/ermite"
1313 )
1414
15-// maxFileTargets bounds the files of a directory offered in the ":" list.
16-const maxFileTargets = 200
17-
1815 // TreeRow is one entry of a directory.
1916 type TreeRow struct {
20- Glyph string // ▾ directory, @ symbolic link, + submodule, · file
21- Name string
22- Href string // "" for a submodule
17+ Glyph string // ▸ directory, @ symbolic link, + submodule, · file
18+ Name string
19+ Href string // "" for a submodule
20+ // Expand is the URL of the rows of a directory (Href?rows); "" for any
21+ // other entry.
22+ Expand string
2323 Size string
2424 Subject string
2525 CHref string
@@ -35,6 +35,9 @@ type TreePage struct {
3535 Readme *Readme
3636 // Notes say what the listing leaves out.
3737 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
3841 }
3942
4043 // 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
5760 entries := res.Msg.GetEntries()
5861 now := s.cfg.Now()
5962
63+ rowsOnly := r.URL.Query().Has("rows")
6064 p := TreePage{}
61- l := s.repoLayout(ctx, d, "tree")
6265 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}
6467 switch e.GetKind() {
6568 case pb.EntryKind_ENTRY_KIND_DIRECTORY:
66- row.Glyph, row.Name, row.Size = "▾", row.Name+"/", "-"
69+ row.Glyph, row.Name, row.Size = "▸", row.Name+"/", "-"
6770 row.Href = repoHref(d.name(), "tree", t.name, e.GetPath())
71+ row.Expand = row.Href + "?rows"
6872 case pb.EntryKind_ENTRY_KIND_SYMLINK:
6973 row.Glyph = "@"
7074 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
7276 row.Glyph, row.Size = "+", "-"
7377 default:
7478 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- }
7879 }
7980 if c := e.GetLastCommit(); c != nil {
8081 last := commitRow(d.name(), c, now)
@@ -82,6 +83,13 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in
8283 }
8384 p.Rows = append(p.Rows, row)
8485 }
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+ }
8593 if res.Msg.GetTruncated() {
8694 p.Notes = append(p.Notes, "this directory has more entries than are listed here: the first "+itoa(len(entries))+" are shown.")
8795 }
@@ -96,6 +104,10 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in
96104 p.Readme = rd.readme
97105 }
98106
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()
99111 pinRef(&l, t)
100112 l.Crumbs = pathCrumbs(d.name(), "tree", t)
101113 if t.path == "" {
M internal/adapters/web/tree.templ +57 -27
@@ -2,52 +2,82 @@
22
33 package web
44
5-// Tree is a directory: the filter, the entries with their last commit, the
5+// Tree is a directory: the file finder, the entries with their last commit, the
66 // notes on what is left out, and the README of the directory below.
77 templ Tree(l Layout, p TreePage) {
88 @Shell(l, nil) {
99 <div class="filter">
10- <label for="tree-filter" class="sr">filter</label>
10+ <label for="finder-q" class="sr">find a file</label>
1111 <input
12- id="tree-filter"
12+ id="finder-q"
1313 type="search"
1414 data-filter
15- placeholder="Filter this directory…"
15+ role="combobox"
16+ aria-autocomplete="list"
17+ aria-controls="finder-list"
18+ aria-expanded="false"
19+ placeholder="Find a file…"
1620 autocomplete="off"
17- hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')"
21+ spellcheck="false"
22+ hx-on:focus="ermiteFinder.load()"
23+ hx-on:input="ermiteFinder.input(this)"
24+ hx-on:keydown="ermiteFinder.key(this, event)"
1825 />
1926 </div>
20- <table class="list tree" aria-label="Entries">
21- <thead>
22- <tr><th class="cur"></th><th class="c-kind"></th><th class="c-name">name</th><th class="c-size">size</th><th class="c-sub">last commit</th><th class="c-who">author</th><th class="c-when">date</th></tr>
23- </thead>
24- <tbody id="tree-rows" data-rows>
25- for _, r := range p.Rows {
26- @TreeRowView(r)
27- }
28- </tbody>
29- </table>
30- if len(p.Rows) == 0 {
31- <p class="lede dim">this directory is empty.</p>
32- }
33- for _, n := range p.Notes {
34- <p class="note">{ n }</p>
35- }
36- if p.Readme != nil {
37- @ReadmeCard(*p.Readme)
38- }
27+ <div id="finder" hidden>
28+ <p id="finder-status" class="dim" aria-live="polite"></p>
29+ <ul id="finder-list" class="cmd-list" role="listbox" aria-label="Files"></ul>
30+ </div>
31+ <div id="tree-view">
32+ <table class="list tree" aria-label="Entries">
33+ <thead>
34+ <tr><th class="cur"></th><th class="c-kind"></th><th class="c-name">name</th><th class="c-size">size</th><th class="c-sub">last commit</th><th class="c-who">author</th><th class="c-when">date</th></tr>
35+ </thead>
36+ <tbody id="tree-rows" data-rows hx-on:click="ermiteTree.click(event)">
37+ @TreeRows(p)
38+ </tbody>
39+ </table>
40+ if len(p.Rows) == 0 {
41+ <p class="lede dim">this directory is empty.</p>
42+ }
43+ for _, n := range p.Notes {
44+ <p class="note">{ n }</p>
45+ }
46+ if p.Readme != nil {
47+ @ReadmeCard(*p.Readme)
48+ }
49+ </div>
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>
3962 }
4063 }
4164
42-// TreeRowView is one entry of a directory. Glyph: ▾ directory, @ symbolic
43-// link, + submodule, · file.
65+// TreeRowView is one entry of a directory. Glyph: ▸ directory (▾ once opened
66+// in place), @ symbolic link, + submodule, · file. A directory link carries
67+// data-expand, the URL of its rows.
4468 templ TreeRowView(r TreeRow) {
4569 <tr data-cursor?={ r.Cursor }>
4670 <td class="cur" aria-hidden="true"></td>
4771 <td class="c-kind" aria-hidden="true">{ r.Glyph }</td>
4872 <td class="c-name">
4973 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>
5181 } else {
5282 { r.Name }
5383 }
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 {
5656 Live bool
5757 // LiveURL is the stream the page follows, with its query.
5858 LiveURL string
59+ // PathsURL is the list of the file paths of the commit the page shows
60+ // (/{repo}/paths/{sha}) and BlobBase the URL its file links start with;
61+ // both are "" when the page has no commit to search.
62+ PathsURL, BlobBase string
5963 }
6064
6165 // Link is a plain label + href pair.
M internal/adapters/web/web.go +6 -2
@@ -85,7 +85,9 @@ type Server struct {
8585 origin string // scheme://host of the public URL, what a POST must come from
8686 assets *assetSet
8787 md *markdown
88- mux *http.ServeMux
88+ // pathCache holds the path lists the file finder fetches.
89+ pathCache *pathCache
90+ mux *http.ServeMux
8991 }
9092
9193 // New builds the web handler.
@@ -109,7 +111,8 @@ func New(cfg Config) (*Server, error) {
109111 }
110112 s := &Server{
111113 cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown(),
112- origin: originOf(u),
114+ pathCache: newPathCache(pathCacheBytes, pathBuilds),
115+ origin: originOf(u),
113116 }
114117 s.mux = s.routes()
115118 return s, nil
@@ -137,6 +140,7 @@ func (s *Server) routes() *http.ServeMux {
137140 mux.HandleFunc("GET /{repo}/tree/{rest...}", s.page(s.tree))
138141 mux.HandleFunc("GET /{repo}/blob/{rest...}", s.page(s.blob))
139142 mux.HandleFunc("GET /{repo}/raw/{rest...}", s.raw)
143+ mux.HandleFunc("GET /{repo}/paths/{sha}", s.paths)
140144 if s.cfg.MergeRequests != nil {
141145 mux.HandleFunc("GET /{repo}/merge-requests", s.page(s.mrList))
142146 mux.HandleFunc("GET /{repo}/merge-requests/{$}", s.page(s.mrList))
M internal/app/browse.go +9 -0
@@ -174,6 +174,15 @@ func (b *Browse) GetTree(ctx context.Context, p domain.Principal, repo domain.Re
174174 return b.reader.GetTree(ctx, repo, rev, path, includeLastCommit)
175175 }
176176
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+
177186 // OpenBlob returns the description of a file and a reader of its content. The
178187 // caller closes the reader.
179188 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 {
7676 }, func(r *mocks.RepoReader) {
7777 r.EXPECT().GetTree(mock.Anything, mock.Anything, mock.Anything, mock.Anything, true).Return(domain.Tree{}, nil)
7878 }},
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+ }},
7985 {"OpenBlob", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
8086 _, rc, err := b.OpenBlob(ctx, p, repo, domain.Revision{}, root)
8187 if rc != nil {
M internal/domain/git.go +79 -9
@@ -155,20 +155,51 @@ func ParseTreePath(s string) (TreePath, error) {
155155 if s == "" {
156156 return TreePath{}, nil
157157 }
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 {
158178 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"
160180 }
161- for _, c := range s {
162- if c < 0x20 || c == 0x7f {
163- return TreePath{}, fmt.Errorf("%w: %q: control character", ErrInvalidPath, truncate(s))
181+ return pathShapeProblem(s)
182+}
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
164195 }
165- }
166- for part := range strings.SplitSeq(s, "/") {
167- if part == "" || part == "." || part == ".." {
168- return TreePath{}, fmt.Errorf("%w: %q: empty, . or .. component", ErrInvalidPath, truncate(s))
196+ n := i - start
197+ if n == 0 || (n == 1 && s[start] == '.') || (n == 2 && s[start] == '.' && s[start+1] == '.') {
198+ return "empty, . or .. component"
169199 }
200+ start = i + 1
170201 }
171- return TreePath{s: s}, nil
202+ return ""
172203 }
173204
174205 func truncate(s string) string {
@@ -325,6 +356,45 @@ type Tree struct {
325356 // MaxTreeEntries bounds the entries of a listed directory.
326357 const MaxTreeEntries = 5000
327358
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+
328398 // MaxLastCommitEntries bounds the entries of one directory that get a last
329399 // commit: each costs a history lookup.
330400 const MaxLastCommitEntries = 200
M internal/domain/git_test.go +72 -3
@@ -73,10 +73,79 @@ func TestParseTreePath(t *testing.T) {
7373 require.NoError(t, err)
7474 assert.Equal(t, "a/b", p.String())
7575 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+}
76115
77- for _, bad := range []string{"/a", "a//b", "./a", "a/..", "..", ".", "a\x00b", "a\nb", "\xff", strings.Repeat("a", 4097), "//"} {
78- _, err := domain.ParseTreePath(bad)
79- assert.ErrorIs(t, err, domain.ErrInvalidPath, "%q", bad)
116+func TestPathListAdd(t *testing.T) {
117+ for _, tt := range []struct {
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+ })
80149 }
81150 }
82151
M internal/ports/mocks/RepoReader.go +84 -0
@@ -283,6 +283,90 @@ func (_c *RepoReader_ListCommits_Call) RunAndReturn(run func(ctx context.Context
283283 return _c
284284 }
285285
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+
286370 // ListRefs provides a mock function for the type RepoReader
287371 func (_mock *RepoReader) ListRefs(ctx context.Context, repo domain.RepoName) ([]domain.Ref, error) {
288372 ret := _mock.Called(ctx, repo)
M internal/ports/ports.go +5 -0
@@ -156,6 +156,11 @@ type RepoReader interface {
156156 // domain.MaxLastCommitEntries entries get their last commit. It returns
157157 // domain.ErrNotDirectory if path names something else.
158158 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)
159164 // OpenBlob returns the description of the file at path and a reader of its
160165 // content, which streams without holding the whole file in memory. The
161166 // 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
264264 }
265265 }
266266 })
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+ })
267278 t.Run("OpenBlob", func(t *testing.T) {
268279 read := func(r ports.RepoReader, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, []byte, error) {
269280 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
338349 _, err := r.GetTree(ctx, nope, domain.Revision{}, domain.TreePath{}, false)
339350 return err
340351 },
352+ "ListPaths": func(r ports.RepoReader) error {
353+ _, err := r.ListPaths(ctx, nope, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes)
354+ return err
355+ },
341356 "OpenBlob": func(r ports.RepoReader) error {
342357 _, _, err := r.OpenBlob(ctx, nope, domain.Revision{}, domain.TreePath{})
343358 return err
M internal/readertest/conformance.go +108 -0
@@ -46,6 +46,7 @@ func Conformance(t *testing.T, mk Maker) {
4646 {"HostileRevisionAndPathStayLiteral", testHostileRevisionAndPathStayLiteral},
4747 {"ResolveRevisionObjectIDBeatsRefs", testResolveRevisionObjectIDBeatsRefs},
4848 {"GetTreeIsBounded", testGetTreeIsBounded},
49+ {"ListPaths", testListPaths},
4950 } {
5051 t.Run(tc.name, func(t *testing.T) { tc.fn(t, mk) })
5152 }
@@ -701,3 +702,110 @@ func testGetTreeIsBounded(t *testing.T, mk Maker) {
701702 require.NoError(t, err)
702703 assert.False(t, small.Truncated)
703704 }
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 {
6363 {"refs", buildRefs},
6464 {"dangling ref", buildDangling},
6565 {"submodule", buildSubmodule},
66+ {"paths", buildPaths},
6667 {"wide directory", buildWide},
6768 {"detached HEAD", func(t *testing.T, data string) *Fixture {
6869 h := NewHistory(t, data)
@@ -265,6 +266,23 @@ func buildSubmodule(t *testing.T, data string) *Fixture {
265266 return f
266267 }
267268
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+
268286 // buildWide has a directory over MaxTreeEntries entries and one over
269287 // MaxLastCommitEntries, with commits that touch some of them.
270288 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 42

↑↓ moveenter openesc close